refactor: replace external Groq TTS proxy with built-in API route
- Add /api/groq-tts route to handle TTS requests directly in Next.js - Add /api/groq-tts/voices endpoint for listing available voices - Remove groq-tts-proxy.py Python server dependency - Remove authentication system (auth route, middleware, lib) - Simplify startup script to only run the Next.js app Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7a13698a1c
commit
41b9793537
10 changed files with 91 additions and 228 deletions
|
|
@ -5,7 +5,6 @@ Scripts for running OpenReader WebUI with Groq Orpheus TTS.
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
- `openreader-webui.sh` - Main startup script
|
- `openreader-webui.sh` - Main startup script
|
||||||
- `groq-tts-proxy.py` - FastAPI proxy that adds `/v1/audio/voices` endpoint for Groq
|
|
||||||
- `.env` - Environment variables (contains `GROQ_API_KEY`)
|
- `.env` - Environment variables (contains `GROQ_API_KEY`)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
@ -21,13 +20,18 @@ http://localhost:3003
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
OpenReader WebUI (Docker :3003)
|
OpenReader WebUI (:3003)
|
||||||
↓
|
↓
|
||||||
Groq TTS Proxy (localhost:8880)
|
Built-in /api/groq-tts route
|
||||||
↓
|
↓
|
||||||
Groq API (canopylabs/orpheus-v1-english)
|
Groq API (canopylabs/orpheus-v1-english)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
- `POST /api/groq-tts` - Generate speech from text
|
||||||
|
- `GET /api/groq-tts/voices` - List available voices
|
||||||
|
|
||||||
## Available Voices
|
## Available Voices
|
||||||
|
|
||||||
- troy, austin, daniel (male)
|
- troy, austin, daniel (male)
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
"""Groq TTS Proxy - OpenAI-compatible TTS server using Groq Orpheus API"""
|
|
||||||
from fastapi import FastAPI, Request, Response, HTTPException
|
|
||||||
import httpx
|
|
||||||
import os
|
|
||||||
|
|
||||||
app = FastAPI(title="Groq TTS Proxy")
|
|
||||||
|
|
||||||
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
|
|
||||||
GROQ_BASE = "https://api.groq.com/openai/v1"
|
|
||||||
|
|
||||||
VOICES = ["troy", "austin", "daniel", "autumn", "diana", "hannah"]
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
async def root():
|
|
||||||
return {"status": "ok", "service": "Groq TTS Proxy"}
|
|
||||||
|
|
||||||
@app.get("/v1/audio/voices")
|
|
||||||
@app.get("/audio/voices")
|
|
||||||
@app.get("/v1/voices")
|
|
||||||
@app.get("/voices")
|
|
||||||
async def get_voices():
|
|
||||||
return {"voices": VOICES}
|
|
||||||
|
|
||||||
@app.post("/v1/audio/speech")
|
|
||||||
@app.post("/audio/speech")
|
|
||||||
async def speech(request: Request):
|
|
||||||
body = await request.json()
|
|
||||||
|
|
||||||
model = body.get("model", "")
|
|
||||||
if not model.startswith("canopylabs/"):
|
|
||||||
body["model"] = "canopylabs/orpheus-v1-english"
|
|
||||||
|
|
||||||
if body.get("voice") not in VOICES:
|
|
||||||
body["voice"] = "troy"
|
|
||||||
|
|
||||||
# Groq requires response_format
|
|
||||||
if "response_format" not in body:
|
|
||||||
body["response_format"] = "wav"
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=120) as client:
|
|
||||||
resp = await client.post(
|
|
||||||
f"{GROQ_BASE}/audio/speech",
|
|
||||||
json=body,
|
|
||||||
headers={"Authorization": f"Bearer {GROQ_API_KEY}"}
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
raise HTTPException(status_code=resp.status_code, detail=resp.text)
|
|
||||||
return Response(content=resp.content, media_type="audio/wav")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import uvicorn
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8880)
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# OpenReader WebUI - TTS Document Reader
|
# OpenReader WebUI - TTS Document Reader
|
||||||
# URL: http://localhost:3003
|
# URL: http://localhost:3003
|
||||||
# Uses Groq Orpheus TTS via local proxy
|
# Uses Groq Orpheus TTS via built-in /api/groq-tts route
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
|
@ -13,11 +13,10 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
source "$SCRIPT_DIR/.env"
|
source "$SCRIPT_DIR/.env"
|
||||||
export GROQ_API_KEY
|
export GROQ_API_KEY
|
||||||
|
|
||||||
# Stop existing services
|
# Stop existing service
|
||||||
fuser -k 8880/tcp 2>/dev/null || true
|
|
||||||
fuser -k 3003/tcp 2>/dev/null || true
|
fuser -k 3003/tcp 2>/dev/null || true
|
||||||
|
|
||||||
# Wait for ports to be released
|
# Wait for port to be released
|
||||||
for i in {1..10}; do
|
for i in {1..10}; do
|
||||||
if ! fuser 3003/tcp 2>/dev/null; then
|
if ! fuser 3003/tcp 2>/dev/null; then
|
||||||
break
|
break
|
||||||
|
|
@ -32,31 +31,16 @@ if fuser 3003/tcp 2>/dev/null; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start Groq TTS proxy (adds /voices endpoint for OpenReader)
|
|
||||||
nohup python3 "$SCRIPT_DIR/groq-tts-proxy.py" > /tmp/groq-proxy.log 2>&1 &
|
|
||||||
disown
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
# Verify proxy is running
|
|
||||||
if ! curl -s http://localhost:8880/ > /dev/null; then
|
|
||||||
echo "ERROR: Groq TTS proxy failed to start"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build and run OpenReader WebUI
|
# Build and run OpenReader WebUI
|
||||||
cd "$PROJECT_DIR"
|
cd "$PROJECT_DIR"
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
# Set environment variables for the app
|
|
||||||
export API_KEY=none
|
|
||||||
export API_BASE=http://localhost:8880/v1
|
|
||||||
|
|
||||||
# Start the app
|
# Start the app
|
||||||
nohup pnpm start > /tmp/openreader.log 2>&1 &
|
nohup pnpm start > /tmp/openreader.log 2>&1 &
|
||||||
disown
|
disown
|
||||||
|
|
||||||
echo "OpenReader WebUI started at http://localhost:3003"
|
echo "OpenReader WebUI started at http://localhost:3003"
|
||||||
echo "Groq TTS proxy running on port 8880"
|
echo "Groq TTS available at /api/groq-tts"
|
||||||
echo "Available voices: troy, austin, daniel, autumn, diana, hannah"
|
echo "Available voices: troy, austin, daniel, autumn, diana, hannah"
|
||||||
echo "Logs: /tmp/openreader.log and /tmp/groq-proxy.log"
|
echo "Logs: /tmp/openreader.log"
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getAuthToken } from '@/lib/auth';
|
|
||||||
|
|
||||||
function isValidReturnTo(returnTo: string): boolean {
|
|
||||||
// Only allow relative paths starting with /
|
|
||||||
// Reject absolute URLs, protocol-relative URLs, and other schemes
|
|
||||||
if (!returnTo.startsWith('/')) return false;
|
|
||||||
if (returnTo.startsWith('//')) return false;
|
|
||||||
// Reject URLs with encoded characters that could bypass checks
|
|
||||||
if (returnTo.includes('%')) {
|
|
||||||
try {
|
|
||||||
const decoded = decodeURIComponent(returnTo);
|
|
||||||
if (decoded.startsWith('//') || decoded.includes('://')) return false;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const token = request.nextUrl.searchParams.get('token');
|
|
||||||
const returnTo = request.nextUrl.searchParams.get('returnTo') || '/';
|
|
||||||
|
|
||||||
if (token !== getAuthToken()) {
|
|
||||||
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate returnTo to prevent open redirect attacks
|
|
||||||
const safeReturnTo = isValidReturnTo(returnTo) ? returnTo : '/';
|
|
||||||
const response = NextResponse.redirect(new URL(safeReturnTo, request.url));
|
|
||||||
response.cookies.set('auth_session', token, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax',
|
|
||||||
maxAge: 60 * 60 * 24 * 30,
|
|
||||||
path: '/',
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
72
src/app/api/groq-tts/route.ts
Normal file
72
src/app/api/groq-tts/route.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
const GROQ_BASE = 'https://api.groq.com/openai/v1';
|
||||||
|
const VOICES = ['troy', 'austin', 'daniel', 'autumn', 'diana', 'hannah'];
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const apiKey = process.env.GROQ_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'GROQ_API_KEY not configured' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
// Set default model if not provided or not a canopylabs model
|
||||||
|
let model = body.model || '';
|
||||||
|
if (!model.startsWith('canopylabs/')) {
|
||||||
|
model = 'canopylabs/orpheus-v1-english';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate voice
|
||||||
|
const voice = VOICES.includes(body.voice) ? body.voice : 'troy';
|
||||||
|
|
||||||
|
// Groq requires response_format
|
||||||
|
const responseFormat = body.response_format || 'wav';
|
||||||
|
|
||||||
|
const groqBody = {
|
||||||
|
model,
|
||||||
|
voice,
|
||||||
|
input: body.input,
|
||||||
|
response_format: responseFormat,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${GROQ_BASE}/audio/speech`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(groqBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioBuffer = await response.arrayBuffer();
|
||||||
|
const contentType = responseFormat === 'mp3' ? 'audio/mpeg' : 'audio/wav';
|
||||||
|
|
||||||
|
return new NextResponse(audioBuffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Groq TTS error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Failed to generate speech' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/app/api/groq-tts/voices/route.ts
Normal file
7
src/app/api/groq-tts/voices/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const VOICES = ['troy', 'austin', 'daniel', 'autumn', 'diana', 'hannah'];
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ voices: VOICES });
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,6 @@ import { createOpenAI } from '@ai-sdk/openai';
|
||||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||||
import { createGroq } from '@ai-sdk/groq';
|
import { createGroq } from '@ai-sdk/groq';
|
||||||
import type { SummarizeRequest, SummarizeResponse, SummarizeError } from '@/types/summary';
|
import type { SummarizeRequest, SummarizeResponse, SummarizeError } from '@/types/summary';
|
||||||
import { getAuthToken } from '@/lib/auth';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
|
@ -75,40 +74,6 @@ function validateBaseUrl(baseUrl: string, provider: string): string | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate request - returns error response if auth fails, null if auth passes
|
|
||||||
function authenticateRequest(req: NextRequest): NextResponse | null {
|
|
||||||
// Auth disabled by default - set AUTH_ENABLED=true to enable
|
|
||||||
if (process.env.AUTH_ENABLED !== 'true') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for valid auth_session cookie
|
|
||||||
const sessionCookie = req.cookies.get('auth_session')?.value;
|
|
||||||
if (sessionCookie) {
|
|
||||||
const validToken = getAuthToken();
|
|
||||||
if (sessionCookie === validToken) {
|
|
||||||
return null; // Auth passed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for Authorization header (Bearer token)
|
|
||||||
const authHeader = req.headers.get('Authorization');
|
|
||||||
if (authHeader?.startsWith('Bearer ')) {
|
|
||||||
const bearerToken = authHeader.slice(7);
|
|
||||||
const validToken = getAuthToken();
|
|
||||||
if (bearerToken === validToken) {
|
|
||||||
return null; // Auth passed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auth failed
|
|
||||||
const errorBody: SummarizeError = {
|
|
||||||
code: 'UNAUTHORIZED',
|
|
||||||
message: 'Authentication required. Please provide a valid session cookie or Authorization header.',
|
|
||||||
};
|
|
||||||
return NextResponse.json(errorBody, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const SYSTEM_PROMPTS: Record<string, string> = {
|
const SYSTEM_PROMPTS: Record<string, string> = {
|
||||||
current_page: `You are a helpful assistant that summarizes text content.
|
current_page: `You are a helpful assistant that summarizes text content.
|
||||||
Provide a clear, concise summary of the current page content provided.
|
Provide a clear, concise summary of the current page content provided.
|
||||||
|
|
@ -144,12 +109,6 @@ Do not include any preamble like "Here is a summary" - just provide the summary
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Authentication check - must pass before processing any other headers
|
|
||||||
const authError = authenticateRequest(req);
|
|
||||||
if (authError) {
|
|
||||||
return authError;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get configuration from headers
|
// Get configuration from headers
|
||||||
const provider = req.headers.get('x-summary-provider') || 'openai';
|
const provider = req.headers.get('x-summary-provider') || 'openai';
|
||||||
const requestedBaseUrl = req.headers.get('x-summary-base-url') || '';
|
const requestedBaseUrl = req.headers.get('x-summary-base-url') || '';
|
||||||
|
|
@ -162,7 +121,6 @@ export async function POST(req: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get API key from headers or environment variables based on provider
|
// Get API key from headers or environment variables based on provider
|
||||||
// API key selection only occurs after authentication has passed
|
|
||||||
let apiKey = req.headers.get('x-summary-api-key') || '';
|
let apiKey = req.headers.get('x-summary-api-key') || '';
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export async function register() {
|
|
||||||
if (process.env.NEXT_RUNTIME === 'nodejs' && process.env.AUTH_ENABLED === 'true') {
|
|
||||||
const { printAuthUrl } = await import('@/lib/auth');
|
|
||||||
printAuthUrl();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import { randomBytes } from 'crypto';
|
|
||||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
const TOKEN_FILE = path.join(process.cwd(), '.data', '.auth-token');
|
|
||||||
|
|
||||||
export function getAuthToken(): string {
|
|
||||||
const dir = path.dirname(TOKEN_FILE);
|
|
||||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
||||||
|
|
||||||
if (existsSync(TOKEN_FILE)) {
|
|
||||||
return readFileSync(TOKEN_FILE, 'utf-8').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = randomBytes(32).toString('base64url');
|
|
||||||
writeFileSync(TOKEN_FILE, token);
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function printAuthUrl() {
|
|
||||||
const token = getAuthToken();
|
|
||||||
const port = process.env.PORT || 3000;
|
|
||||||
console.log('\n🔐 Auth URL: http://localhost:' + port + '?token=' + token + '\n');
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import { NextResponse } from 'next/server';
|
|
||||||
import type { NextRequest } from 'next/server';
|
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
|
||||||
// Auth disabled by default - set AUTH_ENABLED=true to enable
|
|
||||||
if (process.env.AUTH_ENABLED !== 'true') {
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { pathname, searchParams } = request.nextUrl;
|
|
||||||
|
|
||||||
// Skip static files and auth endpoint
|
|
||||||
if (pathname.startsWith('/_next') || pathname.startsWith('/api/auth') || pathname.match(/\.(ico|png|svg|jpg|jpeg|gif|webp|json)$/)) {
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Token in URL -> redirect to auth API
|
|
||||||
const token = searchParams.get('token');
|
|
||||||
if (token) {
|
|
||||||
const authUrl = new URL('/api/auth', request.url);
|
|
||||||
authUrl.searchParams.set('token', token);
|
|
||||||
authUrl.searchParams.set('returnTo', pathname);
|
|
||||||
return NextResponse.redirect(authUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Valid cookie -> allow
|
|
||||||
if (request.cookies.get('auth_session')?.value) {
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unauthorized
|
|
||||||
return new NextResponse('Unauthorized - add ?token=YOUR_TOKEN to URL (check server logs)', {
|
|
||||||
status: 401,
|
|
||||||
headers: { 'Content-Type': 'text/plain' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: ['/((?!_next/static|_next/image).*)'],
|
|
||||||
};
|
|
||||||
Loading…
Reference in a new issue