agentbridge-cli 0.1.11__py3-none-any.whl
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.
- agentbridge/__init__.py +8 -0
- agentbridge/_build_info.py +1 -0
- agentbridge/config.py +85 -0
- agentbridge/dashboard.py +494 -0
- agentbridge/models.py +334 -0
- agentbridge/pool.py +338 -0
- agentbridge/server.py +2881 -0
- agentbridge/templates/dashboard/base.html +160 -0
- agentbridge/templates/dashboard/chat.html +1361 -0
- agentbridge/templates/dashboard/detail.html +142 -0
- agentbridge/templates/dashboard/page.html +900 -0
- agentbridge/templates/dashboard/pool.html +9 -0
- agentbridge/templates/dashboard/requests.html +67 -0
- agentbridge_cli-0.1.11.dist-info/METADATA +157 -0
- agentbridge_cli-0.1.11.dist-info/RECORD +18 -0
- agentbridge_cli-0.1.11.dist-info/WHEEL +4 -0
- agentbridge_cli-0.1.11.dist-info/entry_points.txt +2 -0
- agentbridge_cli-0.1.11.dist-info/licenses/LICENSE +21 -0
agentbridge/models.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""OpenAI-compatible request/response models and provider model mapping."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Annotated, Any, Literal, Union
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Tool-related types (OpenAI format)
|
|
11
|
+
class FunctionDefinition(BaseModel):
|
|
12
|
+
name: str
|
|
13
|
+
description: str | None = None
|
|
14
|
+
parameters: dict | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Tool(BaseModel):
|
|
18
|
+
type: Literal["function"]
|
|
19
|
+
function: FunctionDefinition
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ToolChoiceFunction(BaseModel):
|
|
23
|
+
name: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ToolChoiceObject(BaseModel):
|
|
27
|
+
type: Literal["function"]
|
|
28
|
+
function: ToolChoiceFunction
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Tool call types for responses
|
|
32
|
+
class FunctionCall(BaseModel):
|
|
33
|
+
name: str
|
|
34
|
+
arguments: str # JSON string
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ToolCall(BaseModel):
|
|
38
|
+
id: str
|
|
39
|
+
type: Literal["function"] = "function"
|
|
40
|
+
function: FunctionCall
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Multimodal content types (OpenAI format)
|
|
44
|
+
class ImageUrl(BaseModel):
|
|
45
|
+
url: str # data:image/xxx;base64,... or https://...
|
|
46
|
+
detail: Literal["auto", "low", "high"] | None = None # For future SDK support
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ImageUrlContent(BaseModel):
|
|
50
|
+
type: Literal["image_url"]
|
|
51
|
+
image_url: ImageUrl
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TextContent(BaseModel):
|
|
55
|
+
type: Literal["text"]
|
|
56
|
+
text: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
ContentPart = Annotated[Union[TextContent, ImageUrlContent], Field(discriminator='type')]
|
|
60
|
+
|
|
61
|
+
ReasoningEffort = Literal["minimal", "low", "medium", "high", "xhigh"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Message(BaseModel):
|
|
65
|
+
role: Literal["system", "user", "assistant", "tool"]
|
|
66
|
+
content: str | list[ContentPart] | None = None # None when tool_calls present
|
|
67
|
+
tool_calls: list[ToolCall] | None = None
|
|
68
|
+
tool_call_id: str | None = None # Present when role="tool"
|
|
69
|
+
name: str | None = None # Tool name when role="tool"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ChatCompletionRequest(BaseModel):
|
|
73
|
+
model: str
|
|
74
|
+
messages: list[Message] = Field(min_length=1)
|
|
75
|
+
temperature: float | None = None
|
|
76
|
+
max_tokens: int | None = None
|
|
77
|
+
reasoning_effort: ReasoningEffort | None = None
|
|
78
|
+
reasoning: dict[str, Any] | None = None
|
|
79
|
+
stream: bool = False
|
|
80
|
+
# Tool calling support
|
|
81
|
+
tools: list[Tool] | None = None
|
|
82
|
+
tool_choice: ToolChoiceObject | str | None = None # "auto", "none", or specific
|
|
83
|
+
# Additional fields accepted for OpenRouter/OpenAI compatibility
|
|
84
|
+
top_p: float | None = None
|
|
85
|
+
frequency_penalty: float | None = None
|
|
86
|
+
presence_penalty: float | None = None
|
|
87
|
+
stop: str | list[str] | None = None
|
|
88
|
+
n: int | None = None
|
|
89
|
+
seed: int | None = None
|
|
90
|
+
user: str | None = None
|
|
91
|
+
response_format: dict | None = None
|
|
92
|
+
logit_bias: dict | None = None
|
|
93
|
+
logprobs: bool | None = None
|
|
94
|
+
top_logprobs: int | None = None
|
|
95
|
+
parallel_tool_calls: bool | None = None
|
|
96
|
+
stream_options: dict | None = None
|
|
97
|
+
store: bool | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ImageReference(BaseModel):
|
|
101
|
+
type: Literal["image_url"]
|
|
102
|
+
image_url: ImageUrl
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ImageGenerationRequest(BaseModel):
|
|
106
|
+
model: str
|
|
107
|
+
prompt: str = Field(min_length=1, max_length=20_000)
|
|
108
|
+
input_references: list[ImageReference] = Field(min_length=1, max_length=1)
|
|
109
|
+
n: Literal[1] = 1
|
|
110
|
+
store: Literal[False] = False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ImageData(BaseModel):
|
|
114
|
+
b64_json: str
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Choice(BaseModel):
|
|
118
|
+
index: int = 0
|
|
119
|
+
message: Message
|
|
120
|
+
finish_reason: str = "stop" # "stop" or "tool_calls"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Usage(BaseModel):
|
|
124
|
+
prompt_tokens: int = 0
|
|
125
|
+
completion_tokens: int = 0
|
|
126
|
+
total_tokens: int = 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ImageGenerationResponse(BaseModel):
|
|
130
|
+
id: str
|
|
131
|
+
created: int
|
|
132
|
+
model: str
|
|
133
|
+
data: list[ImageData]
|
|
134
|
+
usage: Usage = Field(default_factory=Usage)
|
|
135
|
+
usage_scope: Literal["codex_orchestration_only"] = "codex_orchestration_only"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ChatCompletionResponse(BaseModel):
|
|
139
|
+
id: str
|
|
140
|
+
object: str = "chat.completion"
|
|
141
|
+
created: int
|
|
142
|
+
model: str
|
|
143
|
+
choices: list[Choice]
|
|
144
|
+
usage: Usage = Field(default_factory=Usage)
|
|
145
|
+
system_fingerprint: str | None = None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class DeltaMessage(BaseModel):
|
|
149
|
+
role: str | None = None
|
|
150
|
+
content: str | None = None
|
|
151
|
+
tool_calls: list[ToolCall] | None = None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class StreamChoice(BaseModel):
|
|
155
|
+
index: int = 0
|
|
156
|
+
delta: DeltaMessage
|
|
157
|
+
finish_reason: str | None = None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class ChatCompletionChunk(BaseModel):
|
|
161
|
+
id: str
|
|
162
|
+
object: str = "chat.completion.chunk"
|
|
163
|
+
created: int
|
|
164
|
+
model: str
|
|
165
|
+
choices: list[StreamChoice]
|
|
166
|
+
usage: Usage | None = None
|
|
167
|
+
system_fingerprint: str | None = None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class ModelInfo(BaseModel):
|
|
171
|
+
id: str
|
|
172
|
+
object: str = "model"
|
|
173
|
+
created: int = 0
|
|
174
|
+
owned_by: str = "agentbridge"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class ModelList(BaseModel):
|
|
178
|
+
object: str = "list"
|
|
179
|
+
data: list[ModelInfo]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# Error response models (OpenAI format)
|
|
183
|
+
class ErrorDetail(BaseModel):
|
|
184
|
+
message: str
|
|
185
|
+
type: str # "invalid_request_error", "server_error", etc.
|
|
186
|
+
param: str | None = None
|
|
187
|
+
code: str | None = None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class ErrorResponse(BaseModel):
|
|
191
|
+
error: ErrorDetail
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
# Provider model mapping
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
# Simple names that map directly to Claude Code model identifiers
|
|
199
|
+
CLAUDE_SIMPLE_NAMES: set[str] = {"opus", "sonnet", "haiku"}
|
|
200
|
+
|
|
201
|
+
# Backwards-compatible export used by tests and callers.
|
|
202
|
+
SIMPLE_NAMES = CLAUDE_SIMPLE_NAMES
|
|
203
|
+
|
|
204
|
+
PROVIDER_NAMES: set[str] = {"claudecode", "codex", "openrouter"}
|
|
205
|
+
|
|
206
|
+
CODEX_MODELS: dict[str, ReasoningEffort | None] = {
|
|
207
|
+
"gpt-5.6-sol": "high",
|
|
208
|
+
"gpt-5.5": "high",
|
|
209
|
+
"gpt-5.4": None,
|
|
210
|
+
"gpt-5.4-mini": None,
|
|
211
|
+
"gpt-5.3-codex": None,
|
|
212
|
+
"gpt-5.3-codex-spark": None,
|
|
213
|
+
"gpt-5.2": None,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
# Backwards-compatible catalog views used by tests and callers.
|
|
217
|
+
CODEX_MODEL_SLUGS: set[str] = set(CODEX_MODELS)
|
|
218
|
+
CODEX_DEFAULT_REASONING_EFFORT_BY_MODEL: dict[str, ReasoningEffort] = {
|
|
219
|
+
model: effort for model, effort in CODEX_MODELS.items() if effort is not None
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
OPENROUTER_EXAMPLE_SLUGS: set[str] = {
|
|
223
|
+
"anthropic/claude-opus-4",
|
|
224
|
+
"anthropic/claude-sonnet-4",
|
|
225
|
+
"openai/gpt-5",
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass(frozen=True)
|
|
230
|
+
class ModelResolution:
|
|
231
|
+
"""Resolved provider and backend model identifier."""
|
|
232
|
+
|
|
233
|
+
provider: Literal["claudecode", "codex", "openrouter"]
|
|
234
|
+
model: str
|
|
235
|
+
|
|
236
|
+
# Word-boundary pattern for matching model names in slugs
|
|
237
|
+
_MODEL_PATTERN = re.compile(
|
|
238
|
+
r'(?:^|[^a-zA-Z])(' + '|'.join(sorted(CLAUDE_SIMPLE_NAMES)) + r')(?:[^a-zA-Z]|$)',
|
|
239
|
+
re.IGNORECASE,
|
|
240
|
+
)
|
|
241
|
+
# Available models for /api/v1/models endpoint.
|
|
242
|
+
AVAILABLE_MODELS: list[dict[str, str]] = [
|
|
243
|
+
*[
|
|
244
|
+
{
|
|
245
|
+
"slug": f"claudecode/{name}",
|
|
246
|
+
"name": f"Claude {name.capitalize()}",
|
|
247
|
+
"owned_by": "claude-code",
|
|
248
|
+
}
|
|
249
|
+
for name in sorted(CLAUDE_SIMPLE_NAMES)
|
|
250
|
+
],
|
|
251
|
+
*[
|
|
252
|
+
{"slug": f"codex/{name}", "name": name.upper(), "owned_by": "codex-cli"}
|
|
253
|
+
for name in sorted(CODEX_MODEL_SLUGS)
|
|
254
|
+
],
|
|
255
|
+
*[
|
|
256
|
+
{
|
|
257
|
+
"slug": f"openrouter/{name}",
|
|
258
|
+
"name": name,
|
|
259
|
+
"owned_by": "openrouter",
|
|
260
|
+
}
|
|
261
|
+
for name in sorted(OPENROUTER_EXAMPLE_SLUGS)
|
|
262
|
+
],
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class UnsupportedModelError(ValueError):
|
|
267
|
+
"""Raised when an unsupported model identifier is provided."""
|
|
268
|
+
|
|
269
|
+
def __init__(self, model: str):
|
|
270
|
+
self.model = model
|
|
271
|
+
super().__init__(
|
|
272
|
+
f"Unsupported model: '{model}'. "
|
|
273
|
+
"Model IDs must start with a provider namespace. "
|
|
274
|
+
"Use claudecode/<opus|sonnet|haiku>, codex/<model>, "
|
|
275
|
+
"or openrouter/<provider>/<model>."
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def resolve_model_request(model: str) -> ModelResolution:
|
|
280
|
+
"""Resolve a namespaced model ID to a backend provider.
|
|
281
|
+
|
|
282
|
+
Model IDs must begin with an AgentBridge provider namespace:
|
|
283
|
+
claudecode/<model>, codex/<model>, or openrouter/<provider>/<model>.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
model: Namespaced model identifier.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Provider and backend model identifier.
|
|
290
|
+
|
|
291
|
+
Raises:
|
|
292
|
+
UnsupportedModelError: If model is not recognized
|
|
293
|
+
"""
|
|
294
|
+
model_stripped = model.strip()
|
|
295
|
+
|
|
296
|
+
provider, sep, provider_model = model_stripped.partition("/")
|
|
297
|
+
if not sep:
|
|
298
|
+
raise UnsupportedModelError(model)
|
|
299
|
+
|
|
300
|
+
provider_lower = provider.lower()
|
|
301
|
+
provider_model = provider_model.strip()
|
|
302
|
+
provider_model_lower = provider_model.lower()
|
|
303
|
+
|
|
304
|
+
if provider_lower not in PROVIDER_NAMES or not provider_model:
|
|
305
|
+
raise UnsupportedModelError(model)
|
|
306
|
+
|
|
307
|
+
if provider_lower == "codex":
|
|
308
|
+
return ModelResolution(provider="codex", model=provider_model)
|
|
309
|
+
|
|
310
|
+
if provider_lower == "openrouter":
|
|
311
|
+
if "/" not in provider_model:
|
|
312
|
+
raise UnsupportedModelError(model)
|
|
313
|
+
return ModelResolution(provider="openrouter", model=provider_model)
|
|
314
|
+
|
|
315
|
+
if provider_model_lower in CLAUDE_SIMPLE_NAMES:
|
|
316
|
+
return ModelResolution(provider="claudecode", model=provider_model_lower)
|
|
317
|
+
|
|
318
|
+
# Word-boundary match: find model name as a distinct segment in the slug
|
|
319
|
+
match = _MODEL_PATTERN.search(provider_model_lower)
|
|
320
|
+
if match:
|
|
321
|
+
return ModelResolution(provider="claudecode", model=match.group(1).lower())
|
|
322
|
+
|
|
323
|
+
# Unknown model - raise error
|
|
324
|
+
raise UnsupportedModelError(model)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def resolve_model(model: str) -> str:
|
|
328
|
+
"""Resolve a model identifier to its backend model name.
|
|
329
|
+
|
|
330
|
+
Kept for backwards compatibility. Use resolve_model_request() when the
|
|
331
|
+
provider is needed.
|
|
332
|
+
"""
|
|
333
|
+
resolution = resolve_model_request(model)
|
|
334
|
+
return resolution.model
|
agentbridge/pool.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Lazy reusable Claude SDK client pool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from typing import TYPE_CHECKING, AsyncIterator, Callable
|
|
11
|
+
|
|
12
|
+
from .config import DEFAULT_POOL_SIZE
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from claude_agent_sdk import ClaudeSDKClient
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# Claude palette (24-bit true color)
|
|
20
|
+
_CLAUDE = "\033[38;2;218;119;86m" # Terracotta — Claude's signature orange
|
|
21
|
+
_GREEN = "\033[32m"
|
|
22
|
+
_RESET = "\033[0m"
|
|
23
|
+
|
|
24
|
+
# Health check interval (seconds)
|
|
25
|
+
HEALTH_CHECK_INTERVAL = 60
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_options(model: str):
|
|
29
|
+
"""Create ClaudeAgentOptions with model."""
|
|
30
|
+
from claude_agent_sdk import ClaudeAgentOptions
|
|
31
|
+
|
|
32
|
+
return ClaudeAgentOptions(
|
|
33
|
+
max_turns=1,
|
|
34
|
+
setting_sources=None, # Don't load user filesystem settings
|
|
35
|
+
system_prompt={"type": "preset", "preset": "claude_code"},
|
|
36
|
+
model=model,
|
|
37
|
+
env={"CLAUDE_CODE_BRIDGE": "1"},
|
|
38
|
+
tools=[], # No built-in tools - pure chat mode
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ClientPool:
|
|
43
|
+
"""Lazy reusable client pool with a hard concurrency limit.
|
|
44
|
+
|
|
45
|
+
Clients are created on demand for the requested model and returned to the
|
|
46
|
+
idle pool after successful use. The pool never creates model-specific
|
|
47
|
+
clients at process startup.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
size: int = DEFAULT_POOL_SIZE,
|
|
53
|
+
on_change: Callable[[], None] | None = None,
|
|
54
|
+
):
|
|
55
|
+
"""Initialize client pool.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
size: Maximum concurrent clients / idle slots.
|
|
59
|
+
on_change: Optional callback invoked on every state mutation.
|
|
60
|
+
"""
|
|
61
|
+
self.size = size
|
|
62
|
+
self._on_change = on_change
|
|
63
|
+
self._client_models: dict[ClaudeSDKClient, str] = {} # client -> model
|
|
64
|
+
self._available: list[ClaudeSDKClient] = [] # idle reusable clients
|
|
65
|
+
self._lock = asyncio.Lock()
|
|
66
|
+
self._semaphore = asyncio.Semaphore(size) # limits concurrent usage
|
|
67
|
+
self._initialized = False
|
|
68
|
+
self._in_use = 0
|
|
69
|
+
self._acquire_timeout = int(os.environ.get("CLAUDE_TIMEOUT", 120))
|
|
70
|
+
self._health_check_task: asyncio.Task | None = None
|
|
71
|
+
|
|
72
|
+
def _fire_change(self) -> None:
|
|
73
|
+
"""Invoke on_change callback if set."""
|
|
74
|
+
if self._on_change is not None:
|
|
75
|
+
self._on_change()
|
|
76
|
+
|
|
77
|
+
async def initialize(self) -> None:
|
|
78
|
+
"""Mark the pool ready without creating any model-specific clients."""
|
|
79
|
+
if self._initialized:
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
self._initialized = True
|
|
83
|
+
logger.info(
|
|
84
|
+
f"{_CLAUDE}[pool]{_RESET} {_GREEN}Ready{_RESET} | max={self.size} available=0"
|
|
85
|
+
)
|
|
86
|
+
self._fire_change()
|
|
87
|
+
|
|
88
|
+
# Start periodic health check
|
|
89
|
+
self._health_check_task = asyncio.create_task(self._periodic_health_check())
|
|
90
|
+
|
|
91
|
+
async def _create_client(self, model: str) -> ClaudeSDKClient:
|
|
92
|
+
"""Create and connect a new client with specified model."""
|
|
93
|
+
from claude_agent_sdk import ClaudeSDKClient
|
|
94
|
+
|
|
95
|
+
client = ClaudeSDKClient(make_options(model))
|
|
96
|
+
try:
|
|
97
|
+
await asyncio.wait_for(client.connect(), timeout=30)
|
|
98
|
+
except BaseException:
|
|
99
|
+
try:
|
|
100
|
+
await client.disconnect()
|
|
101
|
+
except BaseException:
|
|
102
|
+
pass
|
|
103
|
+
raise
|
|
104
|
+
self._client_models[client] = model
|
|
105
|
+
return client
|
|
106
|
+
|
|
107
|
+
async def _disconnect_client(self, client: ClaudeSDKClient) -> None:
|
|
108
|
+
"""Disconnect client and remove from tracking."""
|
|
109
|
+
try:
|
|
110
|
+
await client.disconnect()
|
|
111
|
+
except BaseException:
|
|
112
|
+
pass
|
|
113
|
+
self._client_models.pop(client, None)
|
|
114
|
+
|
|
115
|
+
def _log_tag(self, request_id: str | None = None) -> str:
|
|
116
|
+
"""Return log prefix with optional request ID."""
|
|
117
|
+
if request_id:
|
|
118
|
+
return f"[pool] [{request_id}]"
|
|
119
|
+
return "[pool]"
|
|
120
|
+
|
|
121
|
+
def _log_status(self, action: str, request_id: str | None = None) -> None:
|
|
122
|
+
"""Log current pool status with model breakdown."""
|
|
123
|
+
available_models = [self._client_models[c] for c in self._available]
|
|
124
|
+
available_str = f"[{', '.join(available_models)}]"
|
|
125
|
+
tag = self._log_tag(request_id)
|
|
126
|
+
|
|
127
|
+
logger.info(
|
|
128
|
+
f"{tag} {action} | in_use={self._in_use} available={len(self._available)} "
|
|
129
|
+
f"models={available_str}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def status(self) -> dict:
|
|
133
|
+
"""Return current pool status metrics."""
|
|
134
|
+
# Include in-use clients too
|
|
135
|
+
all_models = list(self._client_models.values())
|
|
136
|
+
return {
|
|
137
|
+
"size": self.size,
|
|
138
|
+
"available": len(self._available),
|
|
139
|
+
"in_use": self._in_use,
|
|
140
|
+
"models": all_models,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
def snapshot(self) -> dict:
|
|
144
|
+
"""Return pool state snapshot for error diagnostics."""
|
|
145
|
+
available_models = [self._client_models[c] for c in self._available]
|
|
146
|
+
all_models = list(self._client_models.values())
|
|
147
|
+
return {
|
|
148
|
+
"size": self.size,
|
|
149
|
+
"in_use": self._in_use,
|
|
150
|
+
"available": len(self._available),
|
|
151
|
+
"available_models": available_models,
|
|
152
|
+
"all_models": all_models,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@asynccontextmanager
|
|
156
|
+
async def acquire(
|
|
157
|
+
self, model: str, request_id: str | None = None
|
|
158
|
+
) -> AsyncIterator[ClaudeSDKClient]:
|
|
159
|
+
"""Get a client for the specified model.
|
|
160
|
+
|
|
161
|
+
Takes an idle client with matching model if available, otherwise creates
|
|
162
|
+
a new one. If the pool is full of idle clients for other models, the
|
|
163
|
+
oldest idle client is discarded to make room for the requested model.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
model: Model name (opus, sonnet, haiku).
|
|
167
|
+
request_id: Optional request ID for log correlation.
|
|
168
|
+
|
|
169
|
+
Yields:
|
|
170
|
+
A ClaudeSDKClient configured for the specified model.
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
HTTPException: 503 if acquire times out waiting for a slot.
|
|
174
|
+
"""
|
|
175
|
+
tag = self._log_tag(request_id)
|
|
176
|
+
try:
|
|
177
|
+
await asyncio.wait_for(
|
|
178
|
+
self._semaphore.acquire(), timeout=self._acquire_timeout
|
|
179
|
+
)
|
|
180
|
+
except asyncio.TimeoutError:
|
|
181
|
+
logger.error(
|
|
182
|
+
f"{tag} Semaphore timeout after {self._acquire_timeout}s | pool={self.snapshot()}"
|
|
183
|
+
)
|
|
184
|
+
from fastapi import HTTPException
|
|
185
|
+
|
|
186
|
+
raise HTTPException(
|
|
187
|
+
status_code=503,
|
|
188
|
+
detail=(
|
|
189
|
+
f"All pool clients busy. Timed out after {self._acquire_timeout}s "
|
|
190
|
+
"waiting for an available client."
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
client: ClaudeSDKClient | None = None
|
|
196
|
+
old_to_discard: ClaudeSDKClient | None = None
|
|
197
|
+
completed = False
|
|
198
|
+
|
|
199
|
+
async with self._lock:
|
|
200
|
+
# Take an idle client with matching model if available.
|
|
201
|
+
matching = [
|
|
202
|
+
c for c in self._available if self._client_models[c] == model
|
|
203
|
+
]
|
|
204
|
+
if matching:
|
|
205
|
+
client = matching[0]
|
|
206
|
+
self._available.remove(client)
|
|
207
|
+
logger.info(f"{tag} Reusing idle {model} client")
|
|
208
|
+
elif len(self._client_models) >= self.size and self._available:
|
|
209
|
+
# Pool is full, but only with idle clients for other models.
|
|
210
|
+
old_to_discard = self._available.pop(0)
|
|
211
|
+
|
|
212
|
+
self._in_use += 1
|
|
213
|
+
self._log_status("Acquired", request_id)
|
|
214
|
+
self._fire_change()
|
|
215
|
+
|
|
216
|
+
# Disconnect non-matching idle client outside the lock.
|
|
217
|
+
if old_to_discard:
|
|
218
|
+
old_model = self._client_models.get(old_to_discard, "unknown")
|
|
219
|
+
logger.info(
|
|
220
|
+
f"{tag} Discarding idle {old_model} client, need {model}"
|
|
221
|
+
)
|
|
222
|
+
try:
|
|
223
|
+
await self._disconnect_client(old_to_discard)
|
|
224
|
+
except asyncio.CancelledError:
|
|
225
|
+
logger.warning(
|
|
226
|
+
f"{tag} Cancelled while discarding {old_model} client"
|
|
227
|
+
)
|
|
228
|
+
raise
|
|
229
|
+
|
|
230
|
+
# Create fresh if no reusable client was available.
|
|
231
|
+
if client is None:
|
|
232
|
+
try:
|
|
233
|
+
client = await self._create_client(model)
|
|
234
|
+
logger.info(f"{tag} Created fresh {model} client")
|
|
235
|
+
except asyncio.CancelledError:
|
|
236
|
+
logger.warning(f"{tag} Cancelled while creating {model} client")
|
|
237
|
+
raise
|
|
238
|
+
|
|
239
|
+
yield client
|
|
240
|
+
completed = True
|
|
241
|
+
except Exception:
|
|
242
|
+
raise
|
|
243
|
+
finally:
|
|
244
|
+
disconnect_client: ClaudeSDKClient | None = None
|
|
245
|
+
returned = False
|
|
246
|
+
async with self._lock:
|
|
247
|
+
self._in_use -= 1
|
|
248
|
+
if (
|
|
249
|
+
client is not None
|
|
250
|
+
and completed
|
|
251
|
+
and self._initialized
|
|
252
|
+
and len(self._available) + self._in_use < self.size
|
|
253
|
+
):
|
|
254
|
+
self._available.append(client)
|
|
255
|
+
returned = True
|
|
256
|
+
else:
|
|
257
|
+
disconnect_client = client
|
|
258
|
+
self._fire_change()
|
|
259
|
+
if returned:
|
|
260
|
+
logger.info(f"{tag} Returned {model} client to idle pool")
|
|
261
|
+
elif disconnect_client is not None:
|
|
262
|
+
status = "after cancellation" if not completed else "during shutdown"
|
|
263
|
+
logger.info(f"{tag} Destroyed {model} client {status}")
|
|
264
|
+
await self._disconnect_client(disconnect_client)
|
|
265
|
+
self._semaphore.release()
|
|
266
|
+
self._log_status("Released", request_id)
|
|
267
|
+
|
|
268
|
+
async def _periodic_health_check(self) -> None:
|
|
269
|
+
"""Background task that checks idle client health every HEALTH_CHECK_INTERVAL seconds."""
|
|
270
|
+
while True:
|
|
271
|
+
await asyncio.sleep(HEALTH_CHECK_INTERVAL)
|
|
272
|
+
try:
|
|
273
|
+
await self._check_idle_clients()
|
|
274
|
+
except Exception as e:
|
|
275
|
+
logger.warning(f"[pool] Health check error: {e}")
|
|
276
|
+
|
|
277
|
+
async def _check_idle_clients(self) -> None:
|
|
278
|
+
"""Check each idle client's process is alive, removing dead clients."""
|
|
279
|
+
async with self._lock:
|
|
280
|
+
clients_to_check = list(self._available)
|
|
281
|
+
|
|
282
|
+
for client in clients_to_check:
|
|
283
|
+
try:
|
|
284
|
+
# Check if the underlying process is still alive
|
|
285
|
+
transport = getattr(client, "_transport", None)
|
|
286
|
+
if transport is not None:
|
|
287
|
+
process = getattr(transport, "_process", None)
|
|
288
|
+
if process is not None and process.returncode is not None:
|
|
289
|
+
raise RuntimeError("Process exited")
|
|
290
|
+
except Exception:
|
|
291
|
+
model = self._client_models.get(client, "unknown")
|
|
292
|
+
logger.warning(f"[pool] Health check: {model} client unresponsive")
|
|
293
|
+
removed = False
|
|
294
|
+
async with self._lock:
|
|
295
|
+
if client in self._available:
|
|
296
|
+
self._available.remove(client)
|
|
297
|
+
removed = True
|
|
298
|
+
if removed:
|
|
299
|
+
await self._disconnect_client(client)
|
|
300
|
+
self._fire_change()
|
|
301
|
+
|
|
302
|
+
async def shutdown(self) -> None:
|
|
303
|
+
"""Disconnect all clients and clean up."""
|
|
304
|
+
logger.info(f"[pool] Shutting down {len(self._client_models)} clients...")
|
|
305
|
+
|
|
306
|
+
# Prevent in-flight requests from returning clients to the idle pool.
|
|
307
|
+
self._initialized = False
|
|
308
|
+
|
|
309
|
+
# Cancel health check task
|
|
310
|
+
if self._health_check_task is not None:
|
|
311
|
+
self._health_check_task.cancel()
|
|
312
|
+
try:
|
|
313
|
+
await self._health_check_task
|
|
314
|
+
except asyncio.CancelledError:
|
|
315
|
+
pass
|
|
316
|
+
self._health_check_task = None
|
|
317
|
+
|
|
318
|
+
# Drain in-flight requests before disconnecting clients
|
|
319
|
+
drain_timeout = int(os.environ.get("SHUTDOWN_DRAIN_TIMEOUT", 30))
|
|
320
|
+
deadline = time.monotonic() + drain_timeout
|
|
321
|
+
while self._in_use > 0:
|
|
322
|
+
remaining = deadline - time.monotonic()
|
|
323
|
+
if remaining <= 0:
|
|
324
|
+
logger.warning(
|
|
325
|
+
f"[pool] Shutdown drain timeout after {drain_timeout}s "
|
|
326
|
+
f"({self._in_use} request(s) still in flight — forcing cleanup)"
|
|
327
|
+
)
|
|
328
|
+
break
|
|
329
|
+
await asyncio.sleep(0.1)
|
|
330
|
+
|
|
331
|
+
for client in list(self._client_models.keys()):
|
|
332
|
+
await self._disconnect_client(client)
|
|
333
|
+
|
|
334
|
+
self._available.clear()
|
|
335
|
+
self._in_use = 0
|
|
336
|
+
self._fire_change()
|
|
337
|
+
|
|
338
|
+
logger.info("[pool] Shutdown complete")
|