Skip to content

Commit fe79545

Browse files
authored
Merge pull request #21 from BraveNewCapital/copilot/update-repo-architect-model-selection
2 parents b5f68a8 + bd6ed78 commit fe79545

File tree

3 files changed

+197
-61
lines changed

3 files changed

+197
-61
lines changed

.github/workflows/repo-architect.yml

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ on:
2121
github_fallback_model:
2222
description: 'GitHub Models fallback model id (used if primary model fails)'
2323
required: false
24-
default: 'openai/gpt-5'
24+
default: ''
2525
type: string
2626
report_path:
2727
description: 'Primary report path'
@@ -85,18 +85,20 @@ jobs:
8585
- name: Resolve GitHub Models configuration
8686
env:
8787
GITHUB_TOKEN: ${{ github.token }}
88+
GITHUB_MODEL: ${{ github.event.inputs.github_model }}
89+
GITHUB_FALLBACK_MODEL: ${{ github.event.inputs.github_fallback_model }}
8890
run: |
8991
python - <<'PY'
9092
import json
9193
import os
9294
import urllib.request
9395
96+
env_github_model = os.environ.get("GITHUB_MODEL", "").strip()
97+
env_github_fallback_model = os.environ.get("GITHUB_FALLBACK_MODEL", "").strip()
9498
order = [
95-
"anthropic/claude-sonnet-4.6",
96-
"anthropic/claude-sonnet-4.5",
97-
"openai/gpt-4.1",
99+
"openai/gpt-5",
100+
"openai/o3",
98101
]
99-
secondary = "google/gemini-3-pro"
100102
available = set()
101103
catalog_ok = False
102104
try:
@@ -131,31 +133,28 @@ jobs:
131133
candidates = sorted(m for m in available if m != exclude)
132134
return candidates[0] if candidates else None
133135
134-
if catalog_ok and available:
135-
preferred = (
136-
first_available(order)
137-
or (secondary if secondary in available else None)
138-
or deterministic_available()
139-
)
136+
if env_github_model:
137+
preferred = env_github_model
138+
elif catalog_ok and available:
139+
preferred = first_available(order) or deterministic_available() or order[0]
140140
else:
141141
preferred = order[0]
142142
143-
if catalog_ok and available:
144-
if secondary in available and secondary != preferred:
145-
fallback = secondary
146-
else:
147-
fallback = (
148-
first_available([c for c in order if c != preferred])
149-
or deterministic_available(exclude=preferred)
150-
or preferred
151-
)
143+
if env_github_fallback_model:
144+
fallback = env_github_fallback_model
145+
elif catalog_ok and available:
146+
fallback = (
147+
first_available([c for c in order if c != preferred])
148+
or deterministic_available(exclude=preferred)
149+
or preferred
150+
)
152151
else:
153-
fallback = secondary
152+
fallback = order[1] if len(order) > 1 and order[1] != preferred else order[0]
154153
155154
if not isinstance(preferred, str) or not preferred:
156155
preferred = order[0]
157156
if not isinstance(fallback, str) or not fallback:
158-
fallback = secondary if secondary != preferred else order[-1]
157+
fallback = order[1] if len(order) > 1 and order[1] != preferred else order[0]
159158
160159
env_file = os.environ.get("GITHUB_ENV")
161160
if not env_file:

repo_architect.py

Lines changed: 85 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import urllib.error
1919
import urllib.parse
2020
import urllib.request
21-
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
21+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
2222

2323
APP_NAME = "repo-architect"
2424
VERSION = "2.1.0"
@@ -60,8 +60,10 @@
6060
}
6161

6262
# Model selection defaults
63-
DEFAULT_PREFERRED_MODEL = "anthropic/claude-sonnet-4.6"
64-
DEFAULT_FALLBACK_MODEL = "google/gemini-3-pro"
63+
DEFAULT_PREFERRED_MODEL = "openai/gpt-5"
64+
DEFAULT_FALLBACK_MODEL = "openai/o3"
65+
# Preferred resolution order for automatic model selection
66+
PREFERRED_MODEL_ORDER: Tuple[str, ...] = ("openai/gpt-5", "openai/o3")
6567
# Substrings in HTTP error bodies that indicate the model itself is unavailable (not a transient error)
6668
_MODEL_UNAVAILABLE_SIGNALS = frozenset({
6769
"unknown_model", "model_not_found", "unsupported_model", "unsupported model",
@@ -347,6 +349,63 @@ def model_available(catalog: List[Dict[str, Any]], model: str) -> bool:
347349
return False
348350

349351

352+
def _resolve_models(
353+
available: Set[str],
354+
catalog_ok: bool,
355+
env_model: str = "",
356+
env_fallback: str = "",
357+
order: Sequence[str] = PREFERRED_MODEL_ORDER,
358+
) -> Tuple[str, str]:
359+
"""Resolve preferred and fallback model IDs using override-first, then catalog-order logic.
360+
361+
Args:
362+
available: Set of model IDs returned by the catalog.
363+
catalog_ok: Whether the catalog fetch succeeded.
364+
env_model: Explicit primary override (GITHUB_MODEL env var). Empty → auto-resolve.
365+
env_fallback: Explicit fallback override (GITHUB_FALLBACK_MODEL env var). Empty → auto-resolve.
366+
order: Preferred resolution order; defaults to PREFERRED_MODEL_ORDER.
367+
368+
Returns:
369+
(preferred, fallback) — never the same value for both unless only one model exists.
370+
"""
371+
order_list = list(order)
372+
373+
def first_available(candidates: Sequence[str]) -> Optional[str]:
374+
for c in candidates:
375+
if c in available:
376+
return c
377+
return None
378+
379+
def deterministic_available(exclude: Optional[str] = None) -> Optional[str]:
380+
candidates = sorted(m for m in available if m != exclude)
381+
return candidates[0] if candidates else None
382+
383+
if env_model:
384+
preferred = env_model
385+
elif catalog_ok and available:
386+
preferred = first_available(order_list) or deterministic_available() or order_list[0]
387+
else:
388+
preferred = order_list[0]
389+
390+
if env_fallback:
391+
fallback = env_fallback
392+
elif catalog_ok and available:
393+
fallback = (
394+
first_available([c for c in order_list if c != preferred])
395+
or deterministic_available(exclude=preferred)
396+
or preferred
397+
)
398+
else:
399+
fallback = order_list[1] if len(order_list) > 1 and order_list[1] != preferred else order_list[0]
400+
401+
if not isinstance(preferred, str) or not preferred:
402+
preferred = order_list[0]
403+
if not isinstance(fallback, str) or not fallback:
404+
fallback = order_list[1] if len(order_list) > 1 and order_list[1] != preferred else order_list[0]
405+
406+
return preferred, fallback
407+
408+
350409
def github_models_chat(token: str, model: str, messages: List[Dict[str, str]]) -> Dict[str, Any]:
351410
req = urllib.request.Request(
352411
GITHUB_MODELS_CHAT,
@@ -1517,14 +1576,14 @@ def workflow_yaml(secret_env_names: Sequence[str], cron: str, github_model: Opti
15171576
- mutate
15181577
- campaign
15191578
github_model:
1520-
description: 'GitHub Models model id (overrides preferred model)'
1579+
description: 'GitHub Models model id (overrides preferred model; leave blank to use catalog resolution)'
15211580
required: false
1522-
default: 'anthropic/claude-sonnet-4.5'
1581+
default: ''
15231582
type: string
15241583
github_fallback_model:
15251584
description: 'GitHub Models fallback model id (used if primary model fails)'
15261585
required: false
1527-
default: 'openai/gpt-5'
1586+
default: ''
15281587
type: string
15291588
report_path:
15301589
description: 'Primary report path'
@@ -1588,18 +1647,20 @@ def workflow_yaml(secret_env_names: Sequence[str], cron: str, github_model: Opti
15881647
- name: Resolve GitHub Models configuration
15891648
env:
15901649
GITHUB_TOKEN: ${{{{ github.token }}}}
1650+
GITHUB_MODEL: ${{{{ github.event.inputs.github_model }}}}
1651+
GITHUB_FALLBACK_MODEL: ${{{{ github.event.inputs.github_fallback_model }}}}
15911652
run: |
15921653
python - <<'PY'
15931654
import json
15941655
import os
15951656
import urllib.request
15961657
1658+
env_github_model = os.environ.get("GITHUB_MODEL", "").strip()
1659+
env_github_fallback_model = os.environ.get("GITHUB_FALLBACK_MODEL", "").strip()
15971660
order = [
1598-
"anthropic/claude-sonnet-4.6",
1599-
"anthropic/claude-sonnet-4.5",
1600-
"openai/gpt-4.1",
1661+
"openai/gpt-5",
1662+
"openai/o3",
16011663
]
1602-
secondary = "google/gemini-3-pro"
16031664
available = set()
16041665
catalog_ok = False
16051666
try:
@@ -1634,31 +1695,28 @@ def deterministic_available(exclude=None):
16341695
candidates = sorted(m for m in available if m != exclude)
16351696
return candidates[0] if candidates else None
16361697
1637-
if catalog_ok and available:
1638-
preferred = (
1639-
first_available(order)
1640-
or (secondary if secondary in available else None)
1641-
or deterministic_available()
1642-
)
1698+
if env_github_model:
1699+
preferred = env_github_model
1700+
elif catalog_ok and available:
1701+
preferred = first_available(order) or deterministic_available() or order[0]
16431702
else:
16441703
preferred = order[0]
16451704
1646-
if catalog_ok and available:
1647-
if secondary in available and secondary != preferred:
1648-
fallback = secondary
1649-
else:
1650-
fallback = (
1651-
first_available([c for c in order if c != preferred])
1652-
or deterministic_available(exclude=preferred)
1653-
or preferred
1654-
)
1705+
if env_github_fallback_model:
1706+
fallback = env_github_fallback_model
1707+
elif catalog_ok and available:
1708+
fallback = (
1709+
first_available([c for c in order if c != preferred])
1710+
or deterministic_available(exclude=preferred)
1711+
or preferred
1712+
)
16551713
else:
1656-
fallback = secondary
1714+
fallback = order[1] if len(order) > 1 and order[1] != preferred else order[0]
16571715
16581716
if not isinstance(preferred, str) or not preferred:
16591717
preferred = order[0]
16601718
if not isinstance(fallback, str) or not fallback:
1661-
fallback = secondary if secondary != preferred else order[-1]
1719+
fallback = order[1] if len(order) > 1 and order[1] != preferred else order[0]
16621720
16631721
env_file = os.environ.get("GITHUB_ENV")
16641722
if not env_file:

tests/test_repo_architect.py

Lines changed: 91 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -427,14 +427,14 @@ class TestModelConfiguration(unittest.TestCase):
427427
def test_build_config_uses_env_models_when_github_model_blank(self) -> None:
428428
env = dict(os.environ)
429429
env.pop("GITHUB_MODEL", None)
430-
env["REPO_ARCHITECT_PREFERRED_MODEL"] = "anthropic/claude-sonnet-4.6"
431-
env["REPO_ARCHITECT_FALLBACK_MODEL"] = "google/gemini-3-pro"
430+
env["REPO_ARCHITECT_PREFERRED_MODEL"] = "openai/gpt-5"
431+
env["REPO_ARCHITECT_FALLBACK_MODEL"] = "openai/o3"
432432
with patch.object(ra, "discover_git_root", return_value=pathlib.Path("/tmp/repo")):
433433
with patch.dict(os.environ, env, clear=True):
434434
config = ra.build_config(ra.parse_args([]))
435435
self.assertIsNone(config.github_model)
436-
self.assertEqual(config.preferred_model, "anthropic/claude-sonnet-4.6")
437-
self.assertEqual(config.fallback_model, "google/gemini-3-pro")
436+
self.assertEqual(config.preferred_model, "openai/gpt-5")
437+
self.assertEqual(config.fallback_model, "openai/o3")
438438

439439
def test_github_model_override_takes_precedence_over_preferred(self) -> None:
440440
analysis = {
@@ -470,10 +470,12 @@ def test_workflow_yaml_resolves_models_via_catalog_and_keeps_blank_override_logi
470470
self.assertIn("Resolve GitHub Models configuration", workflow)
471471
self.assertIn("https://models.github.ai/catalog/models", workflow)
472472
self.assertIn("catalog_ok = False", workflow)
473-
self.assertIn('"anthropic/claude-sonnet-4.6"', workflow)
474-
self.assertIn('"anthropic/claude-sonnet-4.5"', workflow)
475-
self.assertIn('"openai/gpt-4.1"', workflow)
476-
self.assertIn('secondary = "google/gemini-3-pro"', workflow)
473+
self.assertIn('"openai/gpt-5"', workflow)
474+
self.assertIn('"openai/o3"', workflow)
475+
self.assertNotIn('"anthropic/claude-sonnet-4.6"', workflow)
476+
self.assertNotIn('"anthropic/claude-sonnet-4.5"', workflow)
477+
self.assertNotIn('"openai/gpt-4.1"', workflow)
478+
self.assertNotIn('secondary = "google/gemini-3-pro"', workflow)
477479
self.assertIn("def deterministic_available(exclude=None):", workflow)
478480
self.assertIn("or deterministic_available()", workflow)
479481
self.assertIn("or deterministic_available(exclude=preferred)", workflow)
@@ -482,12 +484,15 @@ def test_workflow_yaml_resolves_models_via_catalog_and_keeps_blank_override_logi
482484
self.assertIn("REPO_ARCHITECT_FALLBACK_MODEL={fallback}", workflow)
483485
self.assertIn('if [ -n "$MODEL" ]; then EXTRA_ARGS="$EXTRA_ARGS --github-model $MODEL"; fi', workflow)
484486
self.assertIn("models: read", workflow)
485-
# New: primary/fallback model inputs with defaults
487+
# github_fallback_model input with empty default
486488
self.assertIn("github_fallback_model:", workflow)
487-
self.assertIn("default: 'openai/gpt-5'", workflow)
488-
self.assertIn("default: 'anthropic/claude-sonnet-4.5'", workflow)
489-
# New: GITHUB_MODEL and GITHUB_FALLBACK_MODEL exported as env vars
489+
self.assertIn("default: ''", workflow)
490+
# GITHUB_MODEL and GITHUB_FALLBACK_MODEL exported as env vars to resolver step
490491
self.assertIn("GITHUB_FALLBACK_MODEL:", workflow)
492+
self.assertIn("GITHUB_MODEL:", workflow)
493+
# Override-first logic present
494+
self.assertIn("env_github_model", workflow)
495+
self.assertIn("env_github_fallback_model", workflow)
491496

492497
def test_build_config_reads_github_fallback_model_env(self) -> None:
493498
"""GITHUB_FALLBACK_MODEL env var should populate github_fallback_model in Config."""
@@ -564,6 +569,80 @@ def test_enrich_metadata_primary_success(self) -> None:
564569
self.assertIsNone(meta["fallback_reason"])
565570

566571

572+
# ---------------------------------------------------------------------------
573+
# 3b. Model resolver order
574+
# ---------------------------------------------------------------------------
575+
576+
class TestModelResolverOrder(unittest.TestCase):
577+
"""Tests for the _resolve_models() override-first catalog resolver."""
578+
579+
_ORDER = ("openai/gpt-5", "openai/o3")
580+
581+
def test_empty_inputs_use_resolver_order(self) -> None:
582+
"""No env overrides → picks gpt-5 as preferred and o3 as fallback."""
583+
available = {"openai/gpt-5", "openai/o3"}
584+
preferred, fallback = ra._resolve_models(
585+
available=available,
586+
catalog_ok=True,
587+
env_model="",
588+
env_fallback="",
589+
order=self._ORDER,
590+
)
591+
self.assertEqual(preferred, "openai/gpt-5")
592+
self.assertEqual(fallback, "openai/o3")
593+
594+
def test_explicit_primary_overrides_resolver(self) -> None:
595+
"""GITHUB_MODEL set → use it as preferred regardless of catalog."""
596+
available = {"openai/gpt-5", "openai/o3", "openai/other"}
597+
preferred, fallback = ra._resolve_models(
598+
available=available,
599+
catalog_ok=True,
600+
env_model="openai/manual",
601+
env_fallback="",
602+
order=self._ORDER,
603+
)
604+
self.assertEqual(preferred, "openai/manual")
605+
606+
def test_explicit_fallback_overrides_resolver(self) -> None:
607+
"""GITHUB_FALLBACK_MODEL set → use it as fallback, primary still auto-resolved."""
608+
available = {"openai/gpt-5", "openai/o3"}
609+
preferred, fallback = ra._resolve_models(
610+
available=available,
611+
catalog_ok=True,
612+
env_model="",
613+
env_fallback="openai/manual-fb",
614+
order=self._ORDER,
615+
)
616+
self.assertEqual(preferred, "openai/gpt-5")
617+
self.assertEqual(fallback, "openai/manual-fb")
618+
619+
def test_auto_fallback_chooses_o3_when_gpt5_is_primary(self) -> None:
620+
"""Catalog returns both gpt-5 and o3, no overrides → preferred=gpt-5, fallback=o3."""
621+
available = {"openai/gpt-5", "openai/o3"}
622+
preferred, fallback = ra._resolve_models(
623+
available=available,
624+
catalog_ok=True,
625+
env_model="",
626+
env_fallback="",
627+
order=self._ORDER,
628+
)
629+
self.assertEqual(preferred, "openai/gpt-5")
630+
self.assertEqual(fallback, "openai/o3")
631+
self.assertNotEqual(preferred, fallback)
632+
633+
def test_catalog_failure_falls_back_to_order_defaults(self) -> None:
634+
"""When catalog fails, use order[0] as preferred and order[1] as fallback."""
635+
preferred, fallback = ra._resolve_models(
636+
available=set(),
637+
catalog_ok=False,
638+
env_model="",
639+
env_fallback="",
640+
order=self._ORDER,
641+
)
642+
self.assertEqual(preferred, "openai/gpt-5")
643+
self.assertEqual(fallback, "openai/o3")
644+
645+
567646
# ---------------------------------------------------------------------------
568647
# 4. Syntax validation of generated Python (ast.parse gate)
569648
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)