tokenbiryani 0.2.0__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.
- tokenbiryani-0.2.0/.dockerignore +59 -0
- tokenbiryani-0.2.0/.env.example +19 -0
- tokenbiryani-0.2.0/.github/ISSUE_TEMPLATE/bug_report.yml +55 -0
- tokenbiryani-0.2.0/.github/ISSUE_TEMPLATE/config.yml +6 -0
- tokenbiryani-0.2.0/.github/ISSUE_TEMPLATE/feature_request.yml +27 -0
- tokenbiryani-0.2.0/.github/pull_request_template.md +29 -0
- tokenbiryani-0.2.0/.github/workflows/changelog.yml +44 -0
- tokenbiryani-0.2.0/.github/workflows/ci.yml +235 -0
- tokenbiryani-0.2.0/.github/workflows/release.yml +186 -0
- tokenbiryani-0.2.0/.gitignore +51 -0
- tokenbiryani-0.2.0/CHANGELOG.md +348 -0
- tokenbiryani-0.2.0/CODE_OF_CONDUCT.md +129 -0
- tokenbiryani-0.2.0/CONTRIBUTING.md +199 -0
- tokenbiryani-0.2.0/Dockerfile +79 -0
- tokenbiryani-0.2.0/LICENSE +202 -0
- tokenbiryani-0.2.0/PKG-INFO +697 -0
- tokenbiryani-0.2.0/PLAN.md +413 -0
- tokenbiryani-0.2.0/README.md +445 -0
- tokenbiryani-0.2.0/SECURITY.md +25 -0
- tokenbiryani-0.2.0/TODO.md +103 -0
- tokenbiryani-0.2.0/benchmarks/cache_affinity.py +192 -0
- tokenbiryani-0.2.0/docker/tokenbiryani.yaml +50 -0
- tokenbiryani-0.2.0/docker-compose.yml +80 -0
- tokenbiryani-0.2.0/docs/UI-DESIGN.md +264 -0
- tokenbiryani-0.2.0/docs/UX-PLAN.md +504 -0
- tokenbiryani-0.2.0/docs/adr/0001-sticky-affinity-by-default.md +29 -0
- tokenbiryani-0.2.0/docs/adr/0002-error-taxonomy.md +28 -0
- tokenbiryani-0.2.0/docs/adr/0003-api-keys-only.md +34 -0
- tokenbiryani-0.2.0/docs/adr/0004-subscription-login-in-core.md +88 -0
- tokenbiryani-0.2.0/docs/caching.md +91 -0
- tokenbiryani-0.2.0/docs/configuration.md +156 -0
- tokenbiryani-0.2.0/docs/deployment.md +136 -0
- tokenbiryani-0.2.0/docs/extending.md +97 -0
- tokenbiryani-0.2.0/docs/images/console-account-detail.png +0 -0
- tokenbiryani-0.2.0/docs/images/console-accounts.png +0 -0
- tokenbiryani-0.2.0/docs/images/console-config-override.png +0 -0
- tokenbiryani-0.2.0/docs/images/console-key-editor.png +0 -0
- tokenbiryani-0.2.0/docs/images/console-keys.png +0 -0
- tokenbiryani-0.2.0/docs/images/console-overview.png +0 -0
- tokenbiryani-0.2.0/docs/images/console-settings.png +0 -0
- tokenbiryani-0.2.0/docs/images/console-subscription-session.png +0 -0
- tokenbiryani-0.2.0/docs/images/console-subscription-token.png +0 -0
- tokenbiryani-0.2.0/docs/index.md +39 -0
- tokenbiryani-0.2.0/docs/oauth.md +242 -0
- tokenbiryani-0.2.0/docs/operating.md +125 -0
- tokenbiryani-0.2.0/docs/pacing.md +146 -0
- tokenbiryani-0.2.0/docs/providers.md +89 -0
- tokenbiryani-0.2.0/docs/quickstart.md +103 -0
- tokenbiryani-0.2.0/docs/routing.md +118 -0
- tokenbiryani-0.2.0/docs/streaming.md +52 -0
- tokenbiryani-0.2.0/docs/usage.md +68 -0
- tokenbiryani-0.2.0/mkdocs.yml +55 -0
- tokenbiryani-0.2.0/pyproject.toml +90 -0
- tokenbiryani-0.2.0/scripts/changelog.py +121 -0
- tokenbiryani-0.2.0/scripts/dev.sh +115 -0
- tokenbiryani-0.2.0/scripts/screenshots.py +144 -0
- tokenbiryani-0.2.0/scripts/smoke.sh +177 -0
- tokenbiryani-0.2.0/src/tokenbiryani/__init__.py +3 -0
- tokenbiryani-0.2.0/src/tokenbiryani/api/__init__.py +0 -0
- tokenbiryani-0.2.0/src/tokenbiryani/api/app.py +583 -0
- tokenbiryani-0.2.0/src/tokenbiryani/api/asgi.py +32 -0
- tokenbiryani-0.2.0/src/tokenbiryani/cli.py +1045 -0
- tokenbiryani-0.2.0/src/tokenbiryani/config.py +532 -0
- tokenbiryani-0.2.0/src/tokenbiryani/core/__init__.py +0 -0
- tokenbiryani-0.2.0/src/tokenbiryani/core/account.py +258 -0
- tokenbiryani-0.2.0/src/tokenbiryani/core/batch.py +135 -0
- tokenbiryani-0.2.0/src/tokenbiryani/core/breaker.py +53 -0
- tokenbiryani-0.2.0/src/tokenbiryani/core/cacheadvice.py +239 -0
- tokenbiryani-0.2.0/src/tokenbiryani/core/diagnostics.py +131 -0
- tokenbiryani-0.2.0/src/tokenbiryani/core/estimator.py +180 -0
- tokenbiryani-0.2.0/src/tokenbiryani/core/gateway.py +2395 -0
- tokenbiryani-0.2.0/src/tokenbiryani/core/handoff.py +87 -0
- tokenbiryani-0.2.0/src/tokenbiryani/core/keys.py +199 -0
- tokenbiryani-0.2.0/src/tokenbiryani/core/limits.py +440 -0
- tokenbiryani-0.2.0/src/tokenbiryani/core/oauth.py +222 -0
- tokenbiryani-0.2.0/src/tokenbiryani/core/pacing.py +320 -0
- tokenbiryani-0.2.0/src/tokenbiryani/core/queue.py +132 -0
- tokenbiryani-0.2.0/src/tokenbiryani/core/router.py +323 -0
- tokenbiryani-0.2.0/src/tokenbiryani/core/secrets.py +114 -0
- tokenbiryani-0.2.0/src/tokenbiryani/core/session.py +117 -0
- tokenbiryani-0.2.0/src/tokenbiryani/dashboard/__init__.py +56 -0
- tokenbiryani-0.2.0/src/tokenbiryani/dashboard/console.css +610 -0
- tokenbiryani-0.2.0/src/tokenbiryani/dashboard/console.html +3250 -0
- tokenbiryani-0.2.0/src/tokenbiryani/observability/__init__.py +0 -0
- tokenbiryani-0.2.0/src/tokenbiryani/observability/events.py +171 -0
- tokenbiryani-0.2.0/src/tokenbiryani/observability/usage.py +226 -0
- tokenbiryani-0.2.0/src/tokenbiryani/prices.yaml +77 -0
- tokenbiryani-0.2.0/src/tokenbiryani/providers/__init__.py +0 -0
- tokenbiryani-0.2.0/src/tokenbiryani/providers/anthropic_api.py +118 -0
- tokenbiryani-0.2.0/src/tokenbiryani/providers/base.py +173 -0
- tokenbiryani-0.2.0/src/tokenbiryani/providers/bedrock.py +182 -0
- tokenbiryani-0.2.0/src/tokenbiryani/providers/oauth.py +165 -0
- tokenbiryani-0.2.0/src/tokenbiryani/providers/oauth_credentials.py +293 -0
- tokenbiryani-0.2.0/src/tokenbiryani/providers/translate.py +35 -0
- tokenbiryani-0.2.0/src/tokenbiryani/providers/vertex.py +144 -0
- tokenbiryani-0.2.0/src/tokenbiryani/proxy/__init__.py +0 -0
- tokenbiryani-0.2.0/src/tokenbiryani/proxy/errors.py +169 -0
- tokenbiryani-0.2.0/src/tokenbiryani/proxy/sse.py +98 -0
- tokenbiryani-0.2.0/src/tokenbiryani/store/__init__.py +0 -0
- tokenbiryani-0.2.0/src/tokenbiryani/store/base.py +150 -0
- tokenbiryani-0.2.0/src/tokenbiryani/store/memory.py +149 -0
- tokenbiryani-0.2.0/src/tokenbiryani/store/redis_store.py +222 -0
- tokenbiryani-0.2.0/src/tokenbiryani/store/sqlite.py +336 -0
- tokenbiryani-0.2.0/tests/conftest.py +124 -0
- tokenbiryani-0.2.0/tests/support/__init__.py +12 -0
- tokenbiryani-0.2.0/tests/support/mock_upstream.py +556 -0
- tokenbiryani-0.2.0/tests/support/server.py +128 -0
- tokenbiryani-0.2.0/tests/test_api.py +147 -0
- tokenbiryani-0.2.0/tests/test_batch_spill.py +140 -0
- tokenbiryani-0.2.0/tests/test_benchmark.py +71 -0
- tokenbiryani-0.2.0/tests/test_cache_advice.py +229 -0
- tokenbiryani-0.2.0/tests/test_cli.py +406 -0
- tokenbiryani-0.2.0/tests/test_cloud_providers.py +263 -0
- tokenbiryani-0.2.0/tests/test_concurrency.py +168 -0
- tokenbiryani-0.2.0/tests/test_console.py +202 -0
- tokenbiryani-0.2.0/tests/test_console_admin.py +363 -0
- tokenbiryani-0.2.0/tests/test_console_ui.py +819 -0
- tokenbiryani-0.2.0/tests/test_errors.py +69 -0
- tokenbiryani-0.2.0/tests/test_estimator.py +241 -0
- tokenbiryani-0.2.0/tests/test_gateway.py +236 -0
- tokenbiryani-0.2.0/tests/test_key_admin.py +200 -0
- tokenbiryani-0.2.0/tests/test_limits.py +94 -0
- tokenbiryani-0.2.0/tests/test_managed_accounts.py +426 -0
- tokenbiryani-0.2.0/tests/test_oauth_flow.py +359 -0
- tokenbiryani-0.2.0/tests/test_onboarding_ui.py +194 -0
- tokenbiryani-0.2.0/tests/test_pacing.py +477 -0
- tokenbiryani-0.2.0/tests/test_priority.py +118 -0
- tokenbiryani-0.2.0/tests/test_queue.py +65 -0
- tokenbiryani-0.2.0/tests/test_reload.py +117 -0
- tokenbiryani-0.2.0/tests/test_router.py +164 -0
- tokenbiryani-0.2.0/tests/test_session.py +78 -0
- tokenbiryani-0.2.0/tests/test_sessions.py +191 -0
- tokenbiryani-0.2.0/tests/test_store.py +287 -0
- tokenbiryani-0.2.0/tests/test_strategy_plugins.py +149 -0
- tokenbiryani-0.2.0/tests/test_streaming.py +102 -0
- tokenbiryani-0.2.0/tests/test_unified_limits.py +153 -0
- tokenbiryani-0.2.0/tests/test_unobservable_limits.py +151 -0
- tokenbiryani-0.2.0/tests/test_usage.py +306 -0
- tokenbiryani-0.2.0/tokenbiryani.example.yaml +255 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Keep the build context small and the image free of anything that is not the
|
|
2
|
+
# application. Everything here is either regenerated, machine-local, or a secret.
|
|
3
|
+
|
|
4
|
+
# --- VCS and CI ---
|
|
5
|
+
.git
|
|
6
|
+
.gitignore
|
|
7
|
+
.github
|
|
8
|
+
|
|
9
|
+
# --- local environments ---
|
|
10
|
+
.venv
|
|
11
|
+
venv
|
|
12
|
+
env
|
|
13
|
+
.env
|
|
14
|
+
.env.*
|
|
15
|
+
|
|
16
|
+
# --- Python build and cache artefacts ---
|
|
17
|
+
__pycache__
|
|
18
|
+
*.py[cod]
|
|
19
|
+
*.egg-info
|
|
20
|
+
dist
|
|
21
|
+
build
|
|
22
|
+
.eggs
|
|
23
|
+
.pytest_cache
|
|
24
|
+
.mypy_cache
|
|
25
|
+
.ruff_cache
|
|
26
|
+
.coverage
|
|
27
|
+
.coverage.*
|
|
28
|
+
htmlcov
|
|
29
|
+
.tox
|
|
30
|
+
.nox
|
|
31
|
+
|
|
32
|
+
# --- not part of the application ---
|
|
33
|
+
benchmarks
|
|
34
|
+
docs
|
|
35
|
+
site
|
|
36
|
+
mkdocs.yml
|
|
37
|
+
scripts
|
|
38
|
+
*.md
|
|
39
|
+
!README.md
|
|
40
|
+
|
|
41
|
+
# --- editor and OS noise ---
|
|
42
|
+
.idea
|
|
43
|
+
.vscode
|
|
44
|
+
.DS_Store
|
|
45
|
+
Thumbs.db
|
|
46
|
+
|
|
47
|
+
# --- state, credentials and anything machine-local ---
|
|
48
|
+
# The image must never carry a config or a key baked in: both are mounted or
|
|
49
|
+
# passed as environment at run time.
|
|
50
|
+
tokenbiryani.yaml
|
|
51
|
+
*.db
|
|
52
|
+
*.db-shm
|
|
53
|
+
*.db-wal
|
|
54
|
+
*.sqlite3
|
|
55
|
+
*.key
|
|
56
|
+
.manual-test-key
|
|
57
|
+
docker-compose.yml
|
|
58
|
+
Dockerfile
|
|
59
|
+
.dockerignore
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Copy to .env and edit. docker compose reads .env automatically; it is gitignored.
|
|
2
|
+
#
|
|
3
|
+
# cp .env.example .env
|
|
4
|
+
# docker compose run --rm --no-deps gateway keygen # TOKENBIRYANI_KEY
|
|
5
|
+
# docker compose run --rm --no-deps gateway keygen --secret # TOKENBIRYANI_SECRET_KEY
|
|
6
|
+
|
|
7
|
+
# The virtual key your clients authenticate with. Anything starting bir_ works.
|
|
8
|
+
TOKENBIRYANI_KEY=bir_dev_only_change_me_0123456789
|
|
9
|
+
|
|
10
|
+
# Encrypts account credentials before they reach Redis. Redis outlives the
|
|
11
|
+
# container, so this key must too — lose it and the accounts you added from the
|
|
12
|
+
# console are unrecoverable. Leave blank only for a throwaway stack.
|
|
13
|
+
TOKENBIRYANI_SECRET_KEY=
|
|
14
|
+
|
|
15
|
+
# Optional. The stack boots against a bundled mock upstream without it; set it only
|
|
16
|
+
# if you uncomment the real account in docker/tokenbiryani.yaml. Adding accounts
|
|
17
|
+
# from the console is the better path — they are stored encrypted and can be
|
|
18
|
+
# renamed, tested and rotated in place.
|
|
19
|
+
ANTHROPIC_API_KEY=
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
name: Bug report
|
|
2
|
+
description: Something behaved differently from what the docs say.
|
|
3
|
+
labels: [bug]
|
|
4
|
+
body:
|
|
5
|
+
- type: markdown
|
|
6
|
+
attributes:
|
|
7
|
+
value: |
|
|
8
|
+
Please do not paste API keys, virtual keys, or prompt contents. The request
|
|
9
|
+
id from `/admin/requests` and the account id are usually enough.
|
|
10
|
+
- type: input
|
|
11
|
+
id: version
|
|
12
|
+
attributes:
|
|
13
|
+
label: Version
|
|
14
|
+
placeholder: "0.1.0, or a commit sha"
|
|
15
|
+
validations: { required: true }
|
|
16
|
+
- type: dropdown
|
|
17
|
+
id: surface
|
|
18
|
+
attributes:
|
|
19
|
+
label: Where
|
|
20
|
+
options:
|
|
21
|
+
- Routing / failover
|
|
22
|
+
- Streaming
|
|
23
|
+
- Rate limits, leases or the queue
|
|
24
|
+
- Session affinity / prompt cache
|
|
25
|
+
- Keys and auth
|
|
26
|
+
- Console or admin API
|
|
27
|
+
- CLI
|
|
28
|
+
- A provider adapter (Bedrock / Vertex)
|
|
29
|
+
- Config or startup
|
|
30
|
+
- Something else
|
|
31
|
+
validations: { required: true }
|
|
32
|
+
- type: textarea
|
|
33
|
+
id: expected
|
|
34
|
+
attributes:
|
|
35
|
+
label: What you expected, and what happened instead
|
|
36
|
+
validations: { required: true }
|
|
37
|
+
- type: textarea
|
|
38
|
+
id: repro
|
|
39
|
+
attributes:
|
|
40
|
+
label: Reproduction
|
|
41
|
+
description: >
|
|
42
|
+
The most useful form is a failing test against the mock upstream
|
|
43
|
+
(`tests/support/mock_upstream.py`) — see CONTRIBUTING.
|
|
44
|
+
render: python
|
|
45
|
+
- type: textarea
|
|
46
|
+
id: config
|
|
47
|
+
attributes:
|
|
48
|
+
label: Relevant config
|
|
49
|
+
description: Redact api_key and key values.
|
|
50
|
+
render: yaml
|
|
51
|
+
- type: textarea
|
|
52
|
+
id: logs
|
|
53
|
+
attributes:
|
|
54
|
+
label: Log lines or the request inspector output
|
|
55
|
+
render: shell
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
blank_issues_enabled: true
|
|
2
|
+
# A contact_link needs an absolute URL, and this repository has no home yet, so
|
|
3
|
+
# pointing at a private-advisory page would be a dead link on the one form people
|
|
4
|
+
# use when something is wrong. GitHub surfaces SECURITY.md by itself, and that file
|
|
5
|
+
# says how to report privately.
|
|
6
|
+
contact_links: []
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: Feature request
|
|
2
|
+
description: Propose something the gateway should do.
|
|
3
|
+
labels: [enhancement]
|
|
4
|
+
body:
|
|
5
|
+
- type: textarea
|
|
6
|
+
id: problem
|
|
7
|
+
attributes:
|
|
8
|
+
label: The problem
|
|
9
|
+
description: What are you trying to do that you cannot do today?
|
|
10
|
+
validations: { required: true }
|
|
11
|
+
- type: textarea
|
|
12
|
+
id: proposal
|
|
13
|
+
attributes:
|
|
14
|
+
label: Proposal
|
|
15
|
+
- type: dropdown
|
|
16
|
+
id: shape
|
|
17
|
+
attributes:
|
|
18
|
+
label: Does this fit an existing extension point?
|
|
19
|
+
description: >
|
|
20
|
+
Routing strategies and provider adapters can ship as separate packages;
|
|
21
|
+
see CONTRIBUTING. If yours fits one, it may not need to live here.
|
|
22
|
+
options:
|
|
23
|
+
- A routing strategy
|
|
24
|
+
- A provider adapter
|
|
25
|
+
- A state store backend
|
|
26
|
+
- Neither — it needs to be in the core
|
|
27
|
+
validations: { required: true }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
## What this changes
|
|
2
|
+
|
|
3
|
+
<!-- One or two sentences. -->
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
<!-- The problem, not the patch. -->
|
|
8
|
+
|
|
9
|
+
## Checks
|
|
10
|
+
|
|
11
|
+
- [ ] `ruff check .`
|
|
12
|
+
- [ ] `mypy`
|
|
13
|
+
- [ ] `pytest`
|
|
14
|
+
- [ ] Tests cover the behaviour, not the implementation detail
|
|
15
|
+
- [ ] `CHANGELOG.md` has a line under `## [Unreleased]` — or the `no-changelog`
|
|
16
|
+
label, because this changes nothing a user would notice
|
|
17
|
+
|
|
18
|
+
## House rules this touches
|
|
19
|
+
|
|
20
|
+
<!-- Delete what does not apply, and say how you kept to the rest. -->
|
|
21
|
+
|
|
22
|
+
- [ ] **Router, not a rewriter** — nothing modifies a request body outside
|
|
23
|
+
`providers/translate.py`
|
|
24
|
+
- [ ] **Shared state goes through `StateStore`** — no reaching around it
|
|
25
|
+
- [ ] **New failure modes go in the taxonomy** — account action and request action
|
|
26
|
+
stay separate decisions
|
|
27
|
+
- [ ] **No prompt bodies logged** on any default path
|
|
28
|
+
- [ ] Cache hit rate is not quietly traded away — `benchmarks/cache_affinity.py`
|
|
29
|
+
still shows sticky winning
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
name: changelog
|
|
2
|
+
|
|
3
|
+
# Its own file, and not a job in ci.yml, because of the label. `pull_request` without
|
|
4
|
+
# a `types` list means [opened, synchronize, reopened] — so applying `no-changelog`
|
|
5
|
+
# would never re-run the check that the label exists to switch off, and the required
|
|
6
|
+
# check would stay red until the author pushed an unrelated commit. Listing the label
|
|
7
|
+
# events here fixes that without re-running the whole test matrix every time someone
|
|
8
|
+
# tidies up a label.
|
|
9
|
+
on:
|
|
10
|
+
pull_request:
|
|
11
|
+
types: [opened, synchronize, reopened, labeled, unlabeled]
|
|
12
|
+
|
|
13
|
+
concurrency:
|
|
14
|
+
group: changelog-${{ github.event.pull_request.number }}
|
|
15
|
+
cancel-in-progress: true
|
|
16
|
+
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
changelog:
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
timeout-minutes: 5
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v4
|
|
26
|
+
with: { fetch-depth: 0 }
|
|
27
|
+
- name: The change is described in CHANGELOG.md
|
|
28
|
+
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-changelog') }}
|
|
29
|
+
env:
|
|
30
|
+
BASE: ${{ github.base_ref }}
|
|
31
|
+
run: |
|
|
32
|
+
git fetch --no-tags origin "$BASE"
|
|
33
|
+
if git diff --name-only "origin/$BASE...HEAD" | grep -qx 'CHANGELOG.md'; then
|
|
34
|
+
echo "CHANGELOG.md updated."
|
|
35
|
+
exit 0
|
|
36
|
+
fi
|
|
37
|
+
echo "::error file=CHANGELOG.md::This pull request changes nothing in \
|
|
38
|
+
CHANGELOG.md. Add a line under '## [Unreleased]' describing what changed \
|
|
39
|
+
for someone upgrading — or apply the 'no-changelog' label if it genuinely \
|
|
40
|
+
changes nothing a user would notice (a test, a refactor, a typo)."
|
|
41
|
+
exit 1
|
|
42
|
+
- name: Labelled no-changelog
|
|
43
|
+
if: ${{ contains(github.event.pull_request.labels.*.name, 'no-changelog') }}
|
|
44
|
+
run: echo "Labelled no-changelog — nothing a user would notice. Not checking."
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
|
|
3
|
+
# Every push to the trunk, every pull request, and — through `workflow_call` — every
|
|
4
|
+
# tag, because release.yml runs this whole file before it publishes anything. There is
|
|
5
|
+
# one definition of "green" and the release gate uses it rather than a second copy.
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
branches: [main]
|
|
9
|
+
pull_request:
|
|
10
|
+
workflow_call:
|
|
11
|
+
|
|
12
|
+
# A force-push or a quick second commit supersedes the run before it. Without this the
|
|
13
|
+
# queue fills with runs nobody will read, which on a free plan is the whole budget.
|
|
14
|
+
concurrency:
|
|
15
|
+
group: ci-${{ github.workflow }}-${{ github.ref }}
|
|
16
|
+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
17
|
+
|
|
18
|
+
# Least privilege by default. The jobs that need more ask for it themselves.
|
|
19
|
+
permissions:
|
|
20
|
+
contents: read
|
|
21
|
+
|
|
22
|
+
jobs:
|
|
23
|
+
lint:
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
timeout-minutes: 10
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v4
|
|
28
|
+
- uses: actions/setup-python@v5
|
|
29
|
+
with:
|
|
30
|
+
python-version: "3.12"
|
|
31
|
+
cache: pip
|
|
32
|
+
- run: pip install -e ".[dev]"
|
|
33
|
+
- run: ruff check .
|
|
34
|
+
# The type check targets 3.8 regardless of the runner, so once is enough. It is
|
|
35
|
+
# deliberately not strict: it exists to catch Optional handling that would crash
|
|
36
|
+
# at runtime, not to make anyone annotate every local.
|
|
37
|
+
- run: mypy
|
|
38
|
+
|
|
39
|
+
test:
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
timeout-minutes: 20
|
|
42
|
+
strategy:
|
|
43
|
+
fail-fast: false
|
|
44
|
+
matrix:
|
|
45
|
+
python-version: ["3.8", "3.10", "3.12"]
|
|
46
|
+
steps:
|
|
47
|
+
- uses: actions/checkout@v4
|
|
48
|
+
- uses: actions/setup-python@v5
|
|
49
|
+
with:
|
|
50
|
+
python-version: ${{ matrix.python-version }}
|
|
51
|
+
cache: pip
|
|
52
|
+
- run: pip install -e ".[dev]"
|
|
53
|
+
# The browser suites are excluded here and run in `ui`, once, against a pinned
|
|
54
|
+
# Chromium. They used to run in every matrix entry against whatever Chrome the
|
|
55
|
+
# runner happened to ship, which made a real timeout look like a 3.8 problem.
|
|
56
|
+
- run: |
|
|
57
|
+
pytest -q \
|
|
58
|
+
--ignore=tests/test_console_ui.py \
|
|
59
|
+
--ignore=tests/test_onboarding_ui.py \
|
|
60
|
+
--cov=tokenbiryani --cov-report=term-missing --cov-fail-under=80
|
|
61
|
+
|
|
62
|
+
redis:
|
|
63
|
+
runs-on: ubuntu-latest
|
|
64
|
+
timeout-minutes: 10
|
|
65
|
+
services:
|
|
66
|
+
redis:
|
|
67
|
+
image: redis:7
|
|
68
|
+
ports: ["6379:6379"]
|
|
69
|
+
options: >-
|
|
70
|
+
--health-cmd "redis-cli ping"
|
|
71
|
+
--health-interval 5s --health-timeout 3s --health-retries 10
|
|
72
|
+
steps:
|
|
73
|
+
- uses: actions/checkout@v4
|
|
74
|
+
- uses: actions/setup-python@v5
|
|
75
|
+
with:
|
|
76
|
+
python-version: "3.12"
|
|
77
|
+
cache: pip
|
|
78
|
+
- run: pip install -e ".[dev]"
|
|
79
|
+
# The suite covers the Redis backend with fakeredis; this job runs the same
|
|
80
|
+
# store against a real server, where inclusive/exclusive range semantics bite.
|
|
81
|
+
- run: pytest -q tests/test_store.py
|
|
82
|
+
env:
|
|
83
|
+
TOKENBIRYANI_REDIS_URL: redis://127.0.0.1:6379/0
|
|
84
|
+
|
|
85
|
+
smoke:
|
|
86
|
+
runs-on: ubuntu-latest
|
|
87
|
+
timeout-minutes: 10
|
|
88
|
+
steps:
|
|
89
|
+
- uses: actions/checkout@v4
|
|
90
|
+
- uses: actions/setup-python@v5
|
|
91
|
+
with:
|
|
92
|
+
python-version: "3.12"
|
|
93
|
+
cache: pip
|
|
94
|
+
- run: pip install -e ".[dev]"
|
|
95
|
+
- run: bash scripts/smoke.sh
|
|
96
|
+
|
|
97
|
+
ui:
|
|
98
|
+
runs-on: ubuntu-latest
|
|
99
|
+
timeout-minutes: 20
|
|
100
|
+
steps:
|
|
101
|
+
- uses: actions/checkout@v4
|
|
102
|
+
- uses: actions/setup-python@v5
|
|
103
|
+
with:
|
|
104
|
+
python-version: "3.12"
|
|
105
|
+
cache: pip
|
|
106
|
+
- run: pip install -e ".[dev]"
|
|
107
|
+
- run: python -m playwright install --with-deps chromium
|
|
108
|
+
# Drives the real console in a real browser. The static assertions in
|
|
109
|
+
# tests/test_console.py cannot catch a panel that renders empty.
|
|
110
|
+
- run: pytest -q tests/test_console_ui.py tests/test_onboarding_ui.py
|
|
111
|
+
env:
|
|
112
|
+
TOKENBIRYANI_UI_BROWSER: chromium
|
|
113
|
+
|
|
114
|
+
docs:
|
|
115
|
+
runs-on: ubuntu-latest
|
|
116
|
+
timeout-minutes: 10
|
|
117
|
+
steps:
|
|
118
|
+
- uses: actions/checkout@v4
|
|
119
|
+
- uses: actions/setup-python@v5
|
|
120
|
+
with:
|
|
121
|
+
python-version: "3.12"
|
|
122
|
+
cache: pip
|
|
123
|
+
- run: pip install -e ".[docs]"
|
|
124
|
+
# --strict turns a broken internal link into a failure, which is the only way
|
|
125
|
+
# docs links stay correct.
|
|
126
|
+
- run: mkdocs build --strict
|
|
127
|
+
|
|
128
|
+
package:
|
|
129
|
+
# A wheel that does not build, or that quietly starts shipping the test doubles
|
|
130
|
+
# again, is only discovered at release time otherwise — which is the worst moment.
|
|
131
|
+
runs-on: ubuntu-latest
|
|
132
|
+
timeout-minutes: 10
|
|
133
|
+
steps:
|
|
134
|
+
- uses: actions/checkout@v4
|
|
135
|
+
- uses: actions/setup-python@v5
|
|
136
|
+
with:
|
|
137
|
+
python-version: "3.12"
|
|
138
|
+
cache: pip
|
|
139
|
+
- run: pip install build twine
|
|
140
|
+
- run: python -m build
|
|
141
|
+
- run: twine check dist/*
|
|
142
|
+
- name: The wheel carries the package and nothing else
|
|
143
|
+
run: |
|
|
144
|
+
wheel=$(ls dist/*.whl)
|
|
145
|
+
python - "$wheel" <<'PY'
|
|
146
|
+
import sys, zipfile
|
|
147
|
+
names = zipfile.ZipFile(sys.argv[1]).namelist()
|
|
148
|
+
assert any(n.endswith("prices.yaml") for n in names), "prices.yaml missing"
|
|
149
|
+
assert any(n.endswith("console.html") for n in names), "console.html missing"
|
|
150
|
+
leaked = [n for n in names if "/testing/" in n or n.startswith("tests/")]
|
|
151
|
+
assert not leaked, f"test scaffolding in the wheel: {leaked}"
|
|
152
|
+
print("wheel contents look right")
|
|
153
|
+
PY
|
|
154
|
+
|
|
155
|
+
docker:
|
|
156
|
+
runs-on: ubuntu-latest
|
|
157
|
+
timeout-minutes: 25
|
|
158
|
+
steps:
|
|
159
|
+
- uses: actions/checkout@v4
|
|
160
|
+
- uses: docker/setup-buildx-action@v3
|
|
161
|
+
- uses: docker/build-push-action@v6
|
|
162
|
+
with:
|
|
163
|
+
context: .
|
|
164
|
+
load: true
|
|
165
|
+
tags: tokenbiryani:ci
|
|
166
|
+
cache-from: type=gha
|
|
167
|
+
cache-to: type=gha,mode=max
|
|
168
|
+
# Prove the image boots and serves, not just that it builds.
|
|
169
|
+
- name: Run the image
|
|
170
|
+
run: |
|
|
171
|
+
cat > ci.yaml <<'YAML'
|
|
172
|
+
server: {host: 0.0.0.0, port: 8787, allow_remote: true}
|
|
173
|
+
accounts:
|
|
174
|
+
- {id: acct-01, type: anthropic_api, api_key: unused, base_url: "http://127.0.0.1:1"}
|
|
175
|
+
keys:
|
|
176
|
+
- {key: bir_ci_key_0123456789abcdef, name: ci, admin: true}
|
|
177
|
+
YAML
|
|
178
|
+
cid=$(docker run -d -p 8787:8787 \
|
|
179
|
+
-v "$PWD/ci.yaml:/app/tokenbiryani.yaml:ro" tokenbiryani:ci)
|
|
180
|
+
for _ in $(seq 40); do
|
|
181
|
+
curl -sf -o /dev/null http://127.0.0.1:8787/healthz && break || sleep 0.5
|
|
182
|
+
done
|
|
183
|
+
curl -sf http://127.0.0.1:8787/healthz
|
|
184
|
+
curl -sf http://127.0.0.1:8787/console | grep -q "tokenbiryani console"
|
|
185
|
+
# The console is a page plus a stylesheet now. A missing stylesheet still
|
|
186
|
+
# serves a 200 page, so check the asset itself.
|
|
187
|
+
curl -sf http://127.0.0.1:8787/console.css | grep -q -- "--ready:"
|
|
188
|
+
# Adding an account needs cryptography and a writable key path, and the
|
|
189
|
+
# image had neither. Nothing above this line would have caught that.
|
|
190
|
+
curl -sf -X POST http://127.0.0.1:8787/admin/accounts \
|
|
191
|
+
-H "x-api-key: bir_ci_key_0123456789abcdef" \
|
|
192
|
+
-H "content-type: application/json" \
|
|
193
|
+
-d '{"id":"ci-added","name":"Added in CI","api_key":"sk-ant-unused"}' \
|
|
194
|
+
| grep -q '"name": *"Added in CI"'
|
|
195
|
+
curl -sf "http://127.0.0.1:8787/admin/usage?window=1h" \
|
|
196
|
+
-H "x-api-key: bir_ci_key_0123456789abcdef" | grep -q '"buckets"'
|
|
197
|
+
test "$(docker exec "$cid" id -un)" = tokenbiryani
|
|
198
|
+
docker rm -f "$cid"
|
|
199
|
+
|
|
200
|
+
# The dev target is what docker-compose.yml builds, so a break in it breaks
|
|
201
|
+
# every contributor's stack while the released image stays green.
|
|
202
|
+
- uses: docker/build-push-action@v6
|
|
203
|
+
with:
|
|
204
|
+
context: .
|
|
205
|
+
target: dev
|
|
206
|
+
load: true
|
|
207
|
+
tags: tokenbiryani:dev
|
|
208
|
+
cache-from: type=gha
|
|
209
|
+
cache-to: type=gha,mode=max
|
|
210
|
+
- name: The whole stack comes up, empty, and takes its first account
|
|
211
|
+
run: |
|
|
212
|
+
# No --wait on the gateway: the stack ships with an empty pool on purpose,
|
|
213
|
+
# /healthz answers 503 until an account exists, and the container is
|
|
214
|
+
# therefore "unhealthy" by design until someone adds one. That first-run
|
|
215
|
+
# state is exactly what this asserts, and then it adds the account.
|
|
216
|
+
KEY=bir_dev_only_change_me_0123456789
|
|
217
|
+
docker compose up -d
|
|
218
|
+
for _ in $(seq 60); do
|
|
219
|
+
curl -s -o /dev/null http://127.0.0.1:8787/console && break || sleep 1
|
|
220
|
+
done
|
|
221
|
+
curl -s http://127.0.0.1:8787/healthz | grep -q '"status": *"no_capacity"'
|
|
222
|
+
curl -sf http://127.0.0.1:8787/console.css > /dev/null
|
|
223
|
+
curl -sf -X POST http://127.0.0.1:8787/admin/accounts \
|
|
224
|
+
-H "x-api-key: $KEY" -H "content-type: application/json" \
|
|
225
|
+
-d '{"id":"compose-first","name":"First account","api_key":"sk-ant-unused"}' \
|
|
226
|
+
| grep -q '"name": *"First account"'
|
|
227
|
+
# The account is encrypted into Redis by the console's own path, so this
|
|
228
|
+
# also proves the compose stack can reach and write to its store.
|
|
229
|
+
curl -sf http://127.0.0.1:8787/healthz | grep -q '"ready": *1'
|
|
230
|
+
if docker compose logs gateway | grep -qi traceback; then
|
|
231
|
+
echo "::error::the gateway logged a traceback while coming up"
|
|
232
|
+
docker compose logs gateway
|
|
233
|
+
exit 1
|
|
234
|
+
fi
|
|
235
|
+
docker compose down -v
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
# A tag is the only thing that releases. Nothing here runs from a branch, a button or
|
|
4
|
+
# a schedule, so "what is in 0.2.0" always has one answer: whatever v0.2.0 points at.
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
tags: ["v*"]
|
|
8
|
+
|
|
9
|
+
concurrency:
|
|
10
|
+
group: release-${{ github.ref }}
|
|
11
|
+
cancel-in-progress: false
|
|
12
|
+
|
|
13
|
+
permissions:
|
|
14
|
+
contents: read
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
# The tag has to agree with the tree it points at before anything is built. All
|
|
18
|
+
# three of these have been wrong in real projects: a tag one commit early, a
|
|
19
|
+
# version bump nobody made, and a release whose notes describe the version before.
|
|
20
|
+
guard:
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
timeout-minutes: 5
|
|
23
|
+
outputs:
|
|
24
|
+
version: ${{ steps.version.outputs.version }}
|
|
25
|
+
prerelease: ${{ steps.version.outputs.prerelease }}
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v4
|
|
28
|
+
- uses: actions/setup-python@v5
|
|
29
|
+
with:
|
|
30
|
+
python-version: "3.12"
|
|
31
|
+
- run: pip install packaging
|
|
32
|
+
# PEP 440 decides what a pre-release is, rather than a substring match that
|
|
33
|
+
# calls v1.0.0-alpha and v1.0.0 the same thing because both contain an "a".
|
|
34
|
+
- id: version
|
|
35
|
+
run: |
|
|
36
|
+
python - <<'PY' >> "$GITHUB_OUTPUT"
|
|
37
|
+
import os
|
|
38
|
+
from packaging.version import InvalidVersion, Version
|
|
39
|
+
|
|
40
|
+
tag = os.environ["GITHUB_REF_NAME"].lstrip("v")
|
|
41
|
+
try:
|
|
42
|
+
parsed = Version(tag)
|
|
43
|
+
except InvalidVersion:
|
|
44
|
+
raise SystemExit(f"::error::{tag!r} is not a PEP 440 version")
|
|
45
|
+
print(f"version={tag}")
|
|
46
|
+
print(f"prerelease={str(parsed.is_prerelease).lower()}")
|
|
47
|
+
PY
|
|
48
|
+
- name: The tag, pyproject.toml and __init__.py say the same version
|
|
49
|
+
run: |
|
|
50
|
+
tag="${GITHUB_REF_NAME#v}"
|
|
51
|
+
pyproject=$(python -c "import re,pathlib;print(re.search(r'^version = \"([^\"]+)\"', pathlib.Path('pyproject.toml').read_text(), re.M).group(1))")
|
|
52
|
+
dunder=$(python -c "import re,pathlib;print(re.search(r'__version__ = \"([^\"]+)\"', pathlib.Path('src/tokenbiryani/__init__.py').read_text()).group(1))")
|
|
53
|
+
echo "tag=$tag pyproject=$pyproject __init__=$dunder"
|
|
54
|
+
if [ "$tag" != "$pyproject" ] || [ "$tag" != "$dunder" ]; then
|
|
55
|
+
echo "::error::the tag and the tree disagree about the version. Bump both files, commit, then move the tag."
|
|
56
|
+
exit 1
|
|
57
|
+
fi
|
|
58
|
+
- name: The changelog describes it
|
|
59
|
+
run: python scripts/changelog.py check "$GITHUB_REF_NAME"
|
|
60
|
+
|
|
61
|
+
# The full suite, from ci.yml rather than a second copy of it. A release that skips
|
|
62
|
+
# the tests is the only kind of release worth refusing outright.
|
|
63
|
+
verify:
|
|
64
|
+
needs: [guard]
|
|
65
|
+
uses: ./.github/workflows/ci.yml
|
|
66
|
+
permissions:
|
|
67
|
+
contents: read
|
|
68
|
+
|
|
69
|
+
github-release:
|
|
70
|
+
needs: [guard, verify]
|
|
71
|
+
runs-on: ubuntu-latest
|
|
72
|
+
timeout-minutes: 10
|
|
73
|
+
permissions:
|
|
74
|
+
contents: write # create the release and upload the artifacts
|
|
75
|
+
steps:
|
|
76
|
+
- uses: actions/checkout@v4
|
|
77
|
+
- uses: actions/setup-python@v5
|
|
78
|
+
with:
|
|
79
|
+
python-version: "3.12"
|
|
80
|
+
- run: pip install build
|
|
81
|
+
- run: python -m build
|
|
82
|
+
# The notes are quoted from CHANGELOG.md, never retyped — see scripts/changelog.py.
|
|
83
|
+
- name: Release notes, from the changelog
|
|
84
|
+
run: |
|
|
85
|
+
{
|
|
86
|
+
python scripts/changelog.py extract "$GITHUB_REF_NAME"
|
|
87
|
+
echo
|
|
88
|
+
echo "---"
|
|
89
|
+
echo
|
|
90
|
+
echo '```bash'
|
|
91
|
+
echo "pip install tokenbiryani==${{ needs.guard.outputs.version }}"
|
|
92
|
+
echo "docker pull ghcr.io/${{ github.repository }}:${{ needs.guard.outputs.version }}"
|
|
93
|
+
echo '```'
|
|
94
|
+
} > notes.md
|
|
95
|
+
# `gh` ships on the runner and authenticates with the job's own token, so the
|
|
96
|
+
# release is created without trusting a third-party action with contents:write.
|
|
97
|
+
- name: Create the release
|
|
98
|
+
env:
|
|
99
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
100
|
+
PRERELEASE: ${{ needs.guard.outputs.prerelease }}
|
|
101
|
+
run: |
|
|
102
|
+
flags=""
|
|
103
|
+
[ "$PRERELEASE" = "true" ] && flags="--prerelease"
|
|
104
|
+
gh release create "$GITHUB_REF_NAME" \
|
|
105
|
+
--title "tokenbiryani ${{ needs.guard.outputs.version }}" \
|
|
106
|
+
--notes-file notes.md \
|
|
107
|
+
$flags \
|
|
108
|
+
dist/*.whl dist/*.tar.gz
|
|
109
|
+
|
|
110
|
+
pypi:
|
|
111
|
+
needs: [guard, verify]
|
|
112
|
+
# Off until a PyPI trusted publisher exists for this repository. Set the
|
|
113
|
+
# repository variable PYPI_PUBLISH to "true" once it does:
|
|
114
|
+
#
|
|
115
|
+
# gh variable set PYPI_PUBLISH --body true
|
|
116
|
+
#
|
|
117
|
+
# Gated rather than deleted because the alternative — an unconditional publish
|
|
118
|
+
# step — turns every tag into a failed release until the day PyPI is configured,
|
|
119
|
+
# and a red release is one nobody reads the notes of.
|
|
120
|
+
if: vars.PYPI_PUBLISH == 'true'
|
|
121
|
+
runs-on: ubuntu-latest
|
|
122
|
+
timeout-minutes: 10
|
|
123
|
+
environment: pypi
|
|
124
|
+
permissions:
|
|
125
|
+
id-token: write # trusted publishing; there is no API token to leak
|
|
126
|
+
steps:
|
|
127
|
+
- uses: actions/checkout@v4
|
|
128
|
+
- uses: actions/setup-python@v5
|
|
129
|
+
with:
|
|
130
|
+
python-version: "3.12"
|
|
131
|
+
- run: pip install build
|
|
132
|
+
- run: python -m build
|
|
133
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
134
|
+
|
|
135
|
+
ghcr:
|
|
136
|
+
needs: [guard, verify]
|
|
137
|
+
runs-on: ubuntu-latest
|
|
138
|
+
timeout-minutes: 30
|
|
139
|
+
permissions:
|
|
140
|
+
contents: read
|
|
141
|
+
packages: write
|
|
142
|
+
steps:
|
|
143
|
+
- uses: actions/checkout@v4
|
|
144
|
+
- uses: docker/setup-qemu-action@v3
|
|
145
|
+
- uses: docker/setup-buildx-action@v3
|
|
146
|
+
- uses: docker/login-action@v3
|
|
147
|
+
with:
|
|
148
|
+
registry: ghcr.io
|
|
149
|
+
username: ${{ github.actor }}
|
|
150
|
+
password: ${{ secrets.GITHUB_TOKEN }}
|
|
151
|
+
- id: meta
|
|
152
|
+
uses: docker/metadata-action@v5
|
|
153
|
+
with:
|
|
154
|
+
images: ghcr.io/${{ github.repository }}
|
|
155
|
+
tags: |
|
|
156
|
+
type=semver,pattern={{version}}
|
|
157
|
+
type=semver,pattern={{major}}.{{minor}}
|
|
158
|
+
type=raw,value=latest,enable=${{ needs.guard.outputs.prerelease == 'false' }}
|
|
159
|
+
# The default target: wheel only, no source, no test dependencies.
|
|
160
|
+
- uses: docker/build-push-action@v6
|
|
161
|
+
with:
|
|
162
|
+
context: .
|
|
163
|
+
push: true
|
|
164
|
+
platforms: linux/amd64,linux/arm64
|
|
165
|
+
tags: ${{ steps.meta.outputs.tags }}
|
|
166
|
+
labels: ${{ steps.meta.outputs.labels }}
|
|
167
|
+
cache-from: type=gha
|
|
168
|
+
cache-to: type=gha,mode=max
|
|
169
|
+
|
|
170
|
+
docs:
|
|
171
|
+
needs: [guard, verify]
|
|
172
|
+
runs-on: ubuntu-latest
|
|
173
|
+
timeout-minutes: 10
|
|
174
|
+
permissions:
|
|
175
|
+
contents: write # pushes the built site to gh-pages
|
|
176
|
+
steps:
|
|
177
|
+
- uses: actions/checkout@v4
|
|
178
|
+
with: { fetch-depth: 0 }
|
|
179
|
+
- uses: actions/setup-python@v5
|
|
180
|
+
with:
|
|
181
|
+
python-version: "3.12"
|
|
182
|
+
- run: pip install -e ".[docs]"
|
|
183
|
+
- run: |
|
|
184
|
+
git config user.name "github-actions[bot]"
|
|
185
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
186
|
+
mkdocs gh-deploy --force
|