llmtivo 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. llmtivo-0.1.1/.bandit +26 -0
  2. llmtivo-0.1.1/.github/badges/llmtivo.json +7 -0
  3. llmtivo-0.1.1/.github/workflows/ci.yml +125 -0
  4. llmtivo-0.1.1/.github/workflows/llmtivo-badge.yml +115 -0
  5. llmtivo-0.1.1/.github/workflows/pages.yml +37 -0
  6. llmtivo-0.1.1/.github/workflows/release.yml +337 -0
  7. llmtivo-0.1.1/.github/workflows/test-release.yml +195 -0
  8. llmtivo-0.1.1/.gitignore +46 -0
  9. llmtivo-0.1.1/.python-version +1 -0
  10. llmtivo-0.1.1/CHANGELOG.md +29 -0
  11. llmtivo-0.1.1/CONTRIBUTING.md +247 -0
  12. llmtivo-0.1.1/LICENSE +21 -0
  13. llmtivo-0.1.1/MANIFEST.in +20 -0
  14. llmtivo-0.1.1/Makefile +288 -0
  15. llmtivo-0.1.1/PKG-INFO +354 -0
  16. llmtivo-0.1.1/README.md +312 -0
  17. llmtivo-0.1.1/codecov.yml +27 -0
  18. llmtivo-0.1.1/mypy.ini +24 -0
  19. llmtivo-0.1.1/pyproject.toml +135 -0
  20. llmtivo-0.1.1/ruff.toml +95 -0
  21. llmtivo-0.1.1/scripts/check_deps.py +97 -0
  22. llmtivo-0.1.1/src/llmtivo/__init__.py +49 -0
  23. llmtivo-0.1.1/src/llmtivo/cassette.py +131 -0
  24. llmtivo-0.1.1/src/llmtivo/guard.py +80 -0
  25. llmtivo-0.1.1/src/llmtivo/intercept.py +190 -0
  26. llmtivo-0.1.1/src/llmtivo/keys.py +120 -0
  27. llmtivo-0.1.1/src/llmtivo/langchain.py +170 -0
  28. llmtivo-0.1.1/src/llmtivo/litellm.py +146 -0
  29. llmtivo-0.1.1/src/llmtivo/modes.py +79 -0
  30. llmtivo-0.1.1/src/llmtivo/plugin.py +196 -0
  31. llmtivo-0.1.1/src/llmtivo/py.typed +0 -0
  32. llmtivo-0.1.1/src/llmtivo/recorder.py +398 -0
  33. llmtivo-0.1.1/src/llmtivo/store.py +165 -0
  34. llmtivo-0.1.1/tests/integration/__init__.py +1 -0
  35. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_agent_live.py_test_an_agent_loop_with_a_real_search_tool.jsonl.zst +0 -0
  36. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_agent_live.py_test_replaying_the_agent_touches_no_network.jsonl.zst +0 -0
  37. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_langchain_live.py_test_a_real_async_invoke_records_the_awaited_value.jsonl.zst +0 -0
  38. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_langchain_live.py_test_a_real_invoke_replays_as_an_AIMessage_anthropic.jsonl.zst +0 -0
  39. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_langchain_live.py_test_a_real_invoke_replays_as_an_AIMessage_deepseek.jsonl.zst +0 -0
  40. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_langchain_live.py_test_a_real_invoke_replays_as_an_AIMessage_gemini.jsonl.zst +0 -0
  41. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_langchain_live.py_test_a_real_invoke_replays_as_an_AIMessage_openai.jsonl.zst +0 -0
  42. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_langchain_live.py_test_a_real_stream_replays_chunk_by_chunk_anthropic.jsonl.zst +0 -0
  43. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_langchain_live.py_test_a_real_stream_replays_chunk_by_chunk_openai.jsonl.zst +0 -0
  44. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_langchain_live.py_test_four_providers_share_one_tape_in_call_order.jsonl.zst +0 -0
  45. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_litellm_live.py_test_a_real_completion_records_and_replays_anthropic_claude-haiku-4-5-20251001.jsonl.zst +0 -0
  46. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_litellm_live.py_test_a_real_completion_records_and_replays_deepseek_deepseek-chat.jsonl.zst +0 -0
  47. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_litellm_live.py_test_a_real_completion_records_and_replays_gemini_gemini-2.5-flash.jsonl.zst +0 -0
  48. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_litellm_live.py_test_a_real_completion_records_and_replays_openai_gpt-4o-mini.jsonl.zst +0 -0
  49. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_litellm_live.py_test_an_async_completion_records_the_awaited_value.jsonl.zst +0 -0
  50. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_litellm_live.py_test_one_tape_holds_two_providers_in_call_order.jsonl.zst +0 -0
  51. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_mcp_live.py_test_a_real_model_drives_an_mcp_tool.jsonl.zst +0 -0
  52. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_mcp_live.py_test_an_mcp_tool_execution_replays_without_reaching_the_server.jsonl.zst +0 -0
  53. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_tool_calling_live.py_test_a_real_model_asks_for_a_tool_and_the_request_survives_anthropic.jsonl.zst +0 -0
  54. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_tool_calling_live.py_test_a_real_model_asks_for_a_tool_and_the_request_survives_openai.jsonl.zst +0 -0
  55. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_tool_calling_live.py_test_the_full_round_trip_model_tool_model_anthropic.jsonl.zst +0 -0
  56. llmtivo-0.1.1/tests/integration/cassettes/tests_integration_test_tool_calling_live.py_test_the_full_round_trip_model_tool_model_openai.jsonl.zst +0 -0
  57. llmtivo-0.1.1/tests/integration/conftest.py +82 -0
  58. llmtivo-0.1.1/tests/integration/mcp_server.py +33 -0
  59. llmtivo-0.1.1/tests/integration/test_agent_live.py +83 -0
  60. llmtivo-0.1.1/tests/integration/test_langchain_live.py +110 -0
  61. llmtivo-0.1.1/tests/integration/test_litellm_live.py +102 -0
  62. llmtivo-0.1.1/tests/integration/test_mcp_live.py +113 -0
  63. llmtivo-0.1.1/tests/integration/test_replay_needs_no_credentials.py +39 -0
  64. llmtivo-0.1.1/tests/integration/test_tool_calling_live.py +100 -0
  65. llmtivo-0.1.1/tests/test_guard.py +86 -0
  66. llmtivo-0.1.1/tests/test_intercept.py +165 -0
  67. llmtivo-0.1.1/tests/test_langchain.py +265 -0
  68. llmtivo-0.1.1/tests/test_litellm.py +169 -0
  69. llmtivo-0.1.1/tests/test_plugin.py +162 -0
  70. llmtivo-0.1.1/tests/test_recorder.py +359 -0
  71. llmtivo-0.1.1/tests/test_store.py +82 -0
  72. llmtivo-0.1.1/uv.lock +4296 -0
llmtivo-0.1.1/.bandit ADDED
@@ -0,0 +1,26 @@
1
+ # Bandit configuration for LLMTivo project
2
+ [bandit]
3
+ exclude_dirs = [
4
+ "tests",
5
+ ".venv",
6
+ "build",
7
+ "dist"
8
+ ]
9
+
10
+ # Skip certain tests that are not relevant for our use case or are false positives
11
+ skips = [
12
+ "B101", # Test for use of assert
13
+ "B110", # Test for a try/except pass - used for optional dependency handling
14
+ "B404", # Test for subprocess import - we use subprocess securely with absolute paths
15
+ "B601", # paramiko calls (not used)
16
+ "B602", # subprocess popen with shell=true (controlled usage)
17
+ "B603", # subprocess without shell equals true (controlled usage)
18
+ ]
19
+
20
+ # Severity level
21
+ # LOW, MEDIUM, HIGH
22
+ severity = MEDIUM
23
+
24
+ # Confidence level
25
+ # LOW, MEDIUM, HIGH
26
+ confidence = MEDIUM
@@ -0,0 +1,7 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "label": "MFCQI",
4
+ "message": "0.84 (excellent)",
5
+ "color": "brightgreen",
6
+ "cacheSeconds": 3600
7
+ }
@@ -0,0 +1,125 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ pull_request:
7
+ branches: [ main ]
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ test:
12
+ runs-on: ${{ matrix.os }}
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ os: [ubuntu-latest, macos-latest, windows-latest]
17
+ # the project declares requires-python = ">=3.11"; 3.10 was inherited from another
18
+ # project's config and could never have resolved
19
+ python-version: ['3.11', '3.12', '3.13']
20
+
21
+ steps:
22
+ - name: Checkout code
23
+ uses: actions/checkout@v4
24
+
25
+ - name: Set up Python ${{ matrix.python-version }}
26
+ uses: actions/setup-python@v5
27
+ with:
28
+ python-version: ${{ matrix.python-version }}
29
+
30
+ - name: Install UV
31
+ uses: astral-sh/setup-uv@v3
32
+ with:
33
+ enable-cache: true
34
+
35
+ - name: Install dependencies
36
+ run: |
37
+ uv sync --group dev --group integration
38
+ uv run python -c "import llmtivo; print('LLMTivo installed successfully')"
39
+
40
+ - name: Check formatting and types
41
+ shell: bash
42
+ run: |
43
+ uv run ruff format --check src/ tests/
44
+ uv run ruff check src/ tests/
45
+ uv run mypy --strict src/llmtivo/
46
+
47
+ # `pytest --cov` UNDER-REPORTS this project specifically: llmtivo registers a pytest11 plugin,
48
+ # so the package is imported while pytest loads plugins — before pytest-cov starts tracing.
49
+ # Every line executed at import time is then invisible (it measured 59% against a real 93%).
50
+ # Running coverage as the OUTER process starts tracing first and measures the truth.
51
+ # The integration tests run HERE, in replay, with no keys and no network: they are committed
52
+ # tapes of real OpenAI / Anthropic / Gemini / DeepSeek / Tavily / MCP traffic. Excluding them
53
+ # from CI is how a suite stops noticing that a provider changed a response shape.
54
+ - name: Run the suite under coverage
55
+ shell: bash
56
+ env:
57
+ ANTHROPIC_API_KEY: ""
58
+ OPENAI_API_KEY: ""
59
+ run: |
60
+ uv run coverage run --source=src/llmtivo -m pytest tests/ -v
61
+ uv run coverage xml
62
+ uv run coverage report --fail-under=85
63
+
64
+ - name: Upload coverage reports to Codecov
65
+ if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
66
+ uses: codecov/codecov-action@v5
67
+ with:
68
+ token: ${{ secrets.CODECOV_TOKEN }}
69
+ files: ./coverage.xml
70
+ flags: unittests
71
+ name: codecov-${{ matrix.os }}-py${{ matrix.python-version }}
72
+ fail_ci_if_error: false
73
+
74
+ # A quality gate that only prints a warning is not a gate. `--min-score` exits non-zero, so
75
+ # this fails the build rather than narrating the number and passing anyway.
76
+ - name: MFCQI quality gate
77
+ if: matrix.os == 'ubuntu-latest'
78
+ run: uv run mfcqi-py analyze src/llmtivo --skip-llm --min-score 0.75
79
+
80
+ - name: Upload test results
81
+ uses: actions/upload-artifact@v4
82
+ if: always()
83
+ with:
84
+ name: test-results-${{ matrix.os }}-py${{ matrix.python-version }}
85
+ path: |
86
+ coverage.xml
87
+ htmlcov/
88
+
89
+ security:
90
+ runs-on: ubuntu-latest
91
+ steps:
92
+ - uses: actions/checkout@v4
93
+ - uses: astral-sh/setup-uv@v3
94
+ - name: Install dependencies
95
+ run: uv sync --group dev
96
+ - name: Bandit
97
+ run: uv run bandit -r src/ -q
98
+
99
+ build:
100
+ runs-on: ubuntu-latest
101
+ needs: test
102
+
103
+ steps:
104
+ - name: Checkout code
105
+ uses: actions/checkout@v4
106
+
107
+ - name: Set up Python
108
+ uses: actions/setup-python@v5
109
+ with:
110
+ python-version: '3.12'
111
+
112
+ - name: Install build dependencies
113
+ run: pip install build twine
114
+
115
+ - name: Build package
116
+ run: python -m build
117
+
118
+ - name: Check package
119
+ run: python -m twine check dist/*
120
+
121
+ - name: Upload build artifacts
122
+ uses: actions/upload-artifact@v4
123
+ with:
124
+ name: python-package
125
+ path: dist/
@@ -0,0 +1,115 @@
1
+ name: Update LLMTivo Badge
2
+
3
+ on:
4
+ push:
5
+ branches: [ main, master ]
6
+ paths:
7
+ - 'src/**'
8
+ - 'tests/**'
9
+ - 'pyproject.toml'
10
+ pull_request:
11
+ branches: [ main, master ]
12
+ workflow_dispatch: # Allow manual trigger
13
+
14
+ jobs:
15
+ update-badge:
16
+ runs-on: ubuntu-latest
17
+ permissions:
18
+ contents: write # Required for git push to update badge file
19
+ issues: write # Required for PR comments
20
+ pull-requests: write
21
+
22
+ steps:
23
+ - uses: actions/checkout@v3
24
+ with:
25
+ token: ${{ secrets.GITHUB_TOKEN }}
26
+
27
+ - name: Set up Python
28
+ uses: actions/setup-python@v4
29
+ with:
30
+ python-version: '3.13'
31
+
32
+ - name: Install UV
33
+ uses: astral-sh/setup-uv@v3
34
+ with:
35
+ enable-cache: true
36
+
37
+ - name: Install dependencies
38
+ run: |
39
+ uv sync --frozen
40
+
41
+ - name: Calculate LLMTivo Score
42
+ id: llmtivo
43
+ env:
44
+ LLMTivo_DISABLE_KEYRING: "1"
45
+ run: |
46
+ # Save old score if badge exists
47
+ mkdir -p .github/badges
48
+ if [ -f .github/badges/llmtivo.json ]; then
49
+ OLD_SCORE=$(python -c "import json; data=json.load(open('.github/badges/llmtivo.json')); print(data['message'].split()[0])" 2>/dev/null || echo "0.0")
50
+ else
51
+ OLD_SCORE="0.0"
52
+ fi
53
+ echo "old_score=$OLD_SCORE" >> $GITHUB_OUTPUT
54
+
55
+ # Calculate new score and save to JSON
56
+ uv run mfcqi-py badge src/llmtivo -f json -o .github/badges/llmtivo.json
57
+
58
+ # Extract new score
59
+ NEW_SCORE=$(python -c "import json; data=json.load(open('.github/badges/llmtivo.json')); print(data['message'].split()[0])")
60
+ echo "score=$NEW_SCORE" >> $GITHUB_OUTPUT
61
+
62
+ # Calculate delta and determine arrow
63
+ DELTA=$(python -c "print(f'{float('$NEW_SCORE') - float('$OLD_SCORE'):.3f}')")
64
+ echo "delta=$DELTA" >> $GITHUB_OUTPUT
65
+
66
+ # Determine arrow and format delta
67
+ if (( $(echo "$DELTA > 0" | bc -l) )); then
68
+ echo "arrow=↑" >> $GITHUB_OUTPUT
69
+ echo "delta_formatted=(+$DELTA)" >> $GITHUB_OUTPUT
70
+ elif (( $(echo "$DELTA < 0" | bc -l) )); then
71
+ echo "arrow=↓" >> $GITHUB_OUTPUT
72
+ echo "delta_formatted=($DELTA)" >> $GITHUB_OUTPUT
73
+ else
74
+ echo "arrow=→" >> $GITHUB_OUTPUT
75
+ echo "delta_formatted=(±0.000)" >> $GITHUB_OUTPUT
76
+ fi
77
+
78
+ # Determine if this is good enough
79
+ if (( $(echo "$NEW_SCORE >= 0.75" | bc -l) )); then
80
+ echo "status=passing" >> $GITHUB_OUTPUT
81
+ else
82
+ echo "status=failing" >> $GITHUB_OUTPUT
83
+ fi
84
+
85
+ - name: Commit badge update
86
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
87
+ run: |
88
+ git config --local user.email "github-actions[bot]@users.noreply.github.com"
89
+ git config --local user.name "github-actions[bot]"
90
+ git add .
91
+ git diff --quiet && git diff --staged --quiet || (git commit -m "chore: Update LLMTivo badge: ${{ steps.llmtivo.outputs.score }} ${{ steps.llmtivo.outputs.arrow }} ${{ steps.llmtivo.outputs.delta_formatted }}" && git push)
92
+
93
+ - name: Comment on PR
94
+ if: github.event_name == 'pull_request'
95
+ uses: actions/github-script@v6
96
+ with:
97
+ script: |
98
+ const score = '${{ steps.llmtivo.outputs.score }}';
99
+ const oldScore = '${{ steps.llmtivo.outputs.old_score }}';
100
+ const arrow = '${{ steps.llmtivo.outputs.arrow }}';
101
+ const deltaFormatted = '${{ steps.llmtivo.outputs.delta_formatted }}';
102
+ const status = '${{ steps.llmtivo.outputs.status }}';
103
+ const emoji = status === 'passing' ? '✅' : '⚠️';
104
+ const trendEmoji = arrow === '↑' ? '📈' : arrow === '↓' ? '📉' : '➡️';
105
+
106
+ try {
107
+ await github.rest.issues.createComment({
108
+ issue_number: context.issue.number,
109
+ owner: context.repo.owner,
110
+ repo: context.repo.repo,
111
+ body: `## ${emoji} LLMTivo Score: ${score} ${arrow} ${deltaFormatted}\n\n${trendEmoji} **Previous:** ${oldScore}\n\nCode quality ${status === 'passing' ? 'meets' : 'does not meet'} the minimum threshold (0.75).`
112
+ });
113
+ } catch (error) {
114
+ core.warning(`Unable to post LLMTivo PR comment: ${error.message}`);
115
+ }
@@ -0,0 +1,37 @@
1
+ name: Deploy Docs Site
2
+
3
+ # Publishes the static landing page in docs/ to GitHub Pages. No build step — it's hand-crafted
4
+ # HTML/CSS/JS (see docs/). The install commands are version-less (pip/pipx/uv resolve the latest
5
+ # from PyPI), so nothing needs to be injected at deploy time.
6
+ on:
7
+ push:
8
+ branches: [main]
9
+ paths:
10
+ - "docs/**"
11
+ - ".github/workflows/pages.yml"
12
+ workflow_dispatch:
13
+
14
+ permissions:
15
+ contents: read
16
+ pages: write
17
+ id-token: write
18
+
19
+ # Allow one concurrent deployment; let an in-progress run finish rather than cancel it.
20
+ concurrency:
21
+ group: pages
22
+ cancel-in-progress: false
23
+
24
+ jobs:
25
+ deploy:
26
+ runs-on: ubuntu-latest
27
+ environment:
28
+ name: github-pages
29
+ url: ${{ steps.deployment.outputs.page_url }}
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+ - uses: actions/configure-pages@v5
33
+ - uses: actions/upload-pages-artifact@v3
34
+ with:
35
+ path: docs
36
+ - id: deployment
37
+ uses: actions/deploy-pages@v4
@@ -0,0 +1,337 @@
1
+ name: Release to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ env:
8
+ PYTHON_VERSION: "3.12"
9
+
10
+ jobs:
11
+ # Verify version matches release tag (fail fast)
12
+ verify-version:
13
+ runs-on: ubuntu-latest
14
+ outputs:
15
+ version: ${{ steps.get-version.outputs.version }}
16
+
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+
20
+ - name: Install tomli for version parsing
21
+ run: pip install tomli
22
+
23
+ - name: Get package version
24
+ id: get-version
25
+ run: |
26
+ VERSION=$(python -c "
27
+ try:
28
+ import tomllib
29
+ except ModuleNotFoundError:
30
+ import tomli as tomllib
31
+ print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])
32
+ ")
33
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
34
+ echo "Package version: $VERSION"
35
+
36
+ - name: Verify version matches release tag
37
+ if: github.event_name == 'release'
38
+ env:
39
+ VERSION: ${{ steps.get-version.outputs.version }}
40
+ run: |
41
+ RELEASE_TAG=${GITHUB_REF#refs/tags/}
42
+ RELEASE_VERSION=${RELEASE_TAG#v}
43
+
44
+ echo "Package version: $VERSION"
45
+ echo "Release tag: $RELEASE_TAG"
46
+ echo "Release version: $RELEASE_VERSION"
47
+
48
+ if [ "$VERSION" != "$RELEASE_VERSION" ]; then
49
+ echo "❌ Error: Package version ($VERSION) does not match release version ($RELEASE_VERSION)"
50
+ echo "Please update the version in pyproject.toml to match the release tag"
51
+ exit 1
52
+ fi
53
+
54
+ echo "✅ Version verification passed"
55
+
56
+ # Test the release on Test PyPI
57
+ test-release:
58
+ needs: verify-version
59
+ uses: ./.github/workflows/test-release.yml
60
+ permissions: write-all
61
+ secrets: inherit
62
+
63
+ # Run comprehensive pre-release validation
64
+ pre-release-checks:
65
+ needs:
66
+ - verify-version
67
+ - test-release
68
+ runs-on: ubuntu-latest
69
+ strategy:
70
+ fail-fast: false
71
+ matrix:
72
+ python-version: ['3.11', '3.12', '3.13']
73
+ os: [ubuntu-latest, macos-latest]
74
+
75
+ steps:
76
+ - uses: actions/checkout@v4
77
+
78
+ # No caching - we want to catch any dependency issues
79
+ - name: Set up Python ${{ matrix.python-version }}
80
+ uses: actions/setup-python@v5
81
+ with:
82
+ python-version: ${{ matrix.python-version }}
83
+
84
+ - name: Install UV
85
+ uses: astral-sh/setup-uv@v3
86
+
87
+ - name: Upgrade setuptools with UV
88
+ run: |
89
+ uv pip install --system "setuptools>=40.8.0" wheel
90
+
91
+ - name: Install from Test PyPI
92
+ env:
93
+ VERSION: ${{ needs.test-release.outputs.version }}
94
+ run: |
95
+ uv pip install --system \
96
+ --index-strategy unsafe-best-match \
97
+ --extra-index-url https://test.pypi.org/simple/ \
98
+ "llmtivo==$VERSION" || \
99
+ ( \
100
+ sleep 5 && \
101
+ uv pip install --system \
102
+ --index-strategy unsafe-best-match \
103
+ --extra-index-url https://test.pypi.org/simple/ \
104
+ "llmtivo==$VERSION" \
105
+ )
106
+
107
+ - name: Install test dependencies
108
+ run: uv sync --group dev --group integration
109
+
110
+ - name: Reinstall package from Test PyPI
111
+ env:
112
+ VERSION: ${{ needs.test-release.outputs.version }}
113
+ run: |
114
+ uv pip install --system \
115
+ --extra-index-url https://test.pypi.org/simple/ \
116
+ --force-reinstall --no-deps \
117
+ "llmtivo==$VERSION"
118
+
119
+ - name: Run full test suite
120
+ env:
121
+ ANTHROPIC_API_KEY: ""
122
+ OPENAI_API_KEY: ""
123
+ run: |
124
+ uv run pytest tests/ -v --tb=short
125
+ echo "✅ All tests passed on Python ${{ matrix.python-version }}"
126
+
127
+ - name: Run type checking
128
+ run: |
129
+ uv run mypy --strict src/llmtivo/
130
+ echo "✅ Type checking passed"
131
+
132
+ - name: Run linting
133
+ run: |
134
+ uv run ruff check src/
135
+ echo "✅ Linting passed"
136
+
137
+ # `--min-score` exits non-zero. The previous version printed a warning and released anyway,
138
+ # which is a gate that cannot fail — worse than none, because the log says it was checked.
139
+ - name: MFCQI quality gate
140
+ run: uv run mfcqi-py analyze src/llmtivo --skip-llm --min-score 0.75
141
+
142
+ # Get minimum dependency versions and test with them
143
+ test-min-versions:
144
+ needs:
145
+ - verify-version
146
+ - test-release
147
+ runs-on: ubuntu-latest
148
+
149
+ steps:
150
+ - uses: actions/checkout@v4
151
+
152
+ - name: Set up Python ${{ env.PYTHON_VERSION }}
153
+ uses: actions/setup-python@v5
154
+ with:
155
+ python-version: ${{ env.PYTHON_VERSION }}
156
+
157
+ - name: Install UV
158
+ uses: astral-sh/setup-uv@v3
159
+
160
+ - name: Upgrade setuptools with UV
161
+ run: |
162
+ uv pip install --system "setuptools>=40.8.0" wheel
163
+
164
+ - name: Install from Test PyPI
165
+ env:
166
+ VERSION: ${{ needs.test-release.outputs.version }}
167
+ run: |
168
+ uv pip install --system \
169
+ --index-strategy unsafe-best-match \
170
+ --extra-index-url https://test.pypi.org/simple/ \
171
+ "llmtivo==$VERSION"
172
+
173
+ - name: Install tomli for pyproject parsing
174
+ run: uv pip install --system tomli
175
+
176
+ - name: Get minimum versions from pyproject.toml
177
+ id: min-versions
178
+ run: |
179
+ # Extract minimum versions from dependencies
180
+ python -c "
181
+ try:
182
+ import tomllib
183
+ except ModuleNotFoundError:
184
+ import tomli as tomllib
185
+ import re
186
+
187
+ with open('pyproject.toml', 'rb') as f:
188
+ data = tomllib.load(f)
189
+
190
+ deps = data['project']['dependencies']
191
+ min_versions = []
192
+
193
+ for dep in deps:
194
+ # Parse 'package>=1.2.3' -> 'package==1.2.3'
195
+ match = re.match(r'([a-zA-Z0-9_-]+)>=([0-9.]+)', dep)
196
+ if match:
197
+ pkg, ver = match.groups()
198
+ # Skip some packages that have complex version constraints
199
+ if pkg not in ['networkx', 'scipy']:
200
+ min_versions.append(f'{pkg}=={ver}')
201
+
202
+ print(' '.join(min_versions))
203
+ " > /tmp/min_versions.txt
204
+
205
+ MIN_VERSIONS=$(cat /tmp/min_versions.txt)
206
+ echo "min-versions=$MIN_VERSIONS" >> $GITHUB_OUTPUT
207
+ echo "Minimum versions: $MIN_VERSIONS"
208
+
209
+ - name: Install test dependencies
210
+ run: uv sync --group dev --group integration
211
+
212
+ - name: Install minimum dependency versions
213
+ if: steps.min-versions.outputs.min-versions != ''
214
+ env:
215
+ MIN_VERSIONS: ${{ steps.min-versions.outputs.min-versions }}
216
+ run: |
217
+ if [ -n "$MIN_VERSIONS" ]; then
218
+ uv pip install --system $MIN_VERSIONS
219
+ echo "✅ Installed minimum versions"
220
+ fi
221
+
222
+ - name: Run tests with minimum versions
223
+ if: steps.min-versions.outputs.min-versions != ''
224
+ env:
225
+ ANTHROPIC_API_KEY: ""
226
+ OPENAI_API_KEY: ""
227
+ run: |
228
+ uv run pytest tests/ -v --tb=short
229
+ echo "✅ Tests passed with minimum dependency versions"
230
+
231
+ # Only publish to production PyPI after all validations pass
232
+ publish-pypi:
233
+ needs:
234
+ - verify-version
235
+ - test-release
236
+ - pre-release-checks
237
+ - test-min-versions
238
+ runs-on: ubuntu-latest
239
+ environment: release
240
+
241
+ steps:
242
+ - uses: actions/checkout@v4
243
+
244
+ - name: Download build artifacts
245
+ uses: actions/download-artifact@v4
246
+ with:
247
+ name: test-dist
248
+ path: dist/
249
+
250
+ - name: Publish to production PyPI
251
+ uses: pypa/gh-action-pypi-publish@release/v1
252
+ with:
253
+ password: ${{ secrets.PYPI_API_TOKEN }}
254
+ verbose: true
255
+ print-hash: true
256
+ attestations: false
257
+
258
+ # Create or update GitHub release
259
+ mark-release:
260
+ needs:
261
+ - verify-version
262
+ - test-release
263
+ - pre-release-checks
264
+ - test-min-versions
265
+ - publish-pypi
266
+ runs-on: ubuntu-latest
267
+ permissions:
268
+ contents: write
269
+
270
+ steps:
271
+ - uses: actions/checkout@v4
272
+
273
+ - name: Download build artifacts
274
+ uses: actions/download-artifact@v4
275
+ with:
276
+ name: test-dist
277
+ path: dist/
278
+
279
+ - name: Update release with PyPI links
280
+ uses: actions/github-script@v7
281
+ env:
282
+ VERSION: ${{ needs.verify-version.outputs.version }}
283
+ with:
284
+ script: |
285
+ const version = process.env.VERSION;
286
+ const packageName = 'llmtivo';
287
+
288
+ // Get the release (either from the release event or find by tag)
289
+ let release;
290
+ if (context.eventName === 'release') {
291
+ release = context.payload.release;
292
+ } else {
293
+ // For workflow_dispatch, find the latest release
294
+ const { data: releases } = await github.rest.repos.listReleases({
295
+ owner: context.repo.owner,
296
+ repo: context.repo.repo,
297
+ per_page: 1
298
+ });
299
+ release = releases[0];
300
+ }
301
+
302
+ if (!release) {
303
+ console.log('No release found, skipping update');
304
+ return;
305
+ }
306
+
307
+ const pypiLinks = `
308
+
309
+ ## 📦 PyPI Release
310
+
311
+ This release is now available on PyPI:
312
+
313
+ - **PyPI Package**: https://pypi.org/project/${packageName}/${version}/
314
+ - **Installation**: \`pip install ${packageName}==${version}\`
315
+ - **Upgrade**: \`pip install --upgrade ${packageName}\`
316
+
317
+ ### Quick Start
318
+
319
+ \`\`\`bash
320
+ pip install ${packageName}[pytest]
321
+ pytest # replay — CI-safe, costs nothing
322
+ pytest --llmtivo=record-new # record only the tests with no tape yet
323
+ \`\`\`
324
+
325
+ ### What's New in v${version}
326
+
327
+ See the full changelog above for details.
328
+ `;
329
+
330
+ await github.rest.repos.updateRelease({
331
+ owner: context.repo.owner,
332
+ repo: context.repo.repo,
333
+ release_id: release.id,
334
+ body: (release.body || '') + pypiLinks
335
+ });
336
+
337
+ console.log('✅ Updated release with PyPI links');