Skip to content

Commit d9ad590

Browse files
committed
ci: GitHub Actions CI/CD (lint, types, tests, multimodal + token/usage, secret scan) + PyPI publish
1 parent 309dc3b commit d9ad590

6 files changed

Lines changed: 161 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
concurrency:
9+
group: ${{ github.workflow }}-${{ github.ref }}
10+
cancel-in-progress: true
11+
12+
jobs:
13+
test:
14+
name: test (py${{ matrix.python-version }})
15+
runs-on: ubuntu-latest
16+
strategy:
17+
fail-fast: false
18+
matrix:
19+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
20+
steps:
21+
- uses: actions/checkout@v4
22+
- uses: astral-sh/setup-uv@v6
23+
with:
24+
python-version: ${{ matrix.python-version }}
25+
enable-cache: true
26+
- name: Install (all extras)
27+
run: uv sync --all-extras
28+
- name: Lint (ruff)
29+
run: uv run ruff check src tests scripts
30+
- name: Type check (mypy --strict)
31+
run: uv run mypy
32+
- name: Unit tests
33+
run: uv run pytest -q
34+
35+
secret-scan:
36+
name: secret scan (gitleaks)
37+
runs-on: ubuntu-latest
38+
steps:
39+
- uses: actions/checkout@v4
40+
- name: Scan working tree for leaked tokens/secrets
41+
run: |
42+
curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz" | tar -xz gitleaks
43+
./gitleaks dir . --redact --no-banner

.github/workflows/publish.yml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: Publish
2+
3+
on:
4+
release:
5+
types: [published]
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
build:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
- uses: astral-sh/setup-uv@v6
16+
with:
17+
python-version: "3.12"
18+
- name: Build sdist + wheel
19+
run: uv build
20+
- name: Verify metadata
21+
run: uvx twine check dist/*
22+
- uses: actions/upload-artifact@v4
23+
with:
24+
name: dist
25+
path: dist/
26+
27+
testpypi:
28+
needs: build
29+
if: github.event.release.prerelease == true
30+
runs-on: ubuntu-latest
31+
environment:
32+
name: testpypi
33+
url: https://test.pypi.org/p/interfaze
34+
permissions:
35+
id-token: write
36+
steps:
37+
- uses: actions/download-artifact@v4
38+
with:
39+
name: dist
40+
path: dist/
41+
- uses: pypa/gh-action-pypi-publish@release/v1
42+
with:
43+
repository-url: https://test.pypi.org/legacy/
44+
45+
pypi:
46+
needs: build
47+
if: github.event.release.prerelease == false
48+
runs-on: ubuntu-latest
49+
environment:
50+
name: pypi
51+
url: https://pypi.org/p/interfaze
52+
permissions:
53+
id-token: write
54+
steps:
55+
- uses: actions/download-artifact@v4
56+
with:
57+
name: dist
58+
path: dist/
59+
- uses: pypa/gh-action-pypi-publish@release/v1

.github/workflows/qa-live.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Live QA
2+
3+
# Live e2e against the real API; gated on the INTERFAZE_API_KEY secret (never on PRs).
4+
5+
on:
6+
workflow_dispatch:
7+
schedule:
8+
- cron: "0 6 * * 1" # weekly, Monday 06:00 UTC
9+
10+
concurrency:
11+
group: live-qa
12+
cancel-in-progress: true
13+
14+
jobs:
15+
live-qa:
16+
name: live QA (multimodal + token usage + tasks)
17+
runs-on: ubuntu-latest
18+
steps:
19+
- uses: actions/checkout@v4
20+
- uses: astral-sh/setup-uv@v6
21+
with:
22+
python-version: "3.12"
23+
enable-cache: true
24+
- name: Install
25+
run: uv sync
26+
- name: Run live QA
27+
env:
28+
INTERFAZE_API_KEY: ${{ secrets.INTERFAZE_API_KEY }}
29+
run: uv run python scripts/qa_live.py

scripts/qa_live.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ def text_generation():
6868
return f"vcache={r.vcache}"
6969

7070

71+
def token_usage():
72+
r = client.chat.completions.create(messages=[{"role": "user", "content": "Say hi."}], max_tokens=30)
73+
u = r.usage
74+
_assert(u is not None, "no usage object")
75+
_assert(u.prompt_tokens > 0 and u.completion_tokens > 0 and u.total_tokens > 0, "zero token counts")
76+
return f"prompt={u.prompt_tokens} completion={u.completion_tokens} total={u.total_tokens}"
77+
78+
7179
def structured_output():
7280
r = client.chat.completions.create(
7381
messages=[{"role": "user", "content": "Give a greeting and the number 3."}],
@@ -187,6 +195,7 @@ async def go():
187195

188196

189197
check("text generation", text_generation)
198+
check("token usage", token_usage)
190199
check("structured output", structured_output)
191200
check("json_object fence stripped", json_object_fence)
192201
check("tools -> content None", tools_content_none)

tests/test_chat.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,16 @@ def test_tools_content_none():
177177
assert r.choices[0].message.tool_calls[0].function.name == "get_weather"
178178

179179

180+
@respx.mock
181+
def test_usage_tokens_surfaced():
182+
mock_json(BASIC)
183+
r = Interfaze(api_key="t").chat.completions.create(messages=[{"role": "user", "content": "hi"}])
184+
assert r.usage is not None
185+
assert r.usage.prompt_tokens == 5
186+
assert r.usage.completion_tokens == 3
187+
assert r.usage.total_tokens == 8
188+
189+
180190
# ---- async ----
181191
@respx.mock
182192
def test_async_mapping():

tests/test_inputs_and_client.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@ def test_audio_rejects_blacklisted_data_uri():
5050
inputs.audio("data:image/gif;base64,AAAA")
5151

5252

53+
def test_video_uses_file_part():
54+
part = inputs.video("https://x.com/clip.mp4")
55+
assert part["type"] == "file" and part["file"]["file_data"] == "https://x.com/clip.mp4"
56+
57+
58+
def test_base64_image_part():
59+
url = inputs.data_url(b"\x89PNG\r\n", "image/png")
60+
part = inputs.image(url)
61+
assert part["type"] == "image_url" and part["image_url"]["url"].startswith("data:image/png;base64,")
62+
63+
5364
def test_gif_rejected():
5465
with pytest.raises(InterfazeError):
5566
inputs.image("https://x.com/a.gif")

0 commit comments

Comments
 (0)