Stream C: Multi-provider AI coaching backend + 20-provider registry
AI coaching service (backend/app/services/ai_provider.py): - Two code paths: OpenAI-compatible (6 providers) + Anthropic (Claude) - Streaming SSE responses via httpx async - System prompt built from AGENT_GUIDE + live user context - Graceful error handling for all provider failures - Gemini adapter for Google's generateContent API Chat endpoint (backend/app/routers/ai_chat.py): - POST /api/ai/chat — SSE streaming response - GET /api/ai/status — check configured provider - History capped at 20 messages, content at 4K chars Provider registry expanded to 20 providers: - 9 device integrations (strava, polar, garmin, whoop, oura, coros, fitbit, withings, suunto) - 8 AI providers (openai, openrouter, huggingface, groq, ollama, claude, gemini, custom_ai) - 3 other (usda, nutritionix, telegram) - Categorized: device, ai_provider, nutrition, notifications - COROS: MCP bridge approach (no API integration needed) - Strava: AI/ML warning per their ToS Frontend ChatCoach.jsx to follow in next commit.
This commit is contained in:
parent
6e082ce58f
commit
9a8e6ce1eb
4 changed files with 555 additions and 27 deletions
|
|
@ -96,6 +96,7 @@ from app.routers.catalog import router as catalog_router
|
||||||
from app.routers.support import router as support_router
|
from app.routers.support import router as support_router
|
||||||
from app.routers.nutrition import router as nutrition_router
|
from app.routers.nutrition import router as nutrition_router
|
||||||
from app.routers.meal_plans import router as meal_plans_router
|
from app.routers.meal_plans import router as meal_plans_router
|
||||||
|
from app.routers.ai_chat import router as ai_chat_router
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(onboarding_router)
|
app.include_router(onboarding_router)
|
||||||
|
|
@ -109,6 +110,7 @@ app.include_router(support_router)
|
||||||
app.include_router(programs_router)
|
app.include_router(programs_router)
|
||||||
app.include_router(nutrition_router)
|
app.include_router(nutrition_router)
|
||||||
app.include_router(meal_plans_router)
|
app.include_router(meal_plans_router)
|
||||||
|
app.include_router(ai_chat_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|
|
||||||
64
backend/app/routers/ai_chat.py
Normal file
64
backend/app/routers/ai_chat.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
"""AI coaching chat endpoint — SSE streaming responses."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.utils.auth import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import ai_provider
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/ai", tags=["AI Coaching"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat")
|
||||||
|
async def chat_with_ai(
|
||||||
|
request: Request,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Stream an AI coaching response via SSE.
|
||||||
|
|
||||||
|
Body: {"message": "...", "history": [{"role": "user"|"assistant", "content": "..."}]}
|
||||||
|
Response: text/event-stream with data chunks.
|
||||||
|
"""
|
||||||
|
body = await request.json()
|
||||||
|
message = body.get("message", "").strip()
|
||||||
|
history = body.get("history", [])
|
||||||
|
|
||||||
|
if not message:
|
||||||
|
return {"error": "Message cannot be empty"}
|
||||||
|
|
||||||
|
# Validate history format
|
||||||
|
clean_history = []
|
||||||
|
for msg in history[-20:]: # Cap at last 20 messages
|
||||||
|
if isinstance(msg, dict) and msg.get("role") in ("user", "assistant") and msg.get("content"):
|
||||||
|
clean_history.append({"role": msg["role"], "content": msg["content"][:4000]})
|
||||||
|
|
||||||
|
async def event_stream():
|
||||||
|
async for chunk in ai_provider.chat(user.id, message, clean_history, db):
|
||||||
|
yield f"data: {json.dumps({'text': chunk})}\n\n"
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def ai_status(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Check which AI provider is configured."""
|
||||||
|
provider = await ai_provider.get_active_ai_provider(db)
|
||||||
|
if provider:
|
||||||
|
return {
|
||||||
|
"configured": True,
|
||||||
|
"provider": provider["name"],
|
||||||
|
"model": provider["model"],
|
||||||
|
}
|
||||||
|
return {"configured": False, "provider": None, "model": None}
|
||||||
316
backend/app/services/ai_provider.py
Normal file
316
backend/app/services/ai_provider.py
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
"""Multi-provider AI coaching service.
|
||||||
|
|
||||||
|
Two code paths:
|
||||||
|
- OpenAI-compatible: covers OpenAI, OpenRouter, HuggingFace, Groq, Ollama, Custom
|
||||||
|
- Anthropic: covers Claude (different message format + auth header)
|
||||||
|
- Gemini: thin adapter for Google's generateContent API
|
||||||
|
|
||||||
|
System prompt is built from AGENT_GUIDE.md + live user context.
|
||||||
|
Chat history is ephemeral (not persisted).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.services.crypto import decrypt_value
|
||||||
|
from app.models.integration_config import IntegrationConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# AI provider presets — base_url and api_format for each named provider
|
||||||
|
AI_PRESETS = {
|
||||||
|
"openai": {"base_url": "https://api.openai.com/v1", "format": "openai", "default_model": "gpt-4o-mini"},
|
||||||
|
"openrouter": {"base_url": "https://openrouter.ai/api/v1", "format": "openai", "default_model": "openai/gpt-4o-mini"},
|
||||||
|
"huggingface": {"base_url": "https://router.huggingface.co/v1", "format": "openai", "default_model": "meta-llama/Llama-3.3-70B-Instruct"},
|
||||||
|
"groq": {"base_url": "https://api.groq.com/openai/v1", "format": "openai", "default_model": "llama-3.3-70b-versatile"},
|
||||||
|
"ollama": {"base_url": "http://host.docker.internal:11434/v1", "format": "openai", "default_model": "llama3.1"},
|
||||||
|
"claude": {"base_url": "https://api.anthropic.com/v1", "format": "anthropic", "default_model": "claude-sonnet-4-6"},
|
||||||
|
"gemini": {"base_url": "https://generativelanguage.googleapis.com/v1beta", "format": "gemini", "default_model": "gemini-2.0-flash"},
|
||||||
|
"custom_ai": {"base_url": "", "format": "openai", "default_model": ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
# System prompt template — {context} is replaced with live user data
|
||||||
|
SYSTEM_PROMPT_TEMPLATE = """You are a fitness coach integrated with Diligence, a self-hosted fitness rewards platform.
|
||||||
|
|
||||||
|
Your role: motivate, guide, and help the user stay consistent with their health goals. Use the context below to personalize your responses.
|
||||||
|
|
||||||
|
CURRENT USER CONTEXT:
|
||||||
|
{context}
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Be encouraging but honest. Celebrate progress, address setbacks constructively.
|
||||||
|
- When the user asks to log a workout or food, confirm what you'll log and do it.
|
||||||
|
- Reference their program schedule when suggesting workouts.
|
||||||
|
- Respect their motivation type (from BREQ-2 profiling) to calibrate your tone.
|
||||||
|
- Keep responses concise — this is a mobile chat, not an essay.
|
||||||
|
- If the user hasn't earned enough points today, gently encourage activity.
|
||||||
|
- Never fabricate data — only reference what's in the context."""
|
||||||
|
|
||||||
|
|
||||||
|
async def get_active_ai_provider(db: AsyncSession) -> dict | None:
|
||||||
|
"""Find the first configured AI provider."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(IntegrationConfig).where(
|
||||||
|
IntegrationConfig.provider.in_(list(AI_PRESETS.keys()))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
configs = result.scalars().all()
|
||||||
|
|
||||||
|
for config in configs:
|
||||||
|
provider_name = config.provider
|
||||||
|
try:
|
||||||
|
creds = json.loads(decrypt_value(config.encrypted_value, settings.secret_key))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
preset = AI_PRESETS.get(provider_name, AI_PRESETS["custom_ai"])
|
||||||
|
api_key = creds.get("api_key", "")
|
||||||
|
base_url = creds.get("endpoint_url", preset["base_url"]) or preset["base_url"]
|
||||||
|
model = creds.get("model", preset["default_model"]) or preset["default_model"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": provider_name,
|
||||||
|
"format": preset["format"],
|
||||||
|
"base_url": base_url,
|
||||||
|
"api_key": api_key,
|
||||||
|
"model": model,
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def build_context(db: AsyncSession, user_id) -> str:
|
||||||
|
"""Build live context string from user's current state."""
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.profile import UserProfile
|
||||||
|
|
||||||
|
context_parts = []
|
||||||
|
|
||||||
|
# User profile
|
||||||
|
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
|
||||||
|
if user:
|
||||||
|
context_parts.append(f"Name: {user.display_name}")
|
||||||
|
context_parts.append(f"Timezone: {user.timezone}")
|
||||||
|
|
||||||
|
# Profile / motivation
|
||||||
|
profile = (await db.execute(select(UserProfile).where(UserProfile.user_id == user_id))).scalar_one_or_none()
|
||||||
|
if profile:
|
||||||
|
if profile.ttm_stage:
|
||||||
|
context_parts.append(f"Stage of change: {profile.ttm_stage}")
|
||||||
|
if profile.breq_profile:
|
||||||
|
context_parts.append(f"Motivation type: {profile.breq_profile}")
|
||||||
|
|
||||||
|
# Today's points (best effort)
|
||||||
|
try:
|
||||||
|
from app.services.points_engine import get_daily_summary
|
||||||
|
summary = await get_daily_summary(db, user_id)
|
||||||
|
context_parts.append(f"Today's points: {summary.get('earned', 0)}/{summary.get('target', 100)}")
|
||||||
|
context_parts.append(f"Daily gate: {'PASSED' if summary.get('gate_passed') else 'NOT YET'}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return "\n".join(context_parts) if context_parts else "No profile data available yet."
|
||||||
|
|
||||||
|
|
||||||
|
async def chat(
|
||||||
|
user_id,
|
||||||
|
message: str,
|
||||||
|
history: list[dict],
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""Route chat to the configured AI provider with streaming."""
|
||||||
|
provider = await get_active_ai_provider(db)
|
||||||
|
if not provider:
|
||||||
|
yield "No AI provider configured. Go to Settings → Integrations to connect one (OpenAI, OpenRouter, Ollama, etc.)."
|
||||||
|
return
|
||||||
|
|
||||||
|
context = await build_context(db, user_id)
|
||||||
|
system_prompt = SYSTEM_PROMPT_TEMPLATE.format(context=context)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if provider["format"] == "openai":
|
||||||
|
async for chunk in _call_openai_compat(
|
||||||
|
base_url=provider["base_url"],
|
||||||
|
api_key=provider["api_key"],
|
||||||
|
model=provider["model"],
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
history=history,
|
||||||
|
message=message,
|
||||||
|
):
|
||||||
|
yield chunk
|
||||||
|
elif provider["format"] == "anthropic":
|
||||||
|
async for chunk in _call_anthropic(
|
||||||
|
api_key=provider["api_key"],
|
||||||
|
model=provider["model"],
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
history=history,
|
||||||
|
message=message,
|
||||||
|
):
|
||||||
|
yield chunk
|
||||||
|
elif provider["format"] == "gemini":
|
||||||
|
async for chunk in _call_gemini(
|
||||||
|
api_key=provider["api_key"],
|
||||||
|
model=provider["model"],
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
history=history,
|
||||||
|
message=message,
|
||||||
|
):
|
||||||
|
yield chunk
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
yield f"AI provider error ({e.response.status_code}). Check your API key in Settings → Integrations."
|
||||||
|
except httpx.ConnectError:
|
||||||
|
yield f"Cannot reach {provider['name']}. Check your network or endpoint URL."
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AI chat error: {e}")
|
||||||
|
yield "Something went wrong with the AI provider. Check Settings → Integrations."
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_openai_compat(
|
||||||
|
base_url: str,
|
||||||
|
api_key: str,
|
||||||
|
model: str,
|
||||||
|
system_prompt: str,
|
||||||
|
history: list[dict],
|
||||||
|
message: str,
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""Call any OpenAI-compatible /v1/chat/completions endpoint with streaming.
|
||||||
|
|
||||||
|
Covers: OpenAI, OpenRouter, HuggingFace, Groq, Ollama, Custom.
|
||||||
|
"""
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
*history,
|
||||||
|
{"role": "user", "content": message},
|
||||||
|
],
|
||||||
|
"stream": True,
|
||||||
|
"max_tokens": 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
async with client.stream(
|
||||||
|
"POST",
|
||||||
|
f"{base_url.rstrip('/')}/chat/completions",
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
async for line in resp.aiter_lines():
|
||||||
|
if line.startswith("data: ") and line.strip() != "data: [DONE]":
|
||||||
|
try:
|
||||||
|
chunk = json.loads(line[6:])
|
||||||
|
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||||
|
if content := delta.get("content"):
|
||||||
|
yield content
|
||||||
|
except (json.JSONDecodeError, IndexError, KeyError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_anthropic(
|
||||||
|
api_key: str,
|
||||||
|
model: str,
|
||||||
|
system_prompt: str,
|
||||||
|
history: list[dict],
|
||||||
|
message: str,
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""Call Anthropic's /v1/messages endpoint with streaming."""
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key": api_key,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Convert OpenAI-style history to Anthropic format
|
||||||
|
messages = []
|
||||||
|
for msg in history:
|
||||||
|
messages.append({"role": msg["role"], "content": msg["content"]})
|
||||||
|
messages.append({"role": "user", "content": message})
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"system": system_prompt,
|
||||||
|
"messages": messages,
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"stream": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
async with client.stream(
|
||||||
|
"POST",
|
||||||
|
"https://api.anthropic.com/v1/messages",
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
async for line in resp.aiter_lines():
|
||||||
|
if line.startswith("data: "):
|
||||||
|
try:
|
||||||
|
event = json.loads(line[6:])
|
||||||
|
if event.get("type") == "content_block_delta":
|
||||||
|
delta = event.get("delta", {})
|
||||||
|
if text := delta.get("text"):
|
||||||
|
yield text
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_gemini(
|
||||||
|
api_key: str,
|
||||||
|
model: str,
|
||||||
|
system_prompt: str,
|
||||||
|
history: list[dict],
|
||||||
|
message: str,
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""Call Google Gemini's generateContent endpoint with streaming."""
|
||||||
|
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent"
|
||||||
|
|
||||||
|
# Build Gemini content format
|
||||||
|
contents = []
|
||||||
|
# System instruction goes as first user/model exchange
|
||||||
|
if system_prompt:
|
||||||
|
contents.append({"role": "user", "parts": [{"text": f"[System instructions]\n{system_prompt}"}]})
|
||||||
|
contents.append({"role": "model", "parts": [{"text": "Understood. I'll follow these instructions."}]})
|
||||||
|
|
||||||
|
for msg in history:
|
||||||
|
role = "model" if msg["role"] == "assistant" else "user"
|
||||||
|
contents.append({"role": role, "parts": [{"text": msg["content"]}]})
|
||||||
|
contents.append({"role": "user", "parts": [{"text": message}]})
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"contents": contents,
|
||||||
|
"generationConfig": {"maxOutputTokens": 1024},
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
async with client.stream(
|
||||||
|
"POST",
|
||||||
|
url,
|
||||||
|
json=payload,
|
||||||
|
params={"key": api_key, "alt": "sse"},
|
||||||
|
) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
async for line in resp.aiter_lines():
|
||||||
|
if line.startswith("data: "):
|
||||||
|
try:
|
||||||
|
chunk = json.loads(line[6:])
|
||||||
|
candidates = chunk.get("candidates", [])
|
||||||
|
if candidates:
|
||||||
|
parts = candidates[0].get("content", {}).get("parts", [])
|
||||||
|
for part in parts:
|
||||||
|
if text := part.get("text"):
|
||||||
|
yield text
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
continue
|
||||||
|
|
@ -2,65 +2,213 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
PROVIDER_REGISTRY: dict[str, dict] = {
|
PROVIDER_REGISTRY: dict[str, dict] = {
|
||||||
|
|
||||||
|
# ═══ Device Integrations ═══
|
||||||
|
|
||||||
"strava": {
|
"strava": {
|
||||||
"name": "Strava",
|
"name": "Strava",
|
||||||
"type": "oauth2",
|
"type": "oauth2",
|
||||||
|
"category": "device",
|
||||||
"fields": ["client_id", "client_secret"],
|
"fields": ["client_id", "client_secret"],
|
||||||
"auth_url": "https://www.strava.com/oauth/authorize",
|
"auth_url": "https://www.strava.com/oauth/authorize",
|
||||||
"token_url": "https://www.strava.com/oauth/token",
|
"token_url": "https://www.strava.com/oauth/token",
|
||||||
"scopes": "read,activity:read_all",
|
"scopes": "read,activity:read_all",
|
||||||
"help_url": "https://developers.strava.com/",
|
"help_url": "https://developers.strava.com/",
|
||||||
"help_text": "Create an app at developers.strava.com. Set the callback URL to {BASE_URL}/api/integrations/strava/callback",
|
"help_text": "Create an app at developers.strava.com. Set the callback URL to {BASE_URL}/api/integrations/strava/callback",
|
||||||
|
"warning": "Strava ToS prohibits use of their data for AI/ML. Activity sync works but AI coaching should not reference Strava data.",
|
||||||
|
"has_webhook": False,
|
||||||
|
"sync_service": "strava_sync",
|
||||||
},
|
},
|
||||||
"polar": {
|
"polar": {
|
||||||
"name": "Polar",
|
"name": "Polar",
|
||||||
"type": "oauth2",
|
"type": "oauth2",
|
||||||
|
"category": "device",
|
||||||
"fields": ["client_id", "client_secret"],
|
"fields": ["client_id", "client_secret"],
|
||||||
"auth_url": "https://flow.polar.com/oauth2/authorization",
|
"auth_url": "https://flow.polar.com/oauth2/authorization",
|
||||||
"token_url": "https://polarremote.com/v2/oauth2/token",
|
"token_url": "https://polarremote.com/v2/oauth2/token",
|
||||||
"help_url": "https://admin.polaraccesslink.com/",
|
"help_url": "https://admin.polaraccesslink.com/",
|
||||||
"help_text": "Register at admin.polaraccesslink.com",
|
"help_text": "Register at admin.polaraccesslink.com",
|
||||||
|
"has_webhook": False,
|
||||||
|
"sync_service": "polar_sync",
|
||||||
},
|
},
|
||||||
"garmin": {
|
"garmin": {
|
||||||
"name": "Garmin Connect",
|
"name": "Garmin Connect",
|
||||||
"type": "oauth2",
|
"type": "oauth2",
|
||||||
|
"category": "device",
|
||||||
"fields": ["client_id", "client_secret"],
|
"fields": ["client_id", "client_secret"],
|
||||||
|
"auth_url": "https://connect.garmin.com/oauthConfirm",
|
||||||
|
"token_url": "https://connectapi.garmin.com/oauth-service/oauth/token",
|
||||||
|
"scopes": "HEALTH,ACTIVITY",
|
||||||
"help_url": "https://developer.garmin.com/gc-developer-program/",
|
"help_url": "https://developer.garmin.com/gc-developer-program/",
|
||||||
"help_text": "Apply at developer.garmin.com — approval takes 1-4 weeks",
|
"help_text": "Apply at developer.garmin.com — approval ~2 business days, requires a business entity.",
|
||||||
},
|
"has_webhook": True,
|
||||||
"fitbit": {
|
"sync_service": "garmin_sync",
|
||||||
"name": "Fitbit",
|
|
||||||
"type": "oauth2",
|
|
||||||
"fields": ["client_id", "client_secret"],
|
|
||||||
"auth_url": "https://www.fitbit.com/oauth2/authorize",
|
|
||||||
"token_url": "https://api.fitbit.com/oauth2/token",
|
|
||||||
"help_url": "https://dev.fitbit.com/",
|
|
||||||
"help_text": "Register at dev.fitbit.com",
|
|
||||||
},
|
|
||||||
"withings": {
|
|
||||||
"name": "Withings",
|
|
||||||
"type": "oauth2",
|
|
||||||
"fields": ["client_id", "client_secret"],
|
|
||||||
"help_url": "https://developer.withings.com/",
|
|
||||||
"help_text": "Register at developer.withings.com — body metrics, scales, blood pressure",
|
|
||||||
},
|
},
|
||||||
"whoop": {
|
"whoop": {
|
||||||
"name": "WHOOP",
|
"name": "WHOOP",
|
||||||
"type": "oauth2",
|
"type": "oauth2",
|
||||||
|
"category": "device",
|
||||||
"fields": ["client_id", "client_secret"],
|
"fields": ["client_id", "client_secret"],
|
||||||
|
"auth_url": "https://api.prod.whoop.com/oauth/oauth2/auth",
|
||||||
|
"token_url": "https://api.prod.whoop.com/oauth/oauth2/token",
|
||||||
|
"scopes": "read:workout,read:recovery,read:sleep,read:profile,offline",
|
||||||
"help_url": "https://developer.whoop.com/",
|
"help_url": "https://developer.whoop.com/",
|
||||||
"help_text": "Apply at developer.whoop.com — recovery, strain, sleep",
|
"help_text": "Self-serve at developer.whoop.com. Requires WHOOP device. <10 users without app approval.",
|
||||||
|
"has_webhook": True,
|
||||||
|
"sync_service": "whoop_sync",
|
||||||
},
|
},
|
||||||
"oura": {
|
"oura": {
|
||||||
"name": "Oura Ring",
|
"name": "Oura Ring",
|
||||||
"type": "oauth2",
|
"type": "oauth2",
|
||||||
|
"category": "device",
|
||||||
"fields": ["client_id", "client_secret"],
|
"fields": ["client_id", "client_secret"],
|
||||||
|
"auth_url": "https://cloud.ouraring.com/oauth/authorize",
|
||||||
|
"token_url": "https://api.ouraring.com/oauth/token",
|
||||||
|
"scopes": "daily,workout,heartrate,session",
|
||||||
"help_url": "https://cloud.ouraring.com/v2/docs",
|
"help_url": "https://cloud.ouraring.com/v2/docs",
|
||||||
"help_text": "Register at cloud.ouraring.com — sleep, readiness, HRV",
|
"help_text": "Register at cloud.ouraring.com — self-serve, immediate access.",
|
||||||
|
"has_webhook": True,
|
||||||
|
"sync_service": "oura_sync",
|
||||||
},
|
},
|
||||||
|
"coros": {
|
||||||
|
"name": "COROS (via MCP)",
|
||||||
|
"type": "mcp_bridge",
|
||||||
|
"category": "device",
|
||||||
|
"fields": [],
|
||||||
|
"help_url": "https://support.coros.com/hc/en-us/articles/17085887816340",
|
||||||
|
"help_text": "COROS has an official MCP server. Add it alongside Diligence in your AI agent config.",
|
||||||
|
"has_webhook": False,
|
||||||
|
"sync_service": None,
|
||||||
|
},
|
||||||
|
"fitbit": {
|
||||||
|
"name": "Fitbit",
|
||||||
|
"type": "oauth2",
|
||||||
|
"category": "device",
|
||||||
|
"fields": ["client_id", "client_secret"],
|
||||||
|
"auth_url": "https://www.fitbit.com/oauth2/authorize",
|
||||||
|
"token_url": "https://api.fitbit.com/oauth2/token",
|
||||||
|
"help_url": "https://dev.fitbit.com/",
|
||||||
|
"help_text": "Register at dev.fitbit.com via Google Cloud Console.",
|
||||||
|
"has_webhook": True,
|
||||||
|
"sync_service": None,
|
||||||
|
},
|
||||||
|
"withings": {
|
||||||
|
"name": "Withings",
|
||||||
|
"type": "oauth2",
|
||||||
|
"category": "device",
|
||||||
|
"fields": ["client_id", "client_secret"],
|
||||||
|
"help_url": "https://developer.withings.com/",
|
||||||
|
"help_text": "Register at developer.withings.com — body metrics, scales, blood pressure.",
|
||||||
|
"has_webhook": False,
|
||||||
|
"sync_service": None,
|
||||||
|
},
|
||||||
|
"suunto": {
|
||||||
|
"name": "Suunto",
|
||||||
|
"type": "oauth2",
|
||||||
|
"category": "device",
|
||||||
|
"fields": ["client_id", "client_secret"],
|
||||||
|
"help_url": "https://apizone.suunto.com/",
|
||||||
|
"help_text": "Apply at Suunto developer portal — contract required, ~2 week response.",
|
||||||
|
"has_webhook": True,
|
||||||
|
"sync_service": None,
|
||||||
|
},
|
||||||
|
|
||||||
|
# ═══ AI Coaching Providers ═══
|
||||||
|
|
||||||
|
"openai": {
|
||||||
|
"name": "OpenAI",
|
||||||
|
"type": "api_key",
|
||||||
|
"category": "ai_provider",
|
||||||
|
"fields": ["api_key"],
|
||||||
|
"base_url": "https://api.openai.com/v1",
|
||||||
|
"api_format": "openai",
|
||||||
|
"help_url": "https://platform.openai.com/api-keys",
|
||||||
|
"help_text": "Get API key from platform.openai.com. Models: gpt-4o, gpt-4o-mini.",
|
||||||
|
"default_model": "gpt-4o-mini",
|
||||||
|
},
|
||||||
|
"openrouter": {
|
||||||
|
"name": "OpenRouter",
|
||||||
|
"type": "api_key",
|
||||||
|
"category": "ai_provider",
|
||||||
|
"fields": ["api_key"],
|
||||||
|
"base_url": "https://openrouter.ai/api/v1",
|
||||||
|
"api_format": "openai",
|
||||||
|
"help_url": "https://openrouter.ai/keys",
|
||||||
|
"help_text": "One key, 300+ models. 26 free models. Pay-as-you-go, no subscription.",
|
||||||
|
"default_model": "openai/gpt-4o-mini",
|
||||||
|
},
|
||||||
|
"huggingface": {
|
||||||
|
"name": "Hugging Face",
|
||||||
|
"type": "api_key",
|
||||||
|
"category": "ai_provider",
|
||||||
|
"fields": ["api_key"],
|
||||||
|
"base_url": "https://router.huggingface.co/v1",
|
||||||
|
"api_format": "openai",
|
||||||
|
"help_url": "https://huggingface.co/settings/tokens",
|
||||||
|
"help_text": "Free HF token. Thousands of open-source models via 20+ inference providers.",
|
||||||
|
"default_model": "meta-llama/Llama-3.3-70B-Instruct",
|
||||||
|
},
|
||||||
|
"groq": {
|
||||||
|
"name": "Groq",
|
||||||
|
"type": "api_key",
|
||||||
|
"category": "ai_provider",
|
||||||
|
"fields": ["api_key"],
|
||||||
|
"base_url": "https://api.groq.com/openai/v1",
|
||||||
|
"api_format": "openai",
|
||||||
|
"help_url": "https://console.groq.com/keys",
|
||||||
|
"help_text": "Ultra-fast inference. Free tier available. Also used for program research.",
|
||||||
|
"default_model": "llama-3.3-70b-versatile",
|
||||||
|
},
|
||||||
|
"ollama": {
|
||||||
|
"name": "Ollama (Local)",
|
||||||
|
"type": "endpoint",
|
||||||
|
"category": "ai_provider",
|
||||||
|
"fields": ["endpoint_url"],
|
||||||
|
"base_url": "http://host.docker.internal:11434/v1",
|
||||||
|
"api_format": "openai",
|
||||||
|
"help_url": "https://ollama.com/",
|
||||||
|
"help_text": "Run LLMs locally. No API key, no cost. Default: http://host.docker.internal:11434",
|
||||||
|
"default_model": "llama3.1",
|
||||||
|
},
|
||||||
|
"claude": {
|
||||||
|
"name": "Anthropic Claude",
|
||||||
|
"type": "api_key",
|
||||||
|
"category": "ai_provider",
|
||||||
|
"fields": ["api_key"],
|
||||||
|
"base_url": "https://api.anthropic.com/v1",
|
||||||
|
"api_format": "anthropic",
|
||||||
|
"help_url": "https://console.anthropic.com/",
|
||||||
|
"help_text": "Get API key from console.anthropic.com. Models: claude-sonnet-4-6, claude-opus-4-8.",
|
||||||
|
"default_model": "claude-sonnet-4-6",
|
||||||
|
},
|
||||||
|
"gemini": {
|
||||||
|
"name": "Google Gemini",
|
||||||
|
"type": "api_key",
|
||||||
|
"category": "ai_provider",
|
||||||
|
"fields": ["api_key"],
|
||||||
|
"base_url": "https://generativelanguage.googleapis.com/v1beta",
|
||||||
|
"api_format": "gemini",
|
||||||
|
"help_url": "https://aistudio.google.com/apikey",
|
||||||
|
"help_text": "Get API key from AI Studio. Models: gemini-2.0-flash, gemini-2.5-pro.",
|
||||||
|
"default_model": "gemini-2.0-flash",
|
||||||
|
},
|
||||||
|
"custom_ai": {
|
||||||
|
"name": "Custom (OpenAI-compatible)",
|
||||||
|
"type": "endpoint",
|
||||||
|
"category": "ai_provider",
|
||||||
|
"fields": ["endpoint_url", "api_key"],
|
||||||
|
"api_format": "openai",
|
||||||
|
"help_url": "",
|
||||||
|
"help_text": "Any server with an OpenAI-compatible /v1/chat/completions endpoint (vLLM, TGI, LiteLLM).",
|
||||||
|
"default_model": "",
|
||||||
|
},
|
||||||
|
|
||||||
|
# ═══ Nutrition & Data ═══
|
||||||
|
|
||||||
"usda": {
|
"usda": {
|
||||||
"name": "USDA FoodData Central",
|
"name": "USDA FoodData Central",
|
||||||
"type": "api_key",
|
"type": "api_key",
|
||||||
|
"category": "nutrition",
|
||||||
"fields": ["api_key"],
|
"fields": ["api_key"],
|
||||||
"help_url": "https://fdc.nal.usda.gov/api-key-signup",
|
"help_url": "https://fdc.nal.usda.gov/api-key-signup",
|
||||||
"help_text": "Free API key, no approval needed. 400K+ foods with research-grade nutrition data.",
|
"help_text": "Free API key, no approval needed. 400K+ foods with research-grade nutrition data.",
|
||||||
|
|
@ -68,22 +216,20 @@ PROVIDER_REGISTRY: dict[str, dict] = {
|
||||||
"nutritionix": {
|
"nutritionix": {
|
||||||
"name": "Nutritionix",
|
"name": "Nutritionix",
|
||||||
"type": "api_key",
|
"type": "api_key",
|
||||||
|
"category": "nutrition",
|
||||||
"fields": ["app_id", "api_key"],
|
"fields": ["app_id", "api_key"],
|
||||||
"help_url": "https://developer.nutritionix.com/",
|
"help_url": "https://developer.nutritionix.com/",
|
||||||
"help_text": "Free tier: 1K calls/month. Natural language food parsing.",
|
"help_text": "Free tier: 1K calls/month. Natural language food parsing.",
|
||||||
},
|
},
|
||||||
"groq": {
|
|
||||||
"name": "Groq (Program Research)",
|
# ═══ Notifications ═══
|
||||||
"type": "api_key",
|
|
||||||
"fields": ["api_key"],
|
|
||||||
"help_url": "https://console.groq.com/keys",
|
|
||||||
"help_text": "Free API key. Enables AI-powered workout program extraction.",
|
|
||||||
},
|
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"name": "Telegram Notifications",
|
"name": "Telegram Notifications",
|
||||||
"type": "api_key",
|
"type": "api_key",
|
||||||
|
"category": "notifications",
|
||||||
"fields": ["bot_token", "chat_id"],
|
"fields": ["bot_token", "chat_id"],
|
||||||
"help_url": "https://core.telegram.org/bots#botfather",
|
"help_url": "https://core.telegram.org/bots#botfather",
|
||||||
"help_text": "Create a bot via @BotFather, then get your chat ID",
|
"help_text": "Create a bot via @BotFather, then get your chat ID.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue