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.
Files changed (139) hide show
  1. tokenbiryani-0.2.0/.dockerignore +59 -0
  2. tokenbiryani-0.2.0/.env.example +19 -0
  3. tokenbiryani-0.2.0/.github/ISSUE_TEMPLATE/bug_report.yml +55 -0
  4. tokenbiryani-0.2.0/.github/ISSUE_TEMPLATE/config.yml +6 -0
  5. tokenbiryani-0.2.0/.github/ISSUE_TEMPLATE/feature_request.yml +27 -0
  6. tokenbiryani-0.2.0/.github/pull_request_template.md +29 -0
  7. tokenbiryani-0.2.0/.github/workflows/changelog.yml +44 -0
  8. tokenbiryani-0.2.0/.github/workflows/ci.yml +235 -0
  9. tokenbiryani-0.2.0/.github/workflows/release.yml +186 -0
  10. tokenbiryani-0.2.0/.gitignore +51 -0
  11. tokenbiryani-0.2.0/CHANGELOG.md +348 -0
  12. tokenbiryani-0.2.0/CODE_OF_CONDUCT.md +129 -0
  13. tokenbiryani-0.2.0/CONTRIBUTING.md +199 -0
  14. tokenbiryani-0.2.0/Dockerfile +79 -0
  15. tokenbiryani-0.2.0/LICENSE +202 -0
  16. tokenbiryani-0.2.0/PKG-INFO +697 -0
  17. tokenbiryani-0.2.0/PLAN.md +413 -0
  18. tokenbiryani-0.2.0/README.md +445 -0
  19. tokenbiryani-0.2.0/SECURITY.md +25 -0
  20. tokenbiryani-0.2.0/TODO.md +103 -0
  21. tokenbiryani-0.2.0/benchmarks/cache_affinity.py +192 -0
  22. tokenbiryani-0.2.0/docker/tokenbiryani.yaml +50 -0
  23. tokenbiryani-0.2.0/docker-compose.yml +80 -0
  24. tokenbiryani-0.2.0/docs/UI-DESIGN.md +264 -0
  25. tokenbiryani-0.2.0/docs/UX-PLAN.md +504 -0
  26. tokenbiryani-0.2.0/docs/adr/0001-sticky-affinity-by-default.md +29 -0
  27. tokenbiryani-0.2.0/docs/adr/0002-error-taxonomy.md +28 -0
  28. tokenbiryani-0.2.0/docs/adr/0003-api-keys-only.md +34 -0
  29. tokenbiryani-0.2.0/docs/adr/0004-subscription-login-in-core.md +88 -0
  30. tokenbiryani-0.2.0/docs/caching.md +91 -0
  31. tokenbiryani-0.2.0/docs/configuration.md +156 -0
  32. tokenbiryani-0.2.0/docs/deployment.md +136 -0
  33. tokenbiryani-0.2.0/docs/extending.md +97 -0
  34. tokenbiryani-0.2.0/docs/images/console-account-detail.png +0 -0
  35. tokenbiryani-0.2.0/docs/images/console-accounts.png +0 -0
  36. tokenbiryani-0.2.0/docs/images/console-config-override.png +0 -0
  37. tokenbiryani-0.2.0/docs/images/console-key-editor.png +0 -0
  38. tokenbiryani-0.2.0/docs/images/console-keys.png +0 -0
  39. tokenbiryani-0.2.0/docs/images/console-overview.png +0 -0
  40. tokenbiryani-0.2.0/docs/images/console-settings.png +0 -0
  41. tokenbiryani-0.2.0/docs/images/console-subscription-session.png +0 -0
  42. tokenbiryani-0.2.0/docs/images/console-subscription-token.png +0 -0
  43. tokenbiryani-0.2.0/docs/index.md +39 -0
  44. tokenbiryani-0.2.0/docs/oauth.md +242 -0
  45. tokenbiryani-0.2.0/docs/operating.md +125 -0
  46. tokenbiryani-0.2.0/docs/pacing.md +146 -0
  47. tokenbiryani-0.2.0/docs/providers.md +89 -0
  48. tokenbiryani-0.2.0/docs/quickstart.md +103 -0
  49. tokenbiryani-0.2.0/docs/routing.md +118 -0
  50. tokenbiryani-0.2.0/docs/streaming.md +52 -0
  51. tokenbiryani-0.2.0/docs/usage.md +68 -0
  52. tokenbiryani-0.2.0/mkdocs.yml +55 -0
  53. tokenbiryani-0.2.0/pyproject.toml +90 -0
  54. tokenbiryani-0.2.0/scripts/changelog.py +121 -0
  55. tokenbiryani-0.2.0/scripts/dev.sh +115 -0
  56. tokenbiryani-0.2.0/scripts/screenshots.py +144 -0
  57. tokenbiryani-0.2.0/scripts/smoke.sh +177 -0
  58. tokenbiryani-0.2.0/src/tokenbiryani/__init__.py +3 -0
  59. tokenbiryani-0.2.0/src/tokenbiryani/api/__init__.py +0 -0
  60. tokenbiryani-0.2.0/src/tokenbiryani/api/app.py +583 -0
  61. tokenbiryani-0.2.0/src/tokenbiryani/api/asgi.py +32 -0
  62. tokenbiryani-0.2.0/src/tokenbiryani/cli.py +1045 -0
  63. tokenbiryani-0.2.0/src/tokenbiryani/config.py +532 -0
  64. tokenbiryani-0.2.0/src/tokenbiryani/core/__init__.py +0 -0
  65. tokenbiryani-0.2.0/src/tokenbiryani/core/account.py +258 -0
  66. tokenbiryani-0.2.0/src/tokenbiryani/core/batch.py +135 -0
  67. tokenbiryani-0.2.0/src/tokenbiryani/core/breaker.py +53 -0
  68. tokenbiryani-0.2.0/src/tokenbiryani/core/cacheadvice.py +239 -0
  69. tokenbiryani-0.2.0/src/tokenbiryani/core/diagnostics.py +131 -0
  70. tokenbiryani-0.2.0/src/tokenbiryani/core/estimator.py +180 -0
  71. tokenbiryani-0.2.0/src/tokenbiryani/core/gateway.py +2395 -0
  72. tokenbiryani-0.2.0/src/tokenbiryani/core/handoff.py +87 -0
  73. tokenbiryani-0.2.0/src/tokenbiryani/core/keys.py +199 -0
  74. tokenbiryani-0.2.0/src/tokenbiryani/core/limits.py +440 -0
  75. tokenbiryani-0.2.0/src/tokenbiryani/core/oauth.py +222 -0
  76. tokenbiryani-0.2.0/src/tokenbiryani/core/pacing.py +320 -0
  77. tokenbiryani-0.2.0/src/tokenbiryani/core/queue.py +132 -0
  78. tokenbiryani-0.2.0/src/tokenbiryani/core/router.py +323 -0
  79. tokenbiryani-0.2.0/src/tokenbiryani/core/secrets.py +114 -0
  80. tokenbiryani-0.2.0/src/tokenbiryani/core/session.py +117 -0
  81. tokenbiryani-0.2.0/src/tokenbiryani/dashboard/__init__.py +56 -0
  82. tokenbiryani-0.2.0/src/tokenbiryani/dashboard/console.css +610 -0
  83. tokenbiryani-0.2.0/src/tokenbiryani/dashboard/console.html +3250 -0
  84. tokenbiryani-0.2.0/src/tokenbiryani/observability/__init__.py +0 -0
  85. tokenbiryani-0.2.0/src/tokenbiryani/observability/events.py +171 -0
  86. tokenbiryani-0.2.0/src/tokenbiryani/observability/usage.py +226 -0
  87. tokenbiryani-0.2.0/src/tokenbiryani/prices.yaml +77 -0
  88. tokenbiryani-0.2.0/src/tokenbiryani/providers/__init__.py +0 -0
  89. tokenbiryani-0.2.0/src/tokenbiryani/providers/anthropic_api.py +118 -0
  90. tokenbiryani-0.2.0/src/tokenbiryani/providers/base.py +173 -0
  91. tokenbiryani-0.2.0/src/tokenbiryani/providers/bedrock.py +182 -0
  92. tokenbiryani-0.2.0/src/tokenbiryani/providers/oauth.py +165 -0
  93. tokenbiryani-0.2.0/src/tokenbiryani/providers/oauth_credentials.py +293 -0
  94. tokenbiryani-0.2.0/src/tokenbiryani/providers/translate.py +35 -0
  95. tokenbiryani-0.2.0/src/tokenbiryani/providers/vertex.py +144 -0
  96. tokenbiryani-0.2.0/src/tokenbiryani/proxy/__init__.py +0 -0
  97. tokenbiryani-0.2.0/src/tokenbiryani/proxy/errors.py +169 -0
  98. tokenbiryani-0.2.0/src/tokenbiryani/proxy/sse.py +98 -0
  99. tokenbiryani-0.2.0/src/tokenbiryani/store/__init__.py +0 -0
  100. tokenbiryani-0.2.0/src/tokenbiryani/store/base.py +150 -0
  101. tokenbiryani-0.2.0/src/tokenbiryani/store/memory.py +149 -0
  102. tokenbiryani-0.2.0/src/tokenbiryani/store/redis_store.py +222 -0
  103. tokenbiryani-0.2.0/src/tokenbiryani/store/sqlite.py +336 -0
  104. tokenbiryani-0.2.0/tests/conftest.py +124 -0
  105. tokenbiryani-0.2.0/tests/support/__init__.py +12 -0
  106. tokenbiryani-0.2.0/tests/support/mock_upstream.py +556 -0
  107. tokenbiryani-0.2.0/tests/support/server.py +128 -0
  108. tokenbiryani-0.2.0/tests/test_api.py +147 -0
  109. tokenbiryani-0.2.0/tests/test_batch_spill.py +140 -0
  110. tokenbiryani-0.2.0/tests/test_benchmark.py +71 -0
  111. tokenbiryani-0.2.0/tests/test_cache_advice.py +229 -0
  112. tokenbiryani-0.2.0/tests/test_cli.py +406 -0
  113. tokenbiryani-0.2.0/tests/test_cloud_providers.py +263 -0
  114. tokenbiryani-0.2.0/tests/test_concurrency.py +168 -0
  115. tokenbiryani-0.2.0/tests/test_console.py +202 -0
  116. tokenbiryani-0.2.0/tests/test_console_admin.py +363 -0
  117. tokenbiryani-0.2.0/tests/test_console_ui.py +819 -0
  118. tokenbiryani-0.2.0/tests/test_errors.py +69 -0
  119. tokenbiryani-0.2.0/tests/test_estimator.py +241 -0
  120. tokenbiryani-0.2.0/tests/test_gateway.py +236 -0
  121. tokenbiryani-0.2.0/tests/test_key_admin.py +200 -0
  122. tokenbiryani-0.2.0/tests/test_limits.py +94 -0
  123. tokenbiryani-0.2.0/tests/test_managed_accounts.py +426 -0
  124. tokenbiryani-0.2.0/tests/test_oauth_flow.py +359 -0
  125. tokenbiryani-0.2.0/tests/test_onboarding_ui.py +194 -0
  126. tokenbiryani-0.2.0/tests/test_pacing.py +477 -0
  127. tokenbiryani-0.2.0/tests/test_priority.py +118 -0
  128. tokenbiryani-0.2.0/tests/test_queue.py +65 -0
  129. tokenbiryani-0.2.0/tests/test_reload.py +117 -0
  130. tokenbiryani-0.2.0/tests/test_router.py +164 -0
  131. tokenbiryani-0.2.0/tests/test_session.py +78 -0
  132. tokenbiryani-0.2.0/tests/test_sessions.py +191 -0
  133. tokenbiryani-0.2.0/tests/test_store.py +287 -0
  134. tokenbiryani-0.2.0/tests/test_strategy_plugins.py +149 -0
  135. tokenbiryani-0.2.0/tests/test_streaming.py +102 -0
  136. tokenbiryani-0.2.0/tests/test_unified_limits.py +153 -0
  137. tokenbiryani-0.2.0/tests/test_unobservable_limits.py +151 -0
  138. tokenbiryani-0.2.0/tests/test_usage.py +306 -0
  139. 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