agent-runloop 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-runloop
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Explicit, durable agent loop: validated tool calls, human approval, budgets, crash recovery, and composable router / waterfall / parallel / handoff / refine blocks.
5
5
  Project-URL: Homepage, https://github.com/kroq86/agent-loop
6
6
  Project-URL: Repository, https://github.com/kroq86/agent-loop
@@ -8,7 +8,7 @@ Project-URL: Issues, https://github.com/kroq86/agent-loop/issues
8
8
  Author-email: KirillOstapenko <djkroq@gmail.com>
9
9
  License-Expression: MIT
10
10
  License-File: LICENSE
11
- Keywords: LLM,agent,durable,human-in-the-loop,multi-agent,ollama,router,tool-calling
11
+ Keywords: LLM,agent,azure-openai,durable,human-in-the-loop,multi-agent,ollama,openai,router,tool-calling
12
12
  Classifier: Development Status :: 3 - Alpha
13
13
  Classifier: Framework :: AsyncIO
14
14
  Classifier: Intended Audience :: Developers
@@ -21,6 +21,8 @@ Classifier: Programming Language :: Python :: 3.14
21
21
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
22
  Requires-Python: >=3.11
23
23
  Requires-Dist: pydantic>=2.5
24
+ Provides-Extra: openai
25
+ Requires-Dist: httpx>=0.27; extra == 'openai'
24
26
  Provides-Extra: trace
25
27
  Requires-Dist: flow-xray>=0.4.0; extra == 'trace'
26
28
  Description-Content-Type: text/markdown
@@ -208,9 +210,40 @@ async def generate(self, messages, format=None, options=None, trace_id=None) ->
208
210
 
209
211
  `format` is a JSON schema the reply must follow. A backend should report
210
212
  tokens with `agent_loop.usage.add(prompt, completion)`, or `max_tokens`
211
- is not enforced. `agent_loop.ollama.OllamaBackend` is a dependency-free
212
- reference backend with no retries. For production, wrap your client with
213
- retries and a circuit breaker.
213
+ is not enforced. Two are included.
214
+
215
+ **OpenAI-compatible APIs** (`pip install "agent-runloop[openai]"`): OpenAI,
216
+ Azure OpenAI, and servers that speak the same protocol (vLLM, LM Studio,
217
+ OpenRouter, llama.cpp server, Ollama's `/v1`).
218
+
219
+ ```python
220
+ from agent_loop.openai import AzureOpenAIBackend, OpenAIBackend
221
+
222
+ OpenAIBackend("gpt-4.1-mini") # OPENAI_API_KEY
223
+ OpenAIBackend("llama3.2:3b", base_url="http://127.0.0.1:11434/v1", api_key="unused")
224
+ AzureOpenAIBackend("my-deployment", endpoint="https://<resource>.openai.azure.com") # AZURE_OPENAI_API_KEY
225
+ AzureOpenAIBackend("my-deployment", endpoint=..., token_provider=get_token) # Entra ID, refreshed per call
226
+ ```
227
+
228
+ - **Retries.** Only what can succeed on a second try is retried:
229
+ connection errors, timeouts, 429 and 5xx. The wait honours
230
+ `Retry-After` and `retry-after-ms`, otherwise it is exponential backoff
231
+ with jitter. Any other 4xx fails at once with `ModelRequestError`.
232
+ - **Circuit breaker.** After `breaker_threshold` consecutive failed calls,
233
+ every call fails fast with `ModelUnavailable` for `breaker_reset_s`.
234
+ Then one trial call decides whether it closes. The breaker is per
235
+ backend instance and per process.
236
+ - **Structured output.** The loop's schema is sent as a `json_schema`
237
+ response format. Use `structured="json_object"` for servers that only
238
+ support JSON mode, or `"none"`.
239
+ - **Tokens.** Usage from each response feeds the run's token budget and
240
+ the flow-xray trace.
241
+
242
+ Inside a run, an unavailable model ends the run as `model_unavailable`
243
+ with its state saved. It does not crash the loop.
244
+
245
+ **Ollama** (`agent_loop.ollama.OllamaBackend`) is a dependency-free
246
+ reference backend with no retries.
214
247
 
215
248
  ## Observability
216
249
 
@@ -181,9 +181,40 @@ async def generate(self, messages, format=None, options=None, trace_id=None) ->
181
181
 
182
182
  `format` is a JSON schema the reply must follow. A backend should report
183
183
  tokens with `agent_loop.usage.add(prompt, completion)`, or `max_tokens`
184
- is not enforced. `agent_loop.ollama.OllamaBackend` is a dependency-free
185
- reference backend with no retries. For production, wrap your client with
186
- retries and a circuit breaker.
184
+ is not enforced. Two are included.
185
+
186
+ **OpenAI-compatible APIs** (`pip install "agent-runloop[openai]"`): OpenAI,
187
+ Azure OpenAI, and servers that speak the same protocol (vLLM, LM Studio,
188
+ OpenRouter, llama.cpp server, Ollama's `/v1`).
189
+
190
+ ```python
191
+ from agent_loop.openai import AzureOpenAIBackend, OpenAIBackend
192
+
193
+ OpenAIBackend("gpt-4.1-mini") # OPENAI_API_KEY
194
+ OpenAIBackend("llama3.2:3b", base_url="http://127.0.0.1:11434/v1", api_key="unused")
195
+ AzureOpenAIBackend("my-deployment", endpoint="https://<resource>.openai.azure.com") # AZURE_OPENAI_API_KEY
196
+ AzureOpenAIBackend("my-deployment", endpoint=..., token_provider=get_token) # Entra ID, refreshed per call
197
+ ```
198
+
199
+ - **Retries.** Only what can succeed on a second try is retried:
200
+ connection errors, timeouts, 429 and 5xx. The wait honours
201
+ `Retry-After` and `retry-after-ms`, otherwise it is exponential backoff
202
+ with jitter. Any other 4xx fails at once with `ModelRequestError`.
203
+ - **Circuit breaker.** After `breaker_threshold` consecutive failed calls,
204
+ every call fails fast with `ModelUnavailable` for `breaker_reset_s`.
205
+ Then one trial call decides whether it closes. The breaker is per
206
+ backend instance and per process.
207
+ - **Structured output.** The loop's schema is sent as a `json_schema`
208
+ response format. Use `structured="json_object"` for servers that only
209
+ support JSON mode, or `"none"`.
210
+ - **Tokens.** Usage from each response feeds the run's token budget and
211
+ the flow-xray trace.
212
+
213
+ Inside a run, an unavailable model ends the run as `model_unavailable`
214
+ with its state saved. It does not crash the loop.
215
+
216
+ **Ollama** (`agent_loop.ollama.OllamaBackend`) is a dependency-free
217
+ reference backend with no retries.
187
218
 
188
219
  ## Observability
189
220
 
@@ -0,0 +1,243 @@
1
+ """Backends for OpenAI-compatible chat APIs: OpenAI, Azure OpenAI, and the
2
+ many servers that speak the same protocol (vLLM, LM Studio, OpenRouter,
3
+ Ollama's /v1, llama.cpp server, ...).
4
+
5
+ pip install "agent-runloop[openai]" # adds httpx
6
+
7
+ OpenAIBackend("gpt-4.1-mini") # OPENAI_API_KEY
8
+ OpenAIBackend("llama3.2:3b", base_url="http://127.0.0.1:11434/v1", api_key="unused")
9
+ AzureOpenAIBackend("my-deployment", endpoint="https://<resource>.openai.azure.com")
10
+ # AZURE_OPENAI_API_KEY,
11
+ # or token_provider= for Entra ID
12
+
13
+ Failure handling:
14
+
15
+ retry only what can succeed on a second try: connection errors,
16
+ timeouts, 429 and 5xx. The wait honours Retry-After /
17
+ retry-after-ms, otherwise exponential backoff with jitter,
18
+ capped at `max_delay_s`. Any other 4xx (bad request, auth,
19
+ unknown model) fails at once with ModelRequestError.
20
+ breaker per backend instance: after `breaker_threshold` consecutive
21
+ failed calls every call fails fast with ModelUnavailable for
22
+ `breaker_reset_s`, then one trial call decides. Per process:
23
+ several processes keep separate breakers.
24
+
25
+ Structured output: the loop passes a JSON schema in `format`. It is sent
26
+ as `response_format` "json_schema" (default), "json_object" (servers that
27
+ only know JSON mode; the loop's prompt still spells out the shape), or not
28
+ at all ("none").
29
+
30
+ Token usage from the response is reported to `agent_loop.usage`, so
31
+ `Budget.max_tokens` is enforced.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import email.utils
37
+ import os
38
+ import random
39
+ import time
40
+ from collections.abc import Callable
41
+ from typing import Literal
42
+
43
+ from agent_loop import usage
44
+ from agent_loop._trace import trace
45
+ from agent_loop.hooks import log_step
46
+
47
+ try:
48
+ import httpx
49
+ except ImportError as e: # pragma: no cover - depends on the environment
50
+ raise ImportError('agent_loop.openai needs httpx: pip install "agent-runloop[openai]"') from e
51
+
52
+ RETRYABLE_STATUS = {429, 500, 502, 503, 504}
53
+ StructuredMode = Literal["json_schema", "json_object", "none"]
54
+
55
+
56
+ class ModelUnavailable(Exception):
57
+ """The server could not serve the call after retries, or the circuit
58
+ breaker is open. Distinct from a request the server rejected."""
59
+
60
+
61
+ class ModelRequestError(Exception):
62
+ """The server answered and refused the request (4xx other than 429), or
63
+ the model refused to answer. Retrying the same request will not help."""
64
+
65
+ def __init__(self, message: str, status: int | None = None):
66
+ super().__init__(message)
67
+ self.status = status
68
+
69
+
70
+ class CircuitBreaker:
71
+ def __init__(self, threshold: int = 5, reset_s: float = 30.0):
72
+ self.threshold, self.reset_s = threshold, reset_s
73
+ self.failures = 0
74
+ self.opened_at: float | None = None
75
+
76
+ def check(self, target: str) -> None:
77
+ if self.opened_at is not None and time.monotonic() - self.opened_at < self.reset_s:
78
+ raise ModelUnavailable(f"circuit open for {target} ({self.failures} consecutive failures)")
79
+
80
+ def success(self) -> None:
81
+ self.failures, self.opened_at = 0, None
82
+
83
+ def failure(self, target: str) -> None:
84
+ self.failures += 1
85
+ if self.opened_at is not None or self.failures >= self.threshold:
86
+ if self.opened_at is None:
87
+ log_step(None, "circuit_opened", target=target, failures=self.failures)
88
+ self.opened_at = time.monotonic() # opened, or a failed trial call keeps it open
89
+
90
+
91
+ def _retry_after_s(headers: httpx.Headers) -> float | None:
92
+ """Retry-After in seconds or as an HTTP date; retry-after-ms (OpenAI, Azure)."""
93
+ if (ms := headers.get("retry-after-ms")) is not None:
94
+ try:
95
+ return float(ms) / 1000
96
+ except ValueError:
97
+ pass
98
+ value = headers.get("retry-after")
99
+ if value is None:
100
+ return None
101
+ try:
102
+ return float(value)
103
+ except ValueError:
104
+ try:
105
+ return max(0.0, email.utils.parsedate_to_datetime(value).timestamp() - time.time())
106
+ except (TypeError, ValueError):
107
+ return None
108
+
109
+
110
+ # Loop options that have an OpenAI equivalent; the rest are dropped.
111
+ _OPTION_NAMES = {"temperature": "temperature", "seed": "seed", "top_p": "top_p",
112
+ "num_predict": "max_tokens", "max_tokens": "max_tokens", "stop": "stop"}
113
+
114
+
115
+ class OpenAIBackend:
116
+ def __init__(self, model: str, *, base_url: str | None = None, api_key: str | None = None,
117
+ structured: StructuredMode = "json_schema", timeout_s: float = 60.0, max_attempts: int = 3,
118
+ base_delay_s: float = 0.5, max_delay_s: float = 20.0, breaker_threshold: int = 5,
119
+ breaker_reset_s: float = 30.0, extra_body: dict | None = None,
120
+ extra_headers: dict[str, str] | None = None):
121
+ self.model = model
122
+ self.base_url = (base_url or os.getenv("OPENAI_BASE_URL") or "https://api.openai.com/v1").rstrip("/")
123
+ self.api_key = api_key if api_key is not None else os.getenv("OPENAI_API_KEY")
124
+ self.structured = structured
125
+ self.timeout_s, self.max_attempts = timeout_s, max_attempts
126
+ self.base_delay_s, self.max_delay_s = base_delay_s, max_delay_s
127
+ self.breaker = CircuitBreaker(breaker_threshold, breaker_reset_s)
128
+ self.extra_body, self.extra_headers = dict(extra_body or {}), dict(extra_headers or {})
129
+ # httpx clients are bound to the event loop they were made on.
130
+ self._clients: dict[int, httpx.AsyncClient] = {}
131
+
132
+ # -- request shape (overridden by AzureOpenAIBackend) -----------------
133
+
134
+ def _url(self) -> str:
135
+ return f"{self.base_url}/chat/completions"
136
+
137
+ def _auth_headers(self) -> dict[str, str]:
138
+ return {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
139
+
140
+ def _target(self) -> str:
141
+ return self.base_url
142
+
143
+ # ---------------------------------------------------------------------
144
+
145
+ def _client(self) -> httpx.AsyncClient:
146
+ loop = id(asyncio.get_running_loop())
147
+ client = self._clients.get(loop)
148
+ if client is None or client.is_closed:
149
+ client = self._clients[loop] = httpx.AsyncClient(
150
+ timeout=self.timeout_s, limits=httpx.Limits(max_connections=20, max_keepalive_connections=10))
151
+ return client
152
+
153
+ def _payload(self, messages: list[dict], format: str | dict | None, options: dict | None) -> dict:
154
+ payload: dict = {"model": self.model, "messages": messages}
155
+ for key, value in (options or {}).items():
156
+ if key in _OPTION_NAMES:
157
+ payload[_OPTION_NAMES[key]] = value
158
+ if format and self.structured == "json_schema" and isinstance(format, dict):
159
+ payload["response_format"] = {"type": "json_schema",
160
+ "json_schema": {"name": str(format.get("title") or "reply"),
161
+ "schema": format}}
162
+ elif format and self.structured != "none":
163
+ payload["response_format"] = {"type": "json_object"}
164
+ return {**payload, **self.extra_body}
165
+
166
+ @trace
167
+ async def generate(self, messages: list[dict], format: str | dict | None = None,
168
+ options: dict | None = None, trace_id: str | None = None) -> str:
169
+ target = self._target()
170
+ self.breaker.check(target)
171
+ payload = self._payload(messages, format, options)
172
+ headers = {**self._auth_headers(), **self.extra_headers}
173
+ last_error = ""
174
+ for attempt in range(self.max_attempts):
175
+ wait: float | None = None
176
+ try:
177
+ response = await self._client().post(self._url(), json=payload, headers=headers)
178
+ except (httpx.TransportError, httpx.TimeoutException) as e:
179
+ last_error = f"{type(e).__name__}: {e}"
180
+ else:
181
+ if response.status_code < 400:
182
+ self.breaker.success()
183
+ return self._read(response.json())
184
+ if response.status_code not in RETRYABLE_STATUS:
185
+ # The server said no: not an availability problem, so no
186
+ # retry and the breaker is not tripped.
187
+ raise ModelRequestError(f"HTTP {response.status_code}: {response.text[:300]}",
188
+ response.status_code)
189
+ last_error = f"HTTP {response.status_code}: {response.text[:200]}"
190
+ wait = _retry_after_s(response.headers)
191
+ if attempt + 1 < self.max_attempts:
192
+ delay = min(self.max_delay_s, wait if wait is not None
193
+ else random.uniform(0, self.base_delay_s * 2 ** attempt) + self.base_delay_s)
194
+ log_step(trace_id, "model_call_retry", target=target, model=self.model, attempt=attempt + 1,
195
+ of=self.max_attempts, error=last_error, sleep_s=round(delay, 3))
196
+ await asyncio.sleep(delay)
197
+ self.breaker.failure(target)
198
+ log_step(trace_id, "model_call_failed", target=target, model=self.model, attempts=self.max_attempts,
199
+ error=last_error)
200
+ raise ModelUnavailable(f"{self.model} at {target}: {last_error} after {self.max_attempts} attempts")
201
+
202
+ def _read(self, data: dict) -> str:
203
+ u = data.get("usage") or {}
204
+ prompt, completion = u.get("prompt_tokens") or 0, u.get("completion_tokens") or 0
205
+ usage.add(prompt, completion)
206
+ trace.meta(model=data.get("model") or self.model, prompt_tokens=prompt, completion_tokens=completion,
207
+ total_tokens=prompt + completion)
208
+ message = (data.get("choices") or [{}])[0].get("message") or {}
209
+ if message.get("content") is None:
210
+ raise ModelRequestError(f"no content in reply (refusal: {message.get('refusal')!r})")
211
+ return message["content"]
212
+
213
+
214
+ class AzureOpenAIBackend(OpenAIBackend):
215
+ """Azure OpenAI: the deployment name is in the URL, the version is a
216
+ query parameter, and auth is an `api-key` header or an Entra ID bearer
217
+ token (`token_provider() -> str`, e.g. from azure-identity's
218
+ get_bearer_token_provider; called per request, so it can refresh)."""
219
+
220
+ def __init__(self, deployment: str, *, endpoint: str | None = None, api_key: str | None = None,
221
+ api_version: str | None = None, token_provider: Callable[[], str] | None = None, **kwargs):
222
+ endpoint = (endpoint or os.getenv("AZURE_OPENAI_ENDPOINT") or "").rstrip("/")
223
+ if not endpoint:
224
+ raise ValueError("Azure OpenAI needs endpoint= or AZURE_OPENAI_ENDPOINT")
225
+ super().__init__(deployment, base_url=endpoint, api_key="", **kwargs)
226
+ self.deployment = deployment
227
+ self.api_version = api_version or os.getenv("AZURE_OPENAI_API_VERSION") or "2024-10-21"
228
+ self.azure_key = api_key if api_key is not None else os.getenv("AZURE_OPENAI_API_KEY")
229
+ self.token_provider = token_provider
230
+ if not self.azure_key and token_provider is None:
231
+ raise ValueError("Azure OpenAI needs api_key=, AZURE_OPENAI_API_KEY, or token_provider=")
232
+
233
+ def _url(self) -> str:
234
+ return (f"{self.base_url}/openai/deployments/{self.deployment}/chat/completions"
235
+ f"?api-version={self.api_version}")
236
+
237
+ def _auth_headers(self) -> dict[str, str]:
238
+ if self.token_provider is not None:
239
+ return {"Authorization": f"Bearer {self.token_provider()}"}
240
+ return {"api-key": self.azure_key}
241
+
242
+ def _target(self) -> str:
243
+ return f"{self.base_url}/{self.deployment}"
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agent-runloop"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Explicit, durable agent loop: validated tool calls, human approval, budgets, crash recovery, and composable router / waterfall / parallel / handoff / refine blocks."
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.11"
12
12
  authors = [{ name = "KirillOstapenko", email = "djkroq@gmail.com" }]
13
- keywords = ["agent", "LLM", "tool-calling", "human-in-the-loop", "router", "multi-agent", "durable", "ollama"]
13
+ keywords = ["agent", "LLM", "tool-calling", "openai", "azure-openai", "human-in-the-loop", "router", "multi-agent", "durable", "ollama"]
14
14
  classifiers = [
15
15
  "Development Status :: 3 - Alpha",
16
16
  "Intended Audience :: Developers",
@@ -27,6 +27,7 @@ dependencies = ["pydantic>=2.5"]
27
27
 
28
28
  [project.optional-dependencies]
29
29
  trace = ["flow-xray>=0.4.0"]
30
+ openai = ["httpx>=0.27"]
30
31
 
31
32
  [project.urls]
32
33
  Homepage = "https://github.com/kroq86/agent-loop"
File without changes
File without changes