diff --git a/.github/scripts/gate_summary.py b/.github/scripts/gate_summary.py index 07f68e25c..344d31c65 100644 --- a/.github/scripts/gate_summary.py +++ b/.github/scripts/gate_summary.py @@ -361,9 +361,9 @@ def summarize(context: SummaryContext) -> SummaryResult: # files changed. elif not context.python_required and python_result == "skipped": lines.append("- Python CI skipped: no Python-code changes detected.") - elif python_result == "cancelled": + elif python_result in {"cancelled", "abandoned"}: state = "pending" - description = "Python CI cancelled; waiting for rerun." + description = f"Python CI {python_result}; waiting for rerun." elif python_result not in ("success", "skipped") or ( python_result == "skipped" and context.run_core ): diff --git a/.github/sync-manifest.yml b/.github/sync-manifest.yml index ee823d378..de4a41ecd 100644 --- a/.github/sync-manifest.yml +++ b/.github/sync-manifest.yml @@ -682,7 +682,13 @@ scripts: description: "Embedding provider registry used by synced semantic matching helpers" - source: tools/check_model_registry_freshness.py - description: "Model-registry freshness gate - offline check that flags stale/blocked/dominated model pins so old models do not get stuck as primary" + description: "Model-registry freshness gate - validates dated decisions, evidence, lifecycle, and profile-based slots" + + - source: tools/discover_model_catalog.py + description: "Advisory provider-catalog discovery - proposes new model candidates without changing reviewed selections" + + - source: tools/evaluate_model_benchmark.py + description: "Deterministic paired model benchmark evaluator - computes confidence-bound quality gates and cost/latency ranking" # Research-backplane run-contract/v1 validator (run locally + in the conformance gate) - source: scripts/validate_run_contract.py @@ -747,8 +753,15 @@ llm_config: - source: config/model_registry.json description: "Model registry - available LLM models and their capabilities" + - source: config/model_selection_policy.json + description: "Model selection policy - benchmark gates, measurements, optimization order, and review triggers" + # Documentation synced to consumer repos docs: + - source: docs/MODEL_SELECTION_POLICY.md + target: docs/MODEL_SELECTION_POLICY.md + description: "Auditable auxiliary-model evaluation, selection, and refresh policy" + - source: docs/AGENT_ISSUE_FORMAT.md target: docs/AGENT_ISSUE_FORMAT.md description: "Format specification for agent-compatible issues" diff --git a/.github/workflows/maint-77-model-registry-freshness.yml b/.github/workflows/maint-77-model-registry-freshness.yml index 939ed42bd..0fb639b79 100644 --- a/.github/workflows/maint-77-model-registry-freshness.yml +++ b/.github/workflows/maint-77-model-registry-freshness.yml @@ -1,11 +1,8 @@ name: Maint 77 Model Registry Freshness -# Detects when the canonical LLM model configuration has gone stale so old models -# do not get stuck as the primary ones indefinitely. OFFLINE + deterministic: -# it only reads config/model_registry.json + config/llm_slots.json (no provider -# API calls, no secrets). On staleness it opens/refreshes a single tracking issue; -# a human reviews + refreshes the registry, which then propagates to consumers via -# the existing maint-68 sync (model_registry.json is copy-synced). +# The PR gate validates registry decisions, evidence, and slots offline. Scheduled +# and manual runs additionally query provider catalogs when credentials exist. +# Catalog changes are advisory candidates; they never change a selection. # # This does NOT change model selection and does NOT touch the sync workflow. @@ -17,8 +14,11 @@ on: pull_request: paths: - config/model_registry.json + - config/model_selection_policy.json - config/llm_slots.json - tools/check_model_registry_freshness.py + - tools/discover_model_catalog.py + - tools/evaluate_model_benchmark.py permissions: contents: read @@ -32,6 +32,7 @@ jobs: runs-on: ubuntu-latest outputs: rc: ${{ steps.gate.outputs.rc }} + discovery_drift: ${{ steps.discovery.outputs.drift || 'false' }} permissions: contents: read steps: @@ -72,8 +73,45 @@ jobs: echo "::error::Model registry/slots are stale — see job summary." exit 1 + - name: Discover provider catalog drift + if: github.event_name != 'pull_request' + id: discovery + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + CLAUDE_API_STRANSKE: ${{ secrets.CLAUDE_API_STRANSKE }} + run: | + set +e + python3 tools/discover_model_catalog.py --output catalog-discovery.json + rc=$? + if [ "$rc" = "1" ]; then + echo "drift=true" >> "$GITHUB_OUTPUT" + else + echo "drift=false" >> "$GITHUB_OUTPUT" + fi + if [ "$rc" = "2" ]; then + echo "::error::Catalog discovery configuration error." + exit 2 + fi + { + echo '### Provider catalog discovery' + echo '```json' + cat catalog-discovery.json + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload catalog discovery + if: github.event_name != 'pull_request' && always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: model-catalog-discovery-${{ github.run_id }} + path: catalog-discovery.json + if-no-files-found: ignore + tracking-issue: - if: github.event_name != 'pull_request' && needs.freshness.outputs.rc == '1' + if: >- + github.event_name != 'pull_request' && + (needs.freshness.outputs.rc == '1' || needs.freshness.outputs.discovery_drift == 'true') needs: freshness runs-on: ubuntu-latest permissions: @@ -91,17 +129,25 @@ jobs: python-version: '3.14' - name: Recreate freshness report + id: reports + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + CLAUDE_API_STRANSKE: ${{ secrets.CLAUDE_API_STRANSKE }} run: | set +e python3 tools/check_model_registry_freshness.py --json > freshness.json - rc=$? + freshness_rc=$? + python3 tools/discover_model_catalog.py --output catalog-discovery.json + discovery_rc=$? cat freshness.json - if [ "$rc" = "2" ]; then + cat catalog-discovery.json + if [ "$freshness_rc" = "2" ] || [ "$discovery_rc" = "2" ]; then echo "::error::Freshness gate configuration error." exit 2 fi - if [ "$rc" != "1" ]; then - echo "::notice::Freshness finding cleared before issue update." + if [ "$freshness_rc" != "1" ] && [ "$discovery_rc" != "1" ]; then + echo "::notice::Freshness and catalog findings cleared before issue update." exit 0 fi @@ -109,21 +155,31 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - TITLE="🧭 LLM model registry needs review (freshness gate)" + TITLE="LLM model registry needs evidence review" BODY_FILE=issue_body.md { - echo "The model-registry freshness gate (\`tools/check_model_registry_freshness.py\`, maint-77) flagged stale or dominated model configuration." + echo "Maint-77 found an overdue/unproved selection or provider catalog drift." + echo "Catalog additions are candidates only and must not be auto-selected." echo "" - echo "Refresh \`config/model_registry.json\` (and \`config/llm_slots.json\` if pins are dominated/blocked), bump \`review_by\`, and merge — the update propagates to consumers via maint-68 (model_registry.json is copy-synced)." + echo "Follow \`docs/MODEL_SELECTION_POLICY.md\`: refresh facts, run the paired" + echo "adjudicated benchmark, attach evidence, and update the explicit selection" + echo "and review date. Maint-68 then propagates the registry." echo "" echo "**Findings:**" echo '```json' cat freshness.json echo '```' echo "" - echo "_Run:_ ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + echo "**Catalog discovery:**" + echo '```json' + cat catalog-discovery.json + echo '```' + echo "" + echo "_Run:_ ${{ github.server_url }}/${{ github.repository }}/actions/runs/" + echo "${{ github.run_id }}" } > "$BODY_FILE" - EXISTING=$(gh issue list --search "$TITLE in:title" --state open --json number --jq '.[0].number' 2>/dev/null) + EXISTING=$(gh issue list --search "$TITLE in:title" --state open \ + --json number --jq '.[0].number' 2>/dev/null) if [ -n "$EXISTING" ]; then gh issue comment "$EXISTING" --body-file "$BODY_FILE" echo "::warning::Refreshed existing freshness issue #$EXISTING" diff --git a/.github/workflows/maint-78-model-evaluation-pilot.yml b/.github/workflows/maint-78-model-evaluation-pilot.yml new file mode 100644 index 000000000..b559920c3 --- /dev/null +++ b/.github/workflows/maint-78-model-evaluation-pilot.yml @@ -0,0 +1,46 @@ +name: Maint 78 Model Evaluation Pilot + +on: + workflow_dispatch: {} + +permissions: + contents: read + pull-requests: read + models: read + +jobs: + pilot: + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + - name: Run paired 30-case pilot + env: + # Cross-repo read token; github.token is scoped to Workflows only. + GH_TOKEN: ${{ secrets.OWNER_PR_PAT }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + CLAUDE_API_STRANSKE: ${{ secrets.CLAUDE_API_STRANSKE }} + run: | + uv run --extra dev python tools/run_model_eval_pilot.py --output pilot-results.json + - name: Summarize pilot + if: always() + run: | + { + echo '## Verifier model pilot' + echo 'The pilot narrows candidates only; approval still requires the 75-case corpus.' + echo '```json' + jq '{schema, corpus_version, rows:(.results|length), + errors:([.results[]|select(.schema_valid == false)]|length)}' \ + pilot-results.json + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + if: always() + with: + name: verifier-model-pilot-${{ github.run_id }} + path: | + pilot-results.json + if-no-files-found: error diff --git a/README.md b/README.md index 9b3e09e2d..fa49559ae 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Each stage completes, then dispatches auto-pilot again with the next step name. ### Verification Pipeline -After PR merge, applying a `verify:*` label (typically `verify:evaluate` via auto-pilot, or `verify:compare` for dual-model mode) triggers the verifier. In `compare` mode, two LLM providers (gpt-5.4 + claude-sonnet-4-6) independently evaluate the diff against acceptance criteria with unanimous PASS required. On CONCERNS or FAIL, maintainers or automation can apply the `verify:create-new-pr` label to trigger a 4-round LLM pipeline that generates a follow-up issue (analyze -> tasks -> acceptance criteria -> format). +After PR merge, applying a `verify:*` label (typically `verify:evaluate` via auto-pilot, or `verify:compare` for dual-model mode) triggers the verifier. In `compare` mode, two LLM providers (gpt-5.4 + claude-opus-4-6) independently evaluate the diff against acceptance criteria with unanimous PASS required. On CONCERNS or FAIL, maintainers or automation can apply the `verify:create-new-pr` label to trigger a 4-round LLM pipeline that generates a follow-up issue (analyze -> tasks -> acceptance criteria -> format). **Live verifier and pipeline metrics** are surfaced through the weekly summary tracker — see [issue #2211](https://github.com/stranske/Workflows/issues/2211) (durable auto-bot tracker, posted Mondays at 06:00 UTC) and the LangSmith dashboard wired by [`maint-80-langsmith-metrics-dashboard.yml`](.github/workflows/maint-80-langsmith-metrics-dashboard.yml). The original Feb 2026 baseline (40-PR sample, first-fix 35%, avg chain depth 2.7) is preserved at [`docs/analysis/verify-compare-40pr-evaluation-feb-2026.md`](docs/analysis/verify-compare-40pr-evaluation-feb-2026.md) for historical comparison. diff --git a/config/llm_slots.json b/config/llm_slots.json index c794ab449..c880f95bb 100644 --- a/config/llm_slots.json +++ b/config/llm_slots.json @@ -1,20 +1,20 @@ { - "purpose": "Auxiliary judge/evaluator slots only; do not treat these as coding-worker execution profiles.", + "purpose": "Auxiliary judge/evaluator provider preferences. Model versions resolve from explicit, reviewed registry decisions.", "slots": [ { "name": "slot1", "provider": "openai", - "model": "gpt-5.4" + "profile": "verifier-balanced" }, { "name": "slot2", "provider": "anthropic", - "quality_tier": "T5" + "profile": "verifier-balanced" }, { "name": "slot3", "provider": "github-models", - "model": "codex-mini-latest" + "profile": "verifier-balanced" } ] } diff --git a/config/model_eval_candidates.json b/config/model_eval_candidates.json new file mode 100644 index 000000000..1ccd17a04 --- /dev/null +++ b/config/model_eval_candidates.json @@ -0,0 +1,10 @@ +{ + "candidates": [ + {"provider":"openai","model_id":"gpt-5.4","role":"incumbent"}, + {"provider":"openai","model_id":"gpt-5.6-terra","role":"candidate"}, + {"provider":"openai","model_id":"gpt-5.6-sol","role":"candidate"}, + {"provider":"anthropic","model_id":"claude-opus-4-6","role":"incumbent"}, + {"provider":"anthropic","model_id":"claude-sonnet-4-6","role":"candidate"}, + {"provider":"github-models","model_id":"codex-mini-latest","role":"incumbent"} + ] +} diff --git a/config/model_eval_pilot.json b/config/model_eval_pilot.json new file mode 100644 index 000000000..827d76940 --- /dev/null +++ b/config/model_eval_pilot.json @@ -0,0 +1,38 @@ +{ + "schema": "workflows-verifier-pilot-corpus/v1", + "corpus_version": "verifier-balanced-pilot-2026-07-12", + "purpose": "Paired 30-case pilot used to narrow model candidates before the 75-case approval benchmark.", + "adjudication_rule": "PASS requires durable completion disposition; NON_PASS requires a verifier-driven follow-up or unresolved completion gap.", + "cases": [ + {"case_id":"workflows-2744","repo":"stranske/Workflows","pr":2744,"expected_verdict":"PASS","category":"clean-pass"}, + {"case_id":"workflows-2746","repo":"stranske/Workflows","pr":2746,"expected_verdict":"PASS","category":"clean-pass"}, + {"case_id":"workflows-2747","repo":"stranske/Workflows","pr":2747,"expected_verdict":"PASS","category":"clean-pass"}, + {"case_id":"pension-704","repo":"stranske/Pension-Data","pr":704,"expected_verdict":"PASS","category":"clean-pass"}, + {"case_id":"pension-702","repo":"stranske/Pension-Data","pr":702,"expected_verdict":"PASS","category":"stale-verifier-claim"}, + {"case_id":"workflows-2755","repo":"stranske/Workflows","pr":2755,"expected_verdict":"NON_PASS","category":"follow-up-required"}, + {"case_id":"workflows-2756","repo":"stranske/Workflows","pr":2756,"expected_verdict":"PASS","category":"clean-pass"}, + {"case_id":"workflows-2757","repo":"stranske/Workflows","pr":2757,"expected_verdict":"PASS","category":"review-thread-debt"}, + {"case_id":"pension-708","repo":"stranske/Pension-Data","pr":708,"expected_verdict":"PASS","category":"stale-verifier-claim"}, + {"case_id":"pension-711","repo":"stranske/Pension-Data","pr":711,"expected_verdict":"PASS","category":"clean-pass"}, + {"case_id":"pension-712","repo":"stranske/Pension-Data","pr":712,"expected_verdict":"PASS","category":"clean-pass"}, + {"case_id":"pension-713","repo":"stranske/Pension-Data","pr":713,"expected_verdict":"PASS","category":"stale-verifier-claim"}, + {"case_id":"pension-714","repo":"stranske/Pension-Data","pr":714,"expected_verdict":"PASS","category":"clean-pass"}, + {"case_id":"manager-1408","repo":"stranske/Manager-Database","pr":1408,"expected_verdict":"PASS","category":"clean-pass"}, + {"case_id":"manager-1411","repo":"stranske/Manager-Database","pr":1411,"expected_verdict":"PASS","category":"clean-pass"}, + {"case_id":"manager-1407","repo":"stranske/Manager-Database","pr":1407,"expected_verdict":"PASS","category":"clean-pass"}, + {"case_id":"manager-1412","repo":"stranske/Manager-Database","pr":1412,"expected_verdict":"PASS","category":"review-thread-debt"}, + {"case_id":"fine-art-237","repo":"stranske/Fine-Art-Archive","pr":237,"expected_verdict":"NON_PASS","category":"follow-up-required"}, + {"case_id":"fine-art-244","repo":"stranske/Fine-Art-Archive","pr":244,"expected_verdict":"PASS","category":"clean-pass"}, + {"case_id":"fine-art-255","repo":"stranske/Fine-Art-Archive","pr":255,"expected_verdict":"PASS","category":"clean-pass"}, + {"case_id":"fine-art-256","repo":"stranske/Fine-Art-Archive","pr":256,"expected_verdict":"PASS","category":"clean-pass"}, + {"case_id":"fine-art-257","repo":"stranske/Fine-Art-Archive","pr":257,"expected_verdict":"NON_PASS","category":"follow-up-required"}, + {"case_id":"inv-man-768","repo":"stranske/Inv-Man-Intake","pr":768,"expected_verdict":"PASS","category":"stale-verifier-claim"}, + {"case_id":"inv-man-781","repo":"stranske/Inv-Man-Intake","pr":781,"expected_verdict":"PASS","category":"clean-pass"}, + {"case_id":"inv-man-778","repo":"stranske/Inv-Man-Intake","pr":778,"expected_verdict":"PASS","category":"clean-pass"}, + {"case_id":"inv-man-776","repo":"stranske/Inv-Man-Intake","pr":776,"expected_verdict":"PASS","category":"clean-pass"}, + {"case_id":"inv-man-774","repo":"stranske/Inv-Man-Intake","pr":774,"expected_verdict":"PASS","category":"clean-pass"}, + {"case_id":"trip-1494","repo":"stranske/trip-planner","pr":1494,"expected_verdict":"PASS","category":"stale-verifier-claim"}, + {"case_id":"lms-393","repo":"stranske/learning-management-system","pr":393,"expected_verdict":"PASS","category":"clean-pass"}, + {"case_id":"workflows-2403","repo":"stranske/Workflows","pr":2403,"expected_verdict":"NON_PASS","category":"missing-acceptance-criterion"} + ] +} diff --git a/config/model_registry.json b/config/model_registry.json index c3aa26af7..99f28319c 100644 --- a/config/model_registry.json +++ b/config/model_registry.json @@ -1,253 +1,284 @@ { - "version": "1.0.0", - "last_updated": "2026-07-10", - "review_interval_days": 60, - "review_by": "2026-09-08", - "purpose": "Auxiliary judge/evaluator catalog plus explicit coding-worker IDs required for execution-profile validation. Trial worker entries intentionally carry no quality or cost claims.", - "models": [ - { - "model_id": "gpt-5.5", - "provider": "openai", - "api": "chat", - "quality": { "T1": 0.96, "T2": 0.96, "T3": 0.96, "T4": 0.96, "T5": 0.96 }, - "cost_score": 0.18, - "speed_score": 0.58, - "notes": "Coding-worker execution profile default. Listed here so execution profile validation can reject typos before dispatch." - }, - { - "model_id": "gpt-5.4", - "provider": "openai", - "api": "chat", - "quality": { "T1": 0.96, "T2": 0.96, "T3": 0.96, "T4": 0.96, "T5": 0.97 }, - "cost_score": 0.25, - "speed_score": 0.64, - "notes": "Current flagship verifier default. Highest-quality general evaluator." + "schema_version": "2.0.0", + "as_of": "2026-07-10", + "review_by": "2026-07-24", + "purpose": "Auditable model facts and auxiliary judge/evaluator selections. Coding-worker execution profiles remain in .github/agents/registry.yml::execution_profiles.", + "selection_policy": "config/model_selection_policy.json", + "catalog_baselines": { + "openai": { + "checked_at": "2026-07-10T00:00:00Z", + "model_ids": ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] }, - { - "model_id": "gpt-5.6-sol", - "provider": "openai", - "api": "chat", - "worker_profile": true, - "lifecycle": "trial", - "quality": {}, - "notes": "Coding-worker trial profile gpt-5.6-sol; do not use as evaluator evidence. Worker requested/selected identity must be recorded separately from verifier telemetry." + "anthropic": { + "checked_at": "2026-07-10T00:00:00Z", + "model_ids": [ + "claude-fable-5", + "claude-opus-4-8", + "claude-sonnet-5", + "claude-haiku-4-5-20251001" + ] }, + "github-models": { + "checked_at": "2026-07-10T00:00:00Z", + "model_ids": [ + "openai/gpt-4.1", + "openai/gpt-4.1-mini", + "openai/gpt-4.1-nano", + "openai/gpt-4o", + "openai/gpt-4o-mini", + "openai/gpt-5", + "openai/gpt-5-chat", + "openai/gpt-5-mini", + "openai/gpt-5-nano", + "openai/o1", + "openai/o1-mini", + "openai/o1-preview", + "openai/o3", + "openai/o3-mini", + "openai/o4-mini" + ] + } + }, + "sources": [ { - "model_id": "gpt-5.6-terra", + "source_id": "openai-current-models-2026-07-10", "provider": "openai", - "api": "chat", - "worker_profile": true, - "lifecycle": "trial", - "quality": {}, - "notes": "Coding-worker trial profile gpt-5.6-terra; do not use as evaluator evidence. Worker requested/selected identity must be recorded separately from verifier telemetry." + "checked_at": "2026-07-10", + "url": "https://developers.openai.com/api/docs/guides/latest-model" }, { - "model_id": "gpt-5.6-luna", + "source_id": "openai-pricing-2026-07-10", "provider": "openai", - "api": "chat", - "worker_profile": true, - "lifecycle": "trial", - "quality": {}, - "notes": "Coding-worker trial profile gpt-5.6-luna; do not use as evaluator evidence. Worker requested/selected identity must be recorded separately from verifier telemetry." + "checked_at": "2026-07-10", + "url": "https://developers.openai.com/api/docs/pricing" }, { - "model_id": "gpt-5.1", - "provider": "openai", - "api": "chat", - "quality": { "T1": 0.90, "T2": 0.90, "T3": 0.92, "T4": 0.92, "T5": 0.93 }, - "cost_score": 0.40, - "speed_score": 0.72, - "notes": "Nov 2025 flagship. Reasoning defaults to none — set effort explicitly." - }, - { - "model_id": "gpt-5.1-codex", - "provider": "openai", - "api": "chat", - "quality": { "T1": 0.88, "T2": 0.92, "T3": 0.95, "T4": 0.93, "T5": 0.94 }, - "cost_score": 0.38, - "speed_score": 0.70, - "notes": "Code-optimized GPT-5.1. Strong candidate for Codex session analysis." + "source_id": "anthropic-current-models-2026-07-10", + "provider": "anthropic", + "checked_at": "2026-07-10", + "url": "https://platform.claude.com/docs/en/about-claude/models/overview" }, { - "model_id": "gpt-5.1-mini", - "provider": "openai", - "api": "chat", - "quality": { "T1": 0.82, "T2": 0.80, "T3": 0.75, "T4": 0.72, "T5": 0.68 }, - "cost_score": 0.82, - "speed_score": 0.92, - "notes": "Lightweight 5.1. Risk of leniency on analysis tasks." - }, + "source_id": "github-models-catalog-2026-07-10", + "provider": "github-models", + "checked_at": "2026-07-10", + "url": "https://models.github.ai/catalog/models" + } + ], + "models": [ { - "model_id": "gpt-5", + "model_id": "gpt-5.6-sol", "provider": "openai", "api": "chat", - "quality": { "T1": 0.88, "T2": 0.88, "T3": 0.90, "T4": 0.90, "T5": 0.90 }, - "cost_score": 0.45, - "speed_score": 0.75, - "notes": "Aug 2025 flagship. Proven, superseded by 5.1+." + "lifecycle": "current", + "positioning": "frontier", + "source_ids": [ + "openai-current-models-2026-07-10", + "openai-pricing-2026-07-10" + ], + "pricing": { + "currency": "USD", + "input_per_million_tokens": 5.0, + "output_per_million_tokens": 30.0, + "as_of": "2026-07-10" + } }, { - "model_id": "gpt-5-mini", + "model_id": "gpt-5.6-terra", "provider": "openai", "api": "chat", - "quality": { "T1": 0.78, "T2": 0.75, "T3": 0.70, "T4": 0.68, "T5": 0.62 }, - "cost_score": 0.85, - "speed_score": 0.93, - "notes": "Budget GPT-5. Good speed/cost, but not chosen for verifier-critical tasks." + "lifecycle": "current", + "positioning": "balanced", + "source_ids": [ + "openai-current-models-2026-07-10", + "openai-pricing-2026-07-10" + ], + "pricing": { + "currency": "USD", + "input_per_million_tokens": 2.5, + "output_per_million_tokens": 15.0, + "as_of": "2026-07-10" + } }, { - "model_id": "gpt-5.4-mini", + "model_id": "gpt-5.6-luna", "provider": "openai", "api": "chat", - "quality": { "T1": 0.84, "T2": 0.82, "T3": 0.78, "T4": 0.76, "T5": 0.72 }, - "cost_score": 0.88, - "speed_score": 0.94, - "notes": "High-quality mini. Default for progress review where speed still matters." + "lifecycle": "current", + "positioning": "efficient", + "source_ids": [ + "openai-current-models-2026-07-10", + "openai-pricing-2026-07-10" + ], + "pricing": { + "currency": "USD", + "input_per_million_tokens": 1.0, + "output_per_million_tokens": 6.0, + "as_of": "2026-07-10" + } }, { - "model_id": "gpt-5-nano", - "provider": "openai", + "model_id": "claude-fable-5", + "provider": "anthropic", "api": "chat", - "quality": { "T1": 0.70, "T2": 0.65, "T3": 0.55, "T4": 0.50, "T5": 0.40 }, - "cost_score": 0.95, - "speed_score": 0.98, - "notes": "Smallest GPT-5. Only suitable for T1 tasks." + "lifecycle": "current", + "positioning": "frontier", + "source_ids": ["anthropic-current-models-2026-07-10"], + "pricing": { + "currency": "USD", + "input_per_million_tokens": 10.0, + "output_per_million_tokens": 50.0, + "as_of": "2026-07-10" + } }, { - "model_id": "gpt-4.1", - "provider": "openai", + "model_id": "claude-opus-4-8", + "provider": "anthropic", "api": "chat", - "quality": { "T1": 0.88, "T2": 0.88, "T3": 0.88, "T4": 0.87, "T5": 0.88 }, - "cost_score": 0.55, - "speed_score": 0.80, - "notes": "Battle-tested (Apr 2025). Excellent stability and structured output." + "lifecycle": "current", + "positioning": "high-capability", + "source_ids": ["anthropic-current-models-2026-07-10"], + "pricing": { + "currency": "USD", + "input_per_million_tokens": 5.0, + "output_per_million_tokens": 25.0, + "as_of": "2026-07-10" + } }, { - "model_id": "gpt-4.1-mini", - "provider": "openai", + "model_id": "claude-sonnet-5", + "provider": "anthropic", "api": "chat", - "quality": { "T1": 0.80, "T2": 0.78, "T3": 0.72, "T4": 0.70, "T5": 0.65 }, - "cost_score": 0.90, - "speed_score": 0.95, - "notes": "Lightweight 4.1. Good for T1/T2, risky for T3+." + "lifecycle": "current", + "positioning": "balanced", + "source_ids": ["anthropic-current-models-2026-07-10"], + "pricing": { + "currency": "USD", + "input_per_million_tokens": 3.0, + "output_per_million_tokens": 15.0, + "as_of": "2026-07-10", + "notes": "Standard price; temporary introductory pricing is not used for durable decisions." + } }, { - "model_id": "gpt-4.1-nano", - "provider": "openai", + "model_id": "claude-haiku-4-5-20251001", + "provider": "anthropic", "api": "chat", - "quality": { "T1": 0.68, "T2": 0.63, "T3": 0.52, "T4": 0.48, "T5": 0.38 }, - "cost_score": 1.00, - "speed_score": 1.00, - "notes": "Cheapest and fastest. Only for trivial classification." + "lifecycle": "current", + "positioning": "efficient", + "source_ids": ["anthropic-current-models-2026-07-10"], + "pricing": { + "currency": "USD", + "input_per_million_tokens": 1.0, + "output_per_million_tokens": 5.0, + "as_of": "2026-07-10" + } }, { - "model_id": "codex-mini-latest", + "model_id": "openai/gpt-5", "provider": "github-models", "api": "chat", - "quality": { "T1": 0.75, "T2": 0.80, "T3": 0.78, "T4": 0.72, "T5": 0.65 }, - "cost_score": 0.80, - "speed_score": 0.88, - "notes": "Rolling Codex alias. Good for code-centric extraction." + "lifecycle": "current", + "positioning": "catalog-fallback", + "source_ids": ["github-models-catalog-2026-07-10"], + "pricing": { + "basis": "GitHub Models quota and rate limits", + "as_of": "2026-07-10" + } }, { - "model_id": "o4-mini", - "provider": "openai", - "api": "chat", - "quality": { "T1": 0.85, "T2": 0.88, "T3": 0.92, "T4": 0.88, "T5": 0.93 }, - "cost_score": 0.30, - "speed_score": 0.35, - "notes": "Reasoning model. High quality but 5-15s latency. Use for high-stakes only." - }, - { - "model_id": "o3", - "provider": "openai", - "api": "chat", - "quality": { "T1": 0.90, "T2": 0.92, "T3": 0.95, "T4": 0.92, "T5": 0.96 }, - "cost_score": 0.15, - "speed_score": 0.15, - "notes": "Deep reasoning. 10-30s response. Generally too slow for CI/CD." - }, - { - "model_id": "o3-mini", - "provider": "openai", - "api": "chat", - "quality": { "T1": 0.83, "T2": 0.85, "T3": 0.88, "T4": 0.85, "T5": 0.90 }, - "cost_score": 0.35, - "speed_score": 0.30, - "notes": "Legacy reasoning mini. Superseded by o4-mini." - }, - { - "model_id": "gpt-4o", + "model_id": "gpt-5.5", "provider": "openai", "api": "chat", - "quality": { "T1": 0.85, "T2": 0.85, "T3": 0.85, "T4": 0.84, "T5": 0.84 }, - "cost_score": 0.55, - "speed_score": 0.80, - "notes": "Legacy — currently used as DEFAULT_MODEL. Two generations behind." + "lifecycle": "compatibility", + "positioning": "coding-worker-profile", + "notes": "Retained only because .github/agents/registry.yml still references this coding-worker model. It is not eligible for auxiliary evaluator selection." }, { - "model_id": "gpt-4o-mini", + "model_id": "gpt-5.4", "provider": "openai", "api": "chat", - "quality": { "T1": 0.60, "T2": 0.55, "T3": 0.45, "T4": 0.40, "T5": 0.30 }, - "cost_score": 0.78, - "speed_score": 0.88, - "blocked": true, - "notes": "BLOCKED: Documented as too lenient, passes obvious deficiencies." + "lifecycle": "current", + "positioning": "incumbent-verifier", + "source_ids": ["openai-current-models-2026-07-10"], + "pricing": { + "basis": "Observed billed cost is captured by the paired pilot", + "as_of": "2026-07-12" + }, + "notes": "Incumbent OpenAI verifier baseline. Remains selected until paired benchmark evidence approves a replacement." }, { "model_id": "claude-opus-4-6", "provider": "anthropic", "api": "chat", - "quality": { "T1": 0.93, "T2": 0.94, "T3": 0.96, "T4": 0.96, "T5": 0.98 }, - "cost_score": 0.08, - "speed_score": 0.38, - "notes": "Just released Feb 5, 2026. Highest quality, very expensive." + "lifecycle": "current", + "positioning": "incumbent-verifier", + "source_ids": ["anthropic-current-models-2026-07-10"], + "pricing": { + "basis": "Observed billed cost is captured by the paired pilot", + "as_of": "2026-07-12" + }, + "notes": "Incumbent Anthropic verifier baseline. Remains selected until paired benchmark evidence approves a replacement." }, { - "model_id": "claude-opus-4-5", - "provider": "anthropic", + "model_id": "codex-mini-latest", + "provider": "github-models", "api": "chat", - "quality": { "T1": 0.92, "T2": 0.93, "T3": 0.95, "T4": 0.95, "T5": 0.97 }, - "cost_score": 0.10, - "speed_score": 0.40, - "notes": "Opus tier. Overkill for most tasks, great for T5." - }, + "lifecycle": "current", + "positioning": "incumbent-verifier", + "source_ids": ["github-models-catalog-2026-07-10"], + "pricing": { + "basis": "GitHub Models quota and rate limits", + "as_of": "2026-07-12" + }, + "notes": "Incumbent GitHub Models verifier fallback. Remains selected until paired benchmark evidence approves a replacement." + } + ], + "selections": [ { - "model_id": "claude-sonnet-4-6", - "provider": "anthropic", - "api": "chat", - "quality": { "T1": 0.91, "T2": 0.91, "T3": 0.93, "T4": 0.93, "T5": 0.95 }, - "cost_score": 0.35, - "speed_score": 0.63, - "notes": "Current Anthropic compare-mode default. Strong cross-provider verifier." + "profile": "verifier-balanced", + "provider": "openai", + "model_id": "gpt-5.4", + "status": "provisional", + "decided_at": "2026-07-10", + "review_by": "2026-07-24", + "evidence_ids": ["catalog-review-2026-07-10"], + "rationale": "Incumbent baseline retained until a paired repository benchmark proves a replacement meets every quality gate." }, { - "model_id": "claude-sonnet-4-0", + "profile": "verifier-balanced", "provider": "anthropic", - "api": "chat", - "quality": { "T1": 0.85, "T2": 0.85, "T3": 0.88, "T4": 0.88, "T5": 0.90 }, - "cost_score": 0.40, - "speed_score": 0.65, - "notes": "Previous Sonnet. Proven but superseded by 4-5." + "model_id": "claude-opus-4-6", + "status": "provisional", + "decided_at": "2026-07-10", + "review_by": "2026-07-24", + "evidence_ids": ["catalog-review-2026-07-10"], + "rationale": "Incumbent baseline retained until a paired repository benchmark proves a replacement meets every quality gate." }, { - "model_id": "claude-haiku-4-5", - "provider": "anthropic", - "api": "chat", - "quality": { "T1": 0.80, "T2": 0.78, "T3": 0.72, "T4": 0.70, "T5": 0.62 }, - "cost_score": 0.75, - "speed_score": 0.90, - "notes": "Fast and cheap Claude. Good for T1/T2, untested for T3+." - }, + "profile": "verifier-balanced", + "provider": "github-models", + "model_id": "codex-mini-latest", + "status": "provisional", + "decided_at": "2026-07-10", + "review_by": "2026-07-24", + "evidence_ids": ["catalog-review-2026-07-10"], + "rationale": "Incumbent fallback retained until catalog availability and paired repository evidence approve a replacement." + } + ], + "evidence": [ { - "model_id": "claude-3-7-sonnet-latest", - "provider": "anthropic", - "api": "chat", - "quality": { "T1": 0.82, "T2": 0.82, "T3": 0.85, "T4": 0.85, "T5": 0.87 }, - "cost_score": 0.42, - "speed_score": 0.65, - "notes": "Legacy Claude 3.7. Superseded by Claude 4 Sonnet." + "evidence_id": "catalog-review-2026-07-10", + "kind": "provider-catalog-review", + "measured_at": "2026-07-10", + "status": "catalog-only", + "source_ids": [ + "openai-current-models-2026-07-10", + "openai-pricing-2026-07-10", + "anthropic-current-models-2026-07-10", + "github-models-catalog-2026-07-10" + ], + "notes": "Confirms availability and list pricing only. It is not repository workload quality evidence and cannot approve a selection." } ] } diff --git a/config/model_selection_policy.json b/config/model_selection_policy.json new file mode 100644 index 000000000..cbde1cc59 --- /dev/null +++ b/config/model_selection_policy.json @@ -0,0 +1,60 @@ +{ + "schema_version": "1.0.0", + "policy_id": "auxiliary-verifier-model-selection-v1", + "as_of": "2026-07-10", + "profiles": { + "verifier-balanced": { + "workload": "Issue-completion and provider-comparison judgments where false PASS is the highest-cost error.", + "candidate_stage": { + "minimum_adjudicated_cases": 30, + "required_case_categories": [ + "clean-pass", + "missing-acceptance-criterion", + "stale-verifier-claim", + "review-thread-debt", + "follow-up-required" + ] + }, + "approval_stage": { + "minimum_adjudicated_cases": 75, + "minimum_cases_per_category": 10, + "confidence_level": 0.95, + "quality_gates": { + "task_success_rate_wilson_lower_bound": 0.85, + "false_pass_rate_wilson_upper_bound": 0.05, + "false_fail_rate_wilson_upper_bound": 0.1, + "schema_error_rate_wilson_upper_bound": 0.05, + "paired_success_noninferiority_margin": 0.02 + } + }, + "optimization_order": [ + "pass_all_quality_gates", + "minimize_observed_cost_per_accepted_review", + "minimize_observed_p95_latency_ms" + ], + "required_measurements": [ + "adjudicated_outcome", + "false_pass", + "false_fail", + "schema_error", + "input_tokens", + "output_tokens", + "total_cost_usd", + "latency_ms" + ], + "promotion_rules": { + "paired_corpus_required": true, + "provider_marketing_claims_are_quality_evidence": false, + "new_catalog_models_auto_promote": false, + "human_approval_required": true + }, + "review_triggers": { + "maximum_days_between_reviews": 30, + "catalog_change": true, + "pricing_change": true, + "material_prompt_or_workload_change": true, + "quality_gate_breach": true + } + } + } +} diff --git a/docs/MODEL_SELECTION_FRAMEWORK.md b/docs/MODEL_SELECTION_FRAMEWORK.md index b92d92e0e..6e92a9c6a 100644 --- a/docs/MODEL_SELECTION_FRAMEWORK.md +++ b/docs/MODEL_SELECTION_FRAMEWORK.md @@ -1,5 +1,9 @@ # Model Selection Framework +> **Historical design only.** This document contains the original estimated +> score proposal and stale model examples. The authoritative, implemented policy +> is [`MODEL_SELECTION_POLICY.md`](MODEL_SELECTION_POLICY.md), which requires +> paired workload evidence and constrained selection rather than weighted scores. > **Last updated**: February 7, 2026 > **Purpose**: Define a model registry, selection algorithm, and slot-system > integration that enables task-aware, performance-driven model selection. diff --git a/docs/MODEL_SELECTION_POLICY.md b/docs/MODEL_SELECTION_POLICY.md new file mode 100644 index 000000000..c002bbd94 --- /dev/null +++ b/docs/MODEL_SELECTION_POLICY.md @@ -0,0 +1,103 @@ +# Auxiliary Model Selection Policy + +> **Status:** Authoritative for `config/model_registry.json` and +> `config/llm_slots.json` +> **Policy version:** `auxiliary-verifier-model-selection-v1` +> **Reviewed:** 2026-07-10 +> **Next decision review:** 2026-07-24 + +## Decision Principle + +Provider positioning and list price are model facts, not workload-quality +evidence. A model is eligible for approval only after it runs the same frozen, +adjudicated verifier corpus as the current baseline. + +Selection is constrained optimization, not a weighted score: + +1. Pass every quality and safety gate. +2. Among passing models, minimize observed cost per accepted review. +3. Use observed p95 latency as the final tie-breaker. + +This avoids arbitrary normalized quality, cost, and speed numbers. It also +prevents an inexpensive model from offsetting an unacceptable false-PASS rate. + +## Data Boundaries + +`config/model_registry.json` keeps three kinds of data separate: + +- **Facts:** provider ID, lifecycle, observed catalog date, source URL, and list + pricing as of a date. +- **Evidence:** a catalog review or a versioned repository workload benchmark. +- **Decision:** one provider/profile model, status, rationale, evidence IDs, + decision date, and review deadline. + +`config/llm_slots.json` keeps consumer-specific provider preferences but carries +only a workload profile. Model versions resolve from the registry decision. + +## Benchmark Protocol + +The `verifier-balanced` policy is defined in +`config/model_selection_policy.json`. + +Evaluate a case-level paired run with: + +```bash +python tools/evaluate_model_benchmark.py benchmark.json --output benchmark-evidence.json +``` + +The evaluator computes the confidence bounds and recommendation from case-level +records. Do not hand-enter aggregate rates or recommendation rankings. + +- Use a frozen, versioned corpus with owner-adjudicated expected outcomes. +- Run every candidate and baseline on the same cases and prompt version. +- Use at least 30 cases to retain a candidate and at least 75 cases, with 10 per + required failure category, to approve it. +- Report Wilson 95% confidence intervals for task success, false PASS, false + FAIL, and schema errors. +- Require the configured bounds and a paired success result no more than two + percentage points below the baseline. +- Record input/output tokens, actual billed cost, and latency per case. Compare + cost per accepted review, not provider list price alone. +- Treat prompt, schema, reasoning-effort, and retry-policy changes as new + benchmark versions. Do not combine unlike runs. + +An approved evidence record uses `kind: workload-benchmark` and +`status: passed`. The freshness gate rejects an approved decision without it. + +## Incumbents and Candidates + +The existing OpenAI, Anthropic, and GitHub Models verifier choices are recorded +as provisional incumbents. They remain the runtime baseline while the pilot is +assembled and run; catalog discovery can add candidates but cannot change a +selection. A replacement requires paired workload evidence that passes every +quality gate and an explicit approval update. + +## Catalog Discovery + +Run: + +```bash +python tools/discover_model_catalog.py --output model-catalog-discovery.json +``` + +GitHub Models discovery is public. OpenAI and Anthropic discovery is enabled +when their API keys are available. The weekly maint-77 workflow attaches the +diff to its tracking issue. + +A newly observed model becomes a candidate. It never changes a selection until +the benchmark and human-approval rules are satisfied. This is the mechanism +that keeps the system current without converting a provider release into an +unreviewed production change. + +## Review Triggers + +Review at least every 30 days and immediately after any of: + +- provider catalog or durable pricing change; +- material prompt, output schema, workload, or retry-policy change; +- observed quality-gate breach; +- selected model lifecycle or availability change. + +Update the facts and catalog baseline first, run the paired benchmark, attach +evidence, then update the explicit selection. Maint-68 propagates the registry; +consumer slot provider preferences remain intact. diff --git a/docs/ci/WORKFLOWS.md b/docs/ci/WORKFLOWS.md index cf7e8c164..be2218979 100644 --- a/docs/ci/WORKFLOWS.md +++ b/docs/ci/WORKFLOWS.md @@ -205,7 +205,8 @@ Scheduled health jobs keep the automation ecosystem aligned: * [`maint-71-merge-sync-prs.yml`](../../.github/workflows/maint-71-merge-sync-prs.yml) automates merging sync PRs in consumer repos - checks status, merges passing PRs, cleans up stale PRs, and deletes leftover `sync/workflows-*` branches tied to closed or merged sync PRs (manual dispatch). * [`maint-72-fix-pr-body-conflicts.yml`](../../.github/workflows/maint-72-fix-pr-body-conflicts.yml) removes pr_body.md from main branch and adds to .gitignore across consumer repos - prevents merge conflicts from PR description files (manual dispatch, weekly schedule). * [`maint-74-ledger-base-sync.yml`](../../.github/workflows/maint-74-ledger-base-sync.yml) aligns `.agents` ledger base entries to the repository default branch on a weekly schedule or manual dispatch. -* [`maint-77-model-registry-freshness.yml`](../../.github/workflows/maint-77-model-registry-freshness.yml) checks the canonical LLM model registry and slot pins for stale review dates, blocked or unknown pins, and provider-dominated pinned models without contacting provider APIs (scheduled weekly, manual dispatch, PR gate for registry/slot/checker changes). +* [`maint-77-model-registry-freshness.yml`](../../.github/workflows/maint-77-model-registry-freshness.yml) checks the canonical LLM registry for overdue or unproved decisions, invalid lifecycle/evidence references, and profile/slot drift. Scheduled and manual runs also perform credential-gated provider catalog discovery; catalog additions become review candidates and never auto-promote (scheduled weekly, manual dispatch, PR gate for registry/slot/policy/checker changes). +* [`maint-78-model-evaluation-pilot.yml`](../../.github/workflows/maint-78-model-evaluation-pilot.yml) runs the frozen 30-case verifier corpus against explicit incumbent and candidate models using repository credentials, then uploads artifact-only paired results. The pilot narrows candidates; it cannot approve or migrate a model. * [`maint-80-langsmith-metrics-dashboard.yml`](../../.github/workflows/maint-80-langsmith-metrics-dashboard.yml) generates weekly LangSmith trace coverage dashboard - downloads metrics from autopilot artifacts, computes coverage, creates issue report (scheduled Monday 9AM UTC, manual dispatch). * [`maint-81-langsmith-fleet-conformance.yml`](../../.github/workflows/maint-81-langsmith-fleet-conformance.yml) validates fleet artifact coverage against `config/langsmith_fleet_registry.json` and reports missing/stale/invalid records (scheduled Monday 9:30AM UTC, manual dispatch with optional enforcement). * [`maint-82-sync-dependency-campaign.yml`](../../.github/workflows/maint-82-sync-dependency-campaign.yml) refreshes a GitHub-visible sync/dependency campaign issue so local Codex only claims queued bot-review work when remote discovery finds active review threads. diff --git a/docs/ci/WORKFLOW_SYSTEM.md b/docs/ci/WORKFLOW_SYSTEM.md index e2d85160a..e99a2be89 100644 --- a/docs/ci/WORKFLOW_SYSTEM.md +++ b/docs/ci/WORKFLOW_SYSTEM.md @@ -744,7 +744,8 @@ Keep this table handy when you are triaging automation: it confirms which workfl | **Merge Sync PRs** (`maint-71-merge-sync-prs.yml`, maintenance bucket) | `workflow_dispatch`, `workflow_call` | Automates merging sync PRs across consumer repos. Checks CI status, merges passing PRs, cleans up stale sync PRs. Reads consumer repo list from maint-68-sync-consumer-repos.yml. | ⚪ Manual/callable | [Merge sync runs](https://github.com/stranske/Workflows/actions/workflows/maint-71-merge-sync-prs.yml) | | **Maint 72 Fix PR Body Conflicts** (`maint-72-fix-pr-body-conflicts.yml`, maintenance bucket) | `workflow_dispatch`, `schedule` (weekly) | Removes `pr_body.md` from main and adds to `.gitignore` in consumer repos to prevent merge conflicts. | ⚪ Manual/scheduled | [PR body fix runs](https://github.com/stranske/Workflows/actions/workflows/maint-72-fix-pr-body-conflicts.yml) | | **Maint 74 Ledger Base Sync** (`maint-74-ledger-base-sync.yml`, maintenance bucket) | `workflow_dispatch`, `schedule` (Mondays 06:00 UTC) | Align `.agents` ledger base entries to the repository default branch. | ⚪ Manual/scheduled | [Ledger base sync runs](https://github.com/stranske/Workflows/actions/workflows/maint-74-ledger-base-sync.yml) | -| **Maint 77 Model Registry Freshness** (`maint-77-model-registry-freshness.yml`, maintenance bucket) | `schedule` (Mondays 05:20 UTC), `workflow_dispatch`, `pull_request` (registry/slot/checker paths) | Runs an offline model-registry freshness gate over `config/model_registry.json` and `config/llm_slots.json`; PRs fail on stale or dominated pins while scheduled/manual runs refresh a tracking issue. | ⚪ Scheduled/manual + PR gate | [Model registry freshness runs](https://github.com/stranske/Workflows/actions/workflows/maint-77-model-registry-freshness.yml) | +| **Maint 77 Model Registry Freshness** (`maint-77-model-registry-freshness.yml`, maintenance bucket) | `schedule` (Mondays 05:20 UTC), `workflow_dispatch`, `pull_request` (registry/slot/policy/checker paths) | Validates explicit model decisions, evidence, lifecycle, and profile slots offline. Scheduled/manual runs add credential-gated provider-catalog drift and refresh one review issue; catalog changes never auto-select a model. | ⚪ Scheduled/manual + PR gate | [Model registry freshness runs](https://github.com/stranske/Workflows/actions/workflows/maint-77-model-registry-freshness.yml) | +| **Maint 78 Model Evaluation Pilot** (`maint-78-model-evaluation-pilot.yml`, maintenance bucket) | `workflow_dispatch` | Runs the frozen 30-case verifier corpus against explicit incumbent and candidate models with repository credentials. Uploads artifact-only paired verdict, schema, and latency evidence; never changes a selection. | ⚪ Manual evaluation | [Model evaluation pilot runs](https://github.com/stranske/Workflows/actions/workflows/maint-78-model-evaluation-pilot.yml) | | **LangSmith Metrics Dashboard** (`maint-80-langsmith-metrics-dashboard.yml`, maintenance bucket) | `workflow_dispatch`, `schedule` (Mondays 09:00 UTC) | Generates weekly LangSmith trace coverage dashboard by downloading metrics from autopilot artifacts, computing coverage, and creating issue reports. | ⚪ Manual/scheduled | [LangSmith metrics runs](https://github.com/stranske/Workflows/actions/workflows/maint-80-langsmith-metrics-dashboard.yml) | | **LangSmith Fleet Conformance** (`maint-81-langsmith-fleet-conformance.yml`, maintenance bucket) | `workflow_dispatch`, `schedule` (Mondays 09:30 UTC) | Validates LangSmith fleet artifact coverage against `config/langsmith_fleet_registry.json`, emits markdown/JSON reports, and can optionally enforce non-valid rows. | ⚪ Manual/scheduled | [LangSmith fleet conformance runs](https://github.com/stranske/Workflows/actions/workflows/maint-81-langsmith-fleet-conformance.yml) | | **Sync/Dependency Campaign** (`maint-82-sync-dependency-campaign.yml`, maintenance bucket) | `schedule`, `workflow_dispatch`, `repository_dispatch` | Refreshes a GitHub-visible campaign issue for sync-generated and dependency-bot PRs with active bot review threads so local Codex only claims queued work when remote discovery finds it. | ⚪ Scheduled/manual | [Sync/Dependency campaign runs](https://github.com/stranske/Workflows/actions/workflows/maint-82-sync-dependency-campaign.yml) | diff --git a/scripts/api_client.py b/scripts/api_client.py index 186c4cc3c..fbc03729c 100644 --- a/scripts/api_client.py +++ b/scripts/api_client.py @@ -78,6 +78,7 @@ def _request_response( token: str, payload: dict[str, Any] | None, *, + accept: str = "application/vnd.github+json", max_attempts: int = DEFAULT_RETRY_ATTEMPTS, backoff: float = DEFAULT_RETRY_BACKOFF, ) -> _Response: @@ -87,7 +88,7 @@ def _request_response( request_kwargs: dict[str, Any] = { "headers": { "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github+json", + "Accept": accept, }, "timeout": DEFAULT_TIMEOUT, } @@ -161,6 +162,49 @@ def fetch_issue( return data +def fetch_pull_request( + repo: str, + pull_number: int, + token: str, + *, + retry_attempts: int | None = None, + retry_backoff: float | None = None, +) -> dict[str, Any]: + """Fetch pull-request metadata through the shared retrying API client.""" + url = f"{GITHUB_API}/repos/{repo}/pulls/{pull_number}" + data = _request_json( + "GET", + url, + token, + payload=None, + **_retry_kwargs(retry_attempts, retry_backoff), + ) + if not isinstance(data, dict): + raise RuntimeError("GitHub API did not return a JSON object for the pull request.") + return data + + +def fetch_pull_request_diff( + repo: str, + pull_number: int, + token: str, + *, + retry_attempts: int | None = None, + retry_backoff: float | None = None, +) -> str: + """Fetch a pull-request diff through the shared retrying API client.""" + url = f"{GITHUB_API}/repos/{repo}/pulls/{pull_number}" + response = _request_response( + "GET", + url, + token, + payload=None, + accept="application/vnd.github.diff", + **_retry_kwargs(retry_attempts, retry_backoff), + ) + return response.text + + def fetch_issues( repo: str, token: str, diff --git a/templates/consumer-repo/.github/scripts/gate_summary.py b/templates/consumer-repo/.github/scripts/gate_summary.py index 07f68e25c..344d31c65 100644 --- a/templates/consumer-repo/.github/scripts/gate_summary.py +++ b/templates/consumer-repo/.github/scripts/gate_summary.py @@ -361,9 +361,9 @@ def summarize(context: SummaryContext) -> SummaryResult: # files changed. elif not context.python_required and python_result == "skipped": lines.append("- Python CI skipped: no Python-code changes detected.") - elif python_result == "cancelled": + elif python_result in {"cancelled", "abandoned"}: state = "pending" - description = "Python CI cancelled; waiting for rerun." + description = f"Python CI {python_result}; waiting for rerun." elif python_result not in ("success", "skipped") or ( python_result == "skipped" and context.run_core ): diff --git a/templates/consumer-repo/config/llm_slots.json b/templates/consumer-repo/config/llm_slots.json index c794ab449..c880f95bb 100644 --- a/templates/consumer-repo/config/llm_slots.json +++ b/templates/consumer-repo/config/llm_slots.json @@ -1,20 +1,20 @@ { - "purpose": "Auxiliary judge/evaluator slots only; do not treat these as coding-worker execution profiles.", + "purpose": "Auxiliary judge/evaluator provider preferences. Model versions resolve from explicit, reviewed registry decisions.", "slots": [ { "name": "slot1", "provider": "openai", - "model": "gpt-5.4" + "profile": "verifier-balanced" }, { "name": "slot2", "provider": "anthropic", - "quality_tier": "T5" + "profile": "verifier-balanced" }, { "name": "slot3", "provider": "github-models", - "model": "codex-mini-latest" + "profile": "verifier-balanced" } ] } diff --git a/templates/consumer-repo/scripts/api_client.py b/templates/consumer-repo/scripts/api_client.py index 186c4cc3c..fbc03729c 100644 --- a/templates/consumer-repo/scripts/api_client.py +++ b/templates/consumer-repo/scripts/api_client.py @@ -78,6 +78,7 @@ def _request_response( token: str, payload: dict[str, Any] | None, *, + accept: str = "application/vnd.github+json", max_attempts: int = DEFAULT_RETRY_ATTEMPTS, backoff: float = DEFAULT_RETRY_BACKOFF, ) -> _Response: @@ -87,7 +88,7 @@ def _request_response( request_kwargs: dict[str, Any] = { "headers": { "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github+json", + "Accept": accept, }, "timeout": DEFAULT_TIMEOUT, } @@ -161,6 +162,49 @@ def fetch_issue( return data +def fetch_pull_request( + repo: str, + pull_number: int, + token: str, + *, + retry_attempts: int | None = None, + retry_backoff: float | None = None, +) -> dict[str, Any]: + """Fetch pull-request metadata through the shared retrying API client.""" + url = f"{GITHUB_API}/repos/{repo}/pulls/{pull_number}" + data = _request_json( + "GET", + url, + token, + payload=None, + **_retry_kwargs(retry_attempts, retry_backoff), + ) + if not isinstance(data, dict): + raise RuntimeError("GitHub API did not return a JSON object for the pull request.") + return data + + +def fetch_pull_request_diff( + repo: str, + pull_number: int, + token: str, + *, + retry_attempts: int | None = None, + retry_backoff: float | None = None, +) -> str: + """Fetch a pull-request diff through the shared retrying API client.""" + url = f"{GITHUB_API}/repos/{repo}/pulls/{pull_number}" + response = _request_response( + "GET", + url, + token, + payload=None, + accept="application/vnd.github.diff", + **_retry_kwargs(retry_attempts, retry_backoff), + ) + return response.text + + def fetch_issues( repo: str, token: str, diff --git a/templates/consumer-repo/tools/llm_provider.py b/templates/consumer-repo/tools/llm_provider.py index c315a7f1e..8d0d89929 100644 --- a/templates/consumer-repo/tools/llm_provider.py +++ b/templates/consumer-repo/tools/llm_provider.py @@ -31,17 +31,15 @@ logger = logging.getLogger(__name__) -# GitHub Models API endpoint (OpenAI-compatible) -GITHUB_MODELS_BASE_URL = "https://models.inference.ai.azure.com" -# Legacy/default model identifier: -# - Not used for issuing requests to primary providers (OpenAI/Anthropic/GitHub Models) -# - Still used internally (e.g., default slot model in langchain_client.py) -# - Kept for backward compatibility with external code that references it -DEFAULT_MODEL = "codex-mini-latest" +# GitHub Models API endpoint (OpenAI-compatible). +GITHUB_MODELS_BASE_URL = "https://models.github.ai/inference" +# Model versions live only in config/model_registry.json. Empty constants keep +# import compatibility while ensuring a missing registry fails closed. +DEFAULT_MODEL = "" ANTHROPIC_API_KEY_ENV = "CLAUDE_API_STRANSKE" SHORT_ANALYSIS_CONFIDENCE_CAP = 0.4 -DEFAULT_OPENAI_ANALYSIS_MODEL = "gpt-5.4" -DEFAULT_ANTHROPIC_ANALYSIS_MODEL = "claude-sonnet-4-6" +DEFAULT_OPENAI_ANALYSIS_MODEL = "" +DEFAULT_ANTHROPIC_ANALYSIS_MODEL = "" def _configured_langchain_model(provider: str, *, fallback: str) -> str: @@ -49,7 +47,8 @@ def _configured_langchain_model(provider: str, *, fallback: str) -> str: from tools.llm_registry import configured_model_for_provider except ImportError: return fallback - return configured_model_for_provider(provider, fallback=fallback) or fallback + configured = configured_model_for_provider(provider, fallback=fallback) + return fallback if configured is None else configured def _setup_langsmith_tracing() -> bool: @@ -81,6 +80,16 @@ def _setup_langsmith_tracing() -> bool: LANGSMITH_TRACE_URL_BASE = "https://smith.langchain.com/r/" +def _ensure_langsmith_enabled() -> bool: + """Ensure LangSmith tracing is enabled when env vars are injected at runtime.""" + global LANGSMITH_ENABLED + if LANGSMITH_ENABLED: + return True + if os.environ.get("LANGSMITH_API_KEY"): + LANGSMITH_ENABLED = _setup_langsmith_tracing() + return LANGSMITH_ENABLED + + def build_langsmith_metadata( *, operation: str, @@ -116,6 +125,8 @@ def build_langsmith_metadata( env_pr if env_pr.isdigit() else env_issue if env_issue.isdigit() else "unknown" ) + _ensure_langsmith_enabled() + metadata: dict[str, object] = { "repo": repo, "run_id": run_id, @@ -125,7 +136,7 @@ def build_langsmith_metadata( "issue_number": str(issue_number) if issue_number is not None else None, } - if LANGSMITH_ENABLED: + if _ensure_langsmith_enabled(): metadata["langsmith_project"] = os.environ.get("LANGCHAIN_PROJECT", "workflows-agents") tags = [ @@ -161,7 +172,7 @@ def extract_trace_id(response) -> str | None: Returns: Trace ID string or None """ - if not LANGSMITH_ENABLED: + if not _ensure_langsmith_enabled(): return None # LangChain response objects have a response_metadata dict with run_id @@ -220,7 +231,7 @@ class CompletionAnalysis: confidence: float # 0.0 to 1.0 reasoning: str # Explanation of the analysis provider_used: str # Which provider generated this - model_name: str = "unknown" # Specific model used (e.g., gpt-4o, claude-3.5-sonnet) + model_name: str = "unknown" # Specific model used by the selected provider. # Quality metrics for BS detection raw_confidence: float | None = None # Original confidence before adjustment @@ -322,7 +333,10 @@ def name(self) -> str: return "github-models" def is_available(self) -> bool: - return bool(os.environ.get("GITHUB_TOKEN")) + return bool( + os.environ.get("GITHUB_TOKEN") + and _configured_langchain_model("github-models", fallback=DEFAULT_MODEL) + ) def supports_quality_context(self) -> bool: return True @@ -335,8 +349,12 @@ def _get_client(self): logger.warning("langchain_openai not installed") return None + model_name = _configured_langchain_model("github-models", fallback=DEFAULT_MODEL) + if not model_name: + return None + self._model_name = model_name return ChatOpenAI( - model="gpt-4.1", # Battle-tested, reliable, available on GitHub Models + model=model_name, base_url=GITHUB_MODELS_BASE_URL, api_key=os.environ.get("GITHUB_TOKEN"), temperature=0.1, # Low temperature for consistent analysis @@ -548,7 +566,7 @@ def _parse_response( confidence=adjusted_confidence, reasoning=reasoning, provider_used=self.name, - model_name="gpt-4.1", # Actual model used by GitHubModelsProvider + model_name=getattr(self, "_model_name", "unknown"), raw_confidence=raw_confidence if adjusted_confidence != raw_confidence else None, confidence_adjusted=adjusted_confidence != raw_confidence, quality_warnings=warnings if warnings else None, @@ -563,7 +581,7 @@ def _parse_response( confidence=0.0, reasoning=f"Failed to parse response: {e}", provider_used=self.name, - model_name="gpt-4.1", # Actual model used by GitHubModelsProvider + model_name=getattr(self, "_model_name", "unknown"), ) @@ -575,7 +593,10 @@ def name(self) -> str: return "openai" def is_available(self) -> bool: - return bool(os.environ.get("OPENAI_API_KEY")) + return bool( + os.environ.get("OPENAI_API_KEY") + and _configured_langchain_model("openai", fallback=DEFAULT_OPENAI_ANALYSIS_MODEL) + ) def supports_quality_context(self) -> bool: return True @@ -589,6 +610,8 @@ def _get_client(self): return None model_name = _configured_langchain_model("openai", fallback=DEFAULT_OPENAI_ANALYSIS_MODEL) + if not model_name: + return None resolved = build_chat_client(provider="openai", model=model_name) if resolved: self._model_name = resolved.model @@ -647,7 +670,10 @@ def name(self) -> str: return "anthropic" def is_available(self) -> bool: - return bool(os.environ.get(ANTHROPIC_API_KEY_ENV)) + return bool( + os.environ.get(ANTHROPIC_API_KEY_ENV) + and _configured_langchain_model("anthropic", fallback=DEFAULT_ANTHROPIC_ANALYSIS_MODEL) + ) def supports_quality_context(self) -> bool: return True @@ -662,6 +688,8 @@ def _get_client(self): model_name = _configured_langchain_model( "anthropic", fallback=DEFAULT_ANTHROPIC_ANALYSIS_MODEL ) + if not model_name: + return None resolved = build_chat_client(provider="anthropic", model=model_name) if resolved: self._model_name = resolved.model @@ -967,7 +995,7 @@ def get_llm_provider(force_provider: str | None = None) -> LLMProvider: Returns a FallbackChainProvider that tries: 1. Anthropic configured slot model (if CLAUDE_API_STRANSKE set) - Best reasoning 2. OpenAI configured slot model (if OPENAI_API_KEY set) - Code analysis - 3. GitHub Models gpt-4.1 (if GITHUB_TOKEN set) - Always available, reliable + 3. GitHub Models configured slot model (if GITHUB_TOKEN set) 4. Regex fallback (always available) - 30% confidence baseline """ # Force a specific provider for testing @@ -995,7 +1023,7 @@ def get_llm_provider(force_provider: str | None = None) -> LLMProvider: providers = [ AnthropicProvider(), # Primary: configured Anthropic slot model OpenAIProvider(), # Secondary: configured OpenAI slot model - GitHubModelsProvider(), # Tertiary: gpt-4.1 via GITHUB_TOKEN (always available) + GitHubModelsProvider(), # Tertiary: configured GitHub Models slot RegexFallbackProvider(), # Last resort: 30% confidence pattern matching ] diff --git a/templates/consumer-repo/tools/llm_registry.py b/templates/consumer-repo/tools/llm_registry.py index d217a290e..461122bc6 100644 --- a/templates/consumer-repo/tools/llm_registry.py +++ b/templates/consumer-repo/tools/llm_registry.py @@ -1,4 +1,8 @@ -"""Shared LLM slot and model-registry resolution helpers.""" +"""Shared LLM slot and model-registry resolution helpers. + +The registry records model facts separately from explicit workload-profile +selection decisions. Runtime selection never manufactures quality or cost scores. +""" from __future__ import annotations @@ -16,6 +20,7 @@ PROVIDER_OPENAI = "openai" PROVIDER_ANTHROPIC = "anthropic" PROVIDER_GITHUB = "github-models" +DEFAULT_SELECTION_PROFILE = "verifier-balanced" DEFAULT_SLOT_CONFIG_PATH = Path(__file__).resolve().parent.parent / "config" / "llm_slots.json" DEFAULT_MODEL_REGISTRY_CONFIG_PATH = ( @@ -28,7 +33,21 @@ class ModelRegistryEntry: provider: str model: str blocked: bool - quality: dict[str, float] + lifecycle: str = "unknown" + # Retained as empty compatibility attributes for callers migrating from v1. + # They are deliberately not inputs to model selection. + quality: dict[str, float] | None = None + cost_score: float | None = None + + +@dataclass(frozen=True) +class SelectionDecision: + profile: str + provider: str + model: str + status: str + review_by: str + evidence_ids: tuple[str, ...] @dataclass(frozen=True) @@ -51,6 +70,30 @@ def normalize_provider(value: str | None) -> str | None: return None +def _registry_path() -> Path: + configured = os.environ.get(ENV_MODEL_REGISTRY_CONFIG) + return Path(configured) if configured else DEFAULT_MODEL_REGISTRY_CONFIG_PATH + + +def _slot_path() -> Path: + configured = os.environ.get(ENV_SLOT_CONFIG) + return Path(configured) if configured else DEFAULT_SLOT_CONFIG_PATH + + +def _load_object(path: Path, *, label: str) -> dict[str, object] | None: + if not path.is_file(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + logger.warning("Could not read %s %s", label, path) + return None + if not isinstance(payload, dict): + logger.warning("Invalid %s format in %s; expected object", label, path) + return None + return payload + + def _slot_entries(payload: dict[str, object], path: Path) -> list[dict[str, object]]: raw_slots = payload.get("slots", []) if not isinstance(raw_slots, list): @@ -66,19 +109,10 @@ def _slot_entries(payload: dict[str, object], path: Path) -> list[dict[str, obje def load_model_registry() -> list[ModelRegistryEntry]: - config_path = os.environ.get(ENV_MODEL_REGISTRY_CONFIG) - path = Path(config_path) if config_path else DEFAULT_MODEL_REGISTRY_CONFIG_PATH - if not path.is_file(): - return [] - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - logger.warning("Could not read model registry %s; continuing without registry", path) - return [] - if not isinstance(payload, dict): - logger.warning("Invalid model registry format in %s; expected object", path) + path = _registry_path() + payload = _load_object(path, label="model registry") + if payload is None: return [] - raw_models = payload.get("models", []) if not isinstance(raw_models, list): logger.warning("Invalid model registry format in %s; expected models list", path) @@ -93,44 +127,58 @@ def load_model_registry() -> list[ModelRegistryEntry]: model = str(raw_entry.get("model_id", "")).strip() if not provider or not model: continue - quality_payload = raw_entry.get("quality", {}) - if not isinstance(quality_payload, dict): - logger.warning( - "Ignoring invalid quality scores for %s/%s in %s; expected object", - provider, - model, - path, - ) - quality_payload = {} - quality = { - str(tier).upper(): float(score) - for tier, score in quality_payload.items() - if isinstance(score, int | float) - } entries.append( ModelRegistryEntry( provider=provider, model=model, blocked=bool(raw_entry.get("blocked", False)), - quality=quality, + lifecycle=str(raw_entry.get("lifecycle", "unknown")).strip().lower(), + quality={}, ) ) return entries +def load_selection_decisions() -> list[SelectionDecision]: + path = _registry_path() + payload = _load_object(path, label="model registry") + if payload is None: + return [] + raw_selections = payload.get("selections", []) + if not isinstance(raw_selections, list): + logger.warning("Invalid model registry format in %s; expected selections list", path) + return [] + + decisions: list[SelectionDecision] = [] + for raw in raw_selections: + if not isinstance(raw, dict): + continue + provider = normalize_provider(str(raw.get("provider", ""))) + profile = str(raw.get("profile", "")).strip() + model = str(raw.get("model_id", "")).strip() + evidence = raw.get("evidence_ids", []) + if not provider or not profile or not model or not isinstance(evidence, list): + continue + decisions.append( + SelectionDecision( + profile=profile, + provider=provider, + model=model, + status=str(raw.get("status", "")).strip().lower(), + review_by=str(raw.get("review_by", "")).strip(), + evidence_ids=tuple(str(item) for item in evidence if str(item).strip()), + ) + ) + return decisions + + def _model_registry_format_valid() -> bool: - config_path = os.environ.get(ENV_MODEL_REGISTRY_CONFIG) - path = Path(config_path) if config_path else DEFAULT_MODEL_REGISTRY_CONFIG_PATH - if not path.is_file(): - return True - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return False - if not isinstance(payload, dict): - return False - raw_models = payload.get("models", []) - return isinstance(raw_models, list) + payload = _load_object(_registry_path(), label="model registry") + return bool( + payload is not None + and isinstance(payload.get("models"), list) + and isinstance(payload.get("selections"), list) + ) def registry_entry_for( @@ -139,10 +187,14 @@ def registry_entry_for( entries = registry if registry is not None else load_model_registry() normalized_provider = normalize_provider(provider) normalized_model = model.strip() - for entry in entries: - if entry.provider == normalized_provider and entry.model == normalized_model: - return entry - return None + return next( + ( + entry + for entry in entries + if entry.provider == normalized_provider and entry.model == normalized_model + ), + None, + ) def is_model_blocked( @@ -152,94 +204,127 @@ def is_model_blocked( return bool(entry and entry.blocked) -def select_model_for_tier( +def select_model_for_profile( *, provider: str, - tier: str, + profile: str = DEFAULT_SELECTION_PROFILE, registry: list[ModelRegistryEntry] | None = None, + decisions: list[SelectionDecision] | None = None, ) -> str | None: + """Resolve the one explicit reviewed decision for provider/profile.""" entries = registry if registry is not None else load_model_registry() + selections = decisions if decisions is not None else load_selection_decisions() normalized_provider = normalize_provider(provider) - normalized_tier = tier.strip().upper() - candidates = [ - entry - for entry in entries - if entry.provider == normalized_provider - and not entry.blocked - and normalized_tier in entry.quality + matches = [ + decision + for decision in selections + if decision.provider == normalized_provider and decision.profile == profile ] - if not candidates: + if len(matches) != 1: + if matches: + logger.warning( + "Ambiguous model selections for %s/%s; expected exactly one", + normalized_provider, + profile, + ) + return None + decision = matches[0] + entry = registry_entry_for(decision.provider, decision.model, registry=entries) + if entry is None or entry.blocked or entry.lifecycle != "current": + return None + if decision.status not in {"provisional", "approved"}: return None - selected = max(candidates, key=lambda entry: entry.quality[normalized_tier]) - return selected.model + return decision.model + + +def select_model_for_tier( + *, + provider: str, + tier: str, + registry: list[ModelRegistryEntry] | None = None, + **_ignored: object, +) -> str | None: + """Compatibility adapter for v1 callers; tiers no longer rank models.""" + logger.warning( + "quality tier %s is deprecated; resolving profile %s", + tier, + DEFAULT_SELECTION_PROFILE, + ) + return select_model_for_profile(provider=provider, registry=registry) def configured_model_for_provider( provider: str, *, - fallback: str, - tier: str = "T3", + fallback: str = "", + profile: str = DEFAULT_SELECTION_PROFILE, + tier: str | None = None, registry: list[ModelRegistryEntry] | None = None, ) -> str: normalized_provider = normalize_provider(provider) entries = registry if registry is not None else load_model_registry() - - config_path = os.environ.get(ENV_SLOT_CONFIG) - path = Path(config_path) if config_path else DEFAULT_SLOT_CONFIG_PATH - if path.is_file(): - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - payload = {} - if not isinstance(payload, dict): - logger.warning("Invalid slot config format in %s; expected object", path) - payload = {} + path = _slot_path() + payload = _load_object(path, label="slot config") + if payload is not None: for slot in _slot_entries(payload, path): slot_provider = normalize_provider(str(slot.get("provider", ""))) if slot_provider != normalized_provider: continue - model = str(slot.get("model", "")).strip() - slot_tier = str(slot.get("quality_tier") or slot.get("tier") or tier).strip() - if not model and slot_tier: - model = ( - select_model_for_tier( - provider=slot_provider or "", - tier=slot_tier, - registry=entries, - ) - or "" + explicit_model = str(slot.get("model", "")).strip() + slot_profile = str(slot.get("profile") or profile).strip() + model = ( + select_model_for_profile( + provider=slot_provider or "", + profile=slot_profile, + registry=entries, + ) + or "" + ) + if not model: + logger.warning( + "No reviewed model selection for slot profile %s/%s", + slot_profile, + slot_provider, + ) + return "" + if explicit_model and explicit_model != model: + logger.warning( + "Ignoring slot model pin %s/%s; reviewed %s selection is %s", + slot_provider, + explicit_model, + slot_profile, + model or "unavailable", ) if model and not is_model_blocked(slot_provider or "", model, registry=entries): return model - selected = select_model_for_tier(provider=provider, tier=tier, registry=entries) + selected = select_model_for_profile(provider=provider, profile=profile, registry=entries) if selected: return selected - if not is_model_blocked(provider, fallback, registry=entries): + if fallback and not is_model_blocked(provider, fallback, registry=entries): return fallback return "" -def default_slots(*, github_default_model: str) -> list[SlotDefinition]: - return [ - SlotDefinition(name="slot1", provider=PROVIDER_OPENAI, model="gpt-5.4"), - SlotDefinition(name="slot2", provider=PROVIDER_ANTHROPIC, model="claude-sonnet-4-6"), - SlotDefinition(name="slot3", provider=PROVIDER_GITHUB, model=github_default_model), - ] +def default_slots(*, github_default_model: str = "") -> list[SlotDefinition]: + """Build no-slot-config defaults from registry decisions, never version constants.""" + slots: list[SlotDefinition] = [] + for index, provider in enumerate( + (PROVIDER_OPENAI, PROVIDER_ANTHROPIC, PROVIDER_GITHUB), start=1 + ): + model = select_model_for_profile(provider=provider) + if not model and provider == PROVIDER_GITHUB: + model = github_default_model.strip() + if model: + slots.append(SlotDefinition(name=f"slot{index}", provider=provider, model=model)) + return slots -def load_slot_config(*, github_default_model: str) -> list[SlotDefinition]: - config_path = os.environ.get(ENV_SLOT_CONFIG) - path = Path(config_path) if config_path else DEFAULT_SLOT_CONFIG_PATH +def load_slot_config(*, github_default_model: str = "") -> list[SlotDefinition]: + path = _slot_path() + payload = _load_object(path, label="slot config") fallback_slots = default_slots(github_default_model=github_default_model) - if not path.is_file(): - return fallback_slots - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return fallback_slots - if not isinstance(payload, dict): - logger.warning("Invalid slot config format in %s; expected object", path) + if payload is None: return fallback_slots registry = load_model_registry() @@ -247,26 +332,44 @@ def load_slot_config(*, github_default_model: str) -> list[SlotDefinition]: if not _model_registry_format_valid() and any( str(entry.get("provider", "")).strip() and not str(entry.get("model", "")).strip() - and str(entry.get("quality_tier") or entry.get("tier") or "").strip() + and str( + entry.get("profile") or entry.get("quality_tier") or entry.get("tier") or "" + ).strip() for entry in slot_entries ): return fallback_slots + slots: list[SlotDefinition] = [] - fallback_by_position = dict(enumerate(fallback_slots, start=1)) fallback_by_provider = {slot.provider: slot for slot in fallback_slots} for idx, entry in enumerate(slot_entries, start=1): provider = normalize_provider(str(entry.get("provider", ""))) - model = str(entry.get("model", "")).strip() - tier = str(entry.get("quality_tier") or entry.get("tier") or "").strip() - if provider and not model and tier: - model = select_model_for_tier(provider=provider, tier=tier, registry=registry) or "" + explicit_model = str(entry.get("model", "")).strip() + configured_profile = str(entry.get("profile") or "").strip() + profile = configured_profile or DEFAULT_SELECTION_PROFILE + model = "" + if provider: + model = ( + select_model_for_profile(provider=provider, profile=profile, registry=registry) + or "" + ) + if provider and explicit_model and explicit_model != model: + logger.warning( + "Ignoring slot model pin %s/%s; reviewed %s selection is %s", + provider, + explicit_model, + profile, + model or "unavailable", + ) + if provider and configured_profile and not model: + logger.warning( + "Skipping slot with unresolved reviewed profile: %s/%s", + configured_profile, + provider, + ) + continue if provider and not model: - fallback_slot = fallback_by_position.get(idx) - if fallback_slot and fallback_slot.provider != provider: - fallback_slot = fallback_by_provider.get(provider) - fallback_slot = fallback_slot or fallback_by_provider.get(provider) - if fallback_slot and fallback_slot.provider == provider: - model = fallback_slot.model + fallback_slot = fallback_by_provider.get(provider) + model = fallback_slot.model if fallback_slot else "" if not provider or not model: continue if is_model_blocked(provider, model, registry=registry): @@ -274,8 +377,7 @@ def load_slot_config(*, github_default_model: str) -> list[SlotDefinition]: continue name = str(entry.get("name") or f"slot{idx}").strip() or f"slot{idx}" slots.append(SlotDefinition(name=name, provider=provider, model=model)) - - return slots or fallback_slots + return slots if slot_entries else fallback_slots def apply_slot_env_overrides( @@ -287,10 +389,8 @@ def apply_slot_env_overrides( registry = load_model_registry() updated: list[SlotDefinition] = [] for idx, slot in enumerate(slots, start=1): - provider_key = f"{env_slot_prefix}{idx}_PROVIDER" - model_key = f"{env_slot_prefix}{idx}_MODEL" - provider_override = normalize_provider(os.environ.get(provider_key)) - model_override = os.environ.get(model_key) + provider_override = normalize_provider(os.environ.get(f"{env_slot_prefix}{idx}_PROVIDER")) + model_override = os.environ.get(f"{env_slot_prefix}{idx}_MODEL") if idx == 1: model_override = model_override or os.environ.get(env_model_name) provider = provider_override or slot.provider @@ -303,24 +403,29 @@ def apply_slot_env_overrides( ): updated.append(slot) continue - updated.append( - SlotDefinition( - name=slot.name, - provider=provider, - model=model, - ) - ) + updated.append(SlotDefinition(name=slot.name, provider=provider, model=model)) return updated def resolve_slots( *, - github_default_model: str, + github_default_model: str = "", env_model_name: str = "LANGCHAIN_MODEL", env_slot_prefix: str = "LANGCHAIN_SLOT", ) -> list[SlotDefinition]: + slots = load_slot_config(github_default_model=github_default_model) + # Preserve an explicit runtime override as an emergency bootstrap when the + # registry file is unavailable. Empty models are never invoked directly; + # langchain_client skips them when the override cannot serve that provider. + if not slots and os.environ.get(env_model_name): + slots = [ + SlotDefinition(name=f"slot{index}", provider=provider, model="") + for index, provider in enumerate( + (PROVIDER_OPENAI, PROVIDER_ANTHROPIC, PROVIDER_GITHUB), start=1 + ) + ] return apply_slot_env_overrides( - load_slot_config(github_default_model=github_default_model), + slots, env_model_name=env_model_name, env_slot_prefix=env_slot_prefix, ) diff --git a/tests/scripts/test_api_client.py b/tests/scripts/test_api_client.py index ae32a8aee..b58100e3f 100644 --- a/tests/scripts/test_api_client.py +++ b/tests/scripts/test_api_client.py @@ -1,5 +1,6 @@ from __future__ import annotations +from types import SimpleNamespace from typing import Any import pytest @@ -201,6 +202,31 @@ def test_fetch_issue_validates_shape_and_applies_parser( api_client.fetch_issue("owner/repo", 12, "tok") +def test_fetch_pull_request_and_diff_use_shared_client(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(api_client, "_request_json", lambda *args, **kwargs: {"title": "PR"}) + response = SimpleNamespace(text="diff --git a/a b/a") + calls: list[dict[str, object]] = [] + + def fake_response(*args: object, **kwargs: object) -> object: + calls.append(kwargs) + return response + + monkeypatch.setattr(api_client, "_request_response", fake_response) + + assert api_client.fetch_pull_request("owner/repo", 7, "tok") == {"title": "PR"} + assert api_client.fetch_pull_request_diff("owner/repo", 7, "tok") == response.text + assert calls == [{"payload": None, "accept": "application/vnd.github.diff"}] + + +def test_fetch_pull_request_rejects_nonobject_payload(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + api_client, "_request_json", lambda *args, **kwargs: ["not", "an", "object"] + ) + + with pytest.raises(RuntimeError, match="JSON object for the pull request"): + api_client.fetch_pull_request("owner/repo", 7, "tok") + + def test_fetch_issues_validates_shape_and_encodes_labels( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_check_model_registry_freshness.py b/tests/test_check_model_registry_freshness.py index e19db030a..2243e623b 100644 --- a/tests/test_check_model_registry_freshness.py +++ b/tests/test_check_model_registry_freshness.py @@ -1,4 +1,4 @@ -"""Tests for the model-registry freshness gate (tools/check_model_registry_freshness.py).""" +"""Tests for tools/check_model_registry_freshness.py.""" from __future__ import annotations @@ -6,190 +6,234 @@ import json from pathlib import Path +import pytest from tools import check_model_registry_freshness as gate -TODAY = dt.date(2026, 6, 28) +TODAY = dt.date(2026, 7, 10) def _registry(**over): base = { - "version": "1.0.0", - "review_by": "2026-12-31", + "schema_version": "2.0.0", + "as_of": "2026-07-10", + "review_by": "2026-07-24", + "sources": [ + { + "source_id": "models-1", + "checked_at": "2026-07-10", + "url": "https://example.test/models", + } + ], "models": [ - {"model_id": "gpt-5.4", "provider": "openai", "quality": {"T5": 0.97}}, - {"model_id": "gpt-5.1", "provider": "openai", "quality": {"T5": 0.93}}, - {"model_id": "claude-sonnet-4-6", "provider": "anthropic", "quality": {"T5": 0.95}}, { - "model_id": "old-blocked", + "model_id": "model-current", + "provider": "openai", + "lifecycle": "current", + "source_ids": ["models-1"], + "pricing": {"as_of": "2026-07-10"}, + }, + { + "model_id": "model-blocked", "provider": "openai", - "quality": {"T5": 0.30}, + "lifecycle": "current", "blocked": True, + "source_ids": ["models-1"], + "pricing": {"as_of": "2026-07-10"}, }, ], + "selections": [ + { + "profile": "verifier-balanced", + "provider": "openai", + "model_id": "model-current", + "status": "provisional", + "review_by": "2026-07-24", + "evidence_ids": ["catalog-1"], + } + ], + "evidence": [ + { + "evidence_id": "catalog-1", + "kind": "provider-catalog-review", + "status": "catalog-only", + "source_ids": ["models-1"], + } + ], } base.update(over) return base -def _slots(*pairs): - return { - "slots": [ - {"name": f"slot{i+1}", "provider": p, "model": m} for i, (p, m) in enumerate(pairs) - ] +def _slots(model: str | None = None): + slot = { + "name": "slot1", + "provider": "openai", + "profile": "verifier-balanced", } + if model: + slot["model"] = model + return {"slots": [slot]} -def _kinds(findings): - return sorted(f["kind"] for f in findings) +def _policy(): + return {"policy_id": "test-policy", "profiles": {"verifier-balanced": {}}} -def test_fresh_registry_has_no_findings(): - findings = gate.evaluate(_registry(), _slots(("openai", "gpt-5.4")), today=TODAY) - assert findings == [] +def _kinds(findings): + return sorted(finding["kind"] for finding in findings) -def test_review_overdue_explicit_review_by(): - reg = _registry(review_by="2026-05-01") - findings = gate.evaluate(reg, _slots(("openai", "gpt-5.4")), today=TODAY) - assert _kinds(findings) == ["review_overdue"] - assert "overdue" in findings[0]["detail"] +def test_fresh_provisional_decision_has_no_findings(): + assert gate.evaluate(_registry(), _slots(), today=TODAY, policy=_policy()) == [] -def test_review_overdue_derived_from_last_updated(): - reg = _registry() - reg.pop("review_by") - reg["last_updated"] = "2026-04-14" # +60d -> 2026-06-13 < today - findings = gate.evaluate(reg, _slots(("openai", "gpt-5.4")), today=TODAY, max_age_days=60) +def test_review_overdue_is_reported(): + findings = gate.evaluate( + _registry(review_by="2026-07-01"), _slots(), today=TODAY, policy=_policy() + ) assert "review_overdue" in _kinds(findings) -def test_review_not_overdue_within_window(): - reg = _registry() - reg.pop("review_by") - reg["last_updated"] = "2026-06-20" # +60d in the future - findings = gate.evaluate(reg, _slots(("openai", "gpt-5.4")), today=TODAY, max_age_days=60) - assert "review_overdue" not in _kinds(findings) - - -def test_blocked_pin(): - findings = gate.evaluate(_registry(), _slots(("openai", "old-blocked")), today=TODAY) - assert _kinds(findings) == ["blocked_pin"] +def test_provisional_selection_must_be_resolved_by_review_date(): + registry = _registry() + registry["selections"][0]["review_by"] = "2026-07-01" + findings = gate.evaluate(registry, _slots(), today=TODAY, policy=_policy()) + assert "provisional_overdue" in _kinds(findings) -def test_unknown_pin(): - findings = gate.evaluate(_registry(), _slots(("openai", "gpt-9-imaginary")), today=TODAY) - assert _kinds(findings) == ["unknown_pin"] +def test_unknown_and_blocked_selections_are_reported(): + unknown = _registry() + unknown["selections"][0]["model_id"] = "missing" + blocked = _registry() + blocked["selections"][0]["model_id"] = "model-blocked" - -def test_dominated_pin_flags_better_same_provider_model(): - # Pin sonnet-4-6 (0.95) while registry also has a higher claude model. - reg = _registry() - reg["models"].append( - {"model_id": "claude-opus-4-6", "provider": "anthropic", "quality": {"T5": 0.98}} + assert "unknown_selection" in _kinds( + gate.evaluate(unknown, _slots(), today=TODAY, policy=_policy()) + ) + assert "blocked_selection" in _kinds( + gate.evaluate(blocked, _slots(), today=TODAY, policy=_policy()) ) - findings = gate.evaluate(reg, _slots(("anthropic", "claude-sonnet-4-6")), today=TODAY) - assert _kinds(findings) == ["dominated_pin"] - assert "claude-opus-4-6" in findings[0]["detail"] - -def test_dominated_pin_uses_slot_quality_tier(): - reg = _registry() - reg["models"] = [ - { - "model_id": "primary-for-t4", - "provider": "openai", - "quality": {"T4": 0.95, "T5": 0.80}, - }, - { - "model_id": "better-t5-only", - "provider": "openai", - "quality": {"T4": 0.90, "T5": 0.99}, - }, - ] - slots = { - "slots": [ - { - "name": "slot1", - "provider": "openai", - "model": "primary-for-t4", - "quality_tier": "T4", - } - ] - } - findings = gate.evaluate(reg, slots, today=TODAY) - assert findings == [] +def test_approved_selection_requires_passed_workload_benchmark(): + registry = _registry() + registry["selections"][0]["status"] = "approved" + findings = gate.evaluate(registry, _slots(), today=TODAY, policy=_policy()) + assert "unproved_approval" in _kinds(findings) -def test_dominated_pin_normalizes_slot_quality_tier(): - reg = _registry() - reg["models"] = [ - { - "model_id": "old-t3", - "provider": "openai", - "quality": {"T3": 0.50, "T5": 0.99}, - }, + registry["evidence"].append( { - "model_id": "new-t3", - "provider": "openai", - "quality": {"T3": 0.80, "T5": 0.10}, - }, - ] - slots = { - "slots": [ - { - "name": "slot1", - "provider": "openai", - "model": "old-t3", - "quality_tier": "t3", - } - ] - } - - findings = gate.evaluate(reg, slots, today=TODAY) - - assert _kinds(findings) == ["dominated_pin"] - assert "new-t3" in findings[0]["detail"] - - -def test_tier_derived_slot_without_model_is_not_flagged(): - # A slot with no pinned model derives from the registry at runtime -> non-ossifying. - slots = {"slots": [{"name": "slot1", "provider": "anthropic", "quality_tier": "T5"}]} - findings = gate.evaluate(_registry(), slots, today=TODAY) - assert findings == [] - - -def test_real_repo_files_parse_and_run(tmp_path): - # The shipped config must at least load and evaluate without raising. - root = Path(__file__).resolve().parent.parent - reg = json.loads((root / "config" / "model_registry.json").read_text()) - slots = json.loads((root / "config" / "llm_slots.json").read_text()) - findings = gate.evaluate(reg, slots, today=TODAY) - assert isinstance(findings, list) + "evidence_id": "bench-1", + "schema": "workflows-model-benchmark-evidence/v1", + "kind": "workload-benchmark", + "status": "passed", + "measured_at": "2026-07-10", + "policy_id": "test-policy", + "profile": "verifier-balanced", + "corpus_version": "corpus-v1", + "prompt_version": "prompt-v1", + "model_id": "model-current", + "gate_results": {"all": True}, + } + ) + registry["selections"][0]["evidence_ids"].append("bench-1") + findings = gate.evaluate(registry, _slots(), today=TODAY, policy=_policy()) + assert "unproved_approval" not in _kinds(findings) -def test_consumer_template_registry_defaults_are_fresh(): - root = Path(__file__).resolve().parent.parent - reg = json.loads( - (root / "templates" / "consumer-repo" / "config" / "model_registry.json").read_text() - ) - slots = json.loads( - (root / "templates" / "consumer-repo" / "config" / "llm_slots.json").read_text() +def test_explicit_old_pin_is_flagged_against_reviewed_selection(): + registry = _registry() + registry["models"].append( + {"model_id": "model-old", "provider": "openai", "lifecycle": "compatibility"} ) - findings = gate.evaluate(reg, slots, today=dt.date(2026, 6, 30)) - assert findings == [] + findings = gate.evaluate(registry, _slots("model-old"), today=TODAY, policy=_policy()) + assert "selection_override" in _kinds(findings) -def test_main_exit_codes(tmp_path): - reg = tmp_path / "reg.json" - slots = tmp_path / "slots.json" - reg.write_text(json.dumps(_registry(review_by="2026-05-01"))) - slots.write_text(json.dumps(_slots(("openai", "gpt-5.4")))) - rc = gate.main( - ["--registry", str(reg), "--slots", str(slots), "--today", "2026-06-28", "--json"] +def test_slot_requires_profile_selection(): + findings = gate.evaluate( + _registry(), + {"slots": [{"name": "slot2", "provider": "anthropic", "profile": "missing"}]}, + today=TODAY, + policy=_policy(), ) - assert rc == 1 # overdue - reg.write_text(json.dumps(_registry(review_by="2026-12-31"))) - rc = gate.main(["--registry", str(reg), "--slots", str(slots), "--today", "2026-06-28"]) - assert rc == 0 - rc = gate.main(["--registry", str(reg), "--slots", str(slots), "--today", "not-a-date"]) - assert rc == 2 + assert "missing_selection" in _kinds(findings) + + +@pytest.mark.parametrize( + ("kind", "mutate"), + [ + ("missing_source", lambda value: value.update(sources=[])), + ( + "missing_pricing_date", + lambda value: value["models"][0].pop("pricing"), + ), + ( + "invalid_selection_status", + lambda value: value["selections"][0].update(status="reviewed"), + ), + ( + "unknown_profile", + lambda value: value["selections"][0].update(profile="unknown"), + ), + ( + "inactive_selection", + lambda value: value["models"][0].update(lifecycle="compatibility"), + ), + ( + "missing_evidence", + lambda value: value["selections"][0].update(evidence_ids=[]), + ), + ( + "duplicate_selection", + lambda value: value["selections"].append(dict(value["selections"][0])), + ), + ], +) +def test_selection_and_fact_gate_branches(kind, mutate): + registry = _registry() + mutate(registry) + assert kind in _kinds(gate.evaluate(registry, _slots(), today=TODAY, policy=_policy())) + + +@pytest.mark.parametrize( + ("kind", "slots"), + [ + ("unknown_pin", _slots("absent")), + ("blocked_pin", _slots("model-blocked")), + ("missing_profile", {"slots": [{"name": "slot1", "provider": "openai"}]}), + ], +) +def test_slot_pin_and_profile_gate_branches(kind, slots): + assert kind in _kinds(gate.evaluate(_registry(), slots, today=TODAY, policy=_policy())) + + +def test_real_repo_files_are_fresh(): + root = Path(__file__).resolve().parent.parent + registry = json.loads((root / "config" / "model_registry.json").read_text()) + slots = json.loads((root / "config" / "llm_slots.json").read_text()) + policy = json.loads((root / "config" / "model_selection_policy.json").read_text()) + assert gate.evaluate(registry, slots, today=TODAY, policy=policy) == [] + + +def test_main_exit_codes(tmp_path: Path): + registry_path = tmp_path / "registry.json" + slots_path = tmp_path / "slots.json" + policy_path = tmp_path / "policy.json" + registry_path.write_text(json.dumps(_registry()), encoding="utf-8") + slots_path.write_text(json.dumps(_slots()), encoding="utf-8") + policy_path.write_text(json.dumps(_policy()), encoding="utf-8") + common = [ + "--registry", + str(registry_path), + "--slots", + str(slots_path), + "--policy", + str(policy_path), + "--today", + "2026-07-10", + ] + assert gate.main(common) == 0 + registry_path.write_text(json.dumps(_registry(review_by="2026-07-01")), encoding="utf-8") + assert gate.main(common) == 1 + assert gate.main([*common[:-1], "not-a-date"]) == 2 diff --git a/tests/tools/test_discover_model_catalog.py b/tests/tools/test_discover_model_catalog.py new file mode 100644 index 000000000..fcc5720e2 --- /dev/null +++ b/tests/tools/test_discover_model_catalog.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import datetime as dt +from types import SimpleNamespace + +from tools import discover_model_catalog as discovery + + +def test_request_json_uses_http_only_client(monkeypatch): + response = SimpleNamespace( + raise_for_status=lambda: None, + json=lambda: {"data": []}, + ) + calls = [] + monkeypatch.setattr( + discovery.requests, + "get", + lambda url, **kwargs: calls.append((url, kwargs)) or response, + ) + + assert discovery._request_json("https://provider.example/models", {"X-Test": "yes"}) == { + "data": [] + } + assert calls == [ + ( + "https://provider.example/models", + {"headers": {"X-Test": "yes"}, "timeout": 30}, + ) + ] + + +def test_github_catalog_parser_filters_non_chat_entries(): + payload = [ + {"id": "openai/gpt-current", "publisher": "OpenAI", "capabilities": ["streaming"]}, + {"id": "openai/embedding", "publisher": "OpenAI", "capabilities": []}, + {"id": "other/model", "publisher": "Other", "capabilities": ["streaming"]}, + ] + assert discovery.parse_catalog("github-models", payload) == [ + discovery.CatalogModel("openai/gpt-current") + ] + + +def test_credentialed_catalog_ignores_historical_unknown_models(): + baseline = { + "checked_at": "2026-07-10T00:00:00Z", + "model_ids": ["current"], + } + models = [ + discovery.CatalogModel("current", dt.datetime(2026, 7, 1, tzinfo=dt.UTC)), + discovery.CatalogModel("historical", dt.datetime(2025, 1, 1, tzinfo=dt.UTC)), + discovery.CatalogModel("new", dt.datetime(2026, 7, 11, tzinfo=dt.UTC)), + ] + report = discovery.catalog_diff(provider="openai", models=models, baseline=baseline) + assert report["added_candidates"] == ["new"] + assert report["status"] == "drift" + + +def test_new_model_is_candidate_not_selection(): + baseline = {"checked_at": "2026-07-10T00:00:00Z", "model_ids": ["known"]} + report = discovery.catalog_diff( + provider="github-models", + models=[discovery.CatalogModel("known"), discovery.CatalogModel("new")], + baseline=baseline, + ) + assert report["added_candidates"] == ["new"] + assert "do not auto-promote" in report["note"] + + +def test_removed_model_is_catalog_drift(): + report = discovery.catalog_diff( + provider="github-models", + models=[], + baseline={"checked_at": "2026-07-10T00:00:00Z", "model_ids": ["removed"]}, + ) + assert report["status"] == "drift" + assert report["removed_from_catalog"] == ["removed"] diff --git a/tests/tools/test_evaluate_model_benchmark.py b/tests/tools/test_evaluate_model_benchmark.py new file mode 100644 index 000000000..ed2a5f13b --- /dev/null +++ b/tests/tools/test_evaluate_model_benchmark.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import math + +import pytest +from tools import evaluate_model_benchmark as evaluator + +CATEGORIES = [ + "clean-pass", + "missing-acceptance-criterion", + "stale-verifier-claim", + "review-thread-debt", + "follow-up-required", +] + + +def _policy(): + return { + "policy_id": "test-policy", + "profiles": { + "verifier-balanced": { + "candidate_stage": {"required_case_categories": CATEGORIES}, + "approval_stage": { + "minimum_adjudicated_cases": 75, + "minimum_cases_per_category": 10, + "quality_gates": { + "task_success_rate_wilson_lower_bound": 0.85, + "false_pass_rate_wilson_upper_bound": 0.05, + "false_fail_rate_wilson_upper_bound": 0.1, + "schema_error_rate_wilson_upper_bound": 0.05, + "paired_success_noninferiority_margin": 0.02, + }, + }, + } + }, + } + + +def _cases(*, cost: float, latency: float, false_passes: int = 0): + cases = [] + case_index = 0 + for category in CATEGORIES: + count = 40 if category == "clean-pass" else 20 + expected = "PASS" if category == "clean-pass" else "NON_PASS" + for _ in range(count): + actual = expected + if expected == "NON_PASS" and false_passes > 0: + actual = "PASS" + false_passes -= 1 + cases.append( + { + "case_id": f"case-{case_index}", + "category": category, + "expected_verdict": expected, + "actual_verdict": actual, + "schema_valid": True, + "input_tokens": 100, + "output_tokens": 10, + "total_cost_usd": cost, + "latency_ms": latency, + } + ) + case_index += 1 + return cases + + +def _payload(*, candidate_false_passes: int = 0): + return { + "benchmark_id": "bench-1", + "profile": "verifier-balanced", + "corpus_version": "corpus-v1", + "prompt_version": "prompt-v1", + "measured_at": "2026-07-10", + "baseline_model_id": "baseline", + "candidates": [ + { + "provider": "openai", + "model_id": "baseline", + "cases": _cases(cost=0.02, latency=900), + }, + { + "provider": "openai", + "model_id": "candidate", + "cases": _cases( + cost=0.01, + latency=800, + false_passes=candidate_false_passes, + ), + }, + ], + } + + +def test_wilson_interval_is_conservative_for_zero_errors(): + lower, upper = evaluator.wilson_interval(0, 80) + assert lower == 0.0 + assert 0.04 < upper < 0.05 + + +def test_passing_models_rank_by_cost_after_quality_gates(): + report = evaluator.evaluate_benchmark(_payload(), _policy()) + assert all(result["status"] == "passed" for result in report["results"]) + assert report["recommended_model_id"] == "candidate" + assert report["results"][1]["metrics"]["sample_count"] == 120 + assert report["registry_evidence"][1]["status"] == "passed" + assert report["registry_evidence"][1]["model_id"] == "candidate" + + +def test_false_passes_cannot_be_offset_by_lower_cost(): + report = evaluator.evaluate_benchmark(_payload(candidate_false_passes=8), _policy()) + candidate = report["results"][1] + assert candidate["status"] == "failed" + assert candidate["gate_results"]["false_pass_rate_wilson_upper_bound"] is False + assert report["recommended_model_id"] == "baseline" + + +def test_unpaired_candidate_cases_are_rejected(): + payload = _payload() + payload["candidates"][1]["cases"].pop() + try: + evaluator.evaluate_benchmark(payload, _policy()) + except ValueError as exc: + assert "paired case IDs" in str(exc) + else: # pragma: no cover + raise AssertionError("unpaired benchmark should fail") + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("total_cost_usd", math.nan), + ("total_cost_usd", math.inf), + ("total_cost_usd", -0.01), + ("latency_ms", math.nan), + ("latency_ms", math.inf), + ("latency_ms", -1), + ], +) +def test_nonfinite_or_negative_metrics_are_rejected(field, value): + payload = _payload() + payload["candidates"][1]["cases"][0][field] = value + with pytest.raises(ValueError, match=f"invalid {field}"): + evaluator.evaluate_benchmark(payload, _policy()) + + +def test_nonobject_candidate_is_rejected_as_configuration_error(): + payload = _payload() + payload["candidates"][1] = None + with pytest.raises(ValueError, match="candidate must be an object"): + evaluator.evaluate_benchmark(payload, _policy()) diff --git a/tests/tools/test_langchain_client.py b/tests/tools/test_langchain_client.py index 7b2a43c6c..a4c760afb 100644 --- a/tests/tools/test_langchain_client.py +++ b/tests/tools/test_langchain_client.py @@ -173,8 +173,37 @@ def test_build_chat_client_blocked_model_override_does_not_shift_provider( "provider": "openai", "model_id": "gpt-blocked", "blocked": True, - } - ] + "lifecycle": "current", + }, + { + "provider": "openai", + "model_id": "gpt-safe", + "lifecycle": "current", + }, + { + "provider": "anthropic", + "model_id": "claude-safe", + "lifecycle": "current", + }, + ], + "selections": [ + { + "profile": "verifier-balanced", + "provider": "openai", + "model_id": "gpt-safe", + "status": "approved", + "review_by": "2026-12-31", + "evidence_ids": ["test"], + }, + { + "profile": "verifier-balanced", + "provider": "anthropic", + "model_id": "claude-safe", + "status": "approved", + "review_by": "2026-12-31", + "evidence_ids": ["test"], + }, + ], } ), encoding="utf-8", @@ -189,7 +218,7 @@ def test_build_chat_client_blocked_model_override_does_not_shift_provider( assert resolved is not None assert resolved.provider == langchain_client.PROVIDER_OPENAI - assert resolved.model == "gpt-5.4" + assert resolved.model == "gpt-safe" assert isinstance(resolved.client, FakeChatOpenAI) assert not isinstance(resolved.client, FakeChatAnthropic) @@ -210,8 +239,37 @@ def test_build_chat_client_blocked_override_consumed_when_first_provider_unavail "provider": "openai", "model_id": "gpt-blocked", "blocked": True, - } - ] + "lifecycle": "current", + }, + { + "provider": "openai", + "model_id": "gpt-safe", + "lifecycle": "current", + }, + { + "provider": "anthropic", + "model_id": "claude-safe", + "lifecycle": "current", + }, + ], + "selections": [ + { + "profile": "verifier-balanced", + "provider": "openai", + "model_id": "gpt-safe", + "status": "approved", + "review_by": "2026-12-31", + "evidence_ids": ["test"], + }, + { + "profile": "verifier-balanced", + "provider": "anthropic", + "model_id": "claude-safe", + "status": "approved", + "review_by": "2026-12-31", + "evidence_ids": ["test"], + }, + ], } ), encoding="utf-8", @@ -227,9 +285,9 @@ def test_build_chat_client_blocked_override_consumed_when_first_provider_unavail assert resolved is not None assert resolved.provider == langchain_client.PROVIDER_ANTHROPIC - assert resolved.model == "claude-sonnet-4-6" + assert resolved.model == "claude-safe" assert isinstance(resolved.client, FakeChatAnthropic) - assert resolved.client.kwargs["model"] == "claude-sonnet-4-6" + assert resolved.client.kwargs["model"] == "claude-safe" def test_load_model_registry_ignores_malformed_nested_values( @@ -270,7 +328,7 @@ def test_load_model_registry_rejects_non_list_models( assert llm_registry.load_model_registry() == [] -def test_load_model_registry_excludes_boolean_quality_scores( +def test_load_model_registry_ignores_v1_quality_scores( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -293,7 +351,7 @@ def test_load_model_registry_excludes_boolean_quality_scores( [entry] = llm_registry.load_model_registry() - assert entry.quality == {"T2": 0.8} + assert entry.quality == {} def test_load_slot_config_ignores_tier_slot_when_registry_invalid( @@ -336,11 +394,14 @@ def test_load_slot_config_uses_provider_fallback_after_position_mismatch( ], ) slot_path = tmp_path / "slots.json" + registry_path = tmp_path / "registry.json" + registry_path.write_text(json.dumps(["invalid"]), encoding="utf-8") slot_path.write_text( json.dumps({"slots": [{"name": "primary-claude", "provider": "anthropic"}]}), encoding="utf-8", ) monkeypatch.setenv(langchain_client.ENV_SLOT_CONFIG, str(slot_path)) + monkeypatch.setenv(langchain_client.ENV_MODEL_REGISTRY_CONFIG, str(registry_path)) slots = llm_registry.load_slot_config(github_default_model="github-default") @@ -509,7 +570,7 @@ def test_build_chat_clients_env_model_override(monkeypatch: pytest.MonkeyPatch) assert [client.model for client in clients] == [ "gpt-4.1-mini", - langchain_client.DEFAULT_MODEL, + "codex-mini-latest", ] assert isinstance(clients[0].client, FakeChatOpenAI) diff --git a/tests/tools/test_llm_provider.py b/tests/tools/test_llm_provider.py index bbd54cfaa..06620d9b7 100644 --- a/tests/tools/test_llm_provider.py +++ b/tests/tools/test_llm_provider.py @@ -57,6 +57,21 @@ def test_openai_unavailable_without_key(self): provider = OpenAIProvider() assert provider.is_available() is False + @pytest.mark.parametrize( + ("provider", "environment"), + [ + (GitHubModelsProvider, {"GITHUB_TOKEN": "test-token"}), + (OpenAIProvider, {"OPENAI_API_KEY": "sk-test"}), + (AnthropicProvider, {"CLAUDE_API_STRANSKE": "test-key"}), + ], + ) + def test_provider_unavailable_when_model_selection_cannot_resolve(self, provider, environment): + with ( + patch.dict(os.environ, environment, clear=True), + patch("tools.llm_provider._configured_langchain_model", return_value=""), + ): + assert provider().is_available() is False + def test_regex_always_available(self): """Regex fallback is always available.""" provider = RegexFallbackProvider() @@ -585,7 +600,7 @@ def test_openai_provider_reports_configured_client_model(self): ): result = provider.analyze_completion("output", ["task1"]) - mock_configured.assert_called_with("openai", fallback="gpt-5.4") + mock_configured.assert_called_with("openai", fallback="") mock_build.assert_called_once_with(provider="openai", model="gpt-configured") assert result.model_name == "gpt-configured" @@ -624,7 +639,7 @@ def test_anthropic_provider_reports_configured_client_model(self): ): result = provider.analyze_completion("output", ["task1"]) - mock_configured.assert_called_with("anthropic", fallback="claude-sonnet-4-6") + mock_configured.assert_called_with("anthropic", fallback="") mock_build.assert_called_once_with(provider="anthropic", model="claude-configured") assert result.model_name == "claude-configured" diff --git a/tests/tools/test_llm_registry_selection.py b/tests/tools/test_llm_registry_selection.py new file mode 100644 index 000000000..03b7cd0d2 --- /dev/null +++ b/tests/tools/test_llm_registry_selection.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from tools import llm_registry as registry + + +def _write_registry(path: Path, *, selected: str = "model-balanced") -> None: + path.write_text( + json.dumps( + { + "models": [ + { + "provider": "openai", + "model_id": "model-frontier", + "lifecycle": "current", + }, + { + "provider": "openai", + "model_id": "model-balanced", + "lifecycle": "current", + }, + ], + "selections": [ + { + "profile": "verifier-balanced", + "provider": "openai", + "model_id": selected, + "status": "approved", + "review_by": "2026-12-31", + "evidence_ids": ["benchmark-1"], + } + ], + } + ), + encoding="utf-8", + ) + + +def _write_slots( + path: Path, + *, + model: str | None = None, + profile: str = "verifier-balanced", +) -> None: + slot = { + "name": "slot1", + "provider": "openai", + "profile": profile, + } + if model: + slot["model"] = model + path.write_text( + json.dumps({"slots": [slot]}), + encoding="utf-8", + ) + + +def test_profile_selection_uses_explicit_decision_not_model_position( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + registry_path = tmp_path / "registry.json" + _write_registry(registry_path) + monkeypatch.setenv(registry.ENV_MODEL_REGISTRY_CONFIG, str(registry_path)) + + assert registry.select_model_for_profile(provider="openai") == "model-balanced" + + +def test_new_catalog_model_does_not_auto_promote( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + registry_path = tmp_path / "registry.json" + _write_registry(registry_path) + payload = json.loads(registry_path.read_text(encoding="utf-8")) + payload["models"].append( + {"provider": "openai", "model_id": "model-new", "lifecycle": "current"} + ) + registry_path.write_text(json.dumps(payload), encoding="utf-8") + monkeypatch.setenv(registry.ENV_MODEL_REGISTRY_CONFIG, str(registry_path)) + + assert registry.select_model_for_profile(provider="openai") == "model-balanced" + + +def test_registry_decision_update_changes_slot_without_slot_edit( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + registry_path = tmp_path / "registry.json" + slots_path = tmp_path / "slots.json" + _write_registry(registry_path) + _write_slots(slots_path) + monkeypatch.setenv(registry.ENV_MODEL_REGISTRY_CONFIG, str(registry_path)) + monkeypatch.setenv(registry.ENV_SLOT_CONFIG, str(slots_path)) + + before = registry.load_slot_config() + _write_registry(registry_path, selected="model-frontier") + after = registry.load_slot_config() + + assert before[0].model == "model-balanced" + assert after[0].model == "model-frontier" + assert "model" not in json.loads(slots_path.read_text())["slots"][0] + + +def test_legacy_slot_pin_cannot_override_reviewed_selection( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + registry_path = tmp_path / "registry.json" + slots_path = tmp_path / "slots.json" + _write_registry(registry_path) + _write_slots(slots_path, model="model-frontier") + monkeypatch.setenv(registry.ENV_MODEL_REGISTRY_CONFIG, str(registry_path)) + monkeypatch.setenv(registry.ENV_SLOT_CONFIG, str(slots_path)) + + assert registry.load_slot_config()[0].model == "model-balanced" + assert registry.configured_model_for_provider("openai") == "model-balanced" + + +def test_unknown_explicit_profile_fails_closed( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + registry_path = tmp_path / "registry.json" + slots_path = tmp_path / "slots.json" + _write_registry(registry_path) + _write_slots(slots_path, profile="misspelled-profile") + monkeypatch.setenv(registry.ENV_MODEL_REGISTRY_CONFIG, str(registry_path)) + monkeypatch.setenv(registry.ENV_SLOT_CONFIG, str(slots_path)) + + assert registry.load_slot_config() == [] + assert registry.configured_model_for_provider("openai") == "" + + +def test_noncurrent_selected_model_fails_closed( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + registry_path = tmp_path / "registry.json" + _write_registry(registry_path) + payload = json.loads(registry_path.read_text(encoding="utf-8")) + payload["models"][1]["lifecycle"] = "compatibility" + registry_path.write_text(json.dumps(payload), encoding="utf-8") + monkeypatch.setenv(registry.ENV_MODEL_REGISTRY_CONFIG, str(registry_path)) + + assert registry.select_model_for_profile(provider="openai") is None + + +def test_duplicate_profile_decisions_fail_closed( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + registry_path = tmp_path / "registry.json" + _write_registry(registry_path) + payload = json.loads(registry_path.read_text(encoding="utf-8")) + duplicate = dict(payload["selections"][0]) + duplicate["model_id"] = "model-frontier" + payload["selections"].append(duplicate) + registry_path.write_text(json.dumps(payload), encoding="utf-8") + monkeypatch.setenv(registry.ENV_MODEL_REGISTRY_CONFIG, str(registry_path)) + + assert registry.select_model_for_profile(provider="openai") is None + + +def test_runtime_helpers_contain_no_model_version_literals() -> None: + root = Path(__file__).resolve().parents[2] + for relative in ("tools/llm_registry.py", "tools/llm_provider.py"): + text = (root / relative).read_text(encoding="utf-8") + assert "gpt-5." not in text + assert "gpt-4" not in text + assert "claude-sonnet-" not in text + assert "codex-mini" not in text diff --git a/tests/tools/test_model_eval_pilot.py b/tests/tools/test_model_eval_pilot.py new file mode 100644 index 000000000..8d4498d52 --- /dev/null +++ b/tests/tools/test_model_eval_pilot.py @@ -0,0 +1,35 @@ +import hashlib +import json +from pathlib import Path + + +def test_pilot_corpus_is_frozen_and_well_formed() -> None: + root = Path(__file__).resolve().parents[2] + payload = json.loads((root / "config" / "model_eval_pilot.json").read_text()) + cases = payload["cases"] + + assert payload["schema"] == "workflows-verifier-pilot-corpus/v1" + assert payload["corpus_version"] == "verifier-balanced-pilot-2026-07-12" + assert len(cases) == 30 + assert len({case["case_id"] for case in cases}) == 30 + assert all(case["repo"].startswith("stranske/") for case in cases) + assert all(isinstance(case["pr"], int) and case["pr"] > 0 for case in cases) + assert {case["expected_verdict"] for case in cases} == {"PASS", "NON_PASS"} + + categories = {case["category"] for case in cases} + assert { + "clean-pass", + "missing-acceptance-criterion", + "stale-verifier-claim", + "review-thread-debt", + "follow-up-required", + } <= categories + + identity = [ + {key: case[key] for key in ("case_id", "repo", "pr", "expected_verdict", "category")} + for case in cases + ] + digest = hashlib.sha256( + json.dumps(identity, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + assert digest == "a823e6fca7b3e16d5c6a8cff685ea9288cacedacc1ce7d9ca4c7af887227f287" diff --git a/tests/tools/test_run_model_eval_pilot.py b/tests/tools/test_run_model_eval_pilot.py new file mode 100644 index 000000000..b7022b710 --- /dev/null +++ b/tests/tools/test_run_model_eval_pilot.py @@ -0,0 +1,79 @@ +from types import SimpleNamespace + +from tools.run_model_eval_pilot import run_pilot + + +def test_run_pilot_records_paired_verdicts() -> None: + corpus = { + "corpus_version": "v1", + "cases": [ + { + "case_id": "a", + "repo": "o/r", + "pr": 1, + "category": "clean-pass", + "expected_verdict": "PASS", + }, + { + "case_id": "b", + "repo": "o/r", + "pr": 2, + "category": "follow-up-required", + "expected_verdict": "NON_PASS", + }, + ], + } + candidates = {"candidates": [{"provider": "openai", "model_id": "model-a"}]} + + def evaluator(context, *, diff, model, provider): + verdict = "PASS" if "#1" in context else "CONCERNS" + return SimpleNamespace(verdict=verdict, confidence=0.9, error=None) + + report = run_pilot( + corpus, + candidates, + token="token", + fetcher=lambda repo, pr, token: (f"PR #{pr}", "diff"), + evaluator=evaluator, + ) + + assert [row["actual_verdict"] for row in report["results"]] == ["PASS", "NON_PASS"] + assert all(row["schema_valid"] for row in report["results"]) + + +def test_run_pilot_captures_failure_with_case_and_candidate_metadata() -> None: + corpus = { + "corpus_version": "v1", + "cases": [ + { + "case_id": "broken", + "repo": "o/r", + "pr": 3, + "category": "follow-up-required", + "expected_verdict": "NON_PASS", + } + ], + } + candidates = {"candidates": [{"provider": "openai", "model_id": "model-a"}]} + + report = run_pilot( + corpus, + candidates, + token="token", + fetcher=lambda *_: (_ for _ in ()).throw(RuntimeError("fetch failed")), + ) + + assert report["results"] == [ + { + "case_id": "broken", + "category": "follow-up-required", + "expected_verdict": "NON_PASS", + "actual_verdict": "NON_PASS", + "schema_valid": False, + "provider": "openai", + "model_id": "model-a", + "confidence": 0, + "latency_ms": report["results"][0]["latency_ms"], + "error": "fetch failed", + } + ] diff --git a/tests/workflows/github_scripts/test_gate_summary.py b/tests/workflows/github_scripts/test_gate_summary.py index 837b5cf9d..1ec8956c6 100644 --- a/tests/workflows/github_scripts/test_gate_summary.py +++ b/tests/workflows/github_scripts/test_gate_summary.py @@ -379,6 +379,30 @@ def test_summarize_skipped_python_without_records_is_pending(tmp_path: Path) -> assert "Python CI skipped; waiting for rerun." in result.description +@pytest.mark.parametrize("python_result", ["cancelled", "abandoned"]) +def test_summarize_superseded_python_is_pending_without_autofix( + tmp_path: Path, python_result: str +) -> None: + write_summary(tmp_path, "3.12") + context = gate_summary.SummaryContext( + doc_only=False, + run_core=True, + reason="", + python_result=python_result, + docker_result="skipped", + docker_changed=False, + artifacts_root=tmp_path, + summary_path=None, + output_path=None, + ) + + result = gate_summary.summarize(context) + + assert result.state == "pending" + assert result.cosmetic_failure is False + assert f"Python CI {python_result}; waiting for rerun." == result.description + + def test_summarize_accepts_path_classified_python_skip(tmp_path: Path) -> None: context = gate_summary.SummaryContext( doc_only=False, diff --git a/tests/workflows/test_workflow_naming.py b/tests/workflows/test_workflow_naming.py index 5051391f2..5ac8eb135 100644 --- a/tests/workflows/test_workflow_naming.py +++ b/tests/workflows/test_workflow_naming.py @@ -301,6 +301,7 @@ def test_workflow_display_names_are_unique(): "maint-71-merge-sync-prs.yml": "Merge Sync PRs", "maint-74-ledger-base-sync.yml": "Ledger Base Sync", "maint-77-model-registry-freshness.yml": "Maint 77 Model Registry Freshness", + "maint-78-model-evaluation-pilot.yml": "Maint 78 Model Evaluation Pilot", "maint-80-langsmith-metrics-dashboard.yml": "LangSmith Metrics Dashboard", "maint-81-langsmith-fleet-conformance.yml": "LangSmith Fleet Conformance", "maint-82-sync-dependency-campaign.yml": "Sync/Dependency Campaign", diff --git a/tools/check_model_registry_freshness.py b/tools/check_model_registry_freshness.py index 3b918532a..2f3d1413f 100644 --- a/tools/check_model_registry_freshness.py +++ b/tools/check_model_registry_freshness.py @@ -1,27 +1,9 @@ #!/usr/bin/env python3 -"""Model-registry freshness gate. - -Offline, deterministic, stdlib-only. Detects when the canonical LLM model -configuration has drifted into staleness so old models do not get stuck as the -primary ones indefinitely. It does NOT change model selection — it only reports. - -Inputs (defaults resolve relative to the repo root): - - config/model_registry.json : curated models with per-tier quality scores. - - config/llm_slots.json : provider/model slot pins consumed by - tools/llm_registry.py. - -Findings: - review_overdue : registry `review_by` (or `last_updated` + --max-age-days) is - in the past relative to --today. The registry is hand-curated; - without a periodic review, new GA models never enter it. - blocked_pin : a slot pins a model the registry marks `blocked: true`. - unknown_pin : a slot pins a model absent from the registry. - dominated_pin : a slot pins a model whose best per-tier quality is strictly - lower than another non-blocked model from the SAME provider in - the registry — i.e. the registry's own data already says a - better model exists, but the pin keeps the old one primary. - -Exit codes: 0 = fresh, 1 = stale findings, 2 = config/usage error. +"""Offline model-registry decision and freshness gate. + +The gate validates dated model facts, explicit profile selections, evidence +references, and slot resolution. Provider discovery is a separate advisory tool; +it proposes catalog candidates but never changes a selection. """ from __future__ import annotations @@ -36,32 +18,50 @@ _REPO_ROOT = Path(__file__).resolve().parent.parent DEFAULT_REGISTRY_PATH = _REPO_ROOT / "config" / "model_registry.json" DEFAULT_SLOTS_PATH = _REPO_ROOT / "config" / "llm_slots.json" -DEFAULT_MAX_AGE_DAYS = 60 +DEFAULT_POLICY_PATH = _REPO_ROOT / "config" / "model_selection_policy.json" +DEFAULT_MAX_AGE_DAYS = 30 +VALID_SELECTION_STATUSES = {"provisional", "approved"} def _normalize_provider(provider: str) -> str: - p = (provider or "").strip().lower() - if p in {"anthropic", "claude"}: + normalized = (provider or "").strip().lower() + if normalized in {"anthropic", "claude"}: return "anthropic" - if p in {"openai", "azure-openai"}: + if normalized in {"openai", "azure-openai"}: return "openai" - if p in {"github", "github-models", "github_models"}: - return "github" - return p + if normalized in {"github", "github-models", "github_models"}: + return "github-models" + return normalized -def _headline_quality(entry: dict[str, Any]) -> float: - quality = entry.get("quality") or {} - values = [float(v) for v in quality.values() if isinstance(v, (int, float))] - return max(values) if values else 0.0 +def _parse_date(value: str) -> _dt.date: + return _dt.date.fromisoformat(str(value).strip()) -def _normalize_tier(value: str) -> str: - return value.strip().upper() +def _finding(kind: str, detail: str) -> dict[str, str]: + return {"kind": kind, "detail": detail} -def _parse_date(value: str) -> _dt.date: - return _dt.date.fromisoformat(str(value).strip()) +def _review_date( + registry: dict[str, Any], max_age_days: int, findings: list[dict[str, str]] +) -> _dt.date | None: + raw = registry.get("review_by") + try: + if raw: + return _parse_date(str(raw)) + as_of = registry.get("as_of") or registry.get("last_updated") + if as_of: + return _parse_date(str(as_of)) + _dt.timedelta(days=max_age_days) + except ValueError as exc: + findings.append(_finding("review_overdue", f"unparseable registry date: {exc}")) + return None + findings.append( + _finding( + "review_overdue", + "registry has neither review_by nor as_of; freshness cannot be proved.", + ) + ) + return None def evaluate( @@ -70,159 +70,338 @@ def evaluate( *, today: _dt.date, max_age_days: int = DEFAULT_MAX_AGE_DAYS, + policy: dict[str, Any] | None = None, ) -> list[dict[str, str]]: - """Return a list of finding dicts (empty == fresh). Pure function.""" + """Return deterministic findings; an empty list means configuration is fresh.""" findings: list[dict[str, str]] = [] + review_by = _review_date(registry, max_age_days, findings) + if review_by is not None and review_by < today: + findings.append( + _finding( + "review_overdue", + f"registry review is overdue by {(today - review_by).days} day(s) " + f"(review_by={review_by}, today={today}).", + ) + ) - models = registry.get("models") or [] - # Index by (provider, model_id). - by_key: dict[tuple[str, str], dict[str, Any]] = {} - by_provider: dict[str, list[dict[str, Any]]] = {} - for m in models: - prov = _normalize_provider(str(m.get("provider", ""))) - mid = str(m.get("model_id", "")).strip() - if not mid: + models = registry.get("models") + if not isinstance(models, list): + return findings + [_finding("invalid_registry", "models must be a list.")] + + raw_sources = registry.get("sources") + sources = raw_sources if isinstance(raw_sources, list) else [] + source_by_id = { + str(item.get("source_id", "")).strip(): item + for item in sources + if isinstance(item, dict) and str(item.get("source_id", "")).strip() + } + if not source_by_id: + findings.append(_finding("missing_source", "registry has no dated model sources.")) + + model_by_key: dict[tuple[str, str], dict[str, Any]] = {} + for raw in models: + if not isinstance(raw, dict): + findings.append(_finding("invalid_model", "model entries must be objects.")) continue - by_key[(prov, mid)] = m - by_provider.setdefault(prov, []).append(m) - - # 1. review_overdue ------------------------------------------------------- - review_by_raw = registry.get("review_by") - try: - if review_by_raw: - review_by = _parse_date(review_by_raw) - else: - last_updated = registry.get("last_updated") - if not last_updated: + provider = _normalize_provider(str(raw.get("provider", ""))) + model_id = str(raw.get("model_id", "")).strip() + if not provider or not model_id: + findings.append( + _finding("invalid_model", "model entry is missing provider or model_id.") + ) + continue + key = (provider, model_id) + if key in model_by_key: + findings.append(_finding("duplicate_model", f"duplicate model {provider}/{model_id}.")) + model_by_key[key] = raw + if str(raw.get("lifecycle", "")).strip().lower() == "current": + source_ids = raw.get("source_ids") + if not isinstance(source_ids, list) or not source_ids: findings.append( - { - "kind": "review_overdue", - "detail": "registry has neither `review_by` nor `last_updated`; " - "cannot prove freshness.", - } + _finding( + "missing_source", + f"current model {provider}/{model_id} has no source_ids.", + ) ) - review_by = None else: - review_by = _parse_date(last_updated) + _dt.timedelta(days=max_age_days) - except ValueError as exc: - findings.append({"kind": "review_overdue", "detail": f"unparseable date: {exc}"}) - review_by = None + missing_sources = [ + str(item) for item in source_ids if str(item) not in source_by_id + ] + if missing_sources: + findings.append( + _finding( + "missing_source", + f"current model {provider}/{model_id} references absent sources: " + f"{missing_sources}.", + ) + ) + pricing = raw.get("pricing") + if not isinstance(pricing, dict) or not pricing.get("as_of"): + findings.append( + _finding( + "missing_pricing_date", + f"current model {provider}/{model_id} lacks dated pricing facts.", + ) + ) - if review_by is not None and review_by < today: - days = (today - review_by).days - findings.append( - { - "kind": "review_overdue", - "detail": f"model registry review is overdue by {days} day(s) " - f"(review_by={review_by.isoformat()}, today={today.isoformat()}). " - "Re-check provider model lists and refresh quality scores.", - } - ) + raw_evidence = registry.get("evidence") + evidence = raw_evidence if isinstance(raw_evidence, list) else [] + evidence_by_id = { + str(item.get("evidence_id", "")).strip(): item + for item in evidence + if isinstance(item, dict) and str(item.get("evidence_id", "")).strip() + } + for evidence_id, item in evidence_by_id.items(): + evidence_sources = item.get("source_ids", []) + if isinstance(evidence_sources, list): + missing_sources = [ + str(source) for source in evidence_sources if str(source) not in source_by_id + ] + if missing_sources: + findings.append( + _finding( + "missing_source", + f"evidence {evidence_id} references absent sources: {missing_sources}.", + ) + ) + + selections = registry.get("selections") + if not isinstance(selections, list): + return findings + [_finding("invalid_registry", "selections must be a list.")] - # Slot pins --------------------------------------------------------------- - for slot in slots.get("slots") or []: - name = str(slot.get("name", "?")) - prov = _normalize_provider(str(slot.get("provider", ""))) - model = str(slot.get("model", "")).strip() - if not model: - # Tier-derived slot: model comes from the registry at runtime — this is - # the non-ossifying shape, nothing to flag here. + selection_by_key: dict[tuple[str, str], dict[str, Any]] = {} + policy_profiles = (policy or {}).get("profiles", {}) + if not isinstance(policy_profiles, dict): + policy_profiles = {} + + for raw in selections: + if not isinstance(raw, dict): + findings.append(_finding("invalid_selection", "selection entries must be objects.")) continue - entry = by_key.get((prov, model)) - if entry is None: + profile = str(raw.get("profile", "")).strip() + provider = _normalize_provider(str(raw.get("provider", ""))) + model_id = str(raw.get("model_id", "")).strip() + key = (profile, provider) + if not profile or not provider or not model_id: findings.append( - { - "kind": "unknown_pin", - "detail": f"slot {name!r} pins {prov}/{model} which is absent from " - "the model registry (cannot verify it is current or unblocked).", - } + _finding( + "invalid_selection", + "selection is missing profile, provider, or model_id.", + ) ) continue - if entry.get("blocked"): + if key in selection_by_key: + findings.append( + _finding( + "duplicate_selection", + f"multiple selections exist for profile/provider {profile}/{provider}.", + ) + ) + selection_by_key[key] = raw + + if policy is not None and profile not in policy_profiles: + findings.append( + _finding("unknown_profile", f"selection references unknown profile {profile!r}.") + ) + + model = model_by_key.get((provider, model_id)) + if model is None: + findings.append( + _finding( + "unknown_selection", + f"selection {profile}/{provider} references absent model {model_id}.", + ) + ) + else: + if model.get("blocked"): + findings.append( + _finding( + "blocked_selection", + f"selection {profile}/{provider} uses blocked model {model_id}.", + ) + ) + lifecycle = str(model.get("lifecycle", "")).strip().lower() + if lifecycle != "current": + findings.append( + _finding( + "inactive_selection", + f"selection {profile}/{provider} uses lifecycle={lifecycle or 'missing'} " + f"model {model_id}.", + ) + ) + + status = str(raw.get("status", "")).strip().lower() + if status not in VALID_SELECTION_STATUSES: + findings.append( + _finding( + "invalid_selection_status", + f"selection {profile}/{provider} has status {status!r}.", + ) + ) + + evidence_ids = raw.get("evidence_ids") + if not isinstance(evidence_ids, list) or not evidence_ids: + findings.append( + _finding( + "missing_evidence", + f"selection {profile}/{provider} has no evidence_ids.", + ) + ) + evidence_ids = [] + missing = [str(item) for item in evidence_ids if str(item) not in evidence_by_id] + if missing: + findings.append( + _finding( + "missing_evidence", + f"selection {profile}/{provider} references absent evidence: {missing}.", + ) + ) + if status == "approved": + benchmark_evidence = [ + evidence_by_id[str(item)] + for item in evidence_ids + if str(item) in evidence_by_id + and evidence_by_id[str(item)].get("kind") == "workload-benchmark" + and evidence_by_id[str(item)].get("status") == "passed" + and evidence_by_id[str(item)].get("schema") + == "workflows-model-benchmark-evidence/v1" + and evidence_by_id[str(item)].get("profile") == profile + and evidence_by_id[str(item)].get("model_id") == model_id + and evidence_by_id[str(item)].get("policy_id") == (policy or {}).get("policy_id") + and evidence_by_id[str(item)].get("corpus_version") + and evidence_by_id[str(item)].get("prompt_version") + and evidence_by_id[str(item)].get("measured_at") + and isinstance(evidence_by_id[str(item)].get("gate_results"), dict) + and all(evidence_by_id[str(item)]["gate_results"].values()) + ] + if not benchmark_evidence: + findings.append( + _finding( + "unproved_approval", + f"approved selection {profile}/{provider} lacks passed workload-benchmark evidence.", + ) + ) + + try: + selection_review = _parse_date(str(raw.get("review_by", ""))) + except ValueError: findings.append( - { - "kind": "blocked_pin", - "detail": f"slot {name!r} pins {prov}/{model} which is marked " - "blocked in the registry.", - } + _finding( + "selection_review_overdue", + f"selection {profile}/{provider} has missing or invalid review_by.", + ) ) + else: + if selection_review < today: + kind = ( + "provisional_overdue" if status == "provisional" else "selection_review_overdue" + ) + findings.append( + _finding( + kind, + f"selection {profile}/{provider} review was due {selection_review}.", + ) + ) + + raw_slots = slots.get("slots") + if not isinstance(raw_slots, list): + return findings + [_finding("invalid_slots", "slots must be a list.")] + for raw in raw_slots: + if not isinstance(raw, dict): + findings.append(_finding("invalid_slot", "slot entries must be objects.")) continue - tier = _normalize_tier(str(slot.get("quality_tier", ""))) - - def _slot_quality(model_entry: dict[str, Any], slot_tier: str = tier) -> float: - if not slot_tier: - return _headline_quality(model_entry) - quality = model_entry.get("quality") or {} - value = quality.get(slot_tier) - return float(value) if isinstance(value, (int, float)) else float("-inf") - - pinned_q = _slot_quality(entry) - better = [ - m - for m in by_provider.get(prov, []) - if not m.get("blocked") and _slot_quality(m) > pinned_q - ] - if better: - best = max(better, key=_slot_quality) + name = str(raw.get("name", "?")).strip() + provider = _normalize_provider(str(raw.get("provider", ""))) + profile = str(raw.get("profile", "")).strip() + explicit_model = str(raw.get("model", "")).strip() + if not provider: + findings.append(_finding("invalid_slot", f"slot {name!r} has no provider.")) + continue + if explicit_model: + selected = selection_by_key.get((profile, provider)) if profile else None + if selected and selected.get("model_id") != explicit_model: + findings.append( + _finding( + "selection_override", + f"slot {name!r} pins {provider}/{explicit_model} instead of reviewed " + f"selection {selected.get('model_id')}.", + ) + ) + model = model_by_key.get((provider, explicit_model)) + if model is None: + findings.append( + _finding( + "unknown_pin", + f"slot {name!r} pins absent model {provider}/{explicit_model}.", + ) + ) + elif model.get("blocked"): + findings.append( + _finding( + "blocked_pin", + f"slot {name!r} pins blocked model {provider}/{explicit_model}.", + ) + ) + continue + if not profile: findings.append( - { - "kind": "dominated_pin", - "detail": f"slot {name!r} pins {prov}/{model} " - f"(quality {pinned_q:.2f}) but the registry rates " - f"{prov}/{best.get('model_id')} higher " - f"({_slot_quality(best):.2f}); the pin keeps an older model primary.", - } + _finding( + "missing_profile", + f"slot {name!r} has neither an explicit model nor a profile.", + ) + ) + elif (profile, provider) not in selection_by_key: + findings.append( + _finding( + "missing_selection", + f"slot {name!r} has no selection for {profile}/{provider}.", + ) ) return findings def _load_json(path: Path) -> dict[str, Any]: - return json.loads(path.read_text(encoding="utf-8")) + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"{path} must contain a JSON object") + return payload def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="Model-registry freshness gate.") + parser = argparse.ArgumentParser(description="Model-registry decision freshness gate.") parser.add_argument("--registry", type=Path, default=DEFAULT_REGISTRY_PATH) parser.add_argument("--slots", type=Path, default=DEFAULT_SLOTS_PATH) - parser.add_argument( - "--max-age-days", - type=int, - default=DEFAULT_MAX_AGE_DAYS, - help="Used only when the registry has no explicit `review_by`.", - ) - parser.add_argument( - "--today", - type=str, - default=None, - help="ISO date override (testing); defaults to system date.", - ) - parser.add_argument("--json", action="store_true", help="Emit findings as JSON.") + parser.add_argument("--policy", type=Path, default=DEFAULT_POLICY_PATH) + parser.add_argument("--max-age-days", type=int, default=DEFAULT_MAX_AGE_DAYS) + parser.add_argument("--today", type=str, default=None) + parser.add_argument("--json", action="store_true") args = parser.parse_args(argv) try: registry = _load_json(args.registry) slots = _load_json(args.slots) - except (OSError, json.JSONDecodeError) as exc: - print(f"config error: {exc}", file=sys.stderr) - return 2 - - try: + policy = _load_json(args.policy) today = _parse_date(args.today) if args.today else _dt.date.today() - except ValueError as exc: + except (OSError, ValueError, json.JSONDecodeError) as exc: print(f"config error: {exc}", file=sys.stderr) return 2 - findings = evaluate(registry, slots, today=today, max_age_days=args.max_age_days) + findings = evaluate( + registry, + slots, + today=today, + max_age_days=args.max_age_days, + policy=policy, + ) if args.json: print(json.dumps({"fresh": not findings, "findings": findings}, indent=2)) + elif findings: + print(f"Model registry freshness: {len(findings)} finding(s):") + for finding in findings: + print(f" [{finding['kind']}] {finding['detail']}") else: - if not findings: - print("Model registry is fresh: no stale or dominated pins.") - else: - print(f"Model registry freshness: {len(findings)} finding(s):") - for f in findings: - print(f" [{f['kind']}] {f['detail']}") + print("Model registry is fresh: decisions, evidence, and slots are consistent.") return 1 if findings else 0 diff --git a/tools/discover_model_catalog.py b/tools/discover_model_catalog.py new file mode 100644 index 000000000..4f6df4d9e --- /dev/null +++ b/tools/discover_model_catalog.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Discover provider catalog drift without changing model selections. + +GitHub Models is public. OpenAI and Anthropic discovery run only when their API +keys are available. Newly observed models are review candidates, not evidence +that they are better than an approved selection. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import requests + +_REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_REGISTRY_PATH = _REPO_ROOT / "config" / "model_registry.json" +PROVIDERS = ("openai", "anthropic", "github-models") + + +@dataclass(frozen=True) +class CatalogModel: + model_id: str + created_at: dt.datetime | None = None + + +def _request_json(url: str, headers: dict[str, str]) -> Any: + response = requests.get(url, headers=headers, timeout=30) + response.raise_for_status() + return response.json() + + +def _parse_timestamp(value: object) -> dt.datetime | None: + if isinstance(value, (int, float)): + return dt.datetime.fromtimestamp(value, tz=dt.UTC) + if isinstance(value, str) and value.strip(): + normalized = value.strip().replace("Z", "+00:00") + try: + parsed = dt.datetime.fromisoformat(normalized) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=dt.UTC) + return None + + +def parse_catalog(provider: str, payload: Any) -> list[CatalogModel]: + if provider == "github-models": + if not isinstance(payload, list): + raise ValueError("GitHub Models catalog must be a list") + return [ + CatalogModel(str(item["id"]), _parse_timestamp(item.get("created_at"))) + for item in payload + if isinstance(item, dict) + and item.get("id") + and item.get("publisher") == "OpenAI" + and item.get("capabilities") + ] + if not isinstance(payload, dict) or not isinstance(payload.get("data"), list): + raise ValueError(f"{provider} catalog must contain a data list") + return [ + CatalogModel( + str(item["id"]), + _parse_timestamp(item.get("created") or item.get("created_at")), + ) + for item in payload["data"] + if isinstance(item, dict) and item.get("id") + ] + + +def catalog_diff( + *, + provider: str, + models: list[CatalogModel], + baseline: dict[str, Any], +) -> dict[str, Any]: + known = {str(item) for item in baseline.get("model_ids", [])} + checked_at = _parse_timestamp(baseline.get("checked_at")) + current = {model.model_id for model in models} + additions = [] + for model in models: + if model.model_id in known: + continue + # Credentialed provider APIs contain long-lived historical catalogs. + # Only post-baseline additions are actionable. A missing timestamp is + # retained because silently ignoring it would hide real drift. + if ( + provider != "github-models" + and checked_at + and model.created_at + and model.created_at <= checked_at + ): + continue + additions.append(model.model_id) + removed = sorted(known - current) + return { + "provider": provider, + "status": "drift" if additions or removed else "current", + "added_candidates": sorted(set(additions)), + "removed_from_catalog": removed, + "observed_count": len(current), + "note": "Catalog changes require benchmark review; they do not auto-promote models.", + } + + +def fetch_provider(provider: str) -> tuple[list[CatalogModel] | None, str | None]: + try: + if provider == "github-models": + payload = _request_json("https://models.github.ai/catalog/models", {}) + elif provider == "openai": + token = os.environ.get("OPENAI_API_KEY") + if not token: + return None, "OPENAI_API_KEY unavailable" + payload = _request_json( + "https://api.openai.com/v1/models", + {"Authorization": f"Bearer {token}"}, + ) + elif provider == "anthropic": + token = os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("CLAUDE_API_STRANSKE") + if not token: + return None, "ANTHROPIC_API_KEY/CLAUDE_API_STRANSKE unavailable" + payload = _request_json( + "https://api.anthropic.com/v1/models?limit=1000", + {"x-api-key": token, "anthropic-version": "2023-06-01"}, + ) + else: # pragma: no cover - argparse constrains values + raise ValueError(f"unsupported provider: {provider}") + return parse_catalog(provider, payload), None + except (OSError, ValueError, requests.RequestException) as exc: + return None, str(exc) + + +def build_report(registry: dict[str, Any], providers: list[str]) -> dict[str, Any]: + baselines = registry.get("catalog_baselines", {}) + results: list[dict[str, Any]] = [] + for provider in providers: + baseline = baselines.get(provider) if isinstance(baselines, dict) else None + if not isinstance(baseline, dict): + results.append({"provider": provider, "status": "error", "error": "missing baseline"}) + continue + models, error = fetch_provider(provider) + if models is None: + results.append({"provider": provider, "status": "skipped", "reason": error}) + continue + results.append(catalog_diff(provider=provider, models=models, baseline=baseline)) + return { + "schema": "workflows-model-catalog-discovery/v1", + "generated_at": dt.datetime.now(tz=dt.UTC).isoformat(), + "drift": any(item.get("status") == "drift" for item in results), + "providers": results, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Discover model catalog drift.") + parser.add_argument("--registry", type=Path, default=DEFAULT_REGISTRY_PATH) + parser.add_argument("--provider", action="append", choices=PROVIDERS) + parser.add_argument("--output", type=Path) + args = parser.parse_args(argv) + try: + registry = json.loads(args.registry.read_text(encoding="utf-8")) + if not isinstance(registry, dict): + raise ValueError("registry must be a JSON object") + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"config error: {exc}", file=sys.stderr) + return 2 + + report = build_report(registry, args.provider or list(PROVIDERS)) + rendered = json.dumps(report, indent=2) + print(rendered) + if args.output: + args.output.write_text(rendered + "\n", encoding="utf-8") + return 1 if report["drift"] else 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/tools/evaluate_model_benchmark.py b/tools/evaluate_model_benchmark.py new file mode 100644 index 000000000..5e2d9de34 --- /dev/null +++ b/tools/evaluate_model_benchmark.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Evaluate paired model candidates against the repository selection policy.""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +from pathlib import Path +from typing import Any + +_REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_POLICY_PATH = _REPO_ROOT / "config" / "model_selection_policy.json" +Z_95 = 1.959963984540054 + + +def wilson_interval(successes: int, total: int, *, z: float = Z_95) -> tuple[float, float]: + if total <= 0: + return 0.0, 1.0 + rate = successes / total + denominator = 1 + (z * z / total) + center = (rate + z * z / (2 * total)) / denominator + margin = z * math.sqrt((rate * (1 - rate) + z * z / (4 * total)) / total) / denominator + return max(0.0, center - margin), min(1.0, center + margin) + + +def _nearest_rank_p95(values: list[float]) -> float: + if not values: + return 0.0 + ordered = sorted(values) + return ordered[max(0, math.ceil(0.95 * len(ordered)) - 1)] + + +def _case_outcome(case: dict[str, Any]) -> tuple[str, str, bool]: + expected = str(case.get("expected_verdict", "")).strip().upper() + actual = str(case.get("actual_verdict", "")).strip().upper() + schema_valid = case.get("schema_valid") is True + if expected not in {"PASS", "NON_PASS"}: + raise ValueError(f"case {case.get('case_id')} has invalid expected_verdict") + if actual not in {"PASS", "NON_PASS"}: + raise ValueError(f"case {case.get('case_id')} has invalid actual_verdict") + return expected, actual, schema_valid + + +def _metrics(cases: list[dict[str, Any]]) -> dict[str, Any]: + success = false_pass = false_fail = schema_error = 0 + expected_pass = expected_non_pass = 0 + categories: dict[str, int] = {} + total_cost = 0.0 + latencies: list[float] = [] + case_success: dict[str, bool] = {} + for case in cases: + case_id = str(case.get("case_id", "")).strip() + category = str(case.get("category", "")).strip() + if not case_id or not category: + raise ValueError("every case requires case_id and category") + if case_id in case_success: + raise ValueError(f"duplicate case_id {case_id}") + expected, actual, schema_valid = _case_outcome(case) + correct = schema_valid and expected == actual + case_success[case_id] = correct + success += int(correct) + schema_error += int(not schema_valid) + expected_pass += int(expected == "PASS") + expected_non_pass += int(expected == "NON_PASS") + false_pass += int(expected == "NON_PASS" and actual == "PASS") + false_fail += int(expected == "PASS" and actual == "NON_PASS") + categories[category] = categories.get(category, 0) + 1 + cost = float(case.get("total_cost_usd", 0.0)) + latency = float(case.get("latency_ms", 0.0)) + if not math.isfinite(cost) or cost < 0: + raise ValueError(f"case {case_id} has invalid total_cost_usd") + if not math.isfinite(latency) or latency < 0: + raise ValueError(f"case {case_id} has invalid latency_ms") + total_cost += cost + latencies.append(latency) + + count = len(cases) + success_interval = wilson_interval(success, count) + false_pass_interval = wilson_interval(false_pass, expected_non_pass) + false_fail_interval = wilson_interval(false_fail, expected_pass) + schema_interval = wilson_interval(schema_error, count) + return { + "sample_count": count, + "category_counts": categories, + "task_success_rate": success / count if count else 0.0, + "task_success_rate_wilson_lower_bound": success_interval[0], + "false_pass_rate": false_pass / expected_non_pass if expected_non_pass else 0.0, + "false_pass_rate_wilson_upper_bound": false_pass_interval[1], + "false_fail_rate": false_fail / expected_pass if expected_pass else 0.0, + "false_fail_rate_wilson_upper_bound": false_fail_interval[1], + "schema_error_rate": schema_error / count if count else 0.0, + "schema_error_rate_wilson_upper_bound": schema_interval[1], + "total_cost_usd": total_cost, + "cost_per_accepted_review_usd": total_cost / success if success else None, + "p95_latency_ms": _nearest_rank_p95(latencies), + "case_success": case_success, + } + + +def _validate_paired_cases(candidates: list[Any]) -> None: + expected_ids: set[str] | None = None + for candidate in candidates: + if not isinstance(candidate, dict): + raise ValueError("each candidate must be an object") + cases = candidate.get("cases") + if not isinstance(cases, list): + raise ValueError("each candidate requires a cases list") + case_ids = {str(case.get("case_id", "")) for case in cases if isinstance(case, dict)} + if len(case_ids) != len(cases) or "" in case_ids: + raise ValueError("candidate cases require unique non-empty case_id values") + if expected_ids is None: + expected_ids = case_ids + elif case_ids != expected_ids: + raise ValueError("all candidates must evaluate the same paired case IDs") + + +def evaluate_benchmark(payload: dict[str, Any], policy: dict[str, Any]) -> dict[str, Any]: + profile = str(payload.get("profile", "")).strip() + profiles = policy.get("profiles", {}) + if not isinstance(profiles, dict) or profile not in profiles: + raise ValueError(f"unknown benchmark profile: {profile}") + profile_policy = profiles[profile] + approval = profile_policy["approval_stage"] + gates = approval["quality_gates"] + candidates = payload.get("candidates") + if not isinstance(candidates, list) or len(candidates) < 2: + raise ValueError("benchmark requires a baseline and at least one candidate") + _validate_paired_cases(candidates) + + baseline_model = str(payload.get("baseline_model_id", "")).strip() + results: list[dict[str, Any]] = [] + metrics_by_model: dict[str, dict[str, Any]] = {} + for candidate in candidates: + model_id = str(candidate.get("model_id", "")).strip() + provider = str(candidate.get("provider", "")).strip() + if not model_id or not provider or model_id in metrics_by_model: + raise ValueError("candidate provider/model_id values must be non-empty and unique") + metrics_by_model[model_id] = _metrics(candidate["cases"]) + if baseline_model not in metrics_by_model: + raise ValueError("baseline_model_id must identify one candidate") + + baseline = metrics_by_model[baseline_model] + required_categories = profile_policy["candidate_stage"]["required_case_categories"] + minimum_cases = int(approval["minimum_adjudicated_cases"]) + minimum_per_category = int(approval["minimum_cases_per_category"]) + noninferiority_margin = float(gates["paired_success_noninferiority_margin"]) + + for candidate in candidates: + model_id = str(candidate["model_id"]) + metrics = metrics_by_model[model_id] + gate_results = { + "minimum_adjudicated_cases": metrics["sample_count"] >= minimum_cases, + "minimum_cases_per_category": all( + metrics["category_counts"].get(category, 0) >= minimum_per_category + for category in required_categories + ), + "task_success_rate_wilson_lower_bound": metrics["task_success_rate_wilson_lower_bound"] + >= float(gates["task_success_rate_wilson_lower_bound"]), + "false_pass_rate_wilson_upper_bound": metrics["false_pass_rate_wilson_upper_bound"] + <= float(gates["false_pass_rate_wilson_upper_bound"]), + "false_fail_rate_wilson_upper_bound": metrics["false_fail_rate_wilson_upper_bound"] + <= float(gates["false_fail_rate_wilson_upper_bound"]), + "schema_error_rate_wilson_upper_bound": metrics["schema_error_rate_wilson_upper_bound"] + <= float(gates["schema_error_rate_wilson_upper_bound"]), + "paired_success_noninferiority": ( + metrics["task_success_rate"] - baseline["task_success_rate"] + ) + >= -noninferiority_margin, + } + public_metrics = {key: value for key, value in metrics.items() if key != "case_success"} + results.append( + { + "provider": str(candidate["provider"]), + "model_id": model_id, + "status": "passed" if all(gate_results.values()) else "failed", + "gate_results": gate_results, + "metrics": public_metrics, + } + ) + + passing = [result for result in results if result["status"] == "passed"] + ranked = sorted( + passing, + key=lambda item: ( + ( + float("inf") + if item["metrics"]["cost_per_accepted_review_usd"] is None + else item["metrics"]["cost_per_accepted_review_usd"] + ), + item["metrics"]["p95_latency_ms"], + ), + ) + benchmark_id = str(payload.get("benchmark_id", "")).strip() + corpus_version = str(payload.get("corpus_version", "")).strip() + prompt_version = str(payload.get("prompt_version", "")).strip() + measured_at = str(payload.get("measured_at", "")).strip() + if not benchmark_id or not corpus_version or not prompt_version or not measured_at: + raise ValueError( + "benchmark_id, corpus_version, prompt_version, and measured_at are required" + ) + registry_evidence = [ + { + "evidence_id": f"{benchmark_id}:{result['provider']}:{result['model_id']}", + "schema": "workflows-model-benchmark-evidence/v1", + "kind": "workload-benchmark", + "status": result["status"], + "measured_at": measured_at, + "policy_id": str(policy.get("policy_id", "")).strip(), + "profile": profile, + "corpus_version": corpus_version, + "prompt_version": prompt_version, + "provider": result["provider"], + "model_id": result["model_id"], + "gate_results": result["gate_results"], + "metrics": result["metrics"], + } + for result in results + ] + return { + "schema": "workflows-model-benchmark-evidence/v1", + "benchmark_id": benchmark_id, + "policy_id": str(policy.get("policy_id", "")).strip(), + "profile": profile, + "corpus_version": corpus_version, + "prompt_version": prompt_version, + "measured_at": measured_at, + "baseline_model_id": baseline_model, + "results": results, + "registry_evidence": registry_evidence, + "recommended_model_id": ranked[0]["model_id"] if ranked else None, + "recommendation_rule": "quality gates, then cost per accepted review, then p95 latency", + } + + +def _load_object(path: Path) -> dict[str, Any]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"{path} must contain a JSON object") + return payload + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Evaluate a paired model benchmark.") + parser.add_argument("benchmark", type=Path) + parser.add_argument("--policy", type=Path, default=DEFAULT_POLICY_PATH) + parser.add_argument("--output", type=Path) + args = parser.parse_args(argv) + try: + report = evaluate_benchmark(_load_object(args.benchmark), _load_object(args.policy)) + except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError) as exc: + print(f"benchmark error: {exc}", file=sys.stderr) + return 2 + rendered = json.dumps(report, indent=2) + print(rendered) + if args.output: + args.output.write_text(rendered + "\n", encoding="utf-8") + return 0 if report["recommended_model_id"] else 1 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/tools/langchain_client.py b/tools/langchain_client.py index 52d0de4a6..54d19a002 100644 --- a/tools/langchain_client.py +++ b/tools/langchain_client.py @@ -287,6 +287,8 @@ def build_chat_client( logger.warning("Skipping blocked LLM model override: %s/%s", slot.provider, slot_model) used_override = True slot_model = slot.model + if not slot_model: + continue if _is_model_blocked(slot.provider, slot_model): logger.warning("Skipping blocked LLM model: %s/%s", slot.provider, slot_model) continue @@ -499,6 +501,8 @@ def build_chat_clients( for idx, slot in enumerate(candidate_slots): slot_model = model_overrides[idx] if idx < len(model_overrides) else None slot_model = slot_model or slot.model + if not slot_model: + continue if _is_model_blocked(slot.provider, slot_model, registry=registry): logger.warning("Skipping blocked LLM model override: %s/%s", slot.provider, slot_model) continue diff --git a/tools/llm_provider.py b/tools/llm_provider.py index 05536d5cf..8d0d89929 100644 --- a/tools/llm_provider.py +++ b/tools/llm_provider.py @@ -31,17 +31,15 @@ logger = logging.getLogger(__name__) -# GitHub Models API endpoint (OpenAI-compatible) -GITHUB_MODELS_BASE_URL = "https://models.inference.ai.azure.com" -# Legacy/default model identifier: -# - Not used for issuing requests to primary providers (OpenAI/Anthropic/GitHub Models) -# - Still used internally (e.g., default slot model in langchain_client.py) -# - Kept for backward compatibility with external code that references it -DEFAULT_MODEL = "codex-mini-latest" +# GitHub Models API endpoint (OpenAI-compatible). +GITHUB_MODELS_BASE_URL = "https://models.github.ai/inference" +# Model versions live only in config/model_registry.json. Empty constants keep +# import compatibility while ensuring a missing registry fails closed. +DEFAULT_MODEL = "" ANTHROPIC_API_KEY_ENV = "CLAUDE_API_STRANSKE" SHORT_ANALYSIS_CONFIDENCE_CAP = 0.4 -DEFAULT_OPENAI_ANALYSIS_MODEL = "gpt-5.4" -DEFAULT_ANTHROPIC_ANALYSIS_MODEL = "claude-sonnet-4-6" +DEFAULT_OPENAI_ANALYSIS_MODEL = "" +DEFAULT_ANTHROPIC_ANALYSIS_MODEL = "" def _configured_langchain_model(provider: str, *, fallback: str) -> str: @@ -233,7 +231,7 @@ class CompletionAnalysis: confidence: float # 0.0 to 1.0 reasoning: str # Explanation of the analysis provider_used: str # Which provider generated this - model_name: str = "unknown" # Specific model used (e.g., gpt-4o, claude-3.5-sonnet) + model_name: str = "unknown" # Specific model used by the selected provider. # Quality metrics for BS detection raw_confidence: float | None = None # Original confidence before adjustment @@ -335,7 +333,10 @@ def name(self) -> str: return "github-models" def is_available(self) -> bool: - return bool(os.environ.get("GITHUB_TOKEN")) + return bool( + os.environ.get("GITHUB_TOKEN") + and _configured_langchain_model("github-models", fallback=DEFAULT_MODEL) + ) def supports_quality_context(self) -> bool: return True @@ -348,8 +349,12 @@ def _get_client(self): logger.warning("langchain_openai not installed") return None + model_name = _configured_langchain_model("github-models", fallback=DEFAULT_MODEL) + if not model_name: + return None + self._model_name = model_name return ChatOpenAI( - model="gpt-4.1", # Battle-tested, reliable, available on GitHub Models + model=model_name, base_url=GITHUB_MODELS_BASE_URL, api_key=os.environ.get("GITHUB_TOKEN"), temperature=0.1, # Low temperature for consistent analysis @@ -561,7 +566,7 @@ def _parse_response( confidence=adjusted_confidence, reasoning=reasoning, provider_used=self.name, - model_name="gpt-4.1", # Actual model used by GitHubModelsProvider + model_name=getattr(self, "_model_name", "unknown"), raw_confidence=raw_confidence if adjusted_confidence != raw_confidence else None, confidence_adjusted=adjusted_confidence != raw_confidence, quality_warnings=warnings if warnings else None, @@ -576,7 +581,7 @@ def _parse_response( confidence=0.0, reasoning=f"Failed to parse response: {e}", provider_used=self.name, - model_name="gpt-4.1", # Actual model used by GitHubModelsProvider + model_name=getattr(self, "_model_name", "unknown"), ) @@ -588,7 +593,10 @@ def name(self) -> str: return "openai" def is_available(self) -> bool: - return bool(os.environ.get("OPENAI_API_KEY")) + return bool( + os.environ.get("OPENAI_API_KEY") + and _configured_langchain_model("openai", fallback=DEFAULT_OPENAI_ANALYSIS_MODEL) + ) def supports_quality_context(self) -> bool: return True @@ -662,7 +670,10 @@ def name(self) -> str: return "anthropic" def is_available(self) -> bool: - return bool(os.environ.get(ANTHROPIC_API_KEY_ENV)) + return bool( + os.environ.get(ANTHROPIC_API_KEY_ENV) + and _configured_langchain_model("anthropic", fallback=DEFAULT_ANTHROPIC_ANALYSIS_MODEL) + ) def supports_quality_context(self) -> bool: return True @@ -984,7 +995,7 @@ def get_llm_provider(force_provider: str | None = None) -> LLMProvider: Returns a FallbackChainProvider that tries: 1. Anthropic configured slot model (if CLAUDE_API_STRANSKE set) - Best reasoning 2. OpenAI configured slot model (if OPENAI_API_KEY set) - Code analysis - 3. GitHub Models gpt-4.1 (if GITHUB_TOKEN set) - Always available, reliable + 3. GitHub Models configured slot model (if GITHUB_TOKEN set) 4. Regex fallback (always available) - 30% confidence baseline """ # Force a specific provider for testing @@ -1012,7 +1023,7 @@ def get_llm_provider(force_provider: str | None = None) -> LLMProvider: providers = [ AnthropicProvider(), # Primary: configured Anthropic slot model OpenAIProvider(), # Secondary: configured OpenAI slot model - GitHubModelsProvider(), # Tertiary: gpt-4.1 via GITHUB_TOKEN (always available) + GitHubModelsProvider(), # Tertiary: configured GitHub Models slot RegexFallbackProvider(), # Last resort: 30% confidence pattern matching ] diff --git a/tools/llm_registry.py b/tools/llm_registry.py index bba336736..461122bc6 100644 --- a/tools/llm_registry.py +++ b/tools/llm_registry.py @@ -1,4 +1,8 @@ -"""Shared LLM slot and model-registry resolution helpers.""" +"""Shared LLM slot and model-registry resolution helpers. + +The registry records model facts separately from explicit workload-profile +selection decisions. Runtime selection never manufactures quality or cost scores. +""" from __future__ import annotations @@ -16,6 +20,7 @@ PROVIDER_OPENAI = "openai" PROVIDER_ANTHROPIC = "anthropic" PROVIDER_GITHUB = "github-models" +DEFAULT_SELECTION_PROFILE = "verifier-balanced" DEFAULT_SLOT_CONFIG_PATH = Path(__file__).resolve().parent.parent / "config" / "llm_slots.json" DEFAULT_MODEL_REGISTRY_CONFIG_PATH = ( @@ -28,7 +33,21 @@ class ModelRegistryEntry: provider: str model: str blocked: bool - quality: dict[str, float] + lifecycle: str = "unknown" + # Retained as empty compatibility attributes for callers migrating from v1. + # They are deliberately not inputs to model selection. + quality: dict[str, float] | None = None + cost_score: float | None = None + + +@dataclass(frozen=True) +class SelectionDecision: + profile: str + provider: str + model: str + status: str + review_by: str + evidence_ids: tuple[str, ...] @dataclass(frozen=True) @@ -51,6 +70,30 @@ def normalize_provider(value: str | None) -> str | None: return None +def _registry_path() -> Path: + configured = os.environ.get(ENV_MODEL_REGISTRY_CONFIG) + return Path(configured) if configured else DEFAULT_MODEL_REGISTRY_CONFIG_PATH + + +def _slot_path() -> Path: + configured = os.environ.get(ENV_SLOT_CONFIG) + return Path(configured) if configured else DEFAULT_SLOT_CONFIG_PATH + + +def _load_object(path: Path, *, label: str) -> dict[str, object] | None: + if not path.is_file(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + logger.warning("Could not read %s %s", label, path) + return None + if not isinstance(payload, dict): + logger.warning("Invalid %s format in %s; expected object", label, path) + return None + return payload + + def _slot_entries(payload: dict[str, object], path: Path) -> list[dict[str, object]]: raw_slots = payload.get("slots", []) if not isinstance(raw_slots, list): @@ -66,19 +109,10 @@ def _slot_entries(payload: dict[str, object], path: Path) -> list[dict[str, obje def load_model_registry() -> list[ModelRegistryEntry]: - config_path = os.environ.get(ENV_MODEL_REGISTRY_CONFIG) - path = Path(config_path) if config_path else DEFAULT_MODEL_REGISTRY_CONFIG_PATH - if not path.is_file(): - return [] - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - logger.warning("Could not read model registry %s; continuing without registry", path) - return [] - if not isinstance(payload, dict): - logger.warning("Invalid model registry format in %s; expected object", path) + path = _registry_path() + payload = _load_object(path, label="model registry") + if payload is None: return [] - raw_models = payload.get("models", []) if not isinstance(raw_models, list): logger.warning("Invalid model registry format in %s; expected models list", path) @@ -93,44 +127,58 @@ def load_model_registry() -> list[ModelRegistryEntry]: model = str(raw_entry.get("model_id", "")).strip() if not provider or not model: continue - quality_payload = raw_entry.get("quality", {}) - if not isinstance(quality_payload, dict): - logger.warning( - "Ignoring invalid quality scores for %s/%s in %s; expected object", - provider, - model, - path, - ) - quality_payload = {} - quality = { - str(tier).upper(): float(score) - for tier, score in quality_payload.items() - if isinstance(score, (int, float)) and not isinstance(score, bool) - } entries.append( ModelRegistryEntry( provider=provider, model=model, blocked=bool(raw_entry.get("blocked", False)), - quality=quality, + lifecycle=str(raw_entry.get("lifecycle", "unknown")).strip().lower(), + quality={}, ) ) return entries +def load_selection_decisions() -> list[SelectionDecision]: + path = _registry_path() + payload = _load_object(path, label="model registry") + if payload is None: + return [] + raw_selections = payload.get("selections", []) + if not isinstance(raw_selections, list): + logger.warning("Invalid model registry format in %s; expected selections list", path) + return [] + + decisions: list[SelectionDecision] = [] + for raw in raw_selections: + if not isinstance(raw, dict): + continue + provider = normalize_provider(str(raw.get("provider", ""))) + profile = str(raw.get("profile", "")).strip() + model = str(raw.get("model_id", "")).strip() + evidence = raw.get("evidence_ids", []) + if not provider or not profile or not model or not isinstance(evidence, list): + continue + decisions.append( + SelectionDecision( + profile=profile, + provider=provider, + model=model, + status=str(raw.get("status", "")).strip().lower(), + review_by=str(raw.get("review_by", "")).strip(), + evidence_ids=tuple(str(item) for item in evidence if str(item).strip()), + ) + ) + return decisions + + def _model_registry_format_valid() -> bool: - config_path = os.environ.get(ENV_MODEL_REGISTRY_CONFIG) - path = Path(config_path) if config_path else DEFAULT_MODEL_REGISTRY_CONFIG_PATH - if not path.is_file(): - return True - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return False - if not isinstance(payload, dict): - return False - raw_models = payload.get("models", []) - return isinstance(raw_models, list) + payload = _load_object(_registry_path(), label="model registry") + return bool( + payload is not None + and isinstance(payload.get("models"), list) + and isinstance(payload.get("selections"), list) + ) def registry_entry_for( @@ -139,10 +187,14 @@ def registry_entry_for( entries = registry if registry is not None else load_model_registry() normalized_provider = normalize_provider(provider) normalized_model = model.strip() - for entry in entries: - if entry.provider == normalized_provider and entry.model == normalized_model: - return entry - return None + return next( + ( + entry + for entry in entries + if entry.provider == normalized_provider and entry.model == normalized_model + ), + None, + ) def is_model_blocked( @@ -152,94 +204,127 @@ def is_model_blocked( return bool(entry and entry.blocked) -def select_model_for_tier( +def select_model_for_profile( *, provider: str, - tier: str, + profile: str = DEFAULT_SELECTION_PROFILE, registry: list[ModelRegistryEntry] | None = None, + decisions: list[SelectionDecision] | None = None, ) -> str | None: + """Resolve the one explicit reviewed decision for provider/profile.""" entries = registry if registry is not None else load_model_registry() + selections = decisions if decisions is not None else load_selection_decisions() normalized_provider = normalize_provider(provider) - normalized_tier = tier.strip().upper() - candidates = [ - entry - for entry in entries - if entry.provider == normalized_provider - and not entry.blocked - and normalized_tier in entry.quality + matches = [ + decision + for decision in selections + if decision.provider == normalized_provider and decision.profile == profile ] - if not candidates: + if len(matches) != 1: + if matches: + logger.warning( + "Ambiguous model selections for %s/%s; expected exactly one", + normalized_provider, + profile, + ) + return None + decision = matches[0] + entry = registry_entry_for(decision.provider, decision.model, registry=entries) + if entry is None or entry.blocked or entry.lifecycle != "current": + return None + if decision.status not in {"provisional", "approved"}: return None - selected = max(candidates, key=lambda entry: entry.quality[normalized_tier]) - return selected.model + return decision.model + + +def select_model_for_tier( + *, + provider: str, + tier: str, + registry: list[ModelRegistryEntry] | None = None, + **_ignored: object, +) -> str | None: + """Compatibility adapter for v1 callers; tiers no longer rank models.""" + logger.warning( + "quality tier %s is deprecated; resolving profile %s", + tier, + DEFAULT_SELECTION_PROFILE, + ) + return select_model_for_profile(provider=provider, registry=registry) def configured_model_for_provider( provider: str, *, - fallback: str, - tier: str = "T3", + fallback: str = "", + profile: str = DEFAULT_SELECTION_PROFILE, + tier: str | None = None, registry: list[ModelRegistryEntry] | None = None, ) -> str: normalized_provider = normalize_provider(provider) entries = registry if registry is not None else load_model_registry() - - config_path = os.environ.get(ENV_SLOT_CONFIG) - path = Path(config_path) if config_path else DEFAULT_SLOT_CONFIG_PATH - if path.is_file(): - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - payload = {} - if not isinstance(payload, dict): - logger.warning("Invalid slot config format in %s; expected object", path) - payload = {} + path = _slot_path() + payload = _load_object(path, label="slot config") + if payload is not None: for slot in _slot_entries(payload, path): slot_provider = normalize_provider(str(slot.get("provider", ""))) if slot_provider != normalized_provider: continue - model = str(slot.get("model", "")).strip() - slot_tier = str(slot.get("quality_tier") or slot.get("tier") or tier).strip() - if not model and slot_tier: - model = ( - select_model_for_tier( - provider=slot_provider or "", - tier=slot_tier, - registry=entries, - ) - or "" + explicit_model = str(slot.get("model", "")).strip() + slot_profile = str(slot.get("profile") or profile).strip() + model = ( + select_model_for_profile( + provider=slot_provider or "", + profile=slot_profile, + registry=entries, + ) + or "" + ) + if not model: + logger.warning( + "No reviewed model selection for slot profile %s/%s", + slot_profile, + slot_provider, + ) + return "" + if explicit_model and explicit_model != model: + logger.warning( + "Ignoring slot model pin %s/%s; reviewed %s selection is %s", + slot_provider, + explicit_model, + slot_profile, + model or "unavailable", ) if model and not is_model_blocked(slot_provider or "", model, registry=entries): return model - selected = select_model_for_tier(provider=provider, tier=tier, registry=entries) + selected = select_model_for_profile(provider=provider, profile=profile, registry=entries) if selected: return selected - if not is_model_blocked(provider, fallback, registry=entries): + if fallback and not is_model_blocked(provider, fallback, registry=entries): return fallback return "" -def default_slots(*, github_default_model: str) -> list[SlotDefinition]: - return [ - SlotDefinition(name="slot1", provider=PROVIDER_OPENAI, model="gpt-5.4"), - SlotDefinition(name="slot2", provider=PROVIDER_ANTHROPIC, model="claude-sonnet-4-6"), - SlotDefinition(name="slot3", provider=PROVIDER_GITHUB, model=github_default_model), - ] +def default_slots(*, github_default_model: str = "") -> list[SlotDefinition]: + """Build no-slot-config defaults from registry decisions, never version constants.""" + slots: list[SlotDefinition] = [] + for index, provider in enumerate( + (PROVIDER_OPENAI, PROVIDER_ANTHROPIC, PROVIDER_GITHUB), start=1 + ): + model = select_model_for_profile(provider=provider) + if not model and provider == PROVIDER_GITHUB: + model = github_default_model.strip() + if model: + slots.append(SlotDefinition(name=f"slot{index}", provider=provider, model=model)) + return slots -def load_slot_config(*, github_default_model: str) -> list[SlotDefinition]: - config_path = os.environ.get(ENV_SLOT_CONFIG) - path = Path(config_path) if config_path else DEFAULT_SLOT_CONFIG_PATH +def load_slot_config(*, github_default_model: str = "") -> list[SlotDefinition]: + path = _slot_path() + payload = _load_object(path, label="slot config") fallback_slots = default_slots(github_default_model=github_default_model) - if not path.is_file(): - return fallback_slots - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return fallback_slots - if not isinstance(payload, dict): - logger.warning("Invalid slot config format in %s; expected object", path) + if payload is None: return fallback_slots registry = load_model_registry() @@ -247,26 +332,44 @@ def load_slot_config(*, github_default_model: str) -> list[SlotDefinition]: if not _model_registry_format_valid() and any( str(entry.get("provider", "")).strip() and not str(entry.get("model", "")).strip() - and str(entry.get("quality_tier") or entry.get("tier") or "").strip() + and str( + entry.get("profile") or entry.get("quality_tier") or entry.get("tier") or "" + ).strip() for entry in slot_entries ): return fallback_slots + slots: list[SlotDefinition] = [] - fallback_by_position = dict(enumerate(fallback_slots, start=1)) fallback_by_provider = {slot.provider: slot for slot in fallback_slots} for idx, entry in enumerate(slot_entries, start=1): provider = normalize_provider(str(entry.get("provider", ""))) - model = str(entry.get("model", "")).strip() - tier = str(entry.get("quality_tier") or entry.get("tier") or "").strip() - if provider and not model and tier: - model = select_model_for_tier(provider=provider, tier=tier, registry=registry) or "" + explicit_model = str(entry.get("model", "")).strip() + configured_profile = str(entry.get("profile") or "").strip() + profile = configured_profile or DEFAULT_SELECTION_PROFILE + model = "" + if provider: + model = ( + select_model_for_profile(provider=provider, profile=profile, registry=registry) + or "" + ) + if provider and explicit_model and explicit_model != model: + logger.warning( + "Ignoring slot model pin %s/%s; reviewed %s selection is %s", + provider, + explicit_model, + profile, + model or "unavailable", + ) + if provider and configured_profile and not model: + logger.warning( + "Skipping slot with unresolved reviewed profile: %s/%s", + configured_profile, + provider, + ) + continue if provider and not model: - fallback_slot = fallback_by_position.get(idx) - if fallback_slot and fallback_slot.provider != provider: - fallback_slot = fallback_by_provider.get(provider) - fallback_slot = fallback_slot or fallback_by_provider.get(provider) - if fallback_slot and fallback_slot.provider == provider: - model = fallback_slot.model + fallback_slot = fallback_by_provider.get(provider) + model = fallback_slot.model if fallback_slot else "" if not provider or not model: continue if is_model_blocked(provider, model, registry=registry): @@ -274,8 +377,7 @@ def load_slot_config(*, github_default_model: str) -> list[SlotDefinition]: continue name = str(entry.get("name") or f"slot{idx}").strip() or f"slot{idx}" slots.append(SlotDefinition(name=name, provider=provider, model=model)) - - return slots or fallback_slots + return slots if slot_entries else fallback_slots def apply_slot_env_overrides( @@ -287,10 +389,8 @@ def apply_slot_env_overrides( registry = load_model_registry() updated: list[SlotDefinition] = [] for idx, slot in enumerate(slots, start=1): - provider_key = f"{env_slot_prefix}{idx}_PROVIDER" - model_key = f"{env_slot_prefix}{idx}_MODEL" - provider_override = normalize_provider(os.environ.get(provider_key)) - model_override = os.environ.get(model_key) + provider_override = normalize_provider(os.environ.get(f"{env_slot_prefix}{idx}_PROVIDER")) + model_override = os.environ.get(f"{env_slot_prefix}{idx}_MODEL") if idx == 1: model_override = model_override or os.environ.get(env_model_name) provider = provider_override or slot.provider @@ -303,24 +403,29 @@ def apply_slot_env_overrides( ): updated.append(slot) continue - updated.append( - SlotDefinition( - name=slot.name, - provider=provider, - model=model, - ) - ) + updated.append(SlotDefinition(name=slot.name, provider=provider, model=model)) return updated def resolve_slots( *, - github_default_model: str, + github_default_model: str = "", env_model_name: str = "LANGCHAIN_MODEL", env_slot_prefix: str = "LANGCHAIN_SLOT", ) -> list[SlotDefinition]: + slots = load_slot_config(github_default_model=github_default_model) + # Preserve an explicit runtime override as an emergency bootstrap when the + # registry file is unavailable. Empty models are never invoked directly; + # langchain_client skips them when the override cannot serve that provider. + if not slots and os.environ.get(env_model_name): + slots = [ + SlotDefinition(name=f"slot{index}", provider=provider, model="") + for index, provider in enumerate( + (PROVIDER_OPENAI, PROVIDER_ANTHROPIC, PROVIDER_GITHUB), start=1 + ) + ] return apply_slot_env_overrides( - load_slot_config(github_default_model=github_default_model), + slots, env_model_name=env_model_name, env_slot_prefix=env_slot_prefix, ) diff --git a/tools/run_model_eval_pilot.py b/tools/run_model_eval_pilot.py new file mode 100644 index 000000000..c798cc849 --- /dev/null +++ b/tools/run_model_eval_pilot.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Run the frozen verifier pilot against explicit provider/model candidates.""" + +from __future__ import annotations + +import argparse +import json +import os +import time +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from scripts import api_client +from scripts.langchain import pr_verifier + +ROOT = Path(__file__).resolve().parent.parent + + +def fetch_pr(repo: str, number: int, token: str) -> tuple[str, str]: + payload = api_client.fetch_pull_request(repo, number, token) + diff = api_client.fetch_pull_request_diff(repo, number, token) + context = f"Repository: {repo}\nPR: #{number}\nTitle: {payload['title']}\n\n{payload.get('body') or ''}" + return context, diff + + +def run_pilot( + corpus: dict[str, Any], + candidates: dict[str, Any], + *, + token: str, + fetcher: Callable[[str, int, str], tuple[str, str]] = fetch_pr, + evaluator: Callable[..., Any] = pr_verifier.evaluate_pr, +) -> dict[str, Any]: + results: list[dict[str, Any]] = [] + for candidate in candidates["candidates"]: + provider = candidate["provider"] + model = candidate["model_id"] + for case in corpus["cases"]: + started = time.perf_counter() + try: + context, diff = fetcher(case["repo"], case["pr"], token) + result = evaluator(context, diff=diff, model=model, provider=provider) + actual = "PASS" if result.verdict == "PASS" else "NON_PASS" + row = { + "case_id": case["case_id"], + "category": case["category"], + "expected_verdict": case["expected_verdict"], + "actual_verdict": actual, + "schema_valid": result.error is None, + "provider": provider, + "model_id": model, + "confidence": result.confidence, + "latency_ms": round((time.perf_counter() - started) * 1000, 3), + "error": result.error, + } + except Exception as exc: # keep the paired run auditable + row = { + "case_id": case["case_id"], + "category": case["category"], + "expected_verdict": case["expected_verdict"], + "actual_verdict": "NON_PASS", + "schema_valid": False, + "provider": provider, + "model_id": model, + "confidence": 0, + "latency_ms": round((time.perf_counter() - started) * 1000, 3), + "error": str(exc), + } + results.append(row) + return { + "schema": "workflows-verifier-pilot-results/v1", + "corpus_version": corpus["corpus_version"], + "results": results, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--corpus", type=Path, default=ROOT / "config/model_eval_pilot.json") + parser.add_argument( + "--candidates", type=Path, default=ROOT / "config/model_eval_candidates.json" + ) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") or "" + if not token: + raise SystemExit("GITHUB_TOKEN is required") + payload = run_pilot( + json.loads(args.corpus.read_text()), + json.loads(args.candidates.read_text()), + token=token, + ) + args.output.write_text(json.dumps(payload, indent=2) + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())