Skip to content

Commit e0201f7

Browse files
committed
feat: integrate external agent providers
Add Cursor/Devin providers with per-user keys, repo access checks, and agent testing tools so Omi can drive coding workflows through hosted agents.
1 parent 5a361bc commit e0201f7

5 files changed

Lines changed: 742 additions & 91 deletions

File tree

.env.example

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,24 @@ OAUTH_REDIRECT_URL=http://localhost:8000/auth/callback
88
# OpenAI API Key (for AI issue generation)
99
OPENAI_API_KEY=your_openai_api_key
1010

11+
# Agent Provider Settings (for code_feature tool)
12+
DEFAULT_AGENT_PROVIDER=cursor
13+
AGENT_CALLBACK_URL=
14+
15+
# Cursor Agent API
16+
CURSOR_AGENT_API_URL=https://api.cursor.com
17+
CURSOR_AGENT_API_KEY=
18+
CURSOR_AGENT_API_ENDPOINT=/v0/agents
19+
CURSOR_AGENT_API_AUTH_HEADER=Authorization
20+
CURSOR_AGENT_API_AUTH_PREFIX=Bearer
21+
22+
# Devin API (v1)
23+
DEVIN_API_URL=https://api.devin.ai/v1
24+
DEVIN_API_KEY=
25+
DEVIN_API_ENDPOINT=/sessions
26+
DEVIN_API_AUTH_HEADER=Authorization
27+
DEVIN_API_AUTH_PREFIX=Bearer
28+
1129
# App Settings
1230
APP_HOST=0.0.0.0
1331
APP_PORT=8000

agent_providers.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"""
2+
Agent provider integrations for coding features.
3+
Calls external agent APIs (Cursor Agent, Devin).
4+
"""
5+
from __future__ import annotations
6+
7+
import os
8+
from typing import Dict, Any, Optional
9+
10+
import requests
11+
12+
DEFAULT_BASE_URLS: Dict[str, str] = {
13+
"cursor": "https://api.cursor.com",
14+
"devin": "https://api.devin.ai/v1",
15+
}
16+
17+
PROVIDERS: Dict[str, Dict[str, str]] = {
18+
"cursor": {
19+
"label": "Cursor Agent",
20+
"env_url": "CURSOR_AGENT_API_URL",
21+
"env_key": "CURSOR_AGENT_API_KEY",
22+
"env_endpoint": "CURSOR_AGENT_API_ENDPOINT",
23+
"env_auth_header": "CURSOR_AGENT_API_AUTH_HEADER",
24+
"env_auth_prefix": "CURSOR_AGENT_API_AUTH_PREFIX",
25+
},
26+
"devin": {
27+
"label": "Devin",
28+
"env_url": "DEVIN_API_URL",
29+
"env_key": "DEVIN_API_KEY",
30+
"env_endpoint": "DEVIN_API_ENDPOINT",
31+
"env_auth_header": "DEVIN_API_AUTH_HEADER",
32+
"env_auth_prefix": "DEVIN_API_AUTH_PREFIX",
33+
},
34+
}
35+
36+
37+
def get_provider_config(provider: str) -> Dict[str, str]:
38+
provider_key = provider.lower().strip()
39+
if provider_key not in PROVIDERS:
40+
raise ValueError(f"Unsupported provider: {provider}")
41+
return PROVIDERS[provider_key]
42+
43+
44+
def get_provider_label(provider: str) -> str:
45+
return get_provider_config(provider)["label"]
46+
47+
48+
def get_provider_base_url(provider: str) -> Optional[str]:
49+
config = get_provider_config(provider)
50+
env_url = os.getenv(config["env_url"])
51+
if env_url:
52+
return env_url
53+
return DEFAULT_BASE_URLS.get(provider.lower().strip())
54+
55+
56+
def get_provider_default_key(provider: str) -> Optional[str]:
57+
config = get_provider_config(provider)
58+
return os.getenv(config["env_key"])
59+
60+
61+
def _build_headers(provider: str, api_key: Optional[str]) -> Dict[str, str]:
62+
if not api_key:
63+
return {}
64+
65+
config = get_provider_config(provider)
66+
header_name = os.getenv(config["env_auth_header"], "Authorization")
67+
prefix = os.getenv(config["env_auth_prefix"], "Bearer ")
68+
return {header_name: f"{prefix}{api_key}"}
69+
70+
71+
def run_agent_provider(
72+
provider: str,
73+
repo_full_name: str,
74+
feature_description: str,
75+
branch_name: str,
76+
github_token: str,
77+
api_key: Optional[str],
78+
merge: bool = False,
79+
timeout_seconds: int = 300,
80+
base_url_override: Optional[str] = None
81+
) -> Dict[str, Any]:
82+
"""
83+
Call an external agent provider to implement a feature.
84+
85+
Returns:
86+
Dict with success, message, data (provider response JSON if available)
87+
"""
88+
provider_key = provider.lower().strip()
89+
config = get_provider_config(provider_key)
90+
91+
base_url = base_url_override or get_provider_base_url(provider_key)
92+
if not base_url:
93+
return {
94+
"success": False,
95+
"message": f"Missing {config['env_url']} for {config['label']} API URL"
96+
}
97+
98+
if provider_key == "cursor":
99+
endpoint = os.getenv(config["env_endpoint"], "/v0/agents")
100+
elif provider_key == "devin":
101+
endpoint = os.getenv(config["env_endpoint"], "/sessions")
102+
else:
103+
endpoint = os.getenv(config["env_endpoint"], "/run")
104+
url = f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}"
105+
106+
owner, repo = repo_full_name.split("/", 1)
107+
repo_url = f"https://github.com/{repo_full_name}"
108+
109+
if provider_key == "cursor":
110+
payload = {
111+
"prompt": {
112+
"text": feature_description
113+
},
114+
"source": {
115+
"repository": repo_url
116+
},
117+
"target": {
118+
"autoCreatePr": True,
119+
"branchName": branch_name,
120+
"openAsCursorGithubApp": False,
121+
"skipReviewerRequest": False
122+
}
123+
}
124+
elif provider_key == "devin":
125+
if api_key and api_key.startswith("cog_"):
126+
return {
127+
"success": False,
128+
"message": (
129+
"Devin v3 service-user keys (cog_) are not supported by the v1 /sessions endpoint. "
130+
"Generate a v1 personal/service key (apk_user_*/apk_*) or provide a v3 base URL."
131+
)
132+
}
133+
payload = {
134+
"prompt": f"{feature_description}\n\nRepo: {repo_url}",
135+
"title": f"Repo task: {feature_description[:60]}",
136+
"tags": [repo_full_name]
137+
}
138+
else:
139+
payload = {
140+
"provider": provider_key,
141+
"feature": feature_description,
142+
"branch": branch_name,
143+
"repo_full_name": repo_full_name,
144+
"owner": owner,
145+
"repo": repo,
146+
"repo_url": repo_url,
147+
"github_token": github_token,
148+
"merge": merge,
149+
}
150+
151+
callback_url = os.getenv("AGENT_CALLBACK_URL")
152+
if callback_url:
153+
payload["callback_url"] = callback_url
154+
155+
headers = {
156+
"Accept": "application/json",
157+
"Content-Type": "application/json",
158+
}
159+
headers.update(_build_headers(provider_key, api_key))
160+
161+
try:
162+
if provider_key == "cursor":
163+
if not api_key:
164+
return {
165+
"success": False,
166+
"message": "Missing Cursor API key"
167+
}
168+
response = requests.post(
169+
url,
170+
json=payload,
171+
headers=headers,
172+
auth=(api_key, ""),
173+
timeout=timeout_seconds
174+
)
175+
elif provider_key == "devin":
176+
response = requests.post(
177+
url,
178+
json=payload,
179+
headers=headers,
180+
timeout=timeout_seconds
181+
)
182+
else:
183+
response = requests.post(url, json=payload, headers=headers, timeout=timeout_seconds)
184+
185+
if response.status_code in (401, 403) and provider_key == "devin":
186+
return {
187+
"success": False,
188+
"message": (
189+
f"Devin API unauthorized ({response.status_code}). "
190+
"Ensure you're using a v1 API key (apk_user_*/apk_*) and that it has access to the org. "
191+
f"Response: {response.text}"
192+
)
193+
}
194+
if not response.ok:
195+
return {
196+
"success": False,
197+
"message": f"{config['label']} API error: {response.status_code} - {response.text}"
198+
}
199+
200+
try:
201+
data = response.json()
202+
except ValueError:
203+
return {
204+
"success": False,
205+
"message": f"{config['label']} API returned non-JSON response"
206+
}
207+
208+
return {
209+
"success": True,
210+
"message": data.get("message") if isinstance(data, dict) else None,
211+
"data": data,
212+
}
213+
214+
except requests.RequestException as exc:
215+
return {
216+
"success": False,
217+
"message": f"{config['label']} API request failed: {str(exc)}"
218+
}

github_client.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,3 +370,28 @@ def get_repo_labels_with_details(
370370
print(f"⚠️ Error fetching labels: {e}")
371371
return []
372372

373+
def get_repo_permissions(self, access_token: str, repo_full_name: str) -> Optional[Dict]:
374+
"""
375+
Get repository permissions for the authenticated user.
376+
Returns permissions dict (admin/push/pull) if successful.
377+
"""
378+
try:
379+
response = requests.get(
380+
f"{self.api_base}/repos/{repo_full_name}",
381+
headers={
382+
"Authorization": f"Bearer {access_token}",
383+
"Accept": "application/vnd.github.v3+json"
384+
}
385+
)
386+
387+
if response.status_code == 200:
388+
repo = response.json()
389+
return repo.get("permissions", {})
390+
else:
391+
print(f"⚠️ Could not fetch repo permissions: {response.status_code}")
392+
return None
393+
394+
except Exception as e:
395+
print(f"⚠️ Error fetching repo permissions: {e}")
396+
return None
397+

0 commit comments

Comments
 (0)