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/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")