Run gpt-4o-mini in production and cut OpenAI spend without changing outputs

- .env.example documents the production model (gpt-4o-mini-2024-07-18) and a
  4000-token output cap; the model rejects caps above 16384 with a 400, which
  the old 32768 value triggered on every llm_call request.
- llm_setup: safe default cap and per-call token/cache usage logging.
- agent/prompt: job posts precede the resume so the stable block hits the
  prompt cache for every CV after the first in a sync run.
- inbox: On-Hold rescan pairs candidates with active jobs only; scores against
  closed roles were paid for and never shown.
- app: ruff formatting for the config/model edits from main, and an accurate
  comment on why gpt-4o-mini is admitted while the rest of gpt-4o is not.

Verified live on gpt-4o-mini: llm_call and the scorer both succeed, the
28-check scoring audit passes, ruff/mypy/pytest pass for app.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
pull/86/head^2
Talha Ahmed 2026-09-09 15:58:07 +05:00
parent 9d31cdd198
commit 06427a4f32
6 changed files with 46 additions and 11 deletions

View File

@ -23,9 +23,11 @@ _BACKEND_ENV = Path(__file__).resolve().parents[2] / "backend" / ".env"
# releases faster than this file can be updated, and rejecting a brand-new gpt-5.x
# would be worse than the small risk of admitting one with a different feature set.
#
# The gpt-4o family is excluded on purpose: snapshots before 2024-08-06 lack structured
# outputs, and distinguishing them by alias is not reliable.
SUPPORTED_MODEL_PREFIXES: tuple[str, ...] = ("gpt-5", "gpt-4.1", "o3", "o4","gpt-4o-mini")
# gpt-4o-mini is admitted: its only snapshot (2024-07-18) supports structured outputs
# and it is the production model. The wider gpt-4o family stays excluded because
# snapshots before 2024-08-06 lack structured outputs and aliases do not say which
# snapshot you get. It is not a reasoning model, so `reasoning` is omitted for it.
SUPPORTED_MODEL_PREFIXES: tuple[str, ...] = ("gpt-5", "gpt-4.1", "o3", "o4", "gpt-4o-mini")
# "-chat-latest" variants track the ChatGPT product surface rather than the API model
# line and do not expose reasoning effort.

View File

@ -62,7 +62,9 @@ class ATSScore(StrictModel):
def _normalize(cls, value: Any) -> Any:
return _normalize_keywords(value)
@field_validator("candidate_name", "job_title", "current_company", "professional_summary", mode="before")
@field_validator(
"candidate_name", "job_title", "current_company", "professional_summary", mode="before"
)
@classmethod
def _blank_profile_text_to_none(cls, value: Any) -> Any:
if isinstance(value, str):

View File

@ -74,10 +74,13 @@ APIFY_PROFILE_MODE=Full
APIFY_TIMEOUT=30
OPENAI_API_KEY=
OPENAI_MODEL=gpt-5.4-mini
# Production model. Not a reasoning model: OPENAI_EFFORT is accepted and ignored.
# Define every OPENAI_* name once; python-dotenv takes the LAST occurrence.
OPENAI_MODEL=gpt-4o-mini-2024-07-18
# Blank omits the parameter, for reasoning models that reject it.
OPENAI_TEMPERATURE=0
OPENAI_MAX_OUTPUT_TOKENS=4096
# gpt-4o-mini rejects values above 16384 with a 400.
OPENAI_MAX_OUTPUT_TOKENS=4000
OPENAI_TIMEOUT=60
OPENAI_MAX_RETRIES=3
OPENAI_CONNECT_RETRIES=3

View File

@ -33,11 +33,18 @@ Respond with JSON only:
def user_prompt(state) -> str:
"""The user turn as JSON.
job_posts comes first on purpose: it is identical for every CV in a sync run,
and OpenAI prompt caching works on an exact token prefix. With the stable
block ahead of the per-candidate subject and resume, every CV after the first
reads the whole job list from cache at the discounted input rate.
"""
return json.dumps(
{
"job_posts": state.get("job_posts") or [],
"subject": state.get("subject") or "",
"resume_text": state.get("resume_text") or "",
"job_posts": state.get("job_posts") or [],
},
ensure_ascii=False,
)

View File

@ -483,16 +483,18 @@ class Email:
return serialize_inbox_rescan_run(row)
async def plan_on_hold_pairs(self,channel,sheet=None):
"""Build (candidate, job) pairs that have never been ATS-scored.
"""Build (candidate, active job) pairs that have never been ATS-scored.
Only active openings are scored: a score against a closed role is never
shown for shortlisting, and each pair is a paid model call.
Skip a candidate who already has a score against an active job.
Skip a pair that already exists on ats_results for that person/row.
"""
from g_sheet.models import FormData
from job.job_post.models import JobPosts
job_ids=[str(jid) for jid in await JobPosts.list_ids(self.session)]
active_ids={str(jid) for jid in await JobPosts.list_ids(self.session,active_only=True)}
job_ids=[str(jid) for jid in await JobPosts.list_ids(self.session,active_only=True)]
active_ids=set(job_ids)
inbox_rows=[]
form_rows=[]
if channel in ("all","email"):

View File

@ -31,7 +31,9 @@ OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL") or None
OPENAI_ORGANIZATION = os.getenv("OPENAI_ORGANIZATION") or None
OPENAI_PROJECT = os.getenv("OPENAI_PROJECT") or None
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-5.4-mini")
OPENAI_MAX_OUTPUT_TOKENS = int(os.getenv("OPENAI_MAX_OUTPUT_TOKENS") or 32768)
# Default kept under gpt-4o-mini's 16384 completion ceiling: a larger value is a 400
# on every call, not a bigger budget.
OPENAI_MAX_OUTPUT_TOKENS = int(os.getenv("OPENAI_MAX_OUTPUT_TOKENS") or 4096)
OPENAI_TIMEOUT = float(os.getenv("OPENAI_TIMEOUT") or 60)
OPENAI_MAX_RETRIES = int(os.getenv("OPENAI_MAX_RETRIES") or 3)
OPENAI_CONNECT_RETRIES = int(os.getenv("OPENAI_CONNECT_RETRIES") or 3)
@ -80,6 +82,7 @@ async def llm_call(system, user, *, model=None, temperature=None, json_mode=Fals
kwargs["response_format"] = {"type": "json_object"}
response = await get_client().chat.completions.create(**kwargs)
_log_usage(response, kwargs["model"])
content = (response.choices[0].message.content or "").strip()
if not json_mode:
return content
@ -89,6 +92,22 @@ async def llm_call(system, user, *, model=None, temperature=None, json_mode=Fals
raise RuntimeError(f"model did not return valid JSON: {content[:200]}") from exc
def _log_usage(response, model) -> None:
"""Per-call token and cache visibility. Never logs prompt or reply text."""
usage = getattr(response, "usage", None)
if usage is None:
return
prompt_details = getattr(usage, "prompt_tokens_details", None)
logger.info(
"llm usage: model=%s request_id=%s prompt_tokens=%s completion_tokens=%s cached_tokens=%s",
model,
getattr(response, "_request_id", None),
getattr(usage, "prompt_tokens", None),
getattr(usage, "completion_tokens", None),
getattr(prompt_details, "cached_tokens", None),
)
async def check_connection(retries=None, delay=1.0):
"""Confirm the key works, retrying with a capped backoff."""
attempts = OPENAI_CONNECT_RETRIES if retries is None else retries