Codex commited on
Commit
3a6f2bc
·
1 Parent(s): 3e2561c

Refresh Anovo model routing to GPT-OSS

Browse files
Dockerfile CHANGED
@@ -1,11 +1,12 @@
1
  FROM python:3.11-slim
2
- WORKDIR /app
3
 
4
- LABEL version="4.0"
5
 
6
  COPY requirements.txt .
7
  RUN pip install --no-cache-dir -r requirements.txt
 
8
  COPY . .
9
 
10
  EXPOSE 7860
 
11
  CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
 
1
  FROM python:3.11-slim
 
2
 
3
+ WORKDIR /app
4
 
5
  COPY requirements.txt .
6
  RUN pip install --no-cache-dir -r requirements.txt
7
+
8
  COPY . .
9
 
10
  EXPOSE 7860
11
+
12
  CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -13,12 +13,16 @@ short_description: AI-powered writing tool backend (FastAPI)
13
 
14
  FastAPI backend for [Anovo](https://github.com/rushabhnixen/Anovo) — an open-source AI writing tool.
15
 
16
- ## Required Secrets (set in Space Settings)
17
 
18
  | Secret | Description |
19
  |---|---|
20
- | `JWT_SECRET_KEY` | Random string for JWT signing |
21
- | `GROQ_API_KEYS` | Comma-separated Groq API keys |
22
- | `GITHUB_PAT` | GitHub classic token (ghp_...) for premium models |
23
- | `ADMIN_EMAILS` | Comma-separated admin emails |
24
- | `PREMIUM_PROMO_CODES` | Comma-separated promo codes |
 
 
 
 
 
13
 
14
  FastAPI backend for [Anovo](https://github.com/rushabhnixen/Anovo) — an open-source AI writing tool.
15
 
16
+ ## Required Secrets (set in Space Settings → Repository secrets)
17
 
18
  | Secret | Description |
19
  |---|---|
20
+ | `JWT_SECRET_KEY` | Random 32-byte hex string run `openssl rand -hex 32` |
21
+ | `GROQ_API_KEY` | Free API key from [console.groq.com](https://console.groq.com) |
22
+
23
+ ## Optional Secrets
24
+
25
+ | Secret | Default | Description |
26
+ |---|---|---|
27
+ | `DATABASE_URL` | `sqlite:///./anovo.db` | SQLite (default) or Postgres URL |
28
+ | `CORS_ORIGINS` | Vercel + HF wildcards | JSON array of allowed origins |
config.py CHANGED
@@ -20,15 +20,18 @@ class Settings(BaseSettings):
20
  # e.g. GROQ_API_KEYS=gsk_key1,gsk_key2,gsk_key3,gsk_key4
21
  groq_api_key: str = "" # single key (backward compatible)
22
  groq_api_keys: str = "" # comma-separated list of keys
23
- groq_model: str = "llama-3.3-70b-versatile"
 
 
24
 
25
  # HuggingFace Inference API — middle-tier fallback between Groq and local
26
  hf_api_token: str = ""
27
- hf_model: str = "mistralai/Mistral-7B-Instruct-v0.3"
28
 
29
- # GitHub Models (premium tier) Meta-Llama-3.1-405B-Instruct
 
30
  github_pat: str = ""
31
- github_model: str = "Meta-Llama-3.1-405B-Instruct"
32
 
33
  # Premium promo codes (comma-separated)
34
  premium_promo_codes: str = ""
 
20
  # e.g. GROQ_API_KEYS=gsk_key1,gsk_key2,gsk_key3,gsk_key4
21
  groq_api_key: str = "" # single key (backward compatible)
22
  groq_api_keys: str = "" # comma-separated list of keys
23
+ # Llama 3.3 70B is scheduled to shut down on Groq free/developer tiers on
24
+ # 2026-08-16. GPT-OSS 20B is the supported low-latency replacement.
25
+ groq_model: str = "openai/gpt-oss-20b"
26
 
27
  # HuggingFace Inference API — middle-tier fallback between Groq and local
28
  hf_api_token: str = ""
29
+ hf_model: str = "openai/gpt-oss-20b:fastest"
30
 
31
+ # Retained only so older deployments can boot while the secret is removed.
32
+ # GitHub Models was retired on 2026-07-30 and is no longer called.
33
  github_pat: str = ""
34
+ github_model: str = "gpt-oss-120b"
35
 
36
  # Premium promo codes (comma-separated)
37
  premium_promo_codes: str = ""
main.py CHANGED
@@ -26,7 +26,7 @@ app = FastAPI(
26
  "translation, AI text humanization, plagiarism detection, "
27
  "tone analysis, co-writing, AI chat, and user accounts."
28
  ),
29
- version="2.0.0",
30
  docs_url="/docs",
31
  redoc_url="/redoc",
32
  lifespan=lifespan,
@@ -67,7 +67,7 @@ def health_check() -> dict:
67
  "providers": {
68
  "groq": bool(settings.groq_api_keys or settings.groq_api_key),
69
  "hf": bool(settings.hf_api_token),
70
- "github_models": bool(settings.github_pat),
71
  },
72
  "db": settings.database_url.split("///")[-1] if "sqlite" in settings.database_url else "postgres",
73
  }
 
26
  "translation, AI text humanization, plagiarism detection, "
27
  "tone analysis, co-writing, AI chat, and user accounts."
28
  ),
29
+ version="2.1.0",
30
  docs_url="/docs",
31
  redoc_url="/redoc",
32
  lifespan=lifespan,
 
67
  "providers": {
68
  "groq": bool(settings.groq_api_keys or settings.groq_api_key),
69
  "hf": bool(settings.hf_api_token),
70
+ "premium_models": bool(settings.groq_api_keys or settings.groq_api_key),
71
  },
72
  "db": settings.database_url.split("///")[-1] if "sqlite" in settings.database_url else "postgres",
73
  }
models/schemas.py CHANGED
@@ -7,7 +7,7 @@ from typing import Literal, Optional
7
  class ParaphraseRequest(BaseModel):
8
  text: str = Field(..., min_length=1, max_length=10000, description="Text to paraphrase")
9
  intensity: int = Field(3, ge=1, le=5, description="Paraphrase intensity (1=minimal, 5=aggressive)")
10
- model: str = Field("standard", description="Model to use: 'standard' or a GitHub Models model name")
11
  writing_mode: Literal[
12
  "standard", "fluency", "formal", "simple", "creative",
13
  "academic", "expand", "shorten", "humanize",
@@ -95,7 +95,7 @@ class TranslateResponse(BaseModel):
95
 
96
  class HumanizeRequest(BaseModel):
97
  text: str = Field(..., min_length=1, max_length=10000, description="Text to humanize")
98
- model: str = Field("standard", description="Model to use: 'standard' or a GitHub Models model name")
99
 
100
 
101
  class HumanizeResponse(BaseModel):
@@ -230,3 +230,17 @@ class AdminStatsResponse(BaseModel):
230
  premium_users: int
231
  admin_users: int
232
  total_history_entries: int
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  class ParaphraseRequest(BaseModel):
8
  text: str = Field(..., min_length=1, max_length=10000, description="Text to paraphrase")
9
  intensity: int = Field(3, ge=1, le=5, description="Paraphrase intensity (1=minimal, 5=aggressive)")
10
+ model: str = Field("standard", description="Model to use: 'standard' or a supported Anovo model profile")
11
  writing_mode: Literal[
12
  "standard", "fluency", "formal", "simple", "creative",
13
  "academic", "expand", "shorten", "humanize",
 
95
 
96
  class HumanizeRequest(BaseModel):
97
  text: str = Field(..., min_length=1, max_length=10000, description="Text to humanize")
98
+ model: str = Field("standard", description="Model to use: 'standard' or a supported Anovo model profile")
99
 
100
 
101
  class HumanizeResponse(BaseModel):
 
230
  premium_users: int
231
  admin_users: int
232
  total_history_entries: int
233
+
234
+
235
+ class AdminModelInfo(BaseModel):
236
+ id: str
237
+ label: str
238
+ provider_model: str
239
+ status: Literal["production", "preview"]
240
+
241
+
242
+ class AdminModelsResponse(BaseModel):
243
+ provider: str
244
+ provider_configured: bool
245
+ standard_model: str
246
+ models: list[AdminModelInfo]
routers/admin.py CHANGED
@@ -4,14 +4,28 @@ from fastapi import APIRouter, Depends, HTTPException
4
  from sqlalchemy import func
5
  from sqlalchemy.orm import Session
6
 
 
7
  from database import get_db
8
  from models.db_models import HistoryEntry, User
9
- from models.schemas import AdminStatsResponse, AdminUserUpdate, UserResponse
 
 
 
 
 
 
10
  from routers.auth import _current_user_id
11
  from services.auth_service import get_user_by_id
 
12
 
13
  router = APIRouter(prefix="/api/admin", tags=["admin"])
14
 
 
 
 
 
 
 
15
 
16
  def _require_admin(
17
  user_id: int = Depends(_current_user_id),
@@ -94,3 +108,24 @@ def get_stats(
94
  admin_users=admin_users,
95
  total_history_entries=total_history,
96
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  from sqlalchemy import func
5
  from sqlalchemy.orm import Session
6
 
7
+ from config import settings
8
  from database import get_db
9
  from models.db_models import HistoryEntry, User
10
+ from models.schemas import (
11
+ AdminModelInfo,
12
+ AdminModelsResponse,
13
+ AdminStatsResponse,
14
+ AdminUserUpdate,
15
+ UserResponse,
16
+ )
17
  from routers.auth import _current_user_id
18
  from services.auth_service import get_user_by_id
19
+ from services.llm_client import PREMIUM_MODEL_PROFILES
20
 
21
  router = APIRouter(prefix="/api/admin", tags=["admin"])
22
 
23
+ MODEL_LABELS = {
24
+ "gpt-oss-120b": "GPT-OSS 120B",
25
+ "gpt-oss-20b": "GPT-OSS 20B",
26
+ "qwen-3.6-27b": "Qwen 3.6 27B",
27
+ }
28
+
29
 
30
  def _require_admin(
31
  user_id: int = Depends(_current_user_id),
 
108
  admin_users=admin_users,
109
  total_history_entries=total_history,
110
  )
111
+
112
+
113
+ @router.get("/models", response_model=AdminModelsResponse, summary="Writing model status")
114
+ def model_status(
115
+ admin: User = Depends(_require_admin),
116
+ ) -> AdminModelsResponse:
117
+ """Return safe, non-secret provider information for the admin dashboard."""
118
+ return AdminModelsResponse(
119
+ provider="Groq",
120
+ provider_configured=bool(settings.groq_api_keys or settings.groq_api_key),
121
+ standard_model=settings.groq_model,
122
+ models=[
123
+ AdminModelInfo(
124
+ id=profile,
125
+ label=MODEL_LABELS[profile],
126
+ provider_model=provider_model,
127
+ status="preview" if profile == "qwen-3.6-27b" else "production",
128
+ )
129
+ for profile, provider_model in PREMIUM_MODEL_PROFILES.items()
130
+ ],
131
+ )
routers/humanize.py CHANGED
@@ -20,7 +20,7 @@ def humanize_endpoint(
20
  """
21
  Transform AI-generated text into more natural, human-sounding writing.
22
 
23
- Set `model` to a GitHub Models model name to use premium mode
24
  (requires authentication and premium account).
25
  """
26
  use_premium = request.model != "standard"
 
20
  """
21
  Transform AI-generated text into more natural, human-sounding writing.
22
 
23
+ Set `model` to a supported Anovo model profile to use premium mode
24
  (requires authentication and premium account).
25
  """
26
  use_premium = request.model != "standard"
routers/paraphrase.py CHANGED
@@ -26,7 +26,7 @@ def paraphrase_endpoint(
26
  """
27
  Paraphrase the given text with adjustable intensity.
28
 
29
- Set `model` to a GitHub Models model name to use premium mode
30
  (requires authentication and premium account).
31
  """
32
  use_premium = request.model != "standard"
 
26
  """
27
  Paraphrase the given text with adjustable intensity.
28
 
29
+ Set `model` to a supported Anovo model profile to use premium mode
30
  (requires authentication and premium account).
31
  """
32
  use_premium = request.model != "standard"
services/humanize_service.py CHANGED
@@ -5,7 +5,7 @@ Uses LLM (Groq / HF Inference) for high-quality humanization when available.
5
  Processes large texts by chunking into paragraphs and humanizing each chunk
6
  separately, then reassembling. Falls back to a local pipeline otherwise.
7
 
8
- Premium mode uses GitHub Models for superior rewriting quality.
9
  """
10
  from __future__ import annotations
11
 
@@ -31,8 +31,8 @@ def humanize(text: str) -> dict:
31
  return result
32
 
33
 
34
- def humanize_premium(text: str, model: str = "Meta-Llama-3.1-405B-Instruct") -> dict:
35
- """Humanize using a premium GitHub Models model."""
36
  try:
37
  return _humanize_llm_premium(text, model)
38
  except RuntimeError:
 
5
  Processes large texts by chunking into paragraphs and humanizing each chunk
6
  separately, then reassembling. Falls back to a local pipeline otherwise.
7
 
8
+ Premium mode uses the current Groq-hosted Anovo model profiles.
9
  """
10
  from __future__ import annotations
11
 
 
31
  return result
32
 
33
 
34
+ def humanize_premium(text: str, model: str = "gpt-oss-120b") -> dict:
35
+ """Humanize using a premium writing model."""
36
  try:
37
  return _humanize_llm_premium(text, model)
38
  except RuntimeError:
services/llm_client.py CHANGED
@@ -23,18 +23,28 @@ logger = logging.getLogger(__name__)
23
 
24
  GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
25
  HF_URL = "https://router.huggingface.co/v1/chat/completions"
26
- GITHUB_MODELS_URL = "https://models.inference.ai.azure.com/chat/completions"
27
-
28
- GITHUB_MODELS_AVAILABLE = [
29
- "gpt-4o",
30
- "gpt-4o-mini",
31
- "Meta-Llama-3.1-405B-Instruct",
32
- "Llama-3.3-70B-Instruct",
33
- "Meta-Llama-3.1-8B-Instruct",
34
- "Phi-4",
35
- "DeepSeek-R1",
36
- "Cohere-command-r-plus-08-2024",
37
- ]
 
 
 
 
 
 
 
 
 
 
38
 
39
 
40
  class ProviderError(Exception):
@@ -136,45 +146,58 @@ def _rotate_from(start_key: str) -> list[str]:
136
  return _groq_keys[idx:] + _groq_keys[:idx]
137
 
138
 
 
 
 
 
 
 
 
 
139
  def llm_chat_premium(
140
  system_prompt: str,
141
  user_prompt: str,
142
- model: str = "Meta-Llama-3.1-405B-Instruct",
143
  temperature: float = 0.7,
144
  max_tokens: int = 4096,
145
  ) -> tuple[str, str]:
146
- """Call a premium model via GitHub Models API.
147
 
148
- Returns (content, model_used). Falls back to the standard Groq/HF cascade
149
- if GitHub PAT is not configured or if the call fails.
150
  """
151
  messages = [
152
  {"role": "system", "content": system_prompt},
153
  {"role": "user", "content": user_prompt},
154
  ]
155
 
156
- pat = settings.github_pat
157
- if pat:
158
- logger.info(
159
- "Attempting GitHub Models: model=%s, PAT prefix=%s..., url=%s",
160
- model, pat[:10], GITHUB_MODELS_URL,
161
- )
162
- try:
163
- content = _call_provider(
164
- url=GITHUB_MODELS_URL,
165
- api_key=pat,
166
- model=model,
167
- messages=messages,
168
- temperature=temperature,
169
- max_tokens=max_tokens,
170
- timeout=90.0,
171
- )
172
- logger.info("GitHub Models (%s) succeeded.", model)
173
- return content, model
174
- except ProviderError as e:
175
- logger.error("GitHub Models (%s) FAILED: %s — falling back to standard.", model, e)
 
 
 
 
 
176
  else:
177
- logger.warning("GITHUB_PAT not setskipping premium, using standard cascade.")
178
 
179
  # Fallback: use the standard Groq -> HF cascade
180
  content = llm_chat_messages(messages, temperature=temperature, max_tokens=max_tokens)
 
23
 
24
  GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
25
  HF_URL = "https://router.huggingface.co/v1/chat/completions"
26
+ # GitHub Models was fully retired on 2026-07-30. Keep stable Anovo profile
27
+ # names and route them through models currently offered on Groq's free tier.
28
+ PREMIUM_MODEL_PROFILES = {
29
+ "gpt-oss-120b": "openai/gpt-oss-120b",
30
+ "gpt-oss-20b": "openai/gpt-oss-20b",
31
+ "qwen-3.6-27b": "qwen/qwen3.6-27b",
32
+ }
33
+
34
+ # Existing web/extension clients may retain one of these values in storage.
35
+ # Resolve them instead of returning a hard failure after the provider migration.
36
+ LEGACY_MODEL_ALIASES = {
37
+ "gpt-4o": "openai/gpt-oss-120b",
38
+ "gpt-4o-mini": "openai/gpt-oss-20b",
39
+ "Meta-Llama-3.1-405B-Instruct": "openai/gpt-oss-120b",
40
+ "Llama-3.3-70B-Instruct": "openai/gpt-oss-120b",
41
+ "Meta-Llama-3.1-8B-Instruct": "openai/gpt-oss-20b",
42
+ "Phi-4": "openai/gpt-oss-20b",
43
+ "DeepSeek-R1": "openai/gpt-oss-120b",
44
+ "Cohere-command-r-plus-08-2024": "qwen/qwen3.6-27b",
45
+ }
46
+
47
+ ALLOWED_PREMIUM_MODELS = frozenset(PREMIUM_MODEL_PROFILES.values())
48
 
49
 
50
  class ProviderError(Exception):
 
146
  return _groq_keys[idx:] + _groq_keys[:idx]
147
 
148
 
149
+ def resolve_premium_model(model: str) -> str:
150
+ """Resolve a stable/legacy selector to an allowed current provider model."""
151
+ resolved = PREMIUM_MODEL_PROFILES.get(model, LEGACY_MODEL_ALIASES.get(model, model))
152
+ if resolved not in ALLOWED_PREMIUM_MODELS:
153
+ raise ValueError(f"Unsupported writing model: {model}")
154
+ return resolved
155
+
156
+
157
  def llm_chat_premium(
158
  system_prompt: str,
159
  user_prompt: str,
160
+ model: str = "gpt-oss-120b",
161
  temperature: float = 0.7,
162
  max_tokens: int = 4096,
163
  ) -> tuple[str, str]:
164
+ """Call an allowed premium model through Groq.
165
 
166
+ Returns (content, model_used). Falls back to the standard Groq/HF cascade
167
+ when the selected model is unavailable or its free-tier limit is reached.
168
  """
169
  messages = [
170
  {"role": "system", "content": system_prompt},
171
  {"role": "user", "content": user_prompt},
172
  ]
173
 
174
+ resolved_model = resolve_premium_model(model)
175
+ if _groq_keys:
176
+ with _groq_lock:
177
+ start_key = next(_groq_cycle) # type: ignore[arg-type]
178
+ for index, key in enumerate(_rotate_from(start_key)):
179
+ try:
180
+ content = _call_provider(
181
+ url=GROQ_URL,
182
+ api_key=key,
183
+ model=resolved_model,
184
+ messages=messages,
185
+ temperature=temperature,
186
+ max_tokens=max_tokens,
187
+ timeout=60.0,
188
+ )
189
+ logger.info("Groq premium model %s succeeded.", resolved_model)
190
+ return content, resolved_model
191
+ except ProviderError as exc:
192
+ logger.warning(
193
+ "Groq premium key %d/%d failed for %s: %s",
194
+ index + 1,
195
+ len(_groq_keys),
196
+ resolved_model,
197
+ exc,
198
+ )
199
  else:
200
+ logger.warning("No Groq key configured — using the standard fallback cascade.")
201
 
202
  # Fallback: use the standard Groq -> HF cascade
203
  content = llm_chat_messages(messages, temperature=temperature, max_tokens=max_tokens)
services/paraphrase_service.py CHANGED
@@ -2,7 +2,7 @@
2
  Paraphrase service.
3
 
4
  Uses LLM (Groq / HF Inference) when available; falls back to local T5 model.
5
- Premium mode uses GitHub Models (Meta-Llama-3.1-405B-Instruct).
6
  """
7
  from __future__ import annotations
8
 
@@ -72,10 +72,10 @@ def paraphrase(text: str, intensity: int = 3, writing_mode: str = "standard") ->
72
  def paraphrase_premium(
73
  text: str,
74
  intensity: int = 3,
75
- model: str = "Meta-Llama-3.1-405B-Instruct",
76
  writing_mode: str = "standard",
77
  ) -> tuple[str, str]:
78
- """Paraphrase using a premium GitHub Models model. Returns (text, model_used)."""
79
  try:
80
  return _paraphrase_llm_premium(text, intensity, model, writing_mode)
81
  except RuntimeError:
 
2
  Paraphrase service.
3
 
4
  Uses LLM (Groq / HF Inference) when available; falls back to local T5 model.
5
+ Premium mode uses the current Groq-hosted Anovo model profiles.
6
  """
7
  from __future__ import annotations
8
 
 
72
  def paraphrase_premium(
73
  text: str,
74
  intensity: int = 3,
75
+ model: str = "gpt-oss-120b",
76
  writing_mode: str = "standard",
77
  ) -> tuple[str, str]:
78
+ """Paraphrase using a premium writing model. Returns (text, model_used)."""
79
  try:
80
  return _paraphrase_llm_premium(text, intensity, model, writing_mode)
81
  except RuntimeError:
tests/test_api.py CHANGED
@@ -6,6 +6,7 @@ All service calls are mocked so no ML models or external services are required.
6
  import sys
7
  import os
8
  from unittest.mock import patch
 
9
  from fastapi.testclient import TestClient
10
 
11
  # Ensure the backend directory is on the path when running from backend/
@@ -16,6 +17,19 @@ from main import app # noqa: E402
16
  client = TestClient(app)
17
 
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  # ── Health ────────────────────────────────────────────────────────────────────
20
 
21
  class TestHealth:
 
6
  import sys
7
  import os
8
  from unittest.mock import patch
9
+ from unittest.mock import MagicMock
10
  from fastapi.testclient import TestClient
11
 
12
  # Ensure the backend directory is on the path when running from backend/
 
17
  client = TestClient(app)
18
 
19
 
20
+ class TestAccountDeletion:
21
+ def test_delete_account_removes_user_and_commits(self):
22
+ from routers.auth import delete_me
23
+
24
+ db = MagicMock()
25
+ user = MagicMock()
26
+ with patch("routers.auth.get_user_by_id", return_value=user):
27
+ delete_me(user_id=42, db=db)
28
+
29
+ db.delete.assert_called_once_with(user)
30
+ db.commit.assert_called_once_with()
31
+
32
+
33
  # ── Health ────────────────────────────────────────────────────────────────────
34
 
35
  class TestHealth:
tests/test_llm_client.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the current writing-model registry and legacy migrations."""
2
+
3
+ import pytest
4
+
5
+ from services.llm_client import resolve_premium_model
6
+
7
+
8
+ @pytest.mark.parametrize(
9
+ ("selector", "expected"),
10
+ [
11
+ ("gpt-oss-120b", "openai/gpt-oss-120b"),
12
+ ("gpt-oss-20b", "openai/gpt-oss-20b"),
13
+ ("qwen-3.6-27b", "qwen/qwen3.6-27b"),
14
+ ("gpt-4o", "openai/gpt-oss-120b"),
15
+ ("gpt-4o-mini", "openai/gpt-oss-20b"),
16
+ ("Meta-Llama-3.1-405B-Instruct", "openai/gpt-oss-120b"),
17
+ ],
18
+ )
19
+ def test_resolve_premium_model(selector, expected):
20
+ assert resolve_premium_model(selector) == expected
21
+
22
+
23
+ def test_resolve_premium_model_rejects_arbitrary_provider_ids():
24
+ with pytest.raises(ValueError, match="Unsupported writing model"):
25
+ resolve_premium_model("unknown/provider-model")