Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 60 additions & 39 deletions .github/workflows/opencode.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,60 @@ jobs:
git config --global user.name "opencode-agent[bot]"
git config --global user.email "opencode-agent[bot]@users.noreply.github.com"

# Install the same commit-msg hook humans get via scripts/commit-msg.sh
# (see .pre-commit-config.yaml), so opencode's own `git commit` gets
# rejected with the actual regex+reason inline and can retry, instead
# of only finding out after pushing. Only the commit-msg hook type --
# not the full pre-commit stage, which runs unrelated file-content
# checks that could block a commit for issues in files opencode
# didn't touch.
- name: Install commit-msg hook
# Resolve the model against the LIVE models.dev catalog rather than
# trusting a hardcoded string. Zen/NVIDIA model IDs have broken us
# twice this session (wrong provider id on issue #1587's timeout
# investigation; wrong NVIDIA org/model prefix on issue #1570) --
# both were catalog-format mistakes we'd have caught immediately by
# checking the catalog first. This step keeps that check permanent:
# if our preferred model still exists, use it unchanged (deterministic,
# same choice every run); if it's been renamed or removed, fall back to
# another free tool-calling model on the same provider and post a
# visible ::warning:: so the drift doesn't go unnoticed. Untrusted
# comment body goes through env:, never interpolated into the script.
- name: Resolve model
id: resolve_model
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
pip install --quiet pre-commit
pre-commit install --hook-type commit-msg
set -euo pipefail
if echo "$COMMENT_BODY" | grep -qiF '[security]' || echo "$COMMENT_BODY" | grep -qiF '[feature]'; then
PREFERRED="nvidia/nvidia/nemotron-3-ultra-550b-a55b"
else
PREFERRED="opencode/deepseek-v4-flash-free"
fi

curl -fsSL https://models.dev/api.json -o /tmp/catalog.json

PROVIDER="${PREFERRED%%/*}"
MODEL_ID="${PREFERRED#*/}"

EXISTS=$(jq --arg p "$PROVIDER" --arg m "$MODEL_ID" '.[$p].models[$m] != null' /tmp/catalog.json)

if [ "$EXISTS" = "true" ]; then
echo "model=$PREFERRED" >> "$GITHUB_OUTPUT"
echo "Model verified against live catalog: $PREFERRED"
exit 0
fi

echo "::warning::Preferred model '$PREFERRED' no longer exists in the live models.dev catalog (renamed or removed) -- falling back to a free tool-calling model on the same provider ($PROVIDER)."

FALLBACK=$(jq -r --arg p "$PROVIDER" '
.[$p].models
| to_entries
| map(select(.value.cost.input == 0 and .value.cost.output == 0 and .value.tool_call == true))
| sort_by(.value.release_date // "0000-00-00")
| reverse
| .[0].key // empty
' /tmp/catalog.json)

if [ -z "$FALLBACK" ]; then
echo "::error::No free tool-calling fallback model found for provider $PROVIDER either. Failing closed rather than silently using a paid or non-functional model."
exit 1
fi

echo "::warning::Falling back to $PROVIDER/$FALLBACK"
echo "model=$PROVIDER/$FALLBACK" >> "$GITHUB_OUTPUT"

- name: Run OpenCode
uses: anomalyco/opencode/github@31406ccc51b4bd2a4e1e086b2bcaa5f7f804f26d # v1.18.18
Expand All @@ -52,35 +95,13 @@ jobs:
NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
with:
# Model routing by comment tag: default is the free Zen model;
# add [security] or [feature] anywhere in the comment to route to
# NVIDIA NIM's nemotron-3-ultra (1M context) instead, e.g.
# "/oc [security] fix this CVE". build.nvidia.com labels this
# endpoint "Free Endpoint" in its own product metadata — do not
# trust models.dev's cost field for NVIDIA NIM models without
# cross-checking build.nvidia.com directly, it has been wrong here.
# The bracket tag is required for THIS routing expression to match
# ("/oc-security ..." does not contain "[security]" so it silently
# falls through to the default model below) — confirmed live on a
# sibling repo (fawkes, issue #1587): "/oc-security ..." does still
# trigger the action, just without escalating the model.
#
# Model string is doubled ("nvidia/nvidia/...") deliberately, not a
# typo: NVIDIA-published models on NVIDIA's own NIM catalog carry
# "nvidia/" as part of the model's own org/name identifier (an
# org/model convention, same as e.g. "microsoft/phi-4-..." models
# hosted on the same catalog) -- confirmed against models.dev's raw
# JSON directly (data['nvidia']['models'] keys). opencode splits on
# the FIRST "/" only for provider vs. model, so referencing this
# specific model needs provider "nvidia" + catalog key
# "nvidia/nemotron-3-ultra-550b-a55b" == "nvidia/nvidia/...".
model: ${{ (contains(github.event.comment.body, '[security]') || contains(github.event.comment.body, '[feature]')) && 'nvidia/nvidia/nemotron-3-ultra-550b-a55b' || 'opencode/deepseek-v4-flash-free' }}

# opencode does not reliably follow Conventional Commits format even
# with documentation in place (confirmed on a sibling repo, fawkes,
# PRs #1619 and #1625). Documentation alone isn't reliable enough;
# enforce mechanically as a backstop in case the commit-msg hook above
# doesn't catch it (e.g. opencode retries and still gets it wrong).
model: ${{ steps.resolve_model.outputs.model }}

# opencode does not reliably follow this repo's Conventional Commits
# rule despite it being stated in AGENTS.md and a dedicated skill
# (.opencode/skills/commit-message-format) -- confirmed non-compliant
# on PR #1619 and #1625 even with both in place. Documentation alone
# isn't reliable enough; enforce mechanically instead of hoping.
- name: Normalize commit message if non-compliant
if: always()
run: |
Expand Down
172 changes: 172 additions & 0 deletions core/integrations/market/rentometer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
"""Rentometer API adapter for real rent estimates by ZIP code.

Fetches median rent estimates by address or ZIP+beds from Rentometer's API.
Fallback chain: Rentometer -> HUD FMR -> user-entered rent.

API docs: https://www.rentometer.com/api
Rate limit: 1,000 calls/month on basic plan — cache aggressively (7 days).
"""

from __future__ import annotations

import logging
from decimal import Decimal
from typing import Any, cast

import requests
from django.conf import settings

logger = logging.getLogger(__name__)

RENTO_METER_API_BASE = "https://www.rentometer.com/api/v1"
REQUEST_TIMEOUT = 15
CACHE_TTL_DAYS = 7


class RentometerError(Exception):
"""Base exception for Rentometer API errors."""


class RentometerClient:
"""Client for the Rentometer API."""

def __init__(self, api_key: str | None = None) -> None:
self.api_key = api_key or getattr(settings, "RENTO_METER_API_KEY", "")

def _headers(self) -> dict[str, str]:
return {"Authorization": f"Bearer {self.api_key}", "Accept": "application/json"}

def _get(self, path: str, params: dict[str, str] | None = None) -> dict[str, Any]:
"""Make a GET request to the Rentometer API."""
url = f"{RENTO_METER_API_BASE}/{path.lstrip('/')}"
resp = requests.get(
url, headers=self._headers(), params=params, timeout=REQUEST_TIMEOUT
)
if resp.status_code == 401:
raise RentometerError("Rentometer API key is missing or invalid")
if resp.status_code == 404:
raise RentometerError(f"No data found: {path}")
resp.raise_for_status()
data = resp.json()
return cast(dict[str, Any], data)

def get_rent_by_address(
self,
address: str,
city: str,
state: str,
zip_code: str,
) -> dict[str, Any] | None:
"""Get rent estimate for a specific address.

Returns dict with median_rent, min_rent, max_rent, or None.
"""
params = {
"address": address,
"city": city,
"state": state,
"zip": zip_code,
}
try:
data = self._get("rent", params=params)
except RentometerError:
return None

result = data.get("data", data)
if not result or not result.get("median"):
return None

return {
"median_rent": _parse_rent(result.get("median")),
"min_rent": _parse_rent(result.get("min")),
"max_rent": _parse_rent(result.get("max")),
"count": result.get("count", 0),
"city": result.get("city", ""),
"state": result.get("state", ""),
}


def _parse_rent(value: Any) -> Decimal | None:
"""Parse a rent value to Decimal."""
if value is None or value == "":
return None
try:
val = float(value)
if val <= 0:
return None
return Decimal(str(round(val, 2)))
except ValueError, TypeError:
return None


# ── In-memory cache (simple TTL cache for 7-day window) ──────────────────

_rent_cache: dict[str, tuple[float, dict[str, Any] | None]] = {}
_CACHE_TTL_SECONDS = CACHE_TTL_DAYS * 86400


def get_rent_estimate(
zip_code: str | None = None,
address: str | None = None,
city: str | None = None,
state: str | None = None,
bedrooms: int = 2,
) -> Decimal | None:
"""Get a rent estimate for a location from Rentometer.

Falls back to returning None if no API key or no data found.

Args:
zip_code: 5-digit ZIP code.
address: Street address (improves accuracy).
city: City name.
state: 2-letter state code.
bedrooms: Number of bedrooms (used for future bed-specific lookups).

Returns:
Monthly median rent as Decimal, or None if unavailable.
"""
import time

client = RentometerClient()
if not client.api_key:
logger.debug("RENTO_METER_API_KEY not configured — skipping Rentometer")
return None

# Build cache key from inputs
cache_key = (
f"{address or ''}|{city or ''}|{state or ''}|{zip_code or ''}|{bedrooms}"
)

# Check cache
if cache_key in _rent_cache:
ts, cached = _rent_cache[cache_key]
if time.time() - ts < _CACHE_TTL_SECONDS:
if cached and cached.get("median_rent"):
return cast(Decimal, cached["median_rent"])
return None

# Make API call
if not address or not city or not state or not zip_code:
logger.debug("Incomplete address for Rentometer lookup")
_rent_cache[cache_key] = (time.time(), None)
return None

try:
result = client.get_rent_by_address(address, city, state, zip_code)
_rent_cache[cache_key] = (time.time(), result)
if result and result.get("median_rent"):
logger.debug(
"Rentometer: %s %s, %s %s = $%s",
address,
city,
state,
zip_code,
result["median_rent"],
)
return cast(Decimal, result["median_rent"])
except RentometerError as exc:
logger.warning("Rentometer lookup failed for %s: %s", zip_code, exc)

_rent_cache[cache_key] = (time.time(), None)
return None
73 changes: 58 additions & 15 deletions core/services/screening.py
Original file line number Diff line number Diff line change
Expand Up @@ -514,12 +514,15 @@ def _get_monthly_rent(
pipeline_property: PipelineProperty,
source_record: Any | None,
) -> Decimal | None:
"""Get monthly rent from source_record or PipelineProperty.
"""Get monthly rent from source_record, PipelineProperty, or market APIs.

Prefers source_record (VrmProperty.projected_monthly_rent) over
PipelineProperty.estimated_rent. Falls back to HUD Fair Market Rent
lookup when neither is available.
Priority: source_record (VRM) > PipelineProperty.estimated_rent >
Rentometer (real comps) > HUD FMR > None.

The fallback chain tries real rent data first (Rentometer), then falls
back to HUD FMR when Rentometer is unavailable.
"""
# 1. Prefer source_record (VRM has actual rent data)
if (
source_record is not None
and _is_vrm_source(source_record)
Expand All @@ -529,29 +532,48 @@ def _get_monthly_rent(
if rent is not None and rent > 0:
return Decimal(str(rent))

# 2. PipelineProperty.estimated_rent (user-entered or previously cached)
if (
pipeline_property.estimated_rent is not None
and pipeline_property.estimated_rent > 0
):
return Decimal(str(pipeline_property.estimated_rent))

# Fallback: try HUD FMR if we have a source record with ZIP
zip_code = None
# 3. Try Rentometer (real rent comps) — best data source
zip_code = _extract_zip(source_record, pipeline_property)
bedrooms = pipeline_property.beds
if source_record is not None and hasattr(source_record, "zip_code"):
zip_code = str(source_record.zip_code) if source_record.zip_code else None # type: ignore[union-attr]
# Use getattr for safety — some callers pass _PipelineView proxies
address = getattr(pipeline_property, "address", None)
city = getattr(pipeline_property, "city", None)
state = getattr(pipeline_property, "state", None)

if not zip_code and pipeline_property.address:
# Try extracting ZIP from address (common format: "..., TX 75201")
import re
if zip_code and address and city and state:
try:
from core.integrations.market.rentometer import get_rent_estimate

m = re.search(r"\b(\d{5})\b", pipeline_property.address)
if m:
zip_code = m.group(1)
rent = get_rent_estimate(
zip_code=zip_code,
address=address,
city=city,
state=state,
bedrooms=int(bedrooms) if bedrooms else 2,
)
if rent is not None and rent > 0:
# Cache on pipeline_property for future calls
pipeline_property.estimated_rent = rent
PipelineProperty.objects.filter(pk=pipeline_property.pk).update(
estimated_rent=rent
)
return rent
except Exception:
pass

# 4. Fallback: HUD Fair Market Rent
if zip_code:
try:
from core.integrations.market.hud_fmr import get_rent_estimate
from core.integrations.market.hud_fmr import ( # type: ignore[assignment]
get_rent_estimate,
)

rent = get_rent_estimate(
zip_code=zip_code, bedrooms=int(bedrooms) if bedrooms else 2
Expand All @@ -569,6 +591,27 @@ def _get_monthly_rent(
return None


def _extract_zip(source_record: Any | None, pipeline_property: Any) -> str | None:
"""Extract ZIP code from source_record or pipeline_property."""
if source_record is not None and hasattr(source_record, "zip_code"):
zip_code = str(source_record.zip_code) if source_record.zip_code else None # type: ignore[union-attr]
if zip_code:
return zip_code

# Check PipelineProperty.zip_code directly
if hasattr(pipeline_property, "zip_code") and pipeline_property.zip_code:
return str(pipeline_property.zip_code)

if pipeline_property.address:
import re

m = re.search(r"\b(\d{5})\b", pipeline_property.address)
if m:
return m.group(1)

return None


def _eval_year_built(
pipeline_property: PipelineProperty,
criteria: ScreeningCriteria,
Expand Down
Loading
Loading