sandboxy 0.0.3__py3-none-any.whl → 0.0.5__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.
@@ -0,0 +1,369 @@
1
+ """API routes for local provider management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any, Literal
7
+
8
+ from fastapi import APIRouter, HTTPException, status
9
+ from pydantic import BaseModel, Field
10
+
11
+ from sandboxy.providers.config import (
12
+ LocalProviderConfig,
13
+ ProviderStatusEnum,
14
+ load_providers_config,
15
+ save_providers_config,
16
+ )
17
+ from sandboxy.providers.local import LocalProvider
18
+ from sandboxy.providers.registry import reload_local_providers
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ router = APIRouter(prefix="/providers", tags=["providers"])
23
+
24
+
25
+ # --- Response Models ---
26
+
27
+
28
+ class ProviderSummary(BaseModel):
29
+ """Summary of a provider for list view."""
30
+
31
+ name: str
32
+ type: str
33
+ base_url: str
34
+ enabled: bool
35
+ status: ProviderStatusEnum
36
+ model_count: int
37
+ models: list[str] = Field(default_factory=list)
38
+
39
+
40
+ class ProviderListResponse(BaseModel):
41
+ """Response for GET /api/v1/providers."""
42
+
43
+ providers: list[ProviderSummary]
44
+
45
+
46
+ class LocalModelInfoResponse(BaseModel):
47
+ """Model info in API response."""
48
+
49
+ id: str
50
+ name: str
51
+ context_length: int
52
+ supports_tools: bool
53
+ is_local: bool = True
54
+
55
+
56
+ class ProviderStatusResponse(BaseModel):
57
+ """Provider connection status."""
58
+
59
+ status: ProviderStatusEnum
60
+ last_checked: str | None = None
61
+ available_models: list[str] = Field(default_factory=list)
62
+ latency_ms: int | None = None
63
+ error_message: str | None = None
64
+
65
+
66
+ class ProviderDetailResponse(BaseModel):
67
+ """Response for GET /api/v1/providers/{name}."""
68
+
69
+ config: dict[str, Any] # LocalProviderConfig as dict
70
+ status: ProviderStatusResponse
71
+ models: list[LocalModelInfoResponse]
72
+
73
+
74
+ class AddProviderRequest(BaseModel):
75
+ """Request body for POST /api/v1/providers."""
76
+
77
+ name: str
78
+ type: Literal["ollama", "lmstudio", "vllm", "openai-compatible"] = "openai-compatible"
79
+ base_url: str
80
+ api_key: str | None = None
81
+ models: list[str] = Field(default_factory=list)
82
+ default_params: dict[str, Any] = Field(default_factory=dict)
83
+
84
+
85
+ class UpdateProviderRequest(BaseModel):
86
+ """Request body for PATCH /api/v1/providers/{name}."""
87
+
88
+ enabled: bool | None = None
89
+ api_key: str | None = None
90
+ models: list[str] | None = None
91
+ default_params: dict[str, Any] | None = None
92
+
93
+
94
+ class TestConnectionResponse(BaseModel):
95
+ """Response for POST /api/v1/providers/{name}/test."""
96
+
97
+ success: bool
98
+ latency_ms: int | None = None
99
+ models_found: list[str] = Field(default_factory=list)
100
+ error: str | None = None
101
+
102
+
103
+ class RefreshModelsResponse(BaseModel):
104
+ """Response for POST /api/v1/providers/{name}/refresh."""
105
+
106
+ models_found: list[str]
107
+ models_added: list[str]
108
+ models_removed: list[str]
109
+
110
+
111
+ class ErrorDetail(BaseModel):
112
+ """Standard error response."""
113
+
114
+ code: str
115
+ message: str
116
+ details: dict[str, Any] | None = None
117
+
118
+
119
+ # --- Routes ---
120
+
121
+
122
+ @router.get("", response_model=ProviderListResponse)
123
+ async def list_providers() -> ProviderListResponse:
124
+ """List all configured providers with status."""
125
+ config = load_providers_config()
126
+
127
+ summaries: list[ProviderSummary] = []
128
+ for pconfig in config.providers:
129
+ provider = LocalProvider(pconfig)
130
+ try:
131
+ status = await provider.test_connection()
132
+ summaries.append(
133
+ ProviderSummary(
134
+ name=pconfig.name,
135
+ type=pconfig.type,
136
+ base_url=pconfig.base_url,
137
+ enabled=pconfig.enabled,
138
+ status=status.status,
139
+ model_count=len(status.available_models),
140
+ models=status.available_models,
141
+ )
142
+ )
143
+ except Exception:
144
+ summaries.append(
145
+ ProviderSummary(
146
+ name=pconfig.name,
147
+ type=pconfig.type,
148
+ base_url=pconfig.base_url,
149
+ enabled=pconfig.enabled,
150
+ status=ProviderStatusEnum.ERROR,
151
+ model_count=0,
152
+ models=[],
153
+ )
154
+ )
155
+ finally:
156
+ await provider.close()
157
+
158
+ return ProviderListResponse(providers=summaries)
159
+
160
+
161
+ @router.post("", response_model=ProviderSummary, status_code=status.HTTP_201_CREATED)
162
+ async def add_provider(request: AddProviderRequest) -> ProviderSummary:
163
+ """Add a new provider."""
164
+ config = load_providers_config()
165
+
166
+ # Check for duplicate
167
+ if config.get_provider(request.name):
168
+ raise HTTPException(
169
+ status_code=status.HTTP_409_CONFLICT,
170
+ detail=ErrorDetail(
171
+ code="provider_exists",
172
+ message=f"Provider '{request.name}' already exists",
173
+ ).model_dump(),
174
+ )
175
+
176
+ # Validate and create config
177
+ try:
178
+ provider_config = LocalProviderConfig(
179
+ name=request.name,
180
+ type=request.type,
181
+ base_url=request.base_url,
182
+ api_key=request.api_key,
183
+ models=request.models,
184
+ default_params=request.default_params,
185
+ )
186
+ except ValueError as e:
187
+ raise HTTPException(
188
+ status_code=status.HTTP_400_BAD_REQUEST,
189
+ detail=ErrorDetail(
190
+ code="validation_error",
191
+ message=str(e),
192
+ ).model_dump(),
193
+ ) from e
194
+
195
+ # Test connection
196
+ provider = LocalProvider(provider_config)
197
+ try:
198
+ conn_status = await provider.test_connection()
199
+ finally:
200
+ await provider.close()
201
+
202
+ # Save config
203
+ config.add_provider(provider_config)
204
+ save_providers_config(config)
205
+ reload_local_providers()
206
+
207
+ return ProviderSummary(
208
+ name=provider_config.name,
209
+ type=provider_config.type,
210
+ base_url=provider_config.base_url,
211
+ enabled=provider_config.enabled,
212
+ status=conn_status.status,
213
+ model_count=len(conn_status.available_models),
214
+ )
215
+
216
+
217
+ @router.get("/{name}", response_model=ProviderDetailResponse)
218
+ async def get_provider(name: str) -> ProviderDetailResponse:
219
+ """Get detailed provider info including models."""
220
+ config = load_providers_config()
221
+ provider_config = config.get_provider(name)
222
+
223
+ if not provider_config:
224
+ raise HTTPException(
225
+ status_code=status.HTTP_404_NOT_FOUND,
226
+ detail=ErrorDetail(
227
+ code="provider_not_found",
228
+ message=f"Provider '{name}' not found",
229
+ details={"available_providers": [p.name for p in config.providers]},
230
+ ).model_dump(),
231
+ )
232
+
233
+ provider = LocalProvider(provider_config)
234
+ try:
235
+ conn_status = await provider.test_connection()
236
+ models = await provider.refresh_models()
237
+ finally:
238
+ await provider.close()
239
+
240
+ return ProviderDetailResponse(
241
+ config=provider_config.model_dump(),
242
+ status=ProviderStatusResponse(
243
+ status=conn_status.status,
244
+ last_checked=conn_status.last_checked.isoformat() if conn_status.last_checked else None,
245
+ available_models=conn_status.available_models,
246
+ latency_ms=conn_status.latency_ms,
247
+ error_message=conn_status.error_message,
248
+ ),
249
+ models=[
250
+ LocalModelInfoResponse(
251
+ id=m.id,
252
+ name=m.name,
253
+ context_length=m.context_length,
254
+ supports_tools=m.supports_tools,
255
+ )
256
+ for m in models
257
+ ],
258
+ )
259
+
260
+
261
+ @router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT)
262
+ async def delete_provider(name: str) -> None:
263
+ """Remove a provider."""
264
+ config = load_providers_config()
265
+
266
+ if not config.remove_provider(name):
267
+ raise HTTPException(
268
+ status_code=status.HTTP_404_NOT_FOUND,
269
+ detail=ErrorDetail(
270
+ code="provider_not_found",
271
+ message=f"Provider '{name}' not found",
272
+ ).model_dump(),
273
+ )
274
+
275
+ save_providers_config(config)
276
+ reload_local_providers()
277
+
278
+
279
+ @router.patch("/{name}", response_model=dict)
280
+ async def update_provider(name: str, request: UpdateProviderRequest) -> dict:
281
+ """Update provider configuration."""
282
+ config = load_providers_config()
283
+
284
+ updates = {k: v for k, v in request.model_dump().items() if v is not None}
285
+ if not updates:
286
+ raise HTTPException(
287
+ status_code=status.HTTP_400_BAD_REQUEST,
288
+ detail=ErrorDetail(
289
+ code="validation_error",
290
+ message="No fields to update",
291
+ ).model_dump(),
292
+ )
293
+
294
+ updated = config.update_provider(name, **updates)
295
+ if not updated:
296
+ raise HTTPException(
297
+ status_code=status.HTTP_404_NOT_FOUND,
298
+ detail=ErrorDetail(
299
+ code="provider_not_found",
300
+ message=f"Provider '{name}' not found",
301
+ ).model_dump(),
302
+ )
303
+
304
+ save_providers_config(config)
305
+ reload_local_providers()
306
+
307
+ return updated.model_dump()
308
+
309
+
310
+ @router.post("/{name}/test", response_model=TestConnectionResponse)
311
+ async def test_provider_connection(name: str) -> TestConnectionResponse:
312
+ """Test provider connection."""
313
+ config = load_providers_config()
314
+ provider_config = config.get_provider(name)
315
+
316
+ if not provider_config:
317
+ raise HTTPException(
318
+ status_code=status.HTTP_404_NOT_FOUND,
319
+ detail=ErrorDetail(
320
+ code="provider_not_found",
321
+ message=f"Provider '{name}' not found",
322
+ ).model_dump(),
323
+ )
324
+
325
+ provider = LocalProvider(provider_config)
326
+ try:
327
+ conn_status = await provider.test_connection()
328
+ finally:
329
+ await provider.close()
330
+
331
+ return TestConnectionResponse(
332
+ success=conn_status.status == ProviderStatusEnum.CONNECTED,
333
+ latency_ms=conn_status.latency_ms,
334
+ models_found=conn_status.available_models,
335
+ error=conn_status.error_message,
336
+ )
337
+
338
+
339
+ @router.post("/{name}/refresh", response_model=RefreshModelsResponse)
340
+ async def refresh_provider_models(name: str) -> RefreshModelsResponse:
341
+ """Refresh model list from provider."""
342
+ config = load_providers_config()
343
+ provider_config = config.get_provider(name)
344
+
345
+ if not provider_config:
346
+ raise HTTPException(
347
+ status_code=status.HTTP_404_NOT_FOUND,
348
+ detail=ErrorDetail(
349
+ code="provider_not_found",
350
+ message=f"Provider '{name}' not found",
351
+ ).model_dump(),
352
+ )
353
+
354
+ # Get current models
355
+ old_models = set(provider_config.models)
356
+
357
+ provider = LocalProvider(provider_config)
358
+ try:
359
+ models = await provider.refresh_models()
360
+ finally:
361
+ await provider.close()
362
+
363
+ new_models = {m.id for m in models}
364
+
365
+ return RefreshModelsResponse(
366
+ models_found=list(new_models),
367
+ models_added=list(new_models - old_models),
368
+ models_removed=list(old_models - new_models),
369
+ )