kubiya-control-plane-api 0.1.0__py3-none-any.whl → 0.3.4__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.

Potentially problematic release.


This version of kubiya-control-plane-api might be problematic. Click here for more details.

Files changed (185) hide show
  1. control_plane_api/README.md +266 -0
  2. control_plane_api/__init__.py +0 -0
  3. control_plane_api/__version__.py +1 -0
  4. control_plane_api/alembic/README +1 -0
  5. control_plane_api/alembic/env.py +98 -0
  6. control_plane_api/alembic/script.py.mako +28 -0
  7. control_plane_api/alembic/versions/1382bec74309_initial_migration_with_all_models.py +251 -0
  8. control_plane_api/alembic/versions/1f54bc2a37e3_add_analytics_tables.py +162 -0
  9. control_plane_api/alembic/versions/2e4cb136dc10_rename_toolset_ids_to_skill_ids_in_teams.py +30 -0
  10. control_plane_api/alembic/versions/31cd69a644ce_add_skill_templates_table.py +28 -0
  11. control_plane_api/alembic/versions/89e127caa47d_add_jobs_and_job_executions_tables.py +161 -0
  12. control_plane_api/alembic/versions/add_llm_models_table.py +51 -0
  13. control_plane_api/alembic/versions/b0e10697f212_add_runtime_column_to_teams_simple.py +42 -0
  14. control_plane_api/alembic/versions/ce43b24b63bf_add_execution_trigger_source_and_fix_.py +155 -0
  15. control_plane_api/alembic/versions/d4eaf16e3f8d_rename_toolsets_to_skills.py +84 -0
  16. control_plane_api/alembic/versions/efa2dc427da1_rename_metadata_to_custom_metadata.py +32 -0
  17. control_plane_api/alembic/versions/f973b431d1ce_add_workflow_executor_to_skill_types.py +44 -0
  18. control_plane_api/alembic.ini +148 -0
  19. control_plane_api/api/index.py +12 -0
  20. control_plane_api/app/__init__.py +11 -0
  21. control_plane_api/app/activities/__init__.py +20 -0
  22. control_plane_api/app/activities/agent_activities.py +379 -0
  23. control_plane_api/app/activities/team_activities.py +410 -0
  24. control_plane_api/app/activities/temporal_cloud_activities.py +577 -0
  25. control_plane_api/app/config/__init__.py +35 -0
  26. control_plane_api/app/config/api_config.py +354 -0
  27. control_plane_api/app/config/model_pricing.py +318 -0
  28. control_plane_api/app/config.py +95 -0
  29. control_plane_api/app/database.py +135 -0
  30. control_plane_api/app/exceptions.py +408 -0
  31. control_plane_api/app/lib/__init__.py +11 -0
  32. control_plane_api/app/lib/job_executor.py +312 -0
  33. control_plane_api/app/lib/kubiya_client.py +235 -0
  34. control_plane_api/app/lib/litellm_pricing.py +166 -0
  35. control_plane_api/app/lib/planning_tools/__init__.py +22 -0
  36. control_plane_api/app/lib/planning_tools/agents.py +155 -0
  37. control_plane_api/app/lib/planning_tools/base.py +189 -0
  38. control_plane_api/app/lib/planning_tools/environments.py +214 -0
  39. control_plane_api/app/lib/planning_tools/resources.py +240 -0
  40. control_plane_api/app/lib/planning_tools/teams.py +198 -0
  41. control_plane_api/app/lib/policy_enforcer_client.py +939 -0
  42. control_plane_api/app/lib/redis_client.py +436 -0
  43. control_plane_api/app/lib/supabase.py +71 -0
  44. control_plane_api/app/lib/temporal_client.py +138 -0
  45. control_plane_api/app/lib/validation/__init__.py +20 -0
  46. control_plane_api/app/lib/validation/runtime_validation.py +287 -0
  47. control_plane_api/app/main.py +128 -0
  48. control_plane_api/app/middleware/__init__.py +8 -0
  49. control_plane_api/app/middleware/auth.py +513 -0
  50. control_plane_api/app/middleware/exception_handler.py +267 -0
  51. control_plane_api/app/middleware/rate_limiting.py +384 -0
  52. control_plane_api/app/middleware/request_id.py +202 -0
  53. control_plane_api/app/models/__init__.py +27 -0
  54. control_plane_api/app/models/agent.py +79 -0
  55. control_plane_api/app/models/analytics.py +206 -0
  56. control_plane_api/app/models/associations.py +81 -0
  57. control_plane_api/app/models/environment.py +63 -0
  58. control_plane_api/app/models/execution.py +93 -0
  59. control_plane_api/app/models/job.py +179 -0
  60. control_plane_api/app/models/llm_model.py +75 -0
  61. control_plane_api/app/models/presence.py +49 -0
  62. control_plane_api/app/models/project.py +47 -0
  63. control_plane_api/app/models/session.py +38 -0
  64. control_plane_api/app/models/team.py +66 -0
  65. control_plane_api/app/models/workflow.py +55 -0
  66. control_plane_api/app/policies/README.md +121 -0
  67. control_plane_api/app/policies/approved_users.rego +62 -0
  68. control_plane_api/app/policies/business_hours.rego +51 -0
  69. control_plane_api/app/policies/rate_limiting.rego +100 -0
  70. control_plane_api/app/policies/tool_restrictions.rego +86 -0
  71. control_plane_api/app/routers/__init__.py +4 -0
  72. control_plane_api/app/routers/agents.py +364 -0
  73. control_plane_api/app/routers/agents_v2.py +1260 -0
  74. control_plane_api/app/routers/analytics.py +1014 -0
  75. control_plane_api/app/routers/context_manager.py +562 -0
  76. control_plane_api/app/routers/environment_context.py +270 -0
  77. control_plane_api/app/routers/environments.py +715 -0
  78. control_plane_api/app/routers/execution_environment.py +517 -0
  79. control_plane_api/app/routers/executions.py +1911 -0
  80. control_plane_api/app/routers/health.py +92 -0
  81. control_plane_api/app/routers/health_v2.py +326 -0
  82. control_plane_api/app/routers/integrations.py +274 -0
  83. control_plane_api/app/routers/jobs.py +1344 -0
  84. control_plane_api/app/routers/models.py +82 -0
  85. control_plane_api/app/routers/models_v2.py +361 -0
  86. control_plane_api/app/routers/policies.py +639 -0
  87. control_plane_api/app/routers/presence.py +234 -0
  88. control_plane_api/app/routers/projects.py +902 -0
  89. control_plane_api/app/routers/runners.py +379 -0
  90. control_plane_api/app/routers/runtimes.py +172 -0
  91. control_plane_api/app/routers/secrets.py +155 -0
  92. control_plane_api/app/routers/skills.py +1001 -0
  93. control_plane_api/app/routers/skills_definitions.py +140 -0
  94. control_plane_api/app/routers/task_planning.py +1256 -0
  95. control_plane_api/app/routers/task_queues.py +654 -0
  96. control_plane_api/app/routers/team_context.py +270 -0
  97. control_plane_api/app/routers/teams.py +1400 -0
  98. control_plane_api/app/routers/worker_queues.py +1545 -0
  99. control_plane_api/app/routers/workers.py +935 -0
  100. control_plane_api/app/routers/workflows.py +204 -0
  101. control_plane_api/app/runtimes/__init__.py +6 -0
  102. control_plane_api/app/runtimes/validation.py +344 -0
  103. control_plane_api/app/schemas/job_schemas.py +295 -0
  104. control_plane_api/app/services/__init__.py +1 -0
  105. control_plane_api/app/services/agno_service.py +619 -0
  106. control_plane_api/app/services/litellm_service.py +190 -0
  107. control_plane_api/app/services/policy_service.py +525 -0
  108. control_plane_api/app/services/temporal_cloud_provisioning.py +150 -0
  109. control_plane_api/app/skills/__init__.py +44 -0
  110. control_plane_api/app/skills/base.py +229 -0
  111. control_plane_api/app/skills/business_intelligence.py +189 -0
  112. control_plane_api/app/skills/data_visualization.py +154 -0
  113. control_plane_api/app/skills/docker.py +104 -0
  114. control_plane_api/app/skills/file_generation.py +94 -0
  115. control_plane_api/app/skills/file_system.py +110 -0
  116. control_plane_api/app/skills/python.py +92 -0
  117. control_plane_api/app/skills/registry.py +65 -0
  118. control_plane_api/app/skills/shell.py +102 -0
  119. control_plane_api/app/skills/workflow_executor.py +469 -0
  120. control_plane_api/app/utils/workflow_executor.py +354 -0
  121. control_plane_api/app/workflows/__init__.py +11 -0
  122. control_plane_api/app/workflows/agent_execution.py +507 -0
  123. control_plane_api/app/workflows/agent_execution_with_skills.py +222 -0
  124. control_plane_api/app/workflows/namespace_provisioning.py +326 -0
  125. control_plane_api/app/workflows/team_execution.py +399 -0
  126. control_plane_api/scripts/seed_models.py +239 -0
  127. control_plane_api/worker/__init__.py +0 -0
  128. control_plane_api/worker/activities/__init__.py +0 -0
  129. control_plane_api/worker/activities/agent_activities.py +1241 -0
  130. control_plane_api/worker/activities/approval_activities.py +234 -0
  131. control_plane_api/worker/activities/runtime_activities.py +388 -0
  132. control_plane_api/worker/activities/skill_activities.py +267 -0
  133. control_plane_api/worker/activities/team_activities.py +1217 -0
  134. control_plane_api/worker/config/__init__.py +31 -0
  135. control_plane_api/worker/config/worker_config.py +275 -0
  136. control_plane_api/worker/control_plane_client.py +529 -0
  137. control_plane_api/worker/examples/analytics_integration_example.py +362 -0
  138. control_plane_api/worker/models/__init__.py +1 -0
  139. control_plane_api/worker/models/inputs.py +89 -0
  140. control_plane_api/worker/runtimes/__init__.py +31 -0
  141. control_plane_api/worker/runtimes/base.py +789 -0
  142. control_plane_api/worker/runtimes/claude_code_runtime.py +1443 -0
  143. control_plane_api/worker/runtimes/default_runtime.py +617 -0
  144. control_plane_api/worker/runtimes/factory.py +173 -0
  145. control_plane_api/worker/runtimes/validation.py +93 -0
  146. control_plane_api/worker/services/__init__.py +1 -0
  147. control_plane_api/worker/services/agent_executor.py +422 -0
  148. control_plane_api/worker/services/agent_executor_v2.py +383 -0
  149. control_plane_api/worker/services/analytics_collector.py +457 -0
  150. control_plane_api/worker/services/analytics_service.py +464 -0
  151. control_plane_api/worker/services/approval_tools.py +310 -0
  152. control_plane_api/worker/services/approval_tools_agno.py +207 -0
  153. control_plane_api/worker/services/cancellation_manager.py +177 -0
  154. control_plane_api/worker/services/data_visualization.py +827 -0
  155. control_plane_api/worker/services/jira_tools.py +257 -0
  156. control_plane_api/worker/services/runtime_analytics.py +328 -0
  157. control_plane_api/worker/services/session_service.py +194 -0
  158. control_plane_api/worker/services/skill_factory.py +175 -0
  159. control_plane_api/worker/services/team_executor.py +574 -0
  160. control_plane_api/worker/services/team_executor_v2.py +465 -0
  161. control_plane_api/worker/services/workflow_executor_tools.py +1418 -0
  162. control_plane_api/worker/tests/__init__.py +1 -0
  163. control_plane_api/worker/tests/e2e/__init__.py +0 -0
  164. control_plane_api/worker/tests/e2e/test_execution_flow.py +571 -0
  165. control_plane_api/worker/tests/integration/__init__.py +0 -0
  166. control_plane_api/worker/tests/integration/test_control_plane_integration.py +308 -0
  167. control_plane_api/worker/tests/unit/__init__.py +0 -0
  168. control_plane_api/worker/tests/unit/test_control_plane_client.py +401 -0
  169. control_plane_api/worker/utils/__init__.py +1 -0
  170. control_plane_api/worker/utils/chunk_batcher.py +305 -0
  171. control_plane_api/worker/utils/retry_utils.py +60 -0
  172. control_plane_api/worker/utils/streaming_utils.py +373 -0
  173. control_plane_api/worker/worker.py +753 -0
  174. control_plane_api/worker/workflows/__init__.py +0 -0
  175. control_plane_api/worker/workflows/agent_execution.py +589 -0
  176. control_plane_api/worker/workflows/team_execution.py +429 -0
  177. kubiya_control_plane_api-0.3.4.dist-info/METADATA +229 -0
  178. kubiya_control_plane_api-0.3.4.dist-info/RECORD +182 -0
  179. kubiya_control_plane_api-0.3.4.dist-info/entry_points.txt +2 -0
  180. kubiya_control_plane_api-0.3.4.dist-info/top_level.txt +1 -0
  181. kubiya_control_plane_api-0.1.0.dist-info/METADATA +0 -66
  182. kubiya_control_plane_api-0.1.0.dist-info/RECORD +0 -5
  183. kubiya_control_plane_api-0.1.0.dist-info/top_level.txt +0 -1
  184. {kubiya_control_plane_api-0.1.0.dist-info/licenses → control_plane_api}/LICENSE +0 -0
  185. {kubiya_control_plane_api-0.1.0.dist-info → kubiya_control_plane_api-0.3.4.dist-info}/WHEEL +0 -0
@@ -0,0 +1,82 @@
1
+ """
2
+ API router for LLM models configuration
3
+ """
4
+ from fastapi import APIRouter
5
+ from typing import List, Optional
6
+ from pydantic import BaseModel
7
+
8
+ router = APIRouter(prefix="/api/v1/models", tags=["models"])
9
+
10
+
11
+ class LLMModel(BaseModel):
12
+ """LLM Model configuration"""
13
+ value: str
14
+ label: str
15
+ provider: str
16
+ logo: str
17
+ recommended: bool = False
18
+ description: Optional[str] = None
19
+
20
+
21
+ # Kubiya's supported LLM models
22
+ # NOTE: All models must include "kubiya/" prefix for LiteLLM routing
23
+ KUBIYA_LLM_MODELS = [
24
+ LLMModel(
25
+ value="kubiya/claude-sonnet-4",
26
+ label="Claude Sonnet 4",
27
+ provider="Anthropic",
28
+ logo="/logos/claude-color.svg",
29
+ recommended=True,
30
+ description="Most intelligent model with best reasoning capabilities"
31
+ ),
32
+ LLMModel(
33
+ value="kubiya/claude-opus-4",
34
+ label="Claude Opus 4",
35
+ provider="Anthropic",
36
+ logo="/logos/claude-color.svg",
37
+ description="Powerful model for complex tasks requiring deep analysis"
38
+ ),
39
+ LLMModel(
40
+ value="kubiya/gpt-4o",
41
+ label="GPT-4o",
42
+ provider="OpenAI",
43
+ logo="/thirdparty/logos/openai.svg",
44
+ description="Fast and capable model with vision support"
45
+ ),
46
+ LLMModel(
47
+ value="kubiya/gpt-4-turbo",
48
+ label="GPT-4 Turbo",
49
+ provider="OpenAI",
50
+ logo="/thirdparty/logos/openai.svg",
51
+ description="Enhanced GPT-4 with improved speed and capabilities"
52
+ ),
53
+ LLMModel(
54
+ value="kubiya/claude-3-5-sonnet-20241022",
55
+ label="Claude 3.5 Sonnet",
56
+ provider="Anthropic",
57
+ logo="/logos/claude-color.svg",
58
+ description="Previous generation Sonnet with excellent performance"
59
+ ),
60
+ ]
61
+
62
+
63
+ @router.get("", response_model=List[LLMModel])
64
+ async def list_models():
65
+ """
66
+ Get list of available LLM models.
67
+
68
+ Returns:
69
+ List of LLM model configurations with logos and metadata
70
+ """
71
+ return KUBIYA_LLM_MODELS
72
+
73
+
74
+ @router.get("/default", response_model=LLMModel)
75
+ async def get_default_model():
76
+ """
77
+ Get the default recommended LLM model.
78
+
79
+ Returns:
80
+ The recommended default model configuration
81
+ """
82
+ return next((model for model in KUBIYA_LLM_MODELS if model.recommended), KUBIYA_LLM_MODELS[0])
@@ -0,0 +1,361 @@
1
+ """
2
+ LLM Models CRUD API with database persistence
3
+
4
+ This router provides full CRUD operations for managing LLM models
5
+ that can be used by agents and teams.
6
+ """
7
+ from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
8
+ from typing import List, Optional
9
+ from pydantic import BaseModel, Field
10
+ from sqlalchemy.orm import Session
11
+ from datetime import datetime
12
+ import structlog
13
+
14
+ from control_plane_api.app.middleware.auth import get_current_organization
15
+ from control_plane_api.app.database import get_db
16
+ from control_plane_api.app.models.llm_model import LLMModel as LLMModelDB
17
+
18
+ logger = structlog.get_logger()
19
+
20
+ router = APIRouter()
21
+
22
+
23
+ # ==================== Pydantic Schemas ====================
24
+
25
+ class LLMModelCreate(BaseModel):
26
+ """Schema for creating a new LLM model"""
27
+ value: str = Field(..., description="Model identifier (e.g., 'kubiya/claude-sonnet-4')")
28
+ label: str = Field(..., description="Display name (e.g., 'Claude Sonnet 4')")
29
+ provider: str = Field(..., description="Provider name (e.g., 'Anthropic', 'OpenAI')")
30
+ logo: Optional[str] = Field(None, description="Logo path or URL")
31
+ description: Optional[str] = Field(None, description="Model description")
32
+ enabled: bool = Field(True, description="Whether model is enabled")
33
+ recommended: bool = Field(False, description="Whether model is recommended by default")
34
+ compatible_runtimes: List[str] = Field(
35
+ default_factory=list,
36
+ description="List of compatible runtime IDs (e.g., ['default', 'claude_code'])"
37
+ )
38
+ capabilities: dict = Field(
39
+ default_factory=dict,
40
+ description="Model capabilities (e.g., {'vision': true, 'max_tokens': 4096})"
41
+ )
42
+ pricing: Optional[dict] = Field(None, description="Pricing information")
43
+ display_order: int = Field(1000, description="Display order (lower = shown first)")
44
+
45
+
46
+ class LLMModelUpdate(BaseModel):
47
+ """Schema for updating an existing LLM model"""
48
+ value: Optional[str] = None
49
+ label: Optional[str] = None
50
+ provider: Optional[str] = None
51
+ logo: Optional[str] = None
52
+ description: Optional[str] = None
53
+ enabled: Optional[bool] = None
54
+ recommended: Optional[bool] = None
55
+ compatible_runtimes: Optional[List[str]] = None
56
+ capabilities: Optional[dict] = None
57
+ pricing: Optional[dict] = None
58
+ display_order: Optional[int] = None
59
+
60
+
61
+ class LLMModelResponse(BaseModel):
62
+ """Schema for LLM model responses"""
63
+ id: str
64
+ value: str
65
+ label: str
66
+ provider: str
67
+ logo: Optional[str]
68
+ description: Optional[str]
69
+ enabled: bool
70
+ recommended: bool
71
+ compatible_runtimes: List[str]
72
+ capabilities: dict
73
+ pricing: Optional[dict]
74
+ display_order: int
75
+ created_at: str
76
+ updated_at: str
77
+
78
+ class Config:
79
+ from_attributes = True
80
+
81
+
82
+ # ==================== Helper Functions ====================
83
+
84
+ def check_runtime_compatibility(model: LLMModelDB, runtime_id: Optional[str]) -> bool:
85
+ """Check if a model is compatible with a specific runtime"""
86
+ if not runtime_id:
87
+ return True # No filter specified
88
+ if not model.compatible_runtimes:
89
+ return True # Model doesn't specify compatibility, allow all
90
+ return runtime_id in model.compatible_runtimes
91
+
92
+
93
+ # ==================== CRUD Endpoints ====================
94
+
95
+ @router.post("", response_model=LLMModelResponse, status_code=status.HTTP_201_CREATED)
96
+ def create_model(
97
+ model_data: LLMModelCreate,
98
+ request: Request,
99
+ db: Session = Depends(get_db),
100
+ organization: dict = Depends(get_current_organization),
101
+ ):
102
+ """
103
+ Create a new LLM model.
104
+
105
+ Only accessible by authenticated users (org admins recommended).
106
+ """
107
+ # Check if model with this value already exists
108
+ existing = db.query(LLMModelDB).filter(LLMModelDB.value == model_data.value).first()
109
+ if existing:
110
+ raise HTTPException(
111
+ status_code=status.HTTP_409_CONFLICT,
112
+ detail=f"Model with value '{model_data.value}' already exists"
113
+ )
114
+
115
+ # Create new model
116
+ new_model = LLMModelDB(
117
+ value=model_data.value,
118
+ label=model_data.label,
119
+ provider=model_data.provider,
120
+ logo=model_data.logo,
121
+ description=model_data.description,
122
+ enabled=model_data.enabled,
123
+ recommended=model_data.recommended,
124
+ compatible_runtimes=model_data.compatible_runtimes,
125
+ capabilities=model_data.capabilities,
126
+ pricing=model_data.pricing,
127
+ display_order=model_data.display_order,
128
+ created_by=organization.get("user_id"),
129
+ )
130
+
131
+ db.add(new_model)
132
+ db.commit()
133
+ db.refresh(new_model)
134
+
135
+ logger.info(
136
+ "llm_model_created",
137
+ model_id=new_model.id,
138
+ model_value=new_model.value,
139
+ provider=new_model.provider,
140
+ org_id=organization["id"]
141
+ )
142
+
143
+ return model_to_response(new_model)
144
+
145
+
146
+ @router.get("", response_model=List[LLMModelResponse])
147
+ def list_models(
148
+ db: Session = Depends(get_db),
149
+ enabled_only: bool = Query(True, description="Only return enabled models"),
150
+ provider: Optional[str] = Query(None, description="Filter by provider (e.g., 'Anthropic', 'OpenAI')"),
151
+ runtime: Optional[str] = Query(None, description="Filter by compatible runtime (e.g., 'claude_code')"),
152
+ recommended: Optional[bool] = Query(None, description="Filter by recommended status"),
153
+ skip: int = Query(0, ge=0),
154
+ limit: int = Query(100, ge=1, le=1000),
155
+ ):
156
+ """
157
+ List all LLM models with optional filtering.
158
+
159
+ Query Parameters:
160
+ - enabled_only: Only return enabled models (default: true)
161
+ - provider: Filter by provider name
162
+ - runtime: Filter by compatible runtime
163
+ - recommended: Filter by recommended status
164
+ - skip/limit: Pagination
165
+ """
166
+ query = db.query(LLMModelDB)
167
+
168
+ # Apply filters
169
+ if enabled_only:
170
+ query = query.filter(LLMModelDB.enabled == True)
171
+
172
+ if provider:
173
+ query = query.filter(LLMModelDB.provider == provider)
174
+
175
+ if recommended is not None:
176
+ query = query.filter(LLMModelDB.recommended == recommended)
177
+
178
+ # Order by display_order, then by created_at
179
+ query = query.order_by(LLMModelDB.display_order, LLMModelDB.created_at)
180
+
181
+ # Apply pagination
182
+ models = query.offset(skip).limit(limit).all()
183
+
184
+ # Filter by runtime compatibility (done in Python due to JSON array filtering complexity)
185
+ if runtime:
186
+ models = [m for m in models if check_runtime_compatibility(m, runtime)]
187
+
188
+ return [model_to_response(m) for m in models]
189
+
190
+
191
+ @router.get("/default", response_model=LLMModelResponse)
192
+ def get_default_model(db: Session = Depends(get_db)):
193
+ """
194
+ Get the default recommended LLM model.
195
+
196
+ Returns the first model marked as recommended and enabled.
197
+ If none found, returns the first enabled model.
198
+ """
199
+ # Try to get recommended model first
200
+ model = (
201
+ db.query(LLMModelDB)
202
+ .filter(LLMModelDB.enabled == True, LLMModelDB.recommended == True)
203
+ .order_by(LLMModelDB.display_order)
204
+ .first()
205
+ )
206
+
207
+ # Fallback to first enabled model
208
+ if not model:
209
+ model = (
210
+ db.query(LLMModelDB)
211
+ .filter(LLMModelDB.enabled == True)
212
+ .order_by(LLMModelDB.display_order)
213
+ .first()
214
+ )
215
+
216
+ if not model:
217
+ raise HTTPException(
218
+ status_code=status.HTTP_404_NOT_FOUND,
219
+ detail="No enabled models found"
220
+ )
221
+
222
+ return model_to_response(model)
223
+
224
+
225
+ @router.get("/providers", response_model=List[str])
226
+ def list_providers(db: Session = Depends(get_db)):
227
+ """
228
+ Get list of unique model providers.
229
+
230
+ Returns a list of all unique provider names.
231
+ """
232
+ providers = db.query(LLMModelDB.provider).distinct().all()
233
+ return [p[0] for p in providers]
234
+
235
+
236
+ @router.get("/{model_id}", response_model=LLMModelResponse)
237
+ def get_model(model_id: str, db: Session = Depends(get_db)):
238
+ """
239
+ Get a specific LLM model by ID or value.
240
+
241
+ Accepts either the UUID or the model value (e.g., 'kubiya/claude-sonnet-4').
242
+ """
243
+ # Try by ID first
244
+ model = db.query(LLMModelDB).filter(LLMModelDB.id == model_id).first()
245
+
246
+ # If not found, try by value
247
+ if not model:
248
+ model = db.query(LLMModelDB).filter(LLMModelDB.value == model_id).first()
249
+
250
+ if not model:
251
+ raise HTTPException(
252
+ status_code=status.HTTP_404_NOT_FOUND,
253
+ detail=f"Model '{model_id}' not found"
254
+ )
255
+
256
+ return model_to_response(model)
257
+
258
+
259
+ @router.patch("/{model_id}", response_model=LLMModelResponse)
260
+ def update_model(
261
+ model_id: str,
262
+ model_data: LLMModelUpdate,
263
+ request: Request,
264
+ db: Session = Depends(get_db),
265
+ organization: dict = Depends(get_current_organization),
266
+ ):
267
+ """
268
+ Update an existing LLM model.
269
+
270
+ Only accessible by authenticated users (org admins recommended).
271
+ """
272
+ # Find model
273
+ model = db.query(LLMModelDB).filter(LLMModelDB.id == model_id).first()
274
+ if not model:
275
+ raise HTTPException(
276
+ status_code=status.HTTP_404_NOT_FOUND,
277
+ detail=f"Model '{model_id}' not found"
278
+ )
279
+
280
+ # Check if value is being updated and conflicts with existing
281
+ if model_data.value and model_data.value != model.value:
282
+ existing = db.query(LLMModelDB).filter(LLMModelDB.value == model_data.value).first()
283
+ if existing:
284
+ raise HTTPException(
285
+ status_code=status.HTTP_409_CONFLICT,
286
+ detail=f"Model with value '{model_data.value}' already exists"
287
+ )
288
+
289
+ # Update fields
290
+ update_dict = model_data.model_dump(exclude_unset=True)
291
+ for field, value in update_dict.items():
292
+ setattr(model, field, value)
293
+
294
+ model.updated_at = datetime.utcnow()
295
+
296
+ db.commit()
297
+ db.refresh(model)
298
+
299
+ logger.info(
300
+ "llm_model_updated",
301
+ model_id=model.id,
302
+ model_value=model.value,
303
+ updated_fields=list(update_dict.keys()),
304
+ org_id=organization["id"]
305
+ )
306
+
307
+ return model_to_response(model)
308
+
309
+
310
+ @router.delete("/{model_id}", status_code=status.HTTP_204_NO_CONTENT)
311
+ def delete_model(
312
+ model_id: str,
313
+ request: Request,
314
+ db: Session = Depends(get_db),
315
+ organization: dict = Depends(get_current_organization),
316
+ ):
317
+ """
318
+ Delete an LLM model.
319
+
320
+ Only accessible by authenticated users (org admins recommended).
321
+ """
322
+ model = db.query(LLMModelDB).filter(LLMModelDB.id == model_id).first()
323
+ if not model:
324
+ raise HTTPException(
325
+ status_code=status.HTTP_404_NOT_FOUND,
326
+ detail=f"Model '{model_id}' not found"
327
+ )
328
+
329
+ db.delete(model)
330
+ db.commit()
331
+
332
+ logger.info(
333
+ "llm_model_deleted",
334
+ model_id=model.id,
335
+ model_value=model.value,
336
+ org_id=organization["id"]
337
+ )
338
+
339
+ return None
340
+
341
+
342
+ # ==================== Helper Functions ====================
343
+
344
+ def model_to_response(model: LLMModelDB) -> LLMModelResponse:
345
+ """Convert database model to response schema"""
346
+ return LLMModelResponse(
347
+ id=model.id,
348
+ value=model.value,
349
+ label=model.label,
350
+ provider=model.provider,
351
+ logo=model.logo,
352
+ description=model.description,
353
+ enabled=model.enabled,
354
+ recommended=model.recommended,
355
+ compatible_runtimes=model.compatible_runtimes or [],
356
+ capabilities=model.capabilities or {},
357
+ pricing=model.pricing,
358
+ display_order=model.display_order,
359
+ created_at=model.created_at.isoformat() if model.created_at else "",
360
+ updated_at=model.updated_at.isoformat() if model.updated_at else "",
361
+ )