Optimize model selection for cost-efficiency on structured extraction

The task profile is search tool invocation + JSON extraction — not
reasoning or creative work. Mini models handle this equally well at
3-5x lower cost per call.

OpenAI changes:
- Rename is_mainline_openai_model -> is_search_capable_model
- Include mini variants (gpt-5-mini, gpt-4.1-mini) in candidate pool
- Exclude gpt-4o-mini (no domain filtering) and nano (no web_search)
- select_openai_model() now prefers mini within newest generation
- OPENAI_FALLBACK_MODELS: gpt-5-mini first, mainline as last resort
- MODEL_FALLBACK_ORDER: same mini-first ordering

xAI changes:
- Switch alias from grok-4-1-fast (reasoning) to
  grok-4-1-fast-non-reasoning — same token price, faster response,
  no wasted reasoning tokens for structured extraction

Cost per Reddit search call: ~$0.015 (gpt-5-mini) vs ~$0.044 (gpt-4.1)
This commit is contained in:
Jeffrey Sperling
2026-03-11 17:57:15 -07:00
parent 859f6c5829
commit 588cff3e00
4 changed files with 164 additions and 57 deletions
+55 -20
View File
@@ -1,4 +1,22 @@
"""Model auto-selection for last30days skill.""" """Model auto-selection for last30days skill.
Model selection philosophy: this tool uses LLM APIs exclusively for
search tool invocation + structured JSON extraction. This is not
reasoning-heavy or creative work — mini models handle it equally well
at ~3-5x lower cost. We prefer the newest-generation mini model, falling
back to mainline only when mini isn't available.
OpenAI cost per Reddit search call (web_search tool + JSON output):
gpt-4.1-mini: ~$0.014 (fixed 8K search token block)
gpt-5-mini: ~$0.015
gpt-4.1: ~$0.044
gpt-5.2: ~$0.043
gpt-4o: ~$0.053
xAI: grok-4-1-fast reasoning vs non-reasoning have identical token
pricing ($0.20/1M in, $0.50/1M out). Non-reasoning skips the thinking
phase, saving latency and reasoning token output costs.
"""
import re import re
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
@@ -7,13 +25,17 @@ from . import cache, http, env
# OpenAI API # OpenAI API
OPENAI_MODELS_URL = "https://api.openai.com/v1/models" OPENAI_MODELS_URL = "https://api.openai.com/v1/models"
OPENAI_FALLBACK_MODELS = ["gpt-5.2", "gpt-5.1", "gpt-5", "gpt-4.1", "gpt-4o"] # Ordered by cost-efficiency for web_search + JSON extraction tasks.
# Mini models first: same structured extraction quality at ~3x lower cost.
OPENAI_FALLBACK_MODELS = ["gpt-5-mini", "gpt-4.1-mini", "gpt-4.1", "gpt-4o"]
CODEX_FALLBACK_MODELS = ["gpt-5.1-codex-mini", "gpt-5.2"] CODEX_FALLBACK_MODELS = ["gpt-5.1-codex-mini", "gpt-5.2"]
# xAI API - Agent Tools API requires grok-4 family # xAI API - Agent Tools API requires grok-4 family
# Non-reasoning: same price, faster, no unnecessary thinking tokens.
# Both variants support function calling and structured outputs.
XAI_MODELS_URL = "https://api.x.ai/v1/models" XAI_MODELS_URL = "https://api.x.ai/v1/models"
XAI_ALIASES = { XAI_ALIASES = {
"latest": "grok-4-1-fast-non-reasoning", # Explicit: bare grok-4-1-fast aliases to reasoning variant "latest": "grok-4-1-fast-non-reasoning",
"stable": "grok-4-1-fast-non-reasoning", "stable": "grok-4-1-fast-non-reasoning",
} }
@@ -32,30 +54,45 @@ def parse_version(model_id: str) -> Optional[Tuple[int, ...]]:
return None return None
def is_mainline_openai_model(model_id: str) -> bool: def is_search_capable_model(model_id: str) -> bool:
"""Check if model is a mainline GPT model (not mini/nano/chat/codex/pro).""" """Check if model supports Responses API web_search with domain filtering.
Includes mini variants (same structured extraction quality, lower cost).
Excludes: nano (no web_search), gpt-4o-mini (no domain filtering),
chat/codex/pro/preview/turbo/search (specialized variants).
"""
model_lower = model_id.lower() model_lower = model_id.lower()
# Must be gpt-4o, gpt-4.1+, or gpt-5+ series (mainline, not mini/nano/etc) # gpt-4o-mini does NOT support web_search with filters — exclude it
if not re.match(r'^gpt-(?:4o|4\.1|5)(\.\d+)*$', model_lower): if model_lower.startswith("gpt-4o-mini"):
return False return False
# Exclude variants # Must be gpt-4o, gpt-4.1[-mini], or gpt-5[-mini] series
excludes = ['mini', 'nano', 'chat', 'codex', 'pro', 'preview', 'turbo'] if not re.match(r'^gpt-(?:4o|4\.1|5)(\.\d+)*(-mini)?$', model_lower):
for exc in excludes: return False
# Exclude unsupported variants
for exc in ['nano', 'chat', 'codex', 'pro', 'preview', 'turbo', 'search']:
if exc in model_lower: if exc in model_lower:
return False return False
return True return True
# Backward compat alias
is_mainline_openai_model = is_search_capable_model
def select_openai_model( def select_openai_model(
api_key: str, api_key: str,
policy: str = "auto", policy: str = "auto",
pin: Optional[str] = None, pin: Optional[str] = None,
mock_models: Optional[List[Dict]] = None, mock_models: Optional[List[Dict]] = None,
) -> str: ) -> str:
"""Select the best OpenAI model based on policy. """Select the most cost-efficient OpenAI model for web_search + JSON extraction.
Prefers mini models within the newest generation available, since the task
is structured extraction (not reasoning or creative work).
Args: Args:
api_key: OpenAI API key api_key: OpenAI API key
@@ -83,26 +120,24 @@ def select_openai_model(
response = http.get(OPENAI_MODELS_URL, headers=headers) response = http.get(OPENAI_MODELS_URL, headers=headers)
models = response.get("data", []) models = response.get("data", [])
except http.HTTPError: except http.HTTPError:
# Fall back to known models
return OPENAI_FALLBACK_MODELS[0] return OPENAI_FALLBACK_MODELS[0]
# Filter to mainline models candidates = [m for m in models if is_search_capable_model(m.get("id", ""))]
candidates = [m for m in models if is_mainline_openai_model(m.get("id", ""))]
if not candidates: if not candidates:
# No gpt-5 models found, use fallback
return OPENAI_FALLBACK_MODELS[0] return OPENAI_FALLBACK_MODELS[0]
# Sort by version (descending), then by created timestamp # Sort: newest generation first, prefer mini within same generation
def sort_key(m): def sort_key(m):
version = parse_version(m.get("id", "")) or (0,) model_id = m.get("id", "")
created = m.get("created", 0) version = parse_version(model_id) or (0,)
return (version, created) major = version[0] if version else 0
is_mini = 1 if "mini" in model_id.lower() else 0
return (major, is_mini, version)
candidates.sort(key=sort_key, reverse=True) candidates.sort(key=sort_key, reverse=True)
selected = candidates[0]["id"] selected = candidates[0]["id"]
# Cache the selection
cache.set_cached_model("openai", selected) cache.set_cached_model("openai", selected)
return selected return selected
+4 -3
View File
@@ -7,9 +7,10 @@ from typing import Any, Dict, List, Optional
from . import http, env from . import http, env
# Fallback models when the selected model isn't accessible (e.g., org not verified for GPT-5) # Fallback models when the selected model isn't accessible (e.g., org not verified).
# Note: gpt-4o-mini does NOT support web_search with filters param, so exclude it # Ordered by cost-efficiency: mini models handle structured extraction equally well.
MODEL_FALLBACK_ORDER = ["gpt-4.1", "gpt-4o"] # Note: gpt-4o-mini does NOT support web_search with filters — excluded.
MODEL_FALLBACK_ORDER = ["gpt-5-mini", "gpt-4.1-mini", "gpt-4.1", "gpt-4o"]
def _log_error(msg: str): def _log_error(msg: str):
+95 -29
View File
@@ -28,21 +28,47 @@ class TestParseVersion(unittest.TestCase):
self.assertIsNone(result) self.assertIsNone(result)
class TestIsMainlineOpenAIModel(unittest.TestCase): class TestIsSearchCapableModel(unittest.TestCase):
def test_gpt5_is_mainline(self): def test_gpt5_is_capable(self):
self.assertTrue(models.is_search_capable_model("gpt-5"))
def test_gpt52_is_capable(self):
self.assertTrue(models.is_search_capable_model("gpt-5.2"))
def test_gpt5_mini_is_capable(self):
self.assertTrue(models.is_search_capable_model("gpt-5-mini"))
def test_gpt41_mini_is_capable(self):
self.assertTrue(models.is_search_capable_model("gpt-4.1-mini"))
def test_gpt4o_is_capable(self):
self.assertTrue(models.is_search_capable_model("gpt-4o"))
def test_gpt4o_mini_not_capable(self):
"""gpt-4o-mini does not support web_search with domain filtering."""
self.assertFalse(models.is_search_capable_model("gpt-4o-mini"))
def test_nano_not_capable(self):
"""nano models don't support web_search."""
self.assertFalse(models.is_search_capable_model("gpt-4.1-nano"))
self.assertFalse(models.is_search_capable_model("gpt-5-nano"))
def test_gpt4_not_capable(self):
self.assertFalse(models.is_search_capable_model("gpt-4"))
def test_codex_not_capable(self):
self.assertFalse(models.is_search_capable_model("gpt-5.1-codex"))
def test_backward_compat_alias(self):
"""is_mainline_openai_model still works as alias."""
self.assertTrue(models.is_mainline_openai_model("gpt-5")) self.assertTrue(models.is_mainline_openai_model("gpt-5"))
def test_gpt52_is_mainline(self):
self.assertTrue(models.is_mainline_openai_model("gpt-5.2"))
def test_gpt5_mini_is_not_mainline(self):
self.assertFalse(models.is_mainline_openai_model("gpt-5-mini"))
def test_gpt4_is_not_mainline(self):
self.assertFalse(models.is_mainline_openai_model("gpt-4"))
class TestSelectOpenAIModel(unittest.TestCase): class TestSelectOpenAIModel(unittest.TestCase):
def setUp(self):
from lib import cache
cache.MODEL_CACHE_FILE.unlink(missing_ok=True)
def test_pinned_policy(self): def test_pinned_policy(self):
result = models.select_openai_model( result = models.select_openai_model(
"fake-key", "fake-key",
@@ -51,20 +77,8 @@ class TestSelectOpenAIModel(unittest.TestCase):
) )
self.assertEqual(result, "gpt-5.1") self.assertEqual(result, "gpt-5.1")
def test_auto_with_mock_models(self): def test_prefers_mini_over_mainline(self):
mock_models = [ """Mini models should be preferred for cost-efficiency."""
{"id": "gpt-5.2", "created": 1704067200},
{"id": "gpt-5.1", "created": 1701388800},
{"id": "gpt-5", "created": 1698710400},
]
result = models.select_openai_model(
"fake-key",
policy="auto",
mock_models=mock_models
)
self.assertEqual(result, "gpt-5.2")
def test_auto_filters_variants(self):
mock_models = [ mock_models = [
{"id": "gpt-5.2", "created": 1704067200}, {"id": "gpt-5.2", "created": 1704067200},
{"id": "gpt-5-mini", "created": 1704067200}, {"id": "gpt-5-mini", "created": 1704067200},
@@ -75,8 +89,50 @@ class TestSelectOpenAIModel(unittest.TestCase):
policy="auto", policy="auto",
mock_models=mock_models mock_models=mock_models
) )
self.assertEqual(result, "gpt-5-mini")
def test_prefers_newer_generation_mini(self):
"""gpt-5-mini should beat gpt-4.1-mini (newer generation)."""
mock_models = [
{"id": "gpt-4.1-mini", "created": 1701388800},
{"id": "gpt-5-mini", "created": 1704067200},
{"id": "gpt-4.1", "created": 1698710400},
]
result = models.select_openai_model(
"fake-key",
policy="auto",
mock_models=mock_models
)
self.assertEqual(result, "gpt-5-mini")
def test_falls_back_to_mainline_when_no_mini(self):
"""Without mini models, mainline models are selected."""
mock_models = [
{"id": "gpt-5.2", "created": 1704067200},
{"id": "gpt-4.1", "created": 1698710400},
]
result = models.select_openai_model(
"fake-key",
policy="auto",
mock_models=mock_models
)
self.assertEqual(result, "gpt-5.2") self.assertEqual(result, "gpt-5.2")
def test_filters_unsupported_variants(self):
"""Nano, codex, preview models should be excluded."""
mock_models = [
{"id": "gpt-5-nano", "created": 1704067200},
{"id": "gpt-5.1-codex", "created": 1704067200},
{"id": "gpt-4o-mini", "created": 1704067200},
{"id": "gpt-4.1-mini", "created": 1698710400},
]
result = models.select_openai_model(
"fake-key",
policy="auto",
mock_models=mock_models
)
self.assertEqual(result, "gpt-4.1-mini")
class TestSelectXAIModel(unittest.TestCase): class TestSelectXAIModel(unittest.TestCase):
def test_latest_policy(self): def test_latest_policy(self):
@@ -106,6 +162,10 @@ class TestSelectXAIModel(unittest.TestCase):
class TestGetModels(unittest.TestCase): class TestGetModels(unittest.TestCase):
def setUp(self):
from lib import cache
cache.MODEL_CACHE_FILE.unlink(missing_ok=True)
def test_no_keys_returns_none(self): def test_no_keys_returns_none(self):
config = {} config = {}
result = models.get_models(config) result = models.get_models(config)
@@ -114,9 +174,12 @@ class TestGetModels(unittest.TestCase):
def test_openai_key_only(self): def test_openai_key_only(self):
config = {"OPENAI_API_KEY": "sk-test"} config = {"OPENAI_API_KEY": "sk-test"}
mock_models = [{"id": "gpt-5.2", "created": 1704067200}] mock_models = [
{"id": "gpt-5.2", "created": 1704067200},
{"id": "gpt-5-mini", "created": 1704067200},
]
result = models.get_models(config, mock_openai_models=mock_models) result = models.get_models(config, mock_openai_models=mock_models)
self.assertEqual(result["openai"], "gpt-5.2") self.assertEqual(result["openai"], "gpt-5-mini")
self.assertIsNone(result["xai"]) self.assertIsNone(result["xai"])
def test_both_keys(self): def test_both_keys(self):
@@ -124,10 +187,13 @@ class TestGetModels(unittest.TestCase):
"OPENAI_API_KEY": "sk-test", "OPENAI_API_KEY": "sk-test",
"XAI_API_KEY": "xai-test", "XAI_API_KEY": "xai-test",
} }
mock_openai = [{"id": "gpt-5.2", "created": 1704067200}] mock_openai = [
{"id": "gpt-5.2", "created": 1704067200},
{"id": "gpt-5-mini", "created": 1704067200},
]
mock_xai = [{"id": "grok-4-1-fast-non-reasoning", "created": 1704067200}] mock_xai = [{"id": "grok-4-1-fast-non-reasoning", "created": 1704067200}]
result = models.get_models(config, mock_openai, mock_xai) result = models.get_models(config, mock_openai, mock_xai)
self.assertEqual(result["openai"], "gpt-5.2") self.assertEqual(result["openai"], "gpt-5-mini")
self.assertEqual(result["xai"], "grok-4-1-fast-non-reasoning") self.assertEqual(result["xai"], "grok-4-1-fast-non-reasoning")
+10 -5
View File
@@ -64,13 +64,18 @@ class TestIsModelAccessError(unittest.TestCase):
class TestModelFallbackOrder(unittest.TestCase): class TestModelFallbackOrder(unittest.TestCase):
"""Tests for MODEL_FALLBACK_ORDER constant.""" """Tests for MODEL_FALLBACK_ORDER constant."""
def test_contains_gpt4o(self): def test_mini_first(self):
"""Fallback list should include gpt-4o.""" """Mini models should come first (cost-efficient for structured extraction)."""
self.assertEqual(MODEL_FALLBACK_ORDER[0], "gpt-5-mini")
def test_contains_mainline_fallbacks(self):
"""Fallback list should include mainline models as last resort."""
self.assertIn("gpt-4.1", MODEL_FALLBACK_ORDER)
self.assertIn("gpt-4o", MODEL_FALLBACK_ORDER) self.assertIn("gpt-4o", MODEL_FALLBACK_ORDER)
def test_gpt41_is_first(self): def test_no_gpt4o_mini(self):
"""gpt-4.1 should be the first fallback option.""" """gpt-4o-mini should NOT be in fallback (no domain filtering support)."""
self.assertEqual(MODEL_FALLBACK_ORDER[0], "gpt-4.1") self.assertNotIn("gpt-4o-mini", MODEL_FALLBACK_ORDER)
if __name__ == "__main__": if __name__ == "__main__":