From 2872811667df8fc4e15315304356d70f5ed3c9db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Wed, 16 Sep 2026 11:40:26 +0800 Subject: [PATCH] feat(agui): support LLM request headers --- src/iac_code/agui/adapter.py | 2 ++ src/iac_code/agui/inputs.py | 1 + tests/agui/test_app.py | 24 +++++++++++++++++++ tests/agui/test_inputs.py | 5 +++- tests/agui/test_persistence.py | 10 +++++++- website/docs/agui/protocol-reference.md | 3 +++ .../current/agui/protocol-reference.md | 3 +++ .../current/agui/protocol-reference.md | 3 +++ .../current/agui/protocol-reference.md | 3 +++ .../current/agui/protocol-reference.md | 3 +++ .../current/agui/protocol-reference.md | 3 +++ .../current/agui/protocol-reference.md | 3 +++ 12 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/iac_code/agui/adapter.py b/src/iac_code/agui/adapter.py index eb0684d1d..d6dbe8cbc 100644 --- a/src/iac_code/agui/adapter.py +++ b/src/iac_code/agui/adapter.py @@ -1391,6 +1391,8 @@ def _a2a_request_options( "cleanupOnly": props.cleanup_only, "rosInvocationId": props.ros_invocation_id, } + if props.llm_headers is not None: + metadata["llm_headers"] = props.llm_headers cloud = props.alibaba_cloud if cloud is not None: metadata.update( diff --git a/src/iac_code/agui/inputs.py b/src/iac_code/agui/inputs.py index 505b2aaf4..791141d77 100644 --- a/src/iac_code/agui/inputs.py +++ b/src/iac_code/agui/inputs.py @@ -49,6 +49,7 @@ class IacCodeForwardedProps(StrictModel): cwd: str model: str | None = None llm_api_key: str | None = Field(default=None, alias="llmApiKey", repr=False) + llm_headers: dict[str, str] | None = Field(default=None, alias="llmHeaders", repr=False) thinking: ThinkingOptions | None = None user_id: str | None = Field(default=None, alias="userId") channel: str | None = None diff --git a/tests/agui/test_app.py b/tests/agui/test_app.py index fad0388c1..8aa991725 100644 --- a/tests/agui/test_app.py +++ b/tests/agui/test_app.py @@ -1085,6 +1085,10 @@ async def test_request_runtime_overrides_are_forwarded_only_as_a2a_metadata(tmp_ { "model": "qwen-test", "llmApiKey": "fake-provider-key", + "llmHeaders": { + "Authorization": "Bearer fake-caller-token", + "X-Caller-Session": "session-1", + }, "thinking": {"enabled": True, "effort": "low", "budget": 1024}, "alibabaCloud": { "accessKeyId": "fake-access-key", @@ -1109,6 +1113,10 @@ async def test_request_runtime_overrides_are_forwarded_only_as_a2a_metadata(tmp_ "cleanupOnly": False, "rosInvocationId": "invocation-1", "preferredLanguage": "en", + "llm_headers": { + "Authorization": "Bearer fake-caller-token", + "X-Caller-Session": "session-1", + }, "alibaba_cloud_access_key_id": "fake-access-key", "alibaba_cloud_access_key_secret": "fake-access-secret", "alibaba_cloud_security_token": "fake-sts-token", @@ -1116,6 +1124,22 @@ async def test_request_runtime_overrides_are_forwarded_only_as_a2a_metadata(tmp_ } +@pytest.mark.asyncio +async def test_empty_llm_headers_are_forwarded_to_clear_a2a_context_binding(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + fake = FakeA2AClient() + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake) + app = create_app(adapter=adapter) + payload = _payload(tmp_path) + payload["forwardedProps"]["iacCode"]["llmHeaders"] = {} + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + response = await client.post("/", json=payload) + + assert response.status_code == 200 + assert fake.stream_options[0]["iac_code_metadata"]["llm_headers"] == {} + + @pytest.mark.asyncio async def test_heartbeat_remains_sse_comment_and_not_agui_event(tmp_path, monkeypatch) -> None: monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) diff --git a/tests/agui/test_inputs.py b/tests/agui/test_inputs.py index 50d1ea9a4..edff74301 100644 --- a/tests/agui/test_inputs.py +++ b/tests/agui/test_inputs.py @@ -9,7 +9,8 @@ from iac_code.agui.inputs import latest_user_message, parse_forwarded_props, resolve_cwd -def test_forwarded_props_require_request_workspace_and_identity(tmp_path) -> None: +@pytest.mark.parametrize("llm_headers_field", ["llmHeaders", "llm_headers"]) +def test_forwarded_props_require_request_workspace_and_identity(tmp_path, llm_headers_field) -> None: props = parse_forwarded_props( { "iacCode": { @@ -17,6 +18,7 @@ def test_forwarded_props_require_request_workspace_and_identity(tmp_path) -> Non "rosInvocationId": "invocation-1", "cwd": str(tmp_path), "model": "qwen-test", + llm_headers_field: {"X-Caller-Session": "session-1"}, "runMode": "pipeline", } } @@ -24,6 +26,7 @@ def test_forwarded_props_require_request_workspace_and_identity(tmp_path) -> Non assert props.iac_code.cwd == str(tmp_path) assert props.iac_code.model == "qwen-test" + assert props.iac_code.llm_headers == {"X-Caller-Session": "session-1"} assert props.iac_code.run_mode == "pipeline" diff --git a/tests/agui/test_persistence.py b/tests/agui/test_persistence.py index 6dd3925fd..4ab34ce1f 100644 --- a/tests/agui/test_persistence.py +++ b/tests/agui/test_persistence.py @@ -1021,6 +1021,7 @@ async def test_persisted_state_excludes_request_messages_and_credentials(tmp_pat payload["forwardedProps"]["iacCode"].update( { "llmApiKey": "llm-secret", + "llmHeaders": {"Authorization": "Bearer header-secret"}, "alibabaCloud": { "accessKeyId": "ak-secret", "accessKeySecret": "sk-secret", @@ -1035,7 +1036,14 @@ async def test_persisted_state_excludes_request_messages_and_credentials(tmp_pat thread_path = _thread_state_path(state_dir) raw = thread_path.read_text(encoding="utf-8") - for secret in ("private-user-message", "llm-secret", "ak-secret", "sk-secret", "sts-secret"): + for secret in ( + "private-user-message", + "llm-secret", + "header-secret", + "ak-secret", + "sk-secret", + "sts-secret", + ): assert secret not in raw if os.name != "nt": assert stat.S_IMODE(state_dir.stat().st_mode) == 0o700 diff --git a/website/docs/agui/protocol-reference.md b/website/docs/agui/protocol-reference.md index b73b2500e..6fbf9eee2 100644 --- a/website/docs/agui/protocol-reference.md +++ b/website/docs/agui/protocol-reference.md @@ -83,6 +83,7 @@ This object uses a strict schema; unknown fields are rejected. | `cwd` | string | Yes | Absolute workspace path | | `model` | string | No | Per-request model override | | `llmApiKey` | string | No | Per-request LLM provider key | +| `llmHeaders` / `llm_headers` | object | No | Additional string-to-string HTTP headers for LLM provider calls | | `thinking.enabled` | boolean | No | Request thinking output | | `thinking.effort` | string | No | Provider-specific thinking effort | | `thinking.budget` | positive integer | No | Provider-specific thinking budget | @@ -98,6 +99,8 @@ This object uses a strict schema; unknown fields are rejected. | `alibabaCloud.securityToken` | string | No | Request-local STS token | | `alibabaCloud.regionId` | string | No | Request-local default region | +`llmHeaders` follows the A2A context-binding rules for `metadata.iac_code.llm_headers`: once supplied, later requests on the same AG-UI thread inherit the headers when the field is omitted. A new map replaces the complete binding, and `{}` clears it. Header values may contain credentials, so the adapter does not persist them; callers must supply them again after the local A2A process restarts. + The initial run and its interrupt resumes must retain the same `rosInvocationId`. A later normal turn may use a new value. Cancellation must use the current execution's value. A `threadId` is bound to the first request's `cwd` and `userId`; later requests cannot move the same thread to another workspace or caller. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/agui/protocol-reference.md b/website/i18n/de/docusaurus-plugin-content-docs/current/agui/protocol-reference.md index 2c10ac937..c2fe9418d 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/agui/protocol-reference.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/agui/protocol-reference.md @@ -83,6 +83,7 @@ Das Schema ist strikt; unbekannte Felder werden abgelehnt. | `cwd` | Zeichenfolge | Ja | Absoluter Arbeitsbereichspfad | | `model` | Zeichenfolge | Nein | Modellüberschreibung pro Anfrage | | `llmApiKey` | Zeichenfolge | Nein | LLM-Anbieterschlüssel pro Anfrage | +| `llmHeaders` / `llm_headers` | Objekt | Nein | Zusätzliche HTTP-Header als Zeichenfolge-zu-Zeichenfolge-Zuordnung für Aufrufe des LLM-Anbieters | | `thinking.enabled` | boolesch | Nein | Reasoning-Ausgabe anfordern | | `thinking.effort` | Zeichenfolge | Nein | Anbieterspezifischer Reasoning-Aufwand | | `thinking.budget` | positive Ganzzahl | Nein | Anbieterspezifisches Reasoning-Budget | @@ -98,6 +99,8 @@ Das Schema ist strikt; unbekannte Felder werden abgelehnt. | `alibabaCloud.securityToken` | Zeichenfolge | Nein | Anfragebezogenes STS-Token | | `alibabaCloud.regionId` | Zeichenfolge | Nein | Anfragebezogene Standardregion | +`llmHeaders` folgt den A2A-Kontextbindungsregeln für `metadata.iac_code.llm_headers`: Nach der ersten Angabe erben spätere Anfragen desselben AG-UI-Threads die Header, wenn das Feld fehlt. Eine neue Zuordnung ersetzt die gesamte Bindung, und `{}` löscht sie. Da Headerwerte Anmeldedaten enthalten können, speichert der Adapter sie nicht dauerhaft; nach einem Neustart des lokalen A2A-Prozesses muss der Aufrufer sie erneut senden. + Der erste Lauf und seine Wiederaufnahmen müssen dieselbe `rosInvocationId` behalten. Eine spätere normale Runde darf einen neuen Wert verwenden. Beim Abbruch ist der Wert der aktuellen Ausführung erforderlich. Eine `threadId` wird an `cwd` und `userId` der ersten Anfrage gebunden; spätere Anfragen können denselben Thread nicht in einen anderen Arbeitsbereich oder zu einem anderen Aufrufer verschieben. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/agui/protocol-reference.md b/website/i18n/es/docusaurus-plugin-content-docs/current/agui/protocol-reference.md index f3dfe4f86..b4a895ee1 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/agui/protocol-reference.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/agui/protocol-reference.md @@ -68,6 +68,7 @@ El schema es estricto y rechaza campos desconocidos. | `rosInvocationId` | string | Sí | Identidad de la ejecución, máximo 256 caracteres | | `cwd` | string | Sí | Workspace absoluto | | `model` / `llmApiKey` | string | No | Modelo y clave LLM por solicitud | +| `llmHeaders` / `llm_headers` | objeto | No | Headers HTTP adicionales de string a string para las llamadas al proveedor LLM | | `thinking.enabled/effort/budget` | boolean/string/entero positivo | No | Opciones de thinking | | `userId` / `channel` | string | No | Identidad y canal del llamante | | `preferredLanguage` | string | No | Idioma visible, por ejemplo `es` | @@ -80,6 +81,8 @@ El schema es estricto y rechaza campos desconocidos. | `alibabaCloud.securityToken` | string | No | Token STS temporal | | `alibabaCloud.regionId` | string | No | Región predeterminada | +`llmHeaders` sigue las reglas de vinculación al contexto A2A de `metadata.iac_code.llm_headers`: una vez enviado, las solicitudes posteriores del mismo thread AG-UI heredan los headers cuando se omite el campo. Un nuevo mapa reemplaza toda la vinculación y `{}` la borra. Como los valores pueden contener credenciales, el adaptador no los persiste; el llamante debe volver a enviarlos tras reiniciar el proceso A2A local. + El run inicial y sus Resume conservan el mismo `rosInvocationId`. Un turno normal posterior puede usar otro. El mismo `threadId` queda vinculado al primer `cwd` y `userId`. ## SSE y eventos estándar diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/agui/protocol-reference.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/agui/protocol-reference.md index ec160307a..fa681f30e 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/agui/protocol-reference.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/agui/protocol-reference.md @@ -83,6 +83,7 @@ Le schéma est strict : les champs inconnus sont refusés. | `cwd` | chaîne | Oui | Chemin absolu de l’espace de travail | | `model` | chaîne | Non | Modèle choisi pour cette requête | | `llmApiKey` | chaîne | Non | Clé du fournisseur LLM pour cette requête | +| `llmHeaders` / `llm_headers` | objet | Non | En-têtes HTTP supplémentaires sous forme de chaînes pour les appels au fournisseur LLM | | `thinking.enabled` | booléen | Non | Demander la sortie du raisonnement | | `thinking.effort` | chaîne | Non | Effort de raisonnement propre au fournisseur | | `thinking.budget` | entier positif | Non | Budget de raisonnement propre au fournisseur | @@ -98,6 +99,8 @@ Le schéma est strict : les champs inconnus sont refusés. | `alibabaCloud.securityToken` | chaîne | Non | Jeton STS local à la requête | | `alibabaCloud.regionId` | chaîne | Non | Région par défaut locale à la requête | +`llmHeaders` suit les règles de liaison au contexte A2A de `metadata.iac_code.llm_headers` : après sa première transmission, les requêtes suivantes du même thread AG-UI héritent des en-têtes si le champ est omis. Une nouvelle table remplace toute la liaison et `{}` l’efface. Comme les valeurs peuvent contenir des identifiants secrets, l’adaptateur ne les conserve pas ; le demandeur doit les renvoyer après le redémarrage du processus A2A local. + L’exécution initiale et ses reprises doivent conserver le même `rosInvocationId`. Un tour normal ultérieur peut utiliser une nouvelle valeur. L’annulation doit employer celle de l’exécution courante. Le `threadId` est lié aux `cwd` et `userId` de la première requête ; les requêtes suivantes ne peuvent pas déplacer le thread vers un autre espace de travail ou un autre demandeur. diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/agui/protocol-reference.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/agui/protocol-reference.md index cfd60d8cb..8674f2ff9 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/agui/protocol-reference.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/agui/protocol-reference.md @@ -77,6 +77,7 @@ Authorization: Bearer | `cwd` | string | はい | ワークスペース絶対パス | | `model` | string | いいえ | リクエスト単位のモデル上書き | | `llmApiKey` | string | いいえ | LLM provider key | +| `llmHeaders` / `llm_headers` | object | いいえ | LLM provider 呼び出しに追加する string-to-string の HTTP header | | `thinking.enabled/effort/budget` | boolean/string/正整数 | いいえ | thinking 設定 | | `userId` | string | いいえ | telemetry と呼び出し元の識別 | | `channel` | string | いいえ | チャネルメタデータ | @@ -90,6 +91,8 @@ Authorization: Bearer | `alibabaCloud.securityToken` | string | いいえ | 一時 STS token | | `alibabaCloud.regionId` | string | いいえ | 既定 region | +`llmHeaders` は `metadata.iac_code.llm_headers` の A2A context binding 規則に従います。一度指定すると、同じ AG-UI thread の後続リクエストはこのフィールドを省略しても header を継承します。新しい map は binding 全体を置き換え、`{}` は binding を消去します。値には認証情報が含まれる可能性があるため adapter は永続化せず、local A2A process の再起動後は呼び出し元が再送する必要があります。 + initial run とその Resume は同じ `rosInvocationId` を使います。次の通常ターンでは新しい値を利用できます。Cancel も現在の値が必要です。 同じ `threadId` は最初の `cwd` と `userId` に固定され、後続リクエストで別のワークスペースや呼び出し元へ変更できません。 diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/agui/protocol-reference.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/agui/protocol-reference.md index e46dabcf9..6f10fddea 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/agui/protocol-reference.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/agui/protocol-reference.md @@ -83,6 +83,7 @@ O esquema é estrito; campos desconhecidos são rejeitados. | `cwd` | string | Sim | Caminho absoluto do workspace | | `model` | string | Não | Substituição do modelo para a solicitação | | `llmApiKey` | string | Não | Chave do provedor LLM para a solicitação | +| `llmHeaders` / `llm_headers` | objeto | Não | Headers HTTP adicionais de string para string nas chamadas ao provedor LLM | | `thinking.enabled` | booleano | Não | Solicitar saída de raciocínio | | `thinking.effort` | string | Não | Esforço de raciocínio específico do provedor | | `thinking.budget` | inteiro positivo | Não | Orçamento de raciocínio específico do provedor | @@ -98,6 +99,8 @@ O esquema é estrito; campos desconhecidos são rejeitados. | `alibabaCloud.securityToken` | string | Não | Token STS local à solicitação | | `alibabaCloud.regionId` | string | Não | Região padrão local à solicitação | +`llmHeaders` segue as regras de vínculo ao contexto A2A de `metadata.iac_code.llm_headers`: depois de informado, as solicitações posteriores do mesmo thread AG-UI herdam os headers quando o campo é omitido. Um novo mapa substitui todo o vínculo, e `{}` o limpa. Como os valores podem conter credenciais, o adaptador não os persiste; o chamador deve enviá-los novamente após a reinicialização do processo A2A local. + A execução inicial e suas retomadas devem manter o mesmo `rosInvocationId`. Um turno normal posterior pode usar um novo valor. O cancelamento deve usar o valor da execução atual. O `threadId` é vinculado aos `cwd` e `userId` da primeira solicitação; solicitações posteriores não podem mover o mesmo thread para outro workspace ou chamador. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agui/protocol-reference.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agui/protocol-reference.md index 53bc2f119..a67c77d91 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agui/protocol-reference.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agui/protocol-reference.md @@ -93,6 +93,7 @@ Authorization: Bearer | `cwd` | string | 是 | 本次执行的绝对工作区路径 | | `model` | string | 否 | 单次请求覆盖模型 | | `llmApiKey` | string | 否 | 单次请求覆盖 LLM provider key | +| `llmHeaders` / `llm_headers` | object | 否 | 传给 LLM provider 调用的额外 string-to-string HTTP headers | | `thinking.enabled` | boolean | 否 | 是否请求 thinking | | `thinking.effort` | string | 否 | provider 支持时覆盖 thinking effort | | `thinking.budget` | positive integer | 否 | provider 支持时覆盖 thinking budget | @@ -108,6 +109,8 @@ Authorization: Bearer | `alibabaCloud.securityToken` | string | 否 | 请求级 STS token | | `alibabaCloud.regionId` | string | 否 | 请求级默认 region | +`llmHeaders` 遵循 `metadata.iac_code.llm_headers` 的 A2A context 绑定规则:传入后,同一 AG-UI thread 的后续请求即使省略该字段也会继承这些 headers;传入新的 map 会整体替换绑定,传入 `{}` 会清空绑定。header 值可能包含凭据,因此 adapter 不会持久化它们;本地 A2A 进程重启后,调用方需要重新传入。 + `rosInvocationId` 的生命周期: - initial run 与其 Interrupt Resume 必须使用相同值;