Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06de7c7ab0 | |||
| e3c7547c75 | |||
| 314780d59a | |||
| 091787a6da | |||
| 9119474e71 |
32
.env.example
32
.env.example
@@ -10,18 +10,34 @@ JWT_ALGORITHM=HS256
|
|||||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||||
|
|
||||||
# ── OpenAI ────────────────────────────────────────────────────────────────────
|
# ── LLM ───────────────────────────────────────────────────────────────────────
|
||||||
OPENAI_API_KEY=sk-...
|
# LiteLLM model identifiers — change to swap providers without code changes.
|
||||||
|
# Examples: gpt-4o, anthropic/claude-sonnet-4-20250514, gemini/gemini-pro, ollama/llama3
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
GOOGLE_API_KEY=
|
||||||
|
LLM_MODEL=gpt-4o
|
||||||
|
LLM_ROUTER_MODEL=gpt-4o-mini
|
||||||
|
|
||||||
# ── Stripe ────────────────────────────────────────────────────────────────────
|
# ── Stripe (leave empty to stub billing) ──────────────────────────────────────
|
||||||
STRIPE_SECRET_KEY=sk_test_...
|
STRIPE_SECRET_KEY=
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
|
||||||
# ── AWS / S3 ──────────────────────────────────────────────────────────────────
|
# ── AWS / S3 ──────────────────────────────────────────────────────────────────
|
||||||
S3_BUCKET=adiuva-backups
|
S3_BUCKET=adiuva
|
||||||
S3_REGION=us-east-1
|
S3_REGION=us-east-1
|
||||||
AWS_ACCESS_KEY_ID=AKIA...
|
S3_ENDPOINT_URL=
|
||||||
AWS_SECRET_ACCESS_KEY=...
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
# For MinIO (homelab): S3_ENDPOINT_URL=http://minio:9000
|
||||||
|
|
||||||
|
# ── Vector Store ──────────────────────────────────────────────────────────────
|
||||||
|
# Pinecone is used when PINECONE_API_KEY is set; otherwise falls back to Qdrant.
|
||||||
|
PINECONE_API_KEY=
|
||||||
|
PINECONE_INDEX=adiuva
|
||||||
|
QDRANT_URL=
|
||||||
|
QDRANT_API_KEY=
|
||||||
|
# For local Qdrant (homelab): QDRANT_URL=http://qdrant:6333
|
||||||
|
|
||||||
# ── CORS ──────────────────────────────────────────────────────────────────────
|
# ── CORS ──────────────────────────────────────────────────────────────────────
|
||||||
# Comma-separated list parsed by Settings (override default if needed)
|
# Comma-separated list parsed by Settings (override default if needed)
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ run-name: ${{ gitea.ref_name }} → Docker LXC
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
tags:
|
||||||
tags: ['v*']
|
- 'v*'
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── 1. Run tests in an isolated Python container ──────────────────
|
# ── 1. Run tests in an isolated Python container ──────────────────
|
||||||
@@ -16,8 +14,15 @@ jobs:
|
|||||||
image: python:3.12-slim
|
image: python:3.12-slim
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Install git
|
||||||
|
run: apt-get update && apt-get install -y --no-install-recommends git
|
||||||
|
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v4
|
run: |
|
||||||
|
git clone --depth 1 --branch "${GITHUB_REF_NAME}" \
|
||||||
|
"http://10.0.0.119:3000/${GITHUB_REPOSITORY}.git" . || \
|
||||||
|
git clone --depth 1 "http://10.0.0.119:3000/${GITHUB_REPOSITORY}.git" . && \
|
||||||
|
git checkout "${GITHUB_SHA}"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: pip install --no-cache-dir -r requirements.txt
|
run: pip install --no-cache-dir -r requirements.txt
|
||||||
@@ -28,69 +33,61 @@ jobs:
|
|||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: pytest tests/ -v --tb=short
|
run: pytest tests/ -v --tb=short
|
||||||
|
|
||||||
# ── 2. Deploy to Docker LXC (only main branch & tags) ─────────────
|
# ── 2. Deploy to Docker LXC via SSH ─────────────────────────────────
|
||||||
deploy:
|
deploy:
|
||||||
needs: test
|
needs: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: gitea.event_name == 'push'
|
if: gitea.event_name == 'push'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Deploy via SSH
|
||||||
uses: actions/checkout@v4
|
uses: appleboy/ssh-action@v1.0.0
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USER }}
|
||||||
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
DEPLOY_DIR="/opt/adiuva-api"
|
||||||
|
REPO_URL="http://10.0.0.119:3000/${{ gitea.repository }}.git"
|
||||||
|
TAG="${{ gitea.ref_name }}"
|
||||||
|
|
||||||
- name: Sync to deploy directory
|
# ── Pull latest code ──
|
||||||
run: |
|
cd /tmp && rm -rf adiuva-api-deploy
|
||||||
DEPLOY_DIR="/opt/adiuva-api"
|
git clone --depth 1 --branch "${TAG}" "${REPO_URL}" adiuva-api-deploy
|
||||||
mkdir -p "$DEPLOY_DIR"
|
|
||||||
|
|
||||||
# Sync source, preserve .env and volumes
|
# ── Sync source (preserve .env) ──
|
||||||
cp -rf app/ alembic/ alembic.ini Dockerfile docker-compose.yml requirements.txt "$DEPLOY_DIR/"
|
cp -rf /tmp/adiuva-api-deploy/app/ \
|
||||||
|
/tmp/adiuva-api-deploy/alembic/ \
|
||||||
|
/tmp/adiuva-api-deploy/alembic.ini \
|
||||||
|
/tmp/adiuva-api-deploy/Dockerfile \
|
||||||
|
/tmp/adiuva-api-deploy/docker-compose.yml \
|
||||||
|
/tmp/adiuva-api-deploy/requirements.txt \
|
||||||
|
"$DEPLOY_DIR/"
|
||||||
|
rm -rf /tmp/adiuva-api-deploy
|
||||||
|
|
||||||
- name: Build & restart services
|
# ── Verify .env ──
|
||||||
run: |
|
if [ ! -f "$DEPLOY_DIR/.env" ]; then
|
||||||
cd /opt/adiuva-api
|
echo "❌ $DEPLOY_DIR/.env not found. Create it before deploying."
|
||||||
docker compose up -d --build --remove-orphans
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Run database migrations
|
# ── Build & restart ──
|
||||||
run: |
|
cd "$DEPLOY_DIR"
|
||||||
cd /opt/adiuva-api
|
docker compose down --remove-orphans || true
|
||||||
docker compose exec -T app alembic upgrade head
|
docker compose up -d --build
|
||||||
|
|
||||||
- name: Verify deployment
|
# ── Migrations ──
|
||||||
run: |
|
docker compose exec -T app alembic upgrade head
|
||||||
echo "Waiting for app to be ready..."
|
|
||||||
sleep 5
|
|
||||||
|
|
||||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/api/v1/health)
|
# ── Health check ──
|
||||||
if [ "$HTTP_CODE" -eq 200 ]; then
|
echo "Waiting for app..."
|
||||||
echo "✅ API is healthy (HTTP ${HTTP_CODE})"
|
sleep 5
|
||||||
else
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/v1/health)
|
||||||
echo "❌ Health check failed (HTTP ${HTTP_CODE})"
|
if [ "$HTTP_CODE" -eq 200 ]; then
|
||||||
docker compose -f /opt/adiuva-api/docker-compose.yml logs app --tail=50
|
echo "✅ API is healthy (HTTP ${HTTP_CODE})"
|
||||||
exit 1
|
else
|
||||||
fi
|
echo "❌ Health check failed (HTTP ${HTTP_CODE})"
|
||||||
|
docker compose logs app --tail=50
|
||||||
- name: Create Gitea Release (tags only)
|
exit 1
|
||||||
if: startsWith(gitea.ref, 'refs/tags/')
|
fi
|
||||||
run: |
|
|
||||||
GITEA_URL="http://10.0.0.119:3000"
|
|
||||||
TAG="${GITHUB_REF_NAME}"
|
|
||||||
REPO="${GITHUB_REPOSITORY}"
|
|
||||||
TOKEN="${{ gitea.token }}"
|
|
||||||
|
|
||||||
RELEASE_ID=$(curl -sf \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}" \
|
|
||||||
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
|
||||||
|
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
|
||||||
curl -sf \
|
|
||||||
-X POST \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Adiuva API ${TAG}\",\"body\":\"Deployed to Docker LXC\"}" \
|
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases"
|
|
||||||
echo "✅ Release ${TAG} created"
|
|
||||||
else
|
|
||||||
echo "ℹ️ Release ${TAG} already exists (ID: ${RELEASE_ID})"
|
|
||||||
fi
|
|
||||||
@@ -40,7 +40,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("id", postgresql.UUID(as_uuid=False), nullable=False),
|
sa.Column("id", postgresql.UUID(as_uuid=False), nullable=False),
|
||||||
sa.Column("email", sa.String(255), nullable=False),
|
sa.Column("email", sa.String(255), nullable=False),
|
||||||
sa.Column("password_hash", sa.String(255), nullable=False),
|
sa.Column("password_hash", sa.String(255), nullable=False),
|
||||||
sa.Column("tier", sa.Enum("free", "pro", "power", "team", name="billing_tier"), nullable=False, server_default="free"),
|
sa.Column("tier", sa.Enum("free", "pro", "power", "team", name="billing_tier", create_type=False), nullable=False, server_default="free"),
|
||||||
sa.Column("stripe_customer_id", sa.String(255), nullable=True),
|
sa.Column("stripe_customer_id", sa.String(255), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
@@ -70,7 +70,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("id", postgresql.UUID(as_uuid=False), nullable=False),
|
sa.Column("id", postgresql.UUID(as_uuid=False), nullable=False),
|
||||||
sa.Column("user_id", postgresql.UUID(as_uuid=False), nullable=False),
|
sa.Column("user_id", postgresql.UUID(as_uuid=False), nullable=False),
|
||||||
sa.Column("stripe_subscription_id", sa.String(255), nullable=True),
|
sa.Column("stripe_subscription_id", sa.String(255), nullable=True),
|
||||||
sa.Column("tier", sa.Enum("free", "pro", "power", "team", name="billing_tier"), nullable=False, server_default="free"),
|
sa.Column("tier", sa.Enum("free", "pro", "power", "team", name="billing_tier", create_type=False), nullable=False, server_default="free"),
|
||||||
sa.Column("status", sa.String(50), nullable=False, server_default="free"),
|
sa.Column("status", sa.String(50), nullable=False, server_default="free"),
|
||||||
sa.Column("current_period_end", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("current_period_end", sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
@@ -125,7 +125,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("category", sa.String(100), nullable=False, server_default=""),
|
sa.Column("category", sa.String(100), nullable=False, server_default=""),
|
||||||
sa.Column("price_cents", sa.Integer, nullable=False, server_default="0"),
|
sa.Column("price_cents", sa.Integer, nullable=False, server_default="0"),
|
||||||
sa.Column("permissions", sa.Text, nullable=False, server_default="[]"),
|
sa.Column("permissions", sa.Text, nullable=False, server_default="[]"),
|
||||||
sa.Column("status", sa.Enum("pending_review", "approved", "rejected", name="plugin_status"), nullable=False, server_default="pending_review"),
|
sa.Column("status", sa.Enum("pending_review", "approved", "rejected", name="plugin_status", create_type=False), nullable=False, server_default="pending_review"),
|
||||||
sa.Column("s3_package_key", sa.String(500), nullable=True),
|
sa.Column("s3_package_key", sa.String(500), nullable=True),
|
||||||
sa.Column("install_count", sa.Integer, nullable=False, server_default="0"),
|
sa.Column("install_count", sa.Integer, nullable=False, server_default="0"),
|
||||||
sa.Column("avg_rating", sa.Float, nullable=False, server_default="0.0"),
|
sa.Column("avg_rating", sa.Float, nullable=False, server_default="0.0"),
|
||||||
@@ -157,7 +157,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("id", postgresql.UUID(as_uuid=False), nullable=False),
|
sa.Column("id", postgresql.UUID(as_uuid=False), nullable=False),
|
||||||
sa.Column("plugin_id", sa.String(255), nullable=False),
|
sa.Column("plugin_id", sa.String(255), nullable=False),
|
||||||
sa.Column("reviewer_id", postgresql.UUID(as_uuid=False), nullable=True),
|
sa.Column("reviewer_id", postgresql.UUID(as_uuid=False), nullable=True),
|
||||||
sa.Column("decision", sa.Enum("approved", "rejected", name="review_decision"), nullable=False),
|
sa.Column("decision", sa.Enum("approved", "rejected", name="review_decision", create_type=False), nullable=False),
|
||||||
sa.Column("notes", sa.Text, nullable=True),
|
sa.Column("notes", sa.Text, nullable=True),
|
||||||
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ PostgreSQL ``storage_records`` table.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class Settings(BaseSettings):
|
|||||||
QDRANT_API_KEY: str = ""
|
QDRANT_API_KEY: str = ""
|
||||||
|
|
||||||
OPENAI_API_KEY: str = ""
|
OPENAI_API_KEY: str = ""
|
||||||
|
ANTHROPIC_API_KEY: str = ""
|
||||||
|
GOOGLE_API_KEY: str = ""
|
||||||
|
|
||||||
LLM_MODEL: str = "gpt-4o"
|
LLM_MODEL: str = "gpt-4o"
|
||||||
LLM_ROUTER_MODEL: str = "gpt-4o-mini"
|
LLM_ROUTER_MODEL: str = "gpt-4o-mini"
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ from app.config.settings import settings
|
|||||||
def _api_key_for_model(model: str) -> str | None:
|
def _api_key_for_model(model: str) -> str | None:
|
||||||
"""Return the most appropriate API key for the given LiteLLM model string."""
|
"""Return the most appropriate API key for the given LiteLLM model string."""
|
||||||
if model.startswith("anthropic/"):
|
if model.startswith("anthropic/"):
|
||||||
return getattr(settings, "ANTHROPIC_API_KEY", None) or None
|
return settings.ANTHROPIC_API_KEY or None
|
||||||
if model.startswith("gemini/") or model.startswith("google/"):
|
if model.startswith("gemini/") or model.startswith("google/"):
|
||||||
return getattr(settings, "GOOGLE_API_KEY", None) or None
|
return settings.GOOGLE_API_KEY or None
|
||||||
# Default: OpenAI-compatible (covers plain model names like "gpt-4o")
|
# Default: OpenAI-compatible (covers plain model names like "gpt-4o")
|
||||||
return settings.OPENAI_API_KEY or None
|
return settings.OPENAI_API_KEY or None
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
BigInteger,
|
BigInteger,
|
||||||
Boolean,
|
|
||||||
DateTime,
|
DateTime,
|
||||||
Enum,
|
Enum,
|
||||||
Float,
|
Float,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
from botocore.exceptions import ClientError
|
|
||||||
|
|
||||||
from app.config.settings import settings
|
from app.config.settings import settings
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
version: "3.9"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8080:8000"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- path: .env
|
||||||
|
required: false
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/adiuva
|
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/adiuva
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ a per-test session, and a FastAPI ``TestClient`` wired to use it.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|||||||
@@ -8,11 +8,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import pytest
|
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
|
|
||||||
from app.config.settings import settings
|
from app.config.settings import settings
|
||||||
from tests.conftest import auth_header, make_jwt, TEST_USER_IDS
|
from tests.conftest import auth_header, TEST_USER_IDS
|
||||||
|
|
||||||
|
|
||||||
# ── TestRegister ──────────────────────────────────────────────────────
|
# ── TestRegister ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from tests.conftest import auth_header, TEST_USER_IDS
|
from tests.conftest import auth_header, TEST_USER_IDS
|
||||||
|
|
||||||
@@ -168,7 +167,7 @@ class TestDeleteBackup:
|
|||||||
def _get_backup_id(self, client, tier="power") -> str:
|
def _get_backup_id(self, client, tier="power") -> str:
|
||||||
"""Upload a backup and return its DB id from history."""
|
"""Upload a backup and return its DB id from history."""
|
||||||
_upload(client, tier=tier)
|
_upload(client, tier=tier)
|
||||||
history = client.get(
|
client.get(
|
||||||
"/api/v1/backup/history", headers=auth_header(tier)
|
"/api/v1/backup/history", headers=auth_header(tier)
|
||||||
).json()
|
).json()
|
||||||
# History returns BackupMetadata schema which doesn't have `id`.
|
# History returns BackupMetadata schema which doesn't have `id`.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from app.core.orchestrator import (
|
|||||||
route_pipeline,
|
route_pipeline,
|
||||||
route_single,
|
route_single,
|
||||||
)
|
)
|
||||||
from app.schemas import ChatContext, ChatRequest, ChatResponse, ExecutionPlan
|
from app.schemas import ChatRequest, ChatResponse, ExecutionPlan
|
||||||
|
|
||||||
|
|
||||||
# ── Stub agents ──────────────────────────────────────────────────────
|
# ── Stub agents ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -9,11 +9,9 @@ Covers:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user