feat(a2a): watsonx Orchestrate agent provider (#29410)

* feat(a2a): add watsonx Orchestrate agent provider

Bridge A2A message/send to WXO runs API (CP4D and IBM Cloud IAM auth),
with dashboard agent type metadata and unit tests for transformations.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(a2a): use shared httpx client and cache WXO auth tokens

Route WXO streaming through get_async_httpx_client (TLS verification
enabled). Cache bearer tokens with TTL buffer. Extract A2A reply text via
a dedicated helper instead of hard-coded JSON paths.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(a2a): treat CP4D token expiration as absolute Unix time

CP4D /authorize returns expiration as epoch seconds, not TTL. Compute
remaining lifetime against wall clock so cached tokens refresh before expiry.

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(a2a): black-format watsonx orchestrate handler for CI py312

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix watsonx orchestrate edge cases

* Fix WXO streaming fallback error handling

* Fix watsonx orchestrate run completion handling

* fix(a2a): make WXO username optional in agent create UI

Username is only required for cp4d auth; ibm_cloud uses api_key alone.
Backend still validates username when auth_mode is cp4d.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(a2a): align WXO dashboard field test with optional username

Username is not required in agent_create_fields.json; backend validates
for cp4d auth_mode only.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(a2a): send Accept header on WXO streaming run request

* fix(a2a/wxo): scope streaming transport fallback to initial POST only

Narrow the httpx.TransportError fallback in handle_streaming so it only
covers the initial POST /runs/stream. Errors during polling or SSE
consumption now propagate instead of triggering handle_non_streaming,
which would have submitted a duplicate WXO run for the same request.

* refactor(a2a/wxo): type run-param extraction and use text response_type

Return a typed WXORequestParams NamedTuple from _extract_litellm_params
instead of a positional tuple so call sites read params by name, and send
the user message with response_type 'text' so the run body is valid across
all WXO agent configurations rather than the search-specific type.

* fix(a2a/wxo): evict expired token cache entries and raise asyncio.TimeoutError on poll timeout

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
Sameer Kankute
2026-06-02 18:41:10 +05:30
committed by GitHub
co-authored by Cursor mateo-berri
parent f48a87ef12
commit c8bcfbb20c
7 changed files with 1327 additions and 0 deletions
@@ -48,4 +48,11 @@ class A2AProviderConfigManager:
return BedrockAgentCoreA2AConfig()
if custom_llm_provider == "watsonx_orchestrate":
from litellm.a2a_protocol.providers.watsonx_orchestrate.config import (
WatsonxOrchestrateA2AConfig,
)
return WatsonxOrchestrateA2AConfig()
return None
@@ -0,0 +1,3 @@
"""
IBM watsonx Orchestrate (WXO) A2A provider.
"""
@@ -0,0 +1,55 @@
"""
A2A provider configuration for IBM watsonx Orchestrate (WXO).
"""
from typing import Any, AsyncIterator, Dict, Optional
from litellm.a2a_protocol.providers.base import BaseA2AProviderConfig
from litellm.a2a_protocol.providers.watsonx_orchestrate.handler import (
WatsonxOrchestrateHandler,
)
class WatsonxOrchestrateA2AConfig(BaseA2AProviderConfig):
"""A2A bridge for IBM watsonx Orchestrate (REST runs API + poll/SSE)."""
async def handle_non_streaming(
self,
request_id: str,
params: Dict[str, Any],
api_base: Optional[str] = None,
**kwargs: Any,
) -> Dict[str, Any]:
"""Handle a non-streaming A2A request via WXO runs API."""
litellm_params = kwargs.get("litellm_params")
if not litellm_params:
raise ValueError(
"litellm_params is required for WatsonxOrchestrateA2AConfig "
"(must contain cp4d_host, instance_id, wxo_agent_id, api_key)"
)
return await WatsonxOrchestrateHandler.handle_non_streaming(
request_id=request_id,
params=params,
litellm_params=litellm_params,
)
async def handle_streaming(
self,
request_id: str,
params: Dict[str, Any],
api_base: Optional[str] = None,
**kwargs: Any,
) -> AsyncIterator[Dict[str, Any]]:
"""Handle a streaming A2A request via WXO streaming runs API."""
litellm_params = kwargs.get("litellm_params")
if not litellm_params:
raise ValueError(
"litellm_params is required for WatsonxOrchestrateA2AConfig "
"(must contain cp4d_host, instance_id, wxo_agent_id, api_key)"
)
async for chunk in WatsonxOrchestrateHandler.handle_streaming(
request_id=request_id,
params=params,
litellm_params=litellm_params,
):
yield chunk
@@ -0,0 +1,373 @@
"""
Handler for IBM watsonx Orchestrate (WXO) agent provider.
"""
import asyncio
import hashlib
import json
import time
from typing import Any, AsyncIterator, Dict, NamedTuple, Optional, Tuple, cast
import httpx
from litellm._logging import verbose_logger
from litellm.a2a_protocol.providers.watsonx_orchestrate.transformation import (
WatsonxOrchestrateTransformation,
)
from litellm.llms.custom_httpx.http_handler import (
AsyncHTTPHandler,
get_async_httpx_client,
)
from litellm.types.llms.custom_http import httpxSpecialProvider
_IBM_CLOUD_IAM_URL = "https://iam.cloud.ibm.com/identity/token"
_POLL_INTERVAL_S = 2.0
_MAX_POLL_ATTEMPTS = 90
_TOKEN_CACHE_TTL_BUFFER_S = 60
_token_cache: Dict[str, Tuple[str, float]] = {}
class WXORequestParams(NamedTuple):
cp4d_host: str
instance_id: str
wxo_agent_id: str
api_key: str
username: Optional[str]
auth_mode: str
thread_id: Optional[str]
class WatsonxOrchestrateHandler:
@staticmethod
def _http_client(timeout: float = 90.0) -> AsyncHTTPHandler:
return get_async_httpx_client(
llm_provider=cast(Any, httpxSpecialProvider.A2AProvider),
params={"timeout": timeout},
)
@staticmethod
def _token_cache_key(
auth_mode: str,
cp4d_host: str,
api_key: str,
username: Optional[str],
) -> str:
material = f"{auth_mode}:{cp4d_host}:{username or ''}:{api_key}"
return hashlib.sha256(material.encode()).hexdigest()
@staticmethod
def _cp4d_token_ttl_seconds(
expiration: Any, now_wall: Optional[float] = None
) -> int:
# CP4D returns expiration as absolute Unix epoch seconds, not a duration.
expires_at = int(expiration)
wall = now_wall if now_wall is not None else time.time()
return max(expires_at - int(wall), 0)
@staticmethod
async def _get_bearer_token(
cp4d_host: str,
auth_mode: str,
api_key: str,
username: Optional[str] = None,
client: Optional[AsyncHTTPHandler] = None,
) -> str:
cache_key = WatsonxOrchestrateHandler._token_cache_key(
auth_mode, cp4d_host, api_key, username
)
now = time.monotonic()
cached = _token_cache.get(cache_key)
if cached and cached[1] > now:
return cached[0]
if client is None:
client = WatsonxOrchestrateHandler._http_client(timeout=30.0)
if auth_mode == "ibm_cloud":
response = await client.post(
_IBM_CLOUD_IAM_URL,
data={
"grant_type": "urn:ibm:params:oauth:grant-type:apikey",
"apikey": api_key,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
payload = response.json()
token = str(payload["access_token"])
ttl_s = int(payload.get("expires_in", 3600))
else:
if not username:
raise ValueError(
"'username' is required in litellm_params when auth_mode='cp4d'"
)
token_url = f"{cp4d_host.rstrip('/')}/icp4d-api/v1/authorize"
response = await client.post(
token_url,
json={"username": username, "api_key": api_key},
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
payload = response.json()
token = str(payload["token"])
expiration = payload.get("expiration")
if expiration is None:
ttl_s = 3600
else:
ttl_s = WatsonxOrchestrateHandler._cp4d_token_ttl_seconds(expiration)
expires_at = now + max(ttl_s - _TOKEN_CACHE_TTL_BUFFER_S, 0)
_token_cache[cache_key] = (token, expires_at)
for stale_key, (_, stale_expires_at) in list(_token_cache.items()):
if stale_expires_at <= now:
del _token_cache[stale_key]
return token
@staticmethod
async def _poll_run(
base_url: str,
run_id: str,
auth_headers: Dict[str, str],
client: AsyncHTTPHandler,
max_attempts: int = _MAX_POLL_ATTEMPTS,
interval_s: float = _POLL_INTERVAL_S,
) -> Dict[str, Any]:
url = f"{base_url}/v1/orchestrate/runs/{run_id}"
for attempt in range(max_attempts):
await asyncio.sleep(interval_s)
response = await client.get(url, headers=auth_headers)
response.raise_for_status()
result: Dict[str, Any] = response.json()
status = result.get("status", "")
verbose_logger.debug(
f"WXO: Poll {attempt + 1}/{max_attempts} run='{run_id}' status='{status}'"
)
if status in WatsonxOrchestrateTransformation.TERMINAL_STATES:
return result
raise asyncio.TimeoutError(
f"WXO run '{run_id}' did not reach a terminal state after "
f"{max_attempts * interval_s:.0f}s"
)
@staticmethod
async def _get_successful_run_data(
run_data: Dict[str, Any],
base_url: str,
auth_headers: Dict[str, str],
client: AsyncHTTPHandler,
) -> Dict[str, Any]:
status = run_data.get("status", "")
if status not in WatsonxOrchestrateTransformation.TERMINAL_STATES:
run_id = run_data.get("run_id") or run_data.get("id") or ""
if not run_id:
raise ValueError(f"WXO: No run_id in response: {run_data}")
run_data = await WatsonxOrchestrateHandler._poll_run(
base_url=base_url,
run_id=run_id,
auth_headers=auth_headers,
client=client,
)
status = run_data.get("status", "")
if status not in WatsonxOrchestrateTransformation.SUCCESS_STATES:
raise RuntimeError(
f"WXO run ended with non-success status '{status}': {run_data}"
)
return run_data
@staticmethod
async def _accumulate_wxo_sse_text(response: Any) -> str:
accumulated_text = ""
async for line in response.aiter_lines():
if not line.startswith("data:"):
continue
data_str = line[5:].strip()
if not data_str or data_str == "[DONE]":
continue
try:
event = json.loads(data_str)
except json.JSONDecodeError:
continue
chunk_text = WatsonxOrchestrateTransformation.extract_text_from_wxo_result(
event
)
if chunk_text:
accumulated_text += chunk_text
return accumulated_text
@staticmethod
def _extract_litellm_params(litellm_params: Dict[str, Any]) -> WXORequestParams:
cp4d_host = litellm_params.get("cp4d_host") or ""
instance_id = litellm_params.get("instance_id") or ""
wxo_agent_id = litellm_params.get("wxo_agent_id") or ""
api_key = litellm_params.get("api_key") or ""
if not cp4d_host:
raise ValueError("'cp4d_host' is required in litellm_params for WXO agents")
if not instance_id:
raise ValueError(
"'instance_id' is required in litellm_params for WXO agents"
)
if not wxo_agent_id:
raise ValueError(
"'wxo_agent_id' is required in litellm_params for WXO agents"
)
if not api_key:
raise ValueError("'api_key' is required in litellm_params for WXO agents")
return WXORequestParams(
cp4d_host=cp4d_host,
instance_id=instance_id,
wxo_agent_id=wxo_agent_id,
api_key=api_key,
username=litellm_params.get("username") or None,
auth_mode=litellm_params.get("auth_mode") or "cp4d",
thread_id=litellm_params.get("thread_id") or None,
)
@staticmethod
async def handle_non_streaming(
request_id: str,
params: Dict[str, Any],
litellm_params: Dict[str, Any],
) -> Dict[str, Any]:
wxo = WatsonxOrchestrateHandler._extract_litellm_params(litellm_params)
client = WatsonxOrchestrateHandler._http_client(timeout=90.0)
token = await WatsonxOrchestrateHandler._get_bearer_token(
cp4d_host=wxo.cp4d_host,
auth_mode=wxo.auth_mode,
api_key=wxo.api_key,
username=wxo.username,
client=client,
)
base_url = WatsonxOrchestrateTransformation.get_api_base_url(
wxo.cp4d_host, wxo.instance_id
)
auth_headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
text = WatsonxOrchestrateTransformation.extract_text_from_a2a_params(params)
body = WatsonxOrchestrateTransformation.build_wxo_run_body(
wxo_agent_id=wxo.wxo_agent_id, text=text, thread_id=wxo.thread_id
)
run_response = await client.post(
f"{base_url}/v1/orchestrate/runs",
json=body,
headers=auth_headers,
)
run_response.raise_for_status()
run_data: Dict[str, Any] = run_response.json()
run_data = await WatsonxOrchestrateHandler._get_successful_run_data(
run_data=run_data,
base_url=base_url,
auth_headers=auth_headers,
client=client,
)
response_text = WatsonxOrchestrateTransformation.extract_text_from_wxo_result(
run_data
)
return WatsonxOrchestrateTransformation.build_a2a_message_response(
request_id=request_id, text=response_text
)
@staticmethod
async def handle_streaming(
request_id: str,
params: Dict[str, Any],
litellm_params: Dict[str, Any],
chunk_size: int = 50,
delay_ms: int = 10,
) -> AsyncIterator[Dict[str, Any]]:
wxo = WatsonxOrchestrateHandler._extract_litellm_params(litellm_params)
client = WatsonxOrchestrateHandler._http_client(timeout=120.0)
token = await WatsonxOrchestrateHandler._get_bearer_token(
cp4d_host=wxo.cp4d_host,
auth_mode=wxo.auth_mode,
api_key=wxo.api_key,
username=wxo.username,
client=client,
)
base_url = WatsonxOrchestrateTransformation.get_api_base_url(
wxo.cp4d_host, wxo.instance_id
)
auth_headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "text/event-stream, application/json",
}
text = WatsonxOrchestrateTransformation.extract_text_from_a2a_params(params)
body = WatsonxOrchestrateTransformation.build_wxo_run_body(
wxo_agent_id=wxo.wxo_agent_id, text=text, thread_id=wxo.thread_id
)
try:
response = await client.post(
f"{base_url}/v1/orchestrate/runs/stream",
json=body,
headers=auth_headers,
stream=True,
)
response.raise_for_status()
except httpx.TransportError as exc:
verbose_logger.warning(
f"WXO: Streaming request failed before a run was submitted "
f"({exc!r}), falling back to non-streaming + fake streaming",
exc_info=True,
)
result = await WatsonxOrchestrateHandler.handle_non_streaming(
request_id=request_id,
params=params,
litellm_params=litellm_params,
)
response_text = (
WatsonxOrchestrateTransformation.extract_text_from_a2a_message_response(
result
)
)
async for (
chunk
) in WatsonxOrchestrateTransformation.fake_streaming_from_text(
text=response_text,
request_id=request_id,
chunk_size=chunk_size,
delay_ms=delay_ms,
):
yield chunk
return
content_type = response.headers.get("content-type", "").lower()
if "text/event-stream" not in content_type:
response_body = await response.aread()
result = json.loads(response_body)
result = await WatsonxOrchestrateHandler._get_successful_run_data(
run_data=result,
base_url=base_url,
auth_headers=auth_headers,
client=client,
)
accumulated_text = (
WatsonxOrchestrateTransformation.extract_text_from_wxo_result(result)
)
else:
accumulated_text = await WatsonxOrchestrateHandler._accumulate_wxo_sse_text(
response
)
async for chunk in WatsonxOrchestrateTransformation.fake_streaming_from_text(
text=accumulated_text,
request_id=request_id,
chunk_size=chunk_size,
delay_ms=delay_ms,
):
yield chunk
@@ -0,0 +1,224 @@
"""
Transformation layer for IBM watsonx Orchestrate (WXO) agent provider.
WXO uses a REST API (not A2A/JSON-RPC) with an async-poll execution model:
POST /v1/orchestrate/runs → submit run, get run_id
GET /v1/orchestrate/runs/{id} → poll until terminal state
POST /v1/orchestrate/runs/stream → native SSE streaming
"""
import asyncio
from typing import Any, AsyncIterator, Dict, Optional
from uuid import uuid4
from litellm._logging import verbose_logger
class WatsonxOrchestrateTransformation:
"""
Handles request/response transformation between A2A and the WXO REST API.
"""
TERMINAL_STATES = frozenset(
{"completed", "succeeded", "failed", "error", "cancelled"}
)
SUCCESS_STATES = frozenset({"completed", "succeeded"})
@staticmethod
def get_api_base_url(cp4d_host: str, instance_id: str) -> str:
"""Build the WXO API base URL from host and instance ID."""
return f"{cp4d_host.rstrip('/')}/orchestrate/cpd/instances/{instance_id}"
@staticmethod
def extract_text_from_a2a_params(params: Dict[str, Any]) -> str:
"""
Extract user message text from A2A MessageSendParams.
A2A format: params.message.parts[*] where part.kind == "text"
"""
message = params.get("message", {})
parts = message.get("parts", [])
texts = []
for part in parts:
if not isinstance(part, dict):
continue
kind = part.get("kind")
if kind in (None, "", "text") and part.get("text"):
texts.append(part["text"])
return " ".join(texts) or ""
@staticmethod
def build_wxo_run_body(
wxo_agent_id: str,
text: str,
thread_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Build the WXO POST /v1/orchestrate/runs request body."""
body: Dict[str, Any] = {
"agent_id": wxo_agent_id,
"message": {
"role": "user",
"content": [
{
"response_type": "text",
"text": text,
}
],
},
}
if thread_id:
body["thread_id"] = thread_id
return body
@staticmethod
def extract_text_from_wxo_result(result: Any) -> str:
"""
Extract response text from a WXO run result.
WXO can return text in several locations; checks in priority order per the API spec.
"""
if not isinstance(result, dict):
return ""
# Primary: last_message.content[0].text
try:
text = result["last_message"]["content"][0]["text"]
if text:
return str(text)
except (KeyError, IndexError, TypeError):
pass
# Secondary: result.data.message.content[0].text
try:
text = result["result"]["data"]["message"]["content"][0]["text"]
if text:
return str(text)
except (KeyError, IndexError, TypeError):
pass
# Tertiary: results as a raw string
results = result.get("results")
if results and isinstance(results, str):
return results
return ""
@staticmethod
def extract_text_from_a2a_message_response(a2a_response: Dict[str, Any]) -> str:
result = a2a_response.get("result")
if not isinstance(result, dict):
verbose_logger.warning("WXO: A2A response missing result object")
return ""
parts = result.get("parts")
if not isinstance(parts, list):
verbose_logger.warning("WXO: A2A result has no parts list")
return ""
for part in parts:
if (
isinstance(part, dict)
and part.get("kind") == "text"
and part.get("text")
):
return str(part["text"])
verbose_logger.warning("WXO: A2A result parts contained no text")
return ""
@staticmethod
def build_a2a_message_response(request_id: str, text: str) -> Dict[str, Any]:
"""
Build a standard A2A non-streaming SendMessageResponse (kind=message).
"""
return {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"kind": "message",
"role": "agent",
"parts": [{"kind": "text", "text": text}],
"messageId": str(uuid4()),
},
}
@staticmethod
async def fake_streaming_from_text(
text: str,
request_id: str,
chunk_size: int = 50,
delay_ms: int = 10,
) -> AsyncIterator[Dict[str, Any]]:
"""
Emit standard A2A streaming events from a completed text response.
Event sequence:
1. task (kind="task", state="submitted")
2. status-update (kind="status-update", state="working")
3. artifact-update chunks
4. status-update (kind="status-update", state="completed", final=True)
"""
task_id = str(uuid4())
context_id = str(uuid4())
artifact_id = str(uuid4())
# 1. Task submitted
yield {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"contextId": context_id,
"id": task_id,
"kind": "task",
"status": {"state": "submitted"},
},
}
# 2. Working
yield {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"contextId": context_id,
"final": False,
"kind": "status-update",
"status": {"state": "working"},
"taskId": task_id,
},
}
await asyncio.sleep(delay_ms / 1000.0)
# 3. Artifact chunks (always emit at least one chunk, even for empty text)
text_to_chunk = text or ""
for i in range(0, max(len(text_to_chunk), 1), chunk_size):
chunk_text = text_to_chunk[i : i + chunk_size]
is_last = (i + chunk_size) >= max(len(text_to_chunk), 1)
yield {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"contextId": context_id,
"kind": "artifact-update",
"taskId": task_id,
"artifact": {
"artifactId": artifact_id,
"parts": [{"kind": "text", "text": chunk_text}],
},
},
}
if not is_last:
await asyncio.sleep(delay_ms / 1000.0)
# 4. Completed
yield {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"contextId": context_id,
"final": True,
"kind": "status-update",
"status": {"state": "completed"},
"taskId": task_id,
},
}
verbose_logger.debug(
f"WXO: Fake streaming completed for request_id={request_id}"
)
@@ -189,6 +189,78 @@
"litellm_params_template": {
"custom_llm_provider": "vertex_ai"
}
},
{
"agent_type": "watsonx_orchestrate",
"agent_type_display_name": "watsonx Orchestrate",
"description": "Connect to IBM watsonx Orchestrate agents via CP4D or IBM Cloud IAM",
"logo_url": "/ui/assets/logos/watsonx.svg",
"credential_fields": [
{
"key": "cp4d_host",
"label": "CP4D Host URL",
"placeholder": "https://cpd-cpd.apps.example.com",
"tooltip": "Your CP4D cluster base URL (e.g. https://cpd-cpd.apps.example.com). For IBM Cloud WXO, use the service endpoint.",
"required": true,
"field_type": "text",
"default_value": null,
"include_in_litellm_params": true
},
{
"key": "instance_id",
"label": "WXO Instance ID",
"placeholder": "1769134113217795",
"tooltip": "The numeric watsonx Orchestrate instance ID. Find it in the WXO service URL: /orchestrate/cpd/instances/<INSTANCE_ID>",
"required": true,
"field_type": "text",
"default_value": null,
"include_in_litellm_params": true
},
{
"key": "wxo_agent_id",
"label": "WXO Agent ID",
"placeholder": "588c8cdf-60f4-454b-8468-8702b19dca46",
"tooltip": "UUID of the agent in watsonx Orchestrate. Find it via the WXO console or GET /v1/orchestrate/agents.",
"required": true,
"field_type": "text",
"default_value": null,
"include_in_litellm_params": true
},
{
"key": "auth_mode",
"label": "Authentication Mode",
"placeholder": null,
"tooltip": "cp4d: on-prem / CloudPak for Data (requires username). ibm_cloud: IBM Cloud IAM (api_key only).",
"required": false,
"field_type": "select",
"options": ["cp4d", "ibm_cloud"],
"default_value": "cp4d",
"include_in_litellm_params": true
},
{
"key": "username",
"label": "Username (CP4D only)",
"placeholder": "admin",
"tooltip": "Your CP4D username. Required when auth_mode is 'cp4d'.",
"required": false,
"field_type": "text",
"default_value": null,
"include_in_litellm_params": true
},
{
"key": "api_key",
"label": "API Key",
"placeholder": null,
"tooltip": "CP4D API key (auth_mode=cp4d) or IBM Cloud API key (auth_mode=ibm_cloud).",
"required": true,
"field_type": "password",
"default_value": null,
"include_in_litellm_params": true
}
],
"litellm_params_template": {
"custom_llm_provider": "watsonx_orchestrate"
}
}
]
@@ -0,0 +1,593 @@
import asyncio
import json
import os
import sys
import time
from pathlib import Path
import httpx
import pytest
sys.path.insert(0, os.path.abspath("../../../../.."))
from litellm.a2a_protocol.providers.config_manager import A2AProviderConfigManager
from litellm.a2a_protocol.providers.watsonx_orchestrate import handler as wxo_handler
from litellm.a2a_protocol.providers.watsonx_orchestrate.handler import (
WatsonxOrchestrateHandler,
)
from litellm.a2a_protocol.providers.watsonx_orchestrate.transformation import (
WatsonxOrchestrateTransformation,
)
class _JsonResponse:
def __init__(self, payload):
self.payload = payload
def raise_for_status(self):
pass
def json(self):
return self.payload
class _ShortTtlTokenClient:
def __init__(self):
self.calls = 0
async def post(self, *args, **kwargs):
self.calls += 1
return _JsonResponse({"access_token": f"token-{self.calls}", "expires_in": 30})
class _SSELines:
def __init__(self, lines):
self.lines = lines
async def aiter_lines(self):
for line in self.lines:
yield line
class _InvalidJsonStreamResponse:
headers = {"content-type": "application/json"}
def raise_for_status(self):
pass
async def aread(self):
return b"not-json"
class _InvalidJsonStreamClient:
def __init__(self):
self.post_urls = []
async def post(self, url, **kwargs):
self.post_urls.append(url)
if "identity/token" in url:
return _JsonResponse({"access_token": "token", "expires_in": 3600})
if url.endswith("/runs/stream"):
return _InvalidJsonStreamResponse()
if url.endswith("/runs"):
return _JsonResponse({"status": "completed", "results": "fallback text"})
raise AssertionError(url)
class _JsonStreamResponse:
headers = {"content-type": "application/json"}
def __init__(self, payload):
self.payload = payload
def raise_for_status(self):
pass
async def aread(self):
return json.dumps(self.payload).encode()
class _JsonStreamClient:
def __init__(self, stream_payload):
self.stream_payload = stream_payload
self.post_urls = []
async def post(self, url, **kwargs):
self.post_urls.append(url)
if "identity/token" in url:
return _JsonResponse({"access_token": "token", "expires_in": 3600})
if url.endswith("/runs/stream"):
return _JsonStreamResponse(self.stream_payload)
raise AssertionError(url)
class TestWatsonxOrchestrateTransformation:
def test_get_api_base_url(self):
url = WatsonxOrchestrateTransformation.get_api_base_url(
"https://cpd.example.com/",
"1769134113217795",
)
assert (
url == "https://cpd.example.com/orchestrate/cpd/instances/1769134113217795"
)
def test_extract_text_from_a2a_params(self):
params = {
"message": {
"role": "user",
"parts": [
{"kind": "text", "text": "Hello"},
{"kind": "text", "text": "world"},
],
}
}
assert (
WatsonxOrchestrateTransformation.extract_text_from_a2a_params(params)
== "Hello world"
)
def test_extract_text_from_a2a_params_ignores_non_text_parts_with_text(self):
params = {
"message": {
"role": "user",
"parts": [
{"kind": "data", "text": "metadata label", "data": {}},
{"kind": "file", "text": "file label", "file": {}},
{"kind": "text", "text": "Hello"},
{"text": "legacy"},
{"kind": "", "text": "empty-kind"},
],
}
}
assert (
WatsonxOrchestrateTransformation.extract_text_from_a2a_params(params)
== "Hello legacy empty-kind"
)
def test_build_wxo_run_body_with_thread(self):
body = WatsonxOrchestrateTransformation.build_wxo_run_body(
wxo_agent_id="agent-uuid",
text="Hi",
thread_id="thread-1",
)
assert body["agent_id"] == "agent-uuid"
assert body["thread_id"] == "thread-1"
assert body["message"]["content"][0]["response_type"] == "text"
assert body["message"]["content"][0]["text"] == "Hi"
@pytest.mark.parametrize(
"result,expected",
[
(
{
"last_message": {
"content": [{"type": "text", "text": "from last_message"}]
}
},
"from last_message",
),
(
{
"result": {
"data": {
"message": {"content": [{"text": "from nested result"}]}
}
}
},
"from nested result",
),
({"results": "raw string"}, "raw string"),
],
)
def test_extract_text_from_wxo_result(self, result, expected):
assert (
WatsonxOrchestrateTransformation.extract_text_from_wxo_result(result)
== expected
)
def test_build_a2a_message_response(self):
out = WatsonxOrchestrateTransformation.build_a2a_message_response(
"req-1", "answer"
)
assert out["jsonrpc"] == "2.0"
assert out["id"] == "req-1"
assert out["result"]["kind"] == "message"
assert out["result"]["parts"][0]["text"] == "answer"
def test_extract_text_from_a2a_message_response(self):
envelope = WatsonxOrchestrateTransformation.build_a2a_message_response(
"req-1", "answer"
)
assert (
WatsonxOrchestrateTransformation.extract_text_from_a2a_message_response(
envelope
)
== "answer"
)
assert (
WatsonxOrchestrateTransformation.extract_text_from_a2a_message_response(
{"result": {}}
)
== ""
)
def test_cp4d_token_ttl_from_absolute_expiration():
wall = 1_750_000_000.0
assert (
WatsonxOrchestrateHandler._cp4d_token_ttl_seconds(1_750_003_600, wall) == 3600
)
assert WatsonxOrchestrateHandler._cp4d_token_ttl_seconds(1_749_999_000, wall) == 0
@pytest.mark.asyncio
async def test_accumulate_wxo_sse_text_ignores_non_dict_json_events():
response = _SSELines(
[
"data: null",
"data: true",
'data: {"results": "streamed text"}',
]
)
assert await WatsonxOrchestrateHandler._accumulate_wxo_sse_text(response) == (
"streamed text"
)
@pytest.mark.asyncio
async def test_short_lived_tokens_are_not_served_from_cache():
client = _ShortTtlTokenClient()
token_1 = await WatsonxOrchestrateHandler._get_bearer_token(
cp4d_host="https://cpd.example.com",
auth_mode="ibm_cloud",
api_key="short-ttl-cache-key",
client=client,
)
token_2 = await WatsonxOrchestrateHandler._get_bearer_token(
cp4d_host="https://cpd.example.com",
auth_mode="ibm_cloud",
api_key="short-ttl-cache-key",
client=client,
)
assert token_1 == "token-1"
assert token_2 == "token-2"
assert client.calls == 2
class _CP4DAuthClient:
def __init__(self, expiration):
self.expiration = expiration
self.calls = []
async def post(self, url, **kwargs):
self.calls.append((url, kwargs))
return _JsonResponse({"token": "cp4d-token", "expiration": self.expiration})
@pytest.mark.asyncio
async def test_cp4d_auth_posts_to_authorize_and_caches_token():
client = _CP4DAuthClient(int(time.time()) + 3600)
token_1 = await WatsonxOrchestrateHandler._get_bearer_token(
cp4d_host="https://cpd.example.com/",
auth_mode="cp4d",
api_key="cp4d-e2e-cache-key",
username="cp4d-user",
client=client,
)
token_2 = await WatsonxOrchestrateHandler._get_bearer_token(
cp4d_host="https://cpd.example.com/",
auth_mode="cp4d",
api_key="cp4d-e2e-cache-key",
username="cp4d-user",
client=client,
)
assert token_1 == "cp4d-token"
assert token_2 == "cp4d-token"
assert len(client.calls) == 1
url, kwargs = client.calls[0]
assert url == "https://cpd.example.com/icp4d-api/v1/authorize"
assert kwargs["json"] == {"username": "cp4d-user", "api_key": "cp4d-e2e-cache-key"}
@pytest.mark.asyncio
async def test_cp4d_auth_requires_username():
client = _CP4DAuthClient(int(time.time()) + 3600)
with pytest.raises(ValueError, match="username"):
await WatsonxOrchestrateHandler._get_bearer_token(
cp4d_host="https://cpd.example.com",
auth_mode="cp4d",
api_key="cp4d-missing-username-key",
username=None,
client=client,
)
assert client.calls == []
@pytest.mark.asyncio
async def test_expired_token_cache_entries_are_evicted():
stale_key = "wxo-stale-cache-entry"
wxo_handler._token_cache[stale_key] = ("stale-token", time.monotonic() - 1)
class _FreshTokenClient:
async def post(self, *args, **kwargs):
return _JsonResponse({"access_token": "fresh", "expires_in": 3600})
await WatsonxOrchestrateHandler._get_bearer_token(
cp4d_host="https://cpd.example.com",
auth_mode="ibm_cloud",
api_key="wxo-eviction-trigger-key",
client=_FreshTokenClient(),
)
assert stale_key not in wxo_handler._token_cache
@pytest.mark.asyncio
async def test_poll_run_raises_asyncio_timeout_when_never_terminal():
class _NeverTerminalClient:
def __init__(self):
self.get_calls = 0
async def get(self, url, headers=None):
self.get_calls += 1
return _JsonResponse({"status": "running"})
client = _NeverTerminalClient()
with pytest.raises(asyncio.TimeoutError):
await WatsonxOrchestrateHandler._poll_run(
base_url="https://cpd.example.com/orchestrate/cpd/instances/i",
run_id="run-1",
auth_headers={},
client=client,
max_attempts=2,
interval_s=0,
)
assert client.get_calls == 2
@pytest.mark.asyncio
async def test_handle_streaming_polls_non_sse_json_until_complete(monkeypatch):
client = _JsonStreamClient({"status": "running", "run_id": "run-1"})
poll_calls = []
async def poll_run(base_url, run_id, auth_headers, client, **kwargs):
poll_calls.append((base_url, run_id, auth_headers, client))
return {"status": "completed", "results": "polled text"}
monkeypatch.setattr(
WatsonxOrchestrateHandler,
"_http_client",
lambda timeout=90.0: client,
)
monkeypatch.setattr(WatsonxOrchestrateHandler, "_poll_run", poll_run)
params = {
"message": {
"parts": [
{"kind": "text", "text": "Hello"},
],
}
}
litellm_params = {
"cp4d_host": "https://cpd.example.com",
"instance_id": "instance-id",
"wxo_agent_id": "agent-id",
"api_key": "pending-json-stream-cache-key",
"auth_mode": "ibm_cloud",
}
events = [
event
async for event in WatsonxOrchestrateHandler.handle_streaming(
request_id="req-1",
params=params,
litellm_params=litellm_params,
delay_ms=0,
)
]
artifact_text = "".join(
event["result"]["artifact"]["parts"][0]["text"]
for event in events
if event["result"].get("kind") == "artifact-update"
)
assert len(poll_calls) == 1
assert poll_calls[0][1] == "run-1"
assert artifact_text == "polled text"
@pytest.mark.asyncio
async def test_handle_streaming_raises_for_non_sse_json_failure(monkeypatch):
client = _JsonStreamClient({"status": "failed", "run_id": "run-1"})
monkeypatch.setattr(
WatsonxOrchestrateHandler,
"_http_client",
lambda timeout=90.0: client,
)
params = {
"message": {
"parts": [
{"kind": "text", "text": "Hello"},
],
}
}
litellm_params = {
"cp4d_host": "https://cpd.example.com",
"instance_id": "instance-id",
"wxo_agent_id": "agent-id",
"api_key": "failed-json-stream-cache-key",
"auth_mode": "ibm_cloud",
}
with pytest.raises(RuntimeError, match="non-success status 'failed'"):
async for _ in WatsonxOrchestrateHandler.handle_streaming(
request_id="req-1",
params=params,
litellm_params=litellm_params,
delay_ms=0,
):
pass
@pytest.mark.asyncio
async def test_handle_streaming_does_not_fallback_on_invalid_json(monkeypatch):
client = _InvalidJsonStreamClient()
monkeypatch.setattr(
WatsonxOrchestrateHandler,
"_http_client",
lambda timeout=90.0: client,
)
params = {
"message": {
"parts": [
{"kind": "text", "text": "Hello"},
],
}
}
litellm_params = {
"cp4d_host": "https://cpd.example.com",
"instance_id": "instance-id",
"wxo_agent_id": "agent-id",
"api_key": "invalid-json-stream-cache-key",
"auth_mode": "ibm_cloud",
}
with pytest.raises(json.JSONDecodeError):
async for _ in WatsonxOrchestrateHandler.handle_streaming(
request_id="req-1",
params=params,
litellm_params=litellm_params,
):
pass
assert not any(url.endswith("/runs") for url in client.post_urls)
@pytest.mark.asyncio
async def test_handle_streaming_does_not_resubmit_run_on_poll_transport_error(
monkeypatch,
):
class _RunSubmissionClient:
def __init__(self):
self.post_urls = []
async def post(self, url, **kwargs):
self.post_urls.append(url)
if "identity/token" in url:
return _JsonResponse({"access_token": "token", "expires_in": 3600})
if url.endswith("/runs/stream"):
return _JsonStreamResponse({"status": "running", "run_id": "run-1"})
if url.endswith("/runs"):
return _JsonResponse({"status": "completed", "results": "duplicate"})
raise AssertionError(url)
client = _RunSubmissionClient()
async def poll_run(base_url, run_id, auth_headers, client, **kwargs):
raise httpx.ConnectError("connection reset during poll")
monkeypatch.setattr(
WatsonxOrchestrateHandler,
"_http_client",
lambda timeout=90.0: client,
)
monkeypatch.setattr(WatsonxOrchestrateHandler, "_poll_run", poll_run)
params = {"message": {"parts": [{"kind": "text", "text": "Hello"}]}}
litellm_params = {
"cp4d_host": "https://cpd.example.com",
"instance_id": "instance-id",
"wxo_agent_id": "agent-id",
"api_key": "poll-transport-error-cache-key",
"auth_mode": "ibm_cloud",
}
with pytest.raises(httpx.TransportError):
async for _ in WatsonxOrchestrateHandler.handle_streaming(
request_id="req-1",
params=params,
litellm_params=litellm_params,
delay_ms=0,
):
pass
assert not any(url.endswith("/runs") for url in client.post_urls)
@pytest.mark.asyncio
async def test_handle_streaming_falls_back_when_initial_post_fails(monkeypatch):
class _StreamPostFailsClient:
def __init__(self):
self.post_urls = []
async def post(self, url, **kwargs):
self.post_urls.append(url)
if "identity/token" in url:
return _JsonResponse({"access_token": "token", "expires_in": 3600})
if url.endswith("/runs/stream"):
raise httpx.ConnectError("cannot reach stream endpoint")
if url.endswith("/runs"):
return _JsonResponse({"status": "completed", "results": "fallback"})
raise AssertionError(url)
client = _StreamPostFailsClient()
monkeypatch.setattr(
WatsonxOrchestrateHandler,
"_http_client",
lambda timeout=90.0: client,
)
params = {"message": {"parts": [{"kind": "text", "text": "Hello"}]}}
litellm_params = {
"cp4d_host": "https://cpd.example.com",
"instance_id": "instance-id",
"wxo_agent_id": "agent-id",
"api_key": "stream-post-fails-cache-key",
"auth_mode": "ibm_cloud",
}
events = [
event
async for event in WatsonxOrchestrateHandler.handle_streaming(
request_id="req-1",
params=params,
litellm_params=litellm_params,
delay_ms=0,
)
]
artifact_text = "".join(
event["result"]["artifact"]["parts"][0]["text"]
for event in events
if event["result"].get("kind") == "artifact-update"
)
assert artifact_text == "fallback"
assert sum(url.endswith("/runs") for url in client.post_urls) == 1
def test_config_manager_returns_wxo_provider():
config = A2AProviderConfigManager.get_provider_config(
custom_llm_provider="watsonx_orchestrate"
)
assert config is not None
assert config.__class__.__name__ == "WatsonxOrchestrateA2AConfig"
def test_wxo_dashboard_auth_fields():
fields_path = (
Path(__file__).resolve().parents[5]
/ "litellm/proxy/public_endpoints/agent_create_fields.json"
)
agent_fields = json.loads(fields_path.read_text())
wxo_agent = next(
agent for agent in agent_fields if agent["agent_type"] == "watsonx_orchestrate"
)
fields_by_key = {field["key"]: field for field in wxo_agent["credential_fields"]}
assert fields_by_key["auth_mode"]["default_value"] == "cp4d"
# Username is CP4D-only; UI does not require it so ibm_cloud users are not blocked.
assert fields_by_key["username"]["required"] is False
assert "cp4d" in fields_by_key["username"]["tooltip"].lower()