Compare commits

...

3 Commits

Author SHA1 Message Date
06de7c7ab0 feat: deploy via SSH with port 8080, idempotent migrations
All checks were successful
Test & Deploy API / test (push) Successful in 1m39s
Test & Deploy API / deploy (push) Successful in 16s
2026-03-03 22:10:03 +01:00
e3c7547c75 Remove unused imports across multiple files to clean up the codebase
Some checks failed
Test & Deploy API / test (push) Successful in 1m39s
Test & Deploy API / deploy (push) Failing after 2s
2026-03-03 17:21:40 +01:00
314780d59a Add LLM configuration options and update deployment workflow
Some checks failed
Test & Deploy API / test (push) Failing after 1m14s
Test & Deploy API / deploy (push) Has been skipped
- Introduced new API keys for Anthropic and Google in .env.example and settings.py
- Updated llm.py to retrieve API keys directly from settings
- Modified deploy.yaml to streamline code checkout and improve deployment process
2026-03-03 16:52:56 +01:00
14 changed files with 94 additions and 86 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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()")),

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -23,7 +23,6 @@ from datetime import datetime, timezone
from sqlalchemy import ( from sqlalchemy import (
BigInteger, BigInteger,
Boolean,
DateTime, DateTime,
Enum, Enum,
Float, Float,

View File

@@ -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

View File

@@ -2,9 +2,10 @@ 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:

View File

@@ -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

View File

@@ -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 ──────────────────────────────────────────────────────

View File

@@ -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`.

View File

@@ -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 ──────────────────────────────────────────────────────

View File

@@ -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