Skip to content

Commit 0fd382f

Browse files
committed
refactor(agents): route worker /agent/* HTTP through ApiClient; retire parallel token cache
Framework workers (claude_agent_sdk, langchain, langgraph) posted to Agentspan /agent/* endpoints with raw requests + a standalone token mint/cache in token_utils, a second token authority parallel to ApiClient. New _internal/agent_http.py reconstructs a cached ApiClient per (server_url, auth_key) inside the spawned worker and posts via ApiClient.call_api, reusing the SDK's token management (mint/cache/TTL-refresh/401-retry). _task_client folds into the same cache so /tasks and /agent/* share one token. token_utils is reduced to decode_jwt_exp. Adds test_agent_http.py (in-process server; mint-once-and-cache guard); test_token_utils trimmed to the decoder.
1 parent 27f75a2 commit 0fd382f

7 files changed

Lines changed: 327 additions & 249 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Copyright (c) 2025 Agentspan
2+
# Licensed under the MIT License. See LICENSE file in the project root for details.
3+
4+
"""Authenticated HTTP to Agentspan ``/agent/*`` endpoints via the SDK ``ApiClient``.
5+
6+
Framework workers (claude_agent_sdk, langchain, langgraph) run in spawned worker
7+
processes and receive ``(server_url, auth_key, auth_secret)`` as plain strings — a
8+
live ``ApiClient`` can't cross the process boundary. This module reconstructs an
9+
``ApiClient`` from those strings inside the worker, cached per
10+
``(server_url, auth_key)`` so the token mint in the constructor happens once per
11+
worker, and posts through :meth:`ApiClient.call_api`. That reuses the SDK's single
12+
token authority (mint/cache/TTL-refresh/401-retry) instead of a parallel token
13+
cache.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import logging
19+
import threading
20+
from typing import Any, Dict, Optional, Tuple
21+
22+
logger = logging.getLogger("conductor.ai.agents.agent_http")
23+
24+
# The auth-setting name the generated clients use to drive X-Authorization
25+
# injection (see OrkesAgentClient._AUTH_SETTINGS).
26+
_AUTH_SETTINGS = ["api_key"]
27+
_JSON_HEADERS = {"Accept": "application/json", "Content-Type": "application/json"}
28+
29+
# One ApiClient per (server_url, auth_key). Building an ApiClient mints a token in
30+
# its constructor, so caching is mandatory to avoid a /token mint on every call.
31+
_API_CLIENTS: Dict[Tuple[str, str], Any] = {}
32+
_API_CLIENTS_LOCK = threading.Lock()
33+
34+
35+
def _agent_api_client(server_url: str, auth_key: str, auth_secret: str):
36+
"""Return a cached ``ApiClient`` for ``(server_url, auth_key)``.
37+
38+
Imports are lazy so importing this module stays cheap and side-effect free
39+
(spawn/pickle safety).
40+
"""
41+
key = (server_url, auth_key or "")
42+
client = _API_CLIENTS.get(key)
43+
if client is not None:
44+
return client
45+
46+
from conductor.client.configuration.configuration import Configuration
47+
from conductor.client.configuration.settings.authentication_settings import (
48+
AuthenticationSettings,
49+
)
50+
from conductor.client.http.api_client import ApiClient
51+
52+
auth = (
53+
AuthenticationSettings(key_id=auth_key, key_secret=auth_secret or "")
54+
if auth_key
55+
else None
56+
)
57+
config = Configuration(server_api_url=server_url, authentication_settings=auth)
58+
with _API_CLIENTS_LOCK:
59+
client = _API_CLIENTS.get(key)
60+
if client is None:
61+
client = ApiClient(config)
62+
_API_CLIENTS[key] = client
63+
return client
64+
65+
66+
def agent_post(
67+
server_url: str,
68+
auth_key: str,
69+
auth_secret: str,
70+
path: str,
71+
body: Optional[Dict[str, Any]] = None,
72+
*,
73+
read_response: bool = False,
74+
) -> Optional[Any]:
75+
"""POST to an Agentspan ``/agent/*`` endpoint through the SDK ``ApiClient``.
76+
77+
``path`` is relative to the server host (which already ends in ``/api``), so it
78+
must start with ``/`` and omit ``/api`` — e.g. ``/agent/events/{id}``.
79+
80+
Returns the deserialized JSON body when ``read_response`` is True, otherwise
81+
``None``. Any transport/HTTP error (including a non-retryable ``ApiException``)
82+
is swallowed and returns ``None`` so callers degrade exactly as the previous
83+
raw-``requests`` paths did — a 401 is still auto-retried once inside
84+
:meth:`ApiClient.call_api`.
85+
"""
86+
try:
87+
client = _agent_api_client(server_url, auth_key, auth_secret)
88+
return client.call_api(
89+
path,
90+
"POST",
91+
{},
92+
[],
93+
dict(_JSON_HEADERS),
94+
body=body if body is not None else {},
95+
post_params=[],
96+
files={},
97+
response_type="object" if read_response else None,
98+
auth_settings=_AUTH_SETTINGS,
99+
_return_http_data_only=True,
100+
_preload_content=True,
101+
)
102+
except Exception as exc: # ApiException + any transport-level error
103+
logger.debug("agent_post failed (path=%s): %s", path, exc)
104+
return None
Lines changed: 5 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,18 @@
11
# Copyright (c) 2025 Agentspan
22
# Licensed under the MIT License. See LICENSE file in the project root for details.
33

4-
"""Auth token helpers shared by the sync/async agent API clients and framework adapters.
4+
"""JWT helper for the agent runtime.
55
6-
Secured Conductor hosts (e.g. orkes) authenticate API calls with a JWT in the
7-
``X-Authorization`` header, minted from an application access key via
8-
``POST {server}/token``. These helpers centralize that mint (with an expiry-aware
9-
process-wide cache) so every HTTP path — agent API, SSE streaming, framework event
10-
pushes — sends the same correct header. Anonymous servers ignore the header.
6+
Auth-token minting/caching lives in the SDK ``ApiClient`` (a single token
7+
authority — mint/cache/TTL-refresh/401-retry); the worker HTTP path reuses it via
8+
``conductor.ai.agents._internal.agent_http``. This module only *decodes* a JWT's
9+
expiry.
1110
"""
1211

1312
from __future__ import annotations
1413

1514
import base64
1615
import json
17-
import logging
18-
import threading
19-
from typing import Dict, Optional, Tuple
20-
21-
logger = logging.getLogger("conductor.ai.agents.token_utils")
2216

2317

2418
def decode_jwt_exp(token: str) -> float:
@@ -36,65 +30,3 @@ def decode_jwt_exp(token: str) -> float:
3630
return float(payload.get("exp", 0) or 0)
3731
except Exception:
3832
return 0.0
39-
40-
41-
# Process-wide mint cache: (server_url, auth_key) -> (token, exp). Framework event
42-
# pushes run on thread pools, so guard with a lock.
43-
_TOKEN_CACHE: Dict[Tuple[str, str], Tuple[str, float]] = {}
44-
_TOKEN_LOCK = threading.Lock()
45-
46-
47-
def resolve_agent_api_token(
48-
server_url: str,
49-
api_key: Optional[str] = None,
50-
auth_key: Optional[str] = None,
51-
auth_secret: Optional[str] = None,
52-
) -> Optional[str]:
53-
"""Resolve the JWT for agent API calls.
54-
55-
An explicit ``api_key`` is already a token and returned as-is. Otherwise a JWT is
56-
minted from ``auth_key``/``auth_secret`` via ``POST {server_url}/token`` and cached
57-
until ~expiry. Returns None when no credentials are configured or the mint fails
58-
(anonymous / security-disabled servers).
59-
"""
60-
if api_key:
61-
return api_key
62-
if not auth_key or not auth_secret:
63-
return None
64-
65-
import time
66-
67-
cache_key = (server_url.rstrip("/"), auth_key)
68-
with _TOKEN_LOCK:
69-
cached = _TOKEN_CACHE.get(cache_key)
70-
if cached:
71-
token, exp = cached
72-
if exp == 0.0 or time.time() < exp - 30:
73-
return token
74-
75-
import requests
76-
77-
url = server_url.rstrip("/") + "/token"
78-
try:
79-
resp = requests.post(url, json={"keyId": auth_key, "keySecret": auth_secret}, timeout=30)
80-
resp.raise_for_status()
81-
token = resp.json().get("token")
82-
except Exception as e: # pragma: no cover - network/credential failures
83-
logger.warning("Failed to mint agent API token: %s", e)
84-
return None
85-
if not token:
86-
return None
87-
with _TOKEN_LOCK:
88-
_TOKEN_CACHE[cache_key] = (token, decode_jwt_exp(token))
89-
return token
90-
91-
92-
def agent_api_auth_headers(
93-
server_url: str,
94-
api_key: Optional[str] = None,
95-
auth_key: Optional[str] = None,
96-
auth_secret: Optional[str] = None,
97-
) -> Dict[str, str]:
98-
"""``X-Authorization`` header dict for agent API calls ({} when anonymous)."""
99-
token = resolve_agent_api_token(server_url, api_key, auth_key, auth_secret)
100-
return {"X-Authorization": token} if token else {}

src/conductor/ai/agents/frameworks/claude_agent_sdk.py

Lines changed: 58 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from dataclasses import is_dataclass, replace
2121
from typing import Any, Dict, List, Optional, Tuple
2222

23-
from conductor.ai.agents._internal.token_utils import agent_api_auth_headers
23+
from conductor.ai.agents._internal.agent_http import _agent_api_client, agent_post
2424
from conductor.ai.agents.frameworks.serializer import WorkerInfo
2525

2626
logger = logging.getLogger("conductor.ai.agents.frameworks.claude_agent_sdk")
@@ -888,17 +888,24 @@ def _push_event_nonblocking(
888888

889889
def _do_push():
890890
try:
891-
import requests
892-
893-
url = f"{server_url}/agent/events/{execution_id}"
894-
headers = agent_api_auth_headers(server_url, auth_key=auth_key, auth_secret=auth_secret)
895-
requests.post(url, json=event, headers=headers, timeout=5)
891+
agent_post(server_url, auth_key, auth_secret, f"/agent/events/{execution_id}", event)
896892
except Exception as exc:
897893
logger.debug("Event push failed (execution_id=%s): %s", execution_id, exc)
898894

899895
_EVENT_PUSH_POOL.submit(_do_push)
900896

901897

898+
def _task_client(server_url: str, auth_key: str, auth_secret: str):
899+
"""Return a ``TaskResourceApi`` backed by the shared cached ``ApiClient``.
900+
901+
Reuses the single per-(server_url, auth_key) ApiClient from ``agent_http`` so
902+
the ``/tasks`` progress update and the ``/agent/*`` posts share one token.
903+
"""
904+
from conductor.client.http.api.task_resource_api import TaskResourceApi
905+
906+
return TaskResourceApi(_agent_api_client(server_url, auth_key, auth_secret))
907+
908+
902909
def _update_task_progress_nonblocking(
903910
task_id: str,
904911
execution_id: str,
@@ -924,20 +931,15 @@ def _update_task_progress_nonblocking(
924931

925932
def _do_update():
926933
try:
927-
import requests
934+
from conductor.client.http.models.task_result import TaskResult
928935

929-
url = f"{server_url}/tasks"
930-
headers: Dict[str, str] = {"Content-Type": "application/json"}
931-
headers.update(
932-
agent_api_auth_headers(server_url, auth_key=auth_key, auth_secret=auth_secret)
936+
result = TaskResult(
937+
task_id=task_id,
938+
workflow_instance_id=execution_id,
939+
status="IN_PROGRESS",
940+
output_data=progress_data,
933941
)
934-
body = {
935-
"taskId": task_id,
936-
"workflowInstanceId": execution_id,
937-
"status": "IN_PROGRESS",
938-
"outputData": progress_data,
939-
}
940-
requests.post(url, json=body, headers=headers, timeout=5)
942+
_task_client(server_url, auth_key, auth_secret).update_task(result)
941943
except Exception as exc:
942944
logger.debug(
943945
"Task progress update failed (task_id=%s, execution_id=%s): %s",
@@ -968,26 +970,15 @@ def _create_tracking_workflow(
968970
Returns the execution ID of the new workflow, or None on failure.
969971
Uses POST /api/agent/execution (Agentspan custom endpoint).
970972
"""
971-
try:
972-
import requests
973-
974-
url = f"{server_url}/agent/execution"
975-
headers: Dict[str, str] = {"Content-Type": "application/json"}
976-
headers.update(
977-
agent_api_auth_headers(server_url, auth_key=auth_key, auth_secret=auth_secret)
978-
)
979-
body: Dict[str, Any] = {"workflowName": workflow_name, "input": input_data}
980-
if parent_workflow_id:
981-
body["parentWorkflowId"] = parent_workflow_id
982-
if parent_workflow_task_id:
983-
body["parentWorkflowTaskId"] = parent_workflow_task_id
984-
resp = requests.post(url, json=body, headers=headers, timeout=5)
985-
if resp.status_code < 400:
986-
return resp.json().get("executionId")
987-
return None
988-
except Exception as exc:
989-
logger.debug("Create tracking workflow failed: %s", exc)
990-
return None
973+
body: Dict[str, Any] = {"workflowName": workflow_name, "input": input_data}
974+
if parent_workflow_id:
975+
body["parentWorkflowId"] = parent_workflow_id
976+
if parent_workflow_task_id:
977+
body["parentWorkflowTaskId"] = parent_workflow_task_id
978+
resp = agent_post(
979+
server_url, auth_key, auth_secret, "/agent/execution", body, read_response=True
980+
)
981+
return resp.get("executionId") if isinstance(resp, dict) else None
991982

992983

993984
def _inject_tool_task(
@@ -1009,32 +1000,23 @@ def _inject_tool_task(
10091000
For SUB_WORKFLOW tasks, pass sub_workflow_param with keys:
10101001
name, version, executionId (the tracking sub-workflow).
10111002
"""
1012-
try:
1013-
import requests
1014-
1015-
url = f"{server_url}/agent/{execution_id}/tasks"
1016-
headers: Dict[str, str] = {"Content-Type": "application/json"}
1017-
headers.update(
1018-
agent_api_auth_headers(server_url, auth_key=auth_key, auth_secret=auth_secret)
1019-
)
1020-
body: Dict[str, Any] = {
1021-
"taskDefName": tool_name,
1022-
"referenceTaskName": ref_name,
1023-
"type": task_type,
1024-
"inputData": input_data,
1025-
}
1026-
if sub_workflow_param:
1027-
body["subWorkflowParam"] = sub_workflow_param
1028-
resp = requests.post(url, json=body, headers=headers, timeout=5)
1029-
return resp.status_code < 400
1030-
except Exception as exc:
1031-
logger.debug(
1032-
"Inject tool task failed (execution_id=%s, ref=%s): %s",
1033-
execution_id,
1034-
ref_name,
1035-
exc,
1036-
)
1037-
return False
1003+
body: Dict[str, Any] = {
1004+
"taskDefName": tool_name,
1005+
"referenceTaskName": ref_name,
1006+
"type": task_type,
1007+
"inputData": input_data,
1008+
}
1009+
if sub_workflow_param:
1010+
body["subWorkflowParam"] = sub_workflow_param
1011+
resp = agent_post(
1012+
server_url,
1013+
auth_key,
1014+
auth_secret,
1015+
f"/agent/{execution_id}/tasks",
1016+
body,
1017+
read_response=True,
1018+
)
1019+
return resp is not None
10381020

10391021

10401022
def _complete_tool_task_nonblocking(
@@ -1053,14 +1035,13 @@ def _complete_tool_task_nonblocking(
10531035

10541036
def _do_complete():
10551037
try:
1056-
import requests
1057-
1058-
url = f"{server_url}/agent/tasks/{execution_id}/{ref_name}/{status}"
1059-
headers: Dict[str, str] = {"Content-Type": "application/json"}
1060-
headers.update(
1061-
agent_api_auth_headers(server_url, auth_key=auth_key, auth_secret=auth_secret)
1038+
agent_post(
1039+
server_url,
1040+
auth_key,
1041+
auth_secret,
1042+
f"/agent/tasks/{execution_id}/{ref_name}/{status}",
1043+
output_data,
10621044
)
1063-
requests.post(url, json=output_data, headers=headers, timeout=5)
10641045
except Exception as exc:
10651046
logger.debug(
10661047
"Complete tool task failed (execution_id=%s, ref=%s): %s",
@@ -1086,14 +1067,13 @@ def _complete_workflow_nonblocking(
10861067

10871068
def _do_complete():
10881069
try:
1089-
import requests
1090-
1091-
url = f"{server_url}/agent/execution/{workflow_execution_id}/complete"
1092-
headers: Dict[str, str] = {"Content-Type": "application/json"}
1093-
headers.update(
1094-
agent_api_auth_headers(server_url, auth_key=auth_key, auth_secret=auth_secret)
1070+
agent_post(
1071+
server_url,
1072+
auth_key,
1073+
auth_secret,
1074+
f"/agent/execution/{workflow_execution_id}/complete",
1075+
output_data or {},
10951076
)
1096-
requests.post(url, json=output_data or {}, headers=headers, timeout=5)
10971077
except Exception as exc:
10981078
logger.debug(
10991079
"Complete workflow failed (execution_id=%s): %s",

0 commit comments

Comments
 (0)