A FastAPI-based service that uses AI to generate professional resumes and cover letters with three distinct design styles.
- AI-Powered Generation: Uses advanced language models (Claude, GPT-5, Gemini, Grok, DeepSeek) to optimize CVs and generate cover letters
- Three Design Styles:
- Classic Professional: Traditional, conservative layout for finance, law, government
- Modern Minimal: Clean, contemporary design for tech, creative, startup environments
- Corporate Clean: Professional design with highlighted header for corporate roles
- ATS-Friendly: Optimized for Applicant Tracking Systems
- PDF Generation: Convert HTML output to professional PDFs
- Multiple AI Models: Support for Claude Sonnet 5, GPT-5.6, Grok 4.5, Gemini, DeepSeek and more
pip install -r requirements.txtCreate a .env file:
OPENROUTER_API_KEY=your_openrouter_api_key_here
GOTENBERG_URL=http://localhost:3000 # Optional: for PDF generationpython -m uvicorn main:app --reload --host 0.0.0.0 --port 8000- API Documentation: http://localhost:8000/docs
- Alternative Docs: http://localhost:8000/redoc
All generation endpoints take the CV and job description directly — there is no separate upload step. For each of the two documents, send either a file or raw text:
cv_file/cv_text— PDF, DOCX or TXT file, or the text itselfjob_desc_file/job_desc_text— same, for the job description
Files are capped at MAX_UPLOAD_MB (10MB by default); a larger one returns
413. A missing document returns 400, as does one that yields no text —
an empty file, or a PDF that is a scan rather than text. Extensions are matched
case-insensitively, so CV.PDF is accepted.
Generate an optimized, ATS-friendly resume.
Parameters:
generate_pdf(default: false): Return PDF instead of JSONmodel(optional): AI model to usestyle(default:classic):classic,modern(aliasminimal) orcreativefull_name,email,phone,address,city_state_zip(optional): Applicant contact detailsuser_query(optional): Free-text preference passed on to the model
The prompt tells the model to rewrite rather than copy the source CV, and the
header used to get caught by that: a supplied phone number came back as a stock
0123456789. Contact details are now excluded from the rewrite rule in the
prompt and the returned HTML's class="contact-info" element is corrected
afterwards, so a phone or email that was passed in is what gets printed —
replacing whatever the model wrote, or appended if it wrote none.
Only those two are enforced: an address or a city has no shape to recognise in
markup, and stays the prompt's job. A detail that was not passed in is left
alone rather than deleted on suspicion, since the only evidence against it is
text extracted from a PDF. The JSON response reports what happened per field in
contact_enforced — corrected, added or unchanged; a supplied field
missing from that map means the model produced no contact-info element to act
on, which is also logged.
Generate a professional cover letter.
Parameters:
generate_pdf(default: false): Return PDF instead of JSONmodel(optional): AI model to usetone(default:professional): Tone of the letterfull_name,email,phone,address,city_state_zip(optional): Applicant contact detailscompany_name(optional): Company being applied tohiring_manager(optional): Named recipient; falls back to "Hiring Manager"job_title(optional): Role being applied forletter_date(optional): Date printed in the letter head, used verbatim. Overridestimezone. Use it to control the wording, e.g.15 August 2026.timezone(optional): IANA name (Pacific/Auckland) or UTC offset (+13:00,-0800,UTC+13) the default date is computed in. May also be sent as anX-Timezoneheader, which is usually one line in a shared HTTP client.
"Today" is not the same date everywhere at once and the container clock is UTC, so an applicant in Auckland filing at 9am local would otherwise date the letter yesterday. Nothing has to be passed for this to work: the zone is resolved from the request itself, most to least trustworthy, and the first hit wins.
| # | Source | Accuracy |
|---|---|---|
| 1 | timezone form field |
Exact |
| 2 | X-Timezone header |
Exact |
| 3 | CDN timezone headers — X-Vercel-IP-Timezone, CloudFront-Viewer-Time-Zone, CF-Timezone |
Exact, set by the edge from the viewer's IP |
| 4 | CDN country headers — CF-IPCountry, X-Vercel-IP-Country, CloudFront-Viewer-Country, X-Appengine-Country |
Exact for single-zone countries, approximate for wide ones |
| 5 | Accept-Language region subtag (en-NZ → New Zealand) |
Weak: a Nigerian in London still sends en-NG |
| 6 | DEFAULT_TIMEZONE, else UTC |
Fixed |
Rows 3–5 need no client changes at all — a deployment behind Cloudflare,
Vercel, CloudFront or App Engine already gets them. Row 3 is the one to aim for;
enable your CDN's geolocation headers and worldwide accuracy comes free. Failing
that, browsers know their own zone, so Intl.DateTimeFormat().resolvedOptions().timeZone
sent as X-Timezone gets you row 2 in one line.
Where a country spans many zones the most populous one is used (US →
America/New_York, AU → Australia/Sydney), which is approximate: it is only
ever wrong by a day, and only for applicants in another zone of that country
within a few hours of midnight. An unresolvable value falls through to the next
source rather than failing the request.
Supplying company_name, hiring_manager and job_title is strongly
recommended — without them the model has to infer each one from the job
description, which is where generic-sounding letters come from.
The JSON response echoes the date used as letter_date, the zone it was computed
in as letter_timezone, and which of the sources above supplied it as
letter_timezone_source (both null when letter_date was passed). Watching
how often letter_timezone_source comes back default tells you what share of
real traffic is falling all the way through. The date is pinned in
the prompt and the returned HTML's class="date" element is rewritten
afterwards, so the printed date does not depend on the model getting it right;
letter_date_enforced reports whether that rewrite found an element to act on.
The letterhead's email and phone are pinned the same way and reported in
contact_enforced — see the /optimize-cv section above. The example letter
inside the prompt carries a sample name and phone number for layout, and a model
rewriting rather than copying will otherwise reach for them.
Generation is capped at the model's advertised max_tokens (see
GET /available-models). A reply that hits the cap is a document cut off
mid-tag, so both generation endpoints report it as truncated: true rather than
as a success, and decline to convert it to a PDF — a partial PDF looks finished,
which is the worst of the available outcomes. Retry or pick a model with a
larger output limit.
Analyze CV against job description.
Parameters:
generate_pdf(default: false): Return PDF instead of JSONmodel(optional): AI model to use
Get list of available AI models, the default key, and full per-model metadata.
Service health plus the state of both external dependencies.
healthy(200) — OpenRouter key configured and Gotenberg reachabledegraded(200) — generation works, PDF conversion unavailableunhealthy(503) — no OpenRouter API key, nothing can run
{
"status": "healthy",
"service": "cv-maker-api",
"default_model": "anthropic/claude-sonnet-5",
"available_models": 7,
"checks": {
"openrouter": {"configured": true, "base_url": "https://openrouter.ai/api/v1"},
"gotenberg": {"url": "http://localhost:3000", "reachable": true}
}
}- Best for: Finance, Law, Government, Traditional Corporate
- Features: Traditional, conservative layout with clear section dividers and centered header
- Style: Formal, structured, professional
Pass the key in the model form field. Defaults to claude-sonnet-5.
Prices are USD per million tokens (input / output).
| Key | Model | Best for | Price |
|---|---|---|---|
claude-sonnet-5 (default) |
Claude Sonnet 5 | Best all-round writing quality and reliable HTML/JSON output | $2 / $10 |
gpt-5.6-terra |
GPT-5.6 Terra | Tailoring CVs to job descriptions | $1 / $6 |
grok-4.5 |
Grok 4.5 | Punchier, less formulaic writing | $2 / $6 |
claude-haiku-4.5 |
Claude Haiku 4.5 | Fast extraction and short cover letters | $1 / $5 |
gpt-5.4-mini |
GPT-5.4 Mini | Low-cost routine rewrites | $0.75 / $4.50 |
gemini-3.5-flash-lite |
Gemini 3.5 Flash Lite | Bulk generation and quick drafts | $0.30 / $2.50 |
deepseek-v4-pro |
DeepSeek V4 Pro | Cheapest option, high-volume use | $0.44 / $0.87 |
gpt-5.6-terra doubles as the legacy model: every retired key (gpt-4,
gpt-4-turbo, gpt-3.5-turbo, claude-3-opus, claude-3-sonnet, claude-3-haiku,
gemini-pro, llama-3, mixtral-8x7b) routes to it, so existing clients keep
working. Unknown keys fall back to the default.
Call GET /available-models for the live list with full metadata.
import requests
# Everything goes in a single call
cv_data = {
'cv_text': 'Your CV content here...',
'job_desc_text': 'Job description here...',
'full_name': 'John Doe',
'email': 'john@example.com',
'model': 'claude-sonnet-5',
'style': 'classic',
'generate_pdf': False
}
response = requests.post('http://localhost:8000/optimize-cv', data=cv_data)
result = response.json()
html_content = result['html_content']
analysis = result['analysis']Or send the CV as a file:
with open('cv.pdf', 'rb') as f:
response = requests.post(
'http://localhost:8000/optimize-cv',
files={'cv_file': f},
data={'job_desc_text': 'Job description here...'}
)# Generate CV with PDF
curl -X POST "http://localhost:8000/optimize-cv" \
-F "cv_text=Your CV content here..." \
-F "job_desc_text=Job description here..." \
-F "full_name=John Doe" \
-F "model=claude-sonnet-5" \
-F "generate_pdf=true" \
--output optimized_cv.pdf{
"message": "CV optimized successfully",
"html_content": "<!DOCTYPE html>...",
"model_used": "anthropic/claude-sonnet-5",
"contact_enforced": {"phone": "corrected"},
"truncated": false
}When generate_pdf=true, returns a PDF file directly. If the conversion fails,
or the reply was truncated, the JSON body is returned instead with the reason in
pdf_error.
OPENROUTER_API_KEY: Your OpenRouter API key (required)GOTENBERG_URL: Gotenberg server URL for PDF generation (optional)GOTENBERG_USERNAME: Gotenberg username (optional)GOTENBERG_PASSWORD: Gotenberg password (optional)OPENROUTER_TIMEOUT: Seconds to wait for a model response (default120)GOTENBERG_TIMEOUT: Seconds to wait for a PDF conversion (default60)ANALYSIS_MAX_TOKENS: Token ceiling for the analysis step (default6000)MAX_UPLOAD_MB: Largest accepted upload per file (default10)CORS_ALLOW_ORIGINS: Comma-separated origins, or*for any (default*)APP_URL/APP_TITLE: Optional OpenRouter dashboard attributionDEFAULT_TIMEZONE: Zone the cover letter date falls back to when the caller names none (defaultUTC). Set it to your audience's zone if they share oneLOG_LEVEL: Logging level (defaultINFO)
pip install pytest
pytestTests stub OpenRouter, so they never make a network call or spend credits.
You can modify the available models in config.py:
# Add new models
AVAILABLE_MODELS["your-model"] = {
"id": "provider/model-name",
"name": "Display Name",
"description": "Model description",
"context_length": 200000,
"tier": "balanced",
"price_per_m": {"input": 1.00, "output": 5.00},
"max_tokens": 8000,
"temperature": 0.7
}- FastAPI: Modern, fast web framework
- OpenRouter: AI model provider (supports multiple providers)
- Gotenberg: PDF generation service
- PyPDF2: PDF text extraction
- python-docx: DOCX text extraction
This project is open source and available under the MIT License.
For issues and questions, please check the API documentation at /docs or create an issue in the repository.
docker build -t cv-maker-app .docker run -d -p 8000:8000 --name cv-maker-app cv-maker-app- Set any required environment variables using
-e VAR_NAME=valuein yourdocker runcommand or via Elestio's dashboard.
- Push your code to your Git repository.
- Connect your repository to Elestio and select the Dockerfile build option.
- Set any required environment variables in the Elestio dashboard.
- Elestio will build and deploy your app automatically.
- The API will be available at
http://<your-elestio-domain>:8000(or the port you configure). - Swagger docs:
/docs
For troubleshooting, check container logs:
docker logs cv-maker-app