oix 0.0.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.
- oix-0.0.1/.github/workflows/ci.yml +27 -0
- oix-0.0.1/.github/workflows/pypi.yml +224 -0
- oix-0.0.1/.github/workflows/release.yml +53 -0
- oix-0.0.1/.gitignore +9 -0
- oix-0.0.1/.python-version +1 -0
- oix-0.0.1/CHANGELOG.md +7 -0
- oix-0.0.1/LICENSE +21 -0
- oix-0.0.1/PKG-INFO +142 -0
- oix-0.0.1/README.md +126 -0
- oix-0.0.1/examples/hello.py +35 -0
- oix-0.0.1/pyproject.toml +34 -0
- oix-0.0.1/src/oix/__init__.py +15 -0
- oix-0.0.1/src/oix/app.py +151 -0
- oix-0.0.1/src/oix/client.py +217 -0
- oix-0.0.1/src/oix/fastapi.py +45 -0
- oix-0.0.1/src/oix/models.py +62 -0
- oix-0.0.1/src/oix/py.typed +0 -0
- oix-0.0.1/tests/test_sdk.py +323 -0
- oix-0.0.1/uv.lock +1796 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main, release]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
strategy:
|
|
15
|
+
matrix:
|
|
16
|
+
python: ['3.11', '3.12', '3.13', '3.14']
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
- uses: astral-sh/setup-uv@v6
|
|
20
|
+
with:
|
|
21
|
+
python-version: ${{ matrix.python }}
|
|
22
|
+
- run: uv sync --locked --extra fastapi
|
|
23
|
+
- run: uv run ruff check .
|
|
24
|
+
- run: uv run ruff format --check .
|
|
25
|
+
- run: uv run pytest -q
|
|
26
|
+
- run: uv build
|
|
27
|
+
- run: uv run twine check dist/*
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [release]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: pypi-${{ github.ref }}
|
|
10
|
+
cancel-in-progress: false
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
preflight:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
outputs:
|
|
19
|
+
version: ${{ steps.pkg.outputs.version }}
|
|
20
|
+
publish_needed: ${{ steps.pypi.outputs.publish_needed }}
|
|
21
|
+
release_assets_needed: ${{ steps.release.outputs.release_assets_needed }}
|
|
22
|
+
steps:
|
|
23
|
+
- name: Checkout
|
|
24
|
+
uses: actions/checkout@v4
|
|
25
|
+
|
|
26
|
+
- name: Read version
|
|
27
|
+
id: pkg
|
|
28
|
+
run: |
|
|
29
|
+
VERSION=$(python3 -c "
|
|
30
|
+
import re, pathlib
|
|
31
|
+
text = pathlib.Path('pyproject.toml').read_text()
|
|
32
|
+
m = re.search(r'^version\s*=\s*\"([^\"]+)\"', text, re.M)
|
|
33
|
+
print(m.group(1))
|
|
34
|
+
")
|
|
35
|
+
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
36
|
+
|
|
37
|
+
- name: Check PyPI
|
|
38
|
+
id: pypi
|
|
39
|
+
env:
|
|
40
|
+
VERSION: ${{ steps.pkg.outputs.version }}
|
|
41
|
+
run: |
|
|
42
|
+
python3 - <<'PY'
|
|
43
|
+
import os
|
|
44
|
+
import urllib.error
|
|
45
|
+
import urllib.request
|
|
46
|
+
|
|
47
|
+
version = os.environ["VERSION"]
|
|
48
|
+
exists = False
|
|
49
|
+
try:
|
|
50
|
+
with urllib.request.urlopen(f"https://pypi.org/pypi/oix/{version}/json", timeout=15):
|
|
51
|
+
exists = True
|
|
52
|
+
except urllib.error.HTTPError as exc:
|
|
53
|
+
if exc.code != 404:
|
|
54
|
+
raise
|
|
55
|
+
|
|
56
|
+
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
|
|
57
|
+
output.write(f"publish_needed={'false' if exists else 'true'}\n")
|
|
58
|
+
print(f"Version {version} {'already exists on PyPI' if exists else 'is not on PyPI'}")
|
|
59
|
+
PY
|
|
60
|
+
|
|
61
|
+
- name: Check GitHub release assets
|
|
62
|
+
id: release
|
|
63
|
+
env:
|
|
64
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
65
|
+
GH_REPO: ${{ github.repository }}
|
|
66
|
+
VERSION: ${{ steps.pkg.outputs.version }}
|
|
67
|
+
run: |
|
|
68
|
+
set -euo pipefail
|
|
69
|
+
expected=(
|
|
70
|
+
"oix-${VERSION}-py3-none-any.whl"
|
|
71
|
+
"oix-${VERSION}.tar.gz"
|
|
72
|
+
"SHA256SUMS"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
assets="$(gh release view "v${VERSION}" --json assets --jq '.assets[].name' 2>/dev/null || true)"
|
|
76
|
+
missing=false
|
|
77
|
+
for name in "${expected[@]}"; do
|
|
78
|
+
if ! grep -Fx "$name" <<< "$assets" >/dev/null; then
|
|
79
|
+
missing=true
|
|
80
|
+
echo "Missing release asset: $name"
|
|
81
|
+
fi
|
|
82
|
+
done
|
|
83
|
+
|
|
84
|
+
echo "release_assets_needed=$missing" >> "$GITHUB_OUTPUT"
|
|
85
|
+
|
|
86
|
+
build:
|
|
87
|
+
needs: preflight
|
|
88
|
+
if: github.ref == 'refs/heads/release' && (needs.preflight.outputs.publish_needed == 'true' || needs.preflight.outputs.release_assets_needed == 'true')
|
|
89
|
+
runs-on: ubuntu-latest
|
|
90
|
+
steps:
|
|
91
|
+
- name: Checkout
|
|
92
|
+
uses: actions/checkout@v4
|
|
93
|
+
|
|
94
|
+
- name: Set up Python
|
|
95
|
+
uses: actions/setup-python@v5
|
|
96
|
+
with:
|
|
97
|
+
python-version: "3.12"
|
|
98
|
+
|
|
99
|
+
- name: Set up uv
|
|
100
|
+
uses: astral-sh/setup-uv@v6
|
|
101
|
+
|
|
102
|
+
- name: Test
|
|
103
|
+
run: |
|
|
104
|
+
uv sync --locked --extra fastapi
|
|
105
|
+
uv run ruff check .
|
|
106
|
+
uv run ruff format --check .
|
|
107
|
+
uv run pytest -q
|
|
108
|
+
|
|
109
|
+
- name: Build package
|
|
110
|
+
run: |
|
|
111
|
+
uv build
|
|
112
|
+
uv run twine check dist/*
|
|
113
|
+
|
|
114
|
+
- name: Generate checksums
|
|
115
|
+
run: |
|
|
116
|
+
mkdir -p release-assets
|
|
117
|
+
cp dist/* release-assets/
|
|
118
|
+
cd release-assets
|
|
119
|
+
sha256sum *.whl *.tar.gz > SHA256SUMS
|
|
120
|
+
|
|
121
|
+
- name: Upload dist artifacts
|
|
122
|
+
uses: actions/upload-artifact@v4
|
|
123
|
+
with:
|
|
124
|
+
name: dist
|
|
125
|
+
path: dist/
|
|
126
|
+
|
|
127
|
+
- name: Upload release artifacts
|
|
128
|
+
uses: actions/upload-artifact@v4
|
|
129
|
+
with:
|
|
130
|
+
name: release-assets
|
|
131
|
+
path: release-assets/
|
|
132
|
+
|
|
133
|
+
publish:
|
|
134
|
+
needs: [preflight, build]
|
|
135
|
+
if: github.ref == 'refs/heads/release' && needs.preflight.outputs.publish_needed == 'true'
|
|
136
|
+
runs-on: ubuntu-latest
|
|
137
|
+
environment: pypi
|
|
138
|
+
permissions:
|
|
139
|
+
id-token: write
|
|
140
|
+
steps:
|
|
141
|
+
- name: Download dist artifacts
|
|
142
|
+
uses: actions/download-artifact@v4
|
|
143
|
+
with:
|
|
144
|
+
name: dist
|
|
145
|
+
path: dist/
|
|
146
|
+
|
|
147
|
+
- name: Check if version already on PyPI
|
|
148
|
+
id: check
|
|
149
|
+
env:
|
|
150
|
+
VERSION: ${{ needs.preflight.outputs.version }}
|
|
151
|
+
run: |
|
|
152
|
+
python3 - <<'PY'
|
|
153
|
+
import os
|
|
154
|
+
import urllib.error
|
|
155
|
+
import urllib.request
|
|
156
|
+
|
|
157
|
+
version = os.environ["VERSION"]
|
|
158
|
+
exists = False
|
|
159
|
+
try:
|
|
160
|
+
with urllib.request.urlopen(f"https://pypi.org/pypi/oix/{version}/json", timeout=15):
|
|
161
|
+
exists = True
|
|
162
|
+
except urllib.error.HTTPError as exc:
|
|
163
|
+
if exc.code != 404:
|
|
164
|
+
raise
|
|
165
|
+
|
|
166
|
+
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
|
|
167
|
+
output.write(f"skip={'true' if exists else 'false'}\n")
|
|
168
|
+
print(f"Version {version} {'already exists on PyPI, skipping publish' if exists else 'is not on PyPI'}")
|
|
169
|
+
PY
|
|
170
|
+
|
|
171
|
+
- name: Publish to PyPI
|
|
172
|
+
if: steps.check.outputs.skip != 'true'
|
|
173
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
174
|
+
# No API token needed - uses OIDC trusted publishing.
|
|
175
|
+
# Configure at: https://pypi.org/manage/project/oix/settings/publishing/
|
|
176
|
+
|
|
177
|
+
github-release-assets:
|
|
178
|
+
needs: [preflight, build]
|
|
179
|
+
if: github.ref == 'refs/heads/release' && needs.preflight.outputs.release_assets_needed == 'true'
|
|
180
|
+
runs-on: ubuntu-latest
|
|
181
|
+
permissions:
|
|
182
|
+
contents: write
|
|
183
|
+
steps:
|
|
184
|
+
- name: Download release artifacts
|
|
185
|
+
uses: actions/download-artifact@v4
|
|
186
|
+
with:
|
|
187
|
+
name: release-assets
|
|
188
|
+
path: release-assets/
|
|
189
|
+
|
|
190
|
+
- name: Attach GitHub release artifacts
|
|
191
|
+
env:
|
|
192
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
193
|
+
GH_REPO: ${{ github.repository }}
|
|
194
|
+
VERSION: ${{ needs.preflight.outputs.version }}
|
|
195
|
+
run: |
|
|
196
|
+
set -euo pipefail
|
|
197
|
+
ls -lah release-assets
|
|
198
|
+
mapfile -t files < <(find release-assets -maxdepth 1 -type f -print | sort)
|
|
199
|
+
if [ "${#files[@]}" -eq 0 ]; then
|
|
200
|
+
echo "No release asset files found" >&2
|
|
201
|
+
exit 1
|
|
202
|
+
fi
|
|
203
|
+
|
|
204
|
+
for attempt in {1..60}; do
|
|
205
|
+
if gh release view "v${VERSION}" >/dev/null 2>&1; then
|
|
206
|
+
gh release upload "v${VERSION}" "${files[@]}" --clobber
|
|
207
|
+
|
|
208
|
+
assets="$(gh release view "v${VERSION}" --json assets --jq '.assets[].name')"
|
|
209
|
+
for expected in \
|
|
210
|
+
"oix-${VERSION}-py3-none-any.whl" \
|
|
211
|
+
"oix-${VERSION}.tar.gz" \
|
|
212
|
+
"SHA256SUMS"
|
|
213
|
+
do
|
|
214
|
+
if ! grep -Fx "$expected" <<< "$assets" >/dev/null; then
|
|
215
|
+
echo "Missing release asset after upload: $expected" >&2
|
|
216
|
+
exit 1
|
|
217
|
+
fi
|
|
218
|
+
done
|
|
219
|
+
exit 0
|
|
220
|
+
fi
|
|
221
|
+
sleep 10
|
|
222
|
+
done
|
|
223
|
+
echo "GitHub release v${VERSION} was not found" >&2
|
|
224
|
+
exit 1
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# -----------------------------------------------------------------------------
|
|
2
|
+
# Release - Create GitHub release from CHANGELOG
|
|
3
|
+
# Runs on pushes to the release branch when version changes.
|
|
4
|
+
# -----------------------------------------------------------------------------
|
|
5
|
+
name: Release
|
|
6
|
+
|
|
7
|
+
on:
|
|
8
|
+
push:
|
|
9
|
+
branches: [release]
|
|
10
|
+
|
|
11
|
+
concurrency:
|
|
12
|
+
group: release-${{ github.ref }}
|
|
13
|
+
cancel-in-progress: false
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
# Create the release used by pypi.yml for package assets.
|
|
17
|
+
publish:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
timeout-minutes: 10
|
|
20
|
+
permissions:
|
|
21
|
+
contents: write
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
|
|
25
|
+
- name: Read version
|
|
26
|
+
id: pkg
|
|
27
|
+
run: |
|
|
28
|
+
VERSION=$(python3 -c "
|
|
29
|
+
import re, pathlib
|
|
30
|
+
text = pathlib.Path('pyproject.toml').read_text()
|
|
31
|
+
m = re.search(r'^version\s*=\s*\"([^\"]+)\"', text, re.M)
|
|
32
|
+
print(m.group(1))
|
|
33
|
+
")
|
|
34
|
+
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
35
|
+
|
|
36
|
+
- name: Extract release notes from CHANGELOG
|
|
37
|
+
run: |
|
|
38
|
+
VER="${{ steps.pkg.outputs.version }}"
|
|
39
|
+
awk "/^## \[${VER}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" \
|
|
40
|
+
CHANGELOG.md > /tmp/release-notes.md
|
|
41
|
+
|
|
42
|
+
- name: Publish GitHub release
|
|
43
|
+
env:
|
|
44
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
45
|
+
run: |
|
|
46
|
+
if gh release view "v${{ steps.pkg.outputs.version }}" &>/dev/null; then
|
|
47
|
+
echo "Release v${{ steps.pkg.outputs.version }} already exists - skipping creation"
|
|
48
|
+
else
|
|
49
|
+
gh release create "v${{ steps.pkg.outputs.version }}" \
|
|
50
|
+
--target "$GITHUB_SHA" \
|
|
51
|
+
--title "v${{ steps.pkg.outputs.version }}" \
|
|
52
|
+
--notes-file /tmp/release-notes.md
|
|
53
|
+
fi
|
oix-0.0.1/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.11
|
oix-0.0.1/CHANGELOG.md
ADDED
oix-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Open WebUI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
oix-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: oix
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Build external apps for Open WebUI
|
|
5
|
+
Author-email: Tim Baek <tim@openwebui.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: aiohttp<4,>=3.12
|
|
10
|
+
Requires-Dist: cryptography>=44
|
|
11
|
+
Requires-Dist: pydantic<3,>=2.10
|
|
12
|
+
Requires-Dist: pyjwt<3,>=2.10
|
|
13
|
+
Provides-Extra: fastapi
|
|
14
|
+
Requires-Dist: fastapi<1,>=0.115; extra == 'fastapi'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# oix — Open WebUI SDK
|
|
18
|
+
|
|
19
|
+
A small Python SDK for building external apps for Open WebUI. Version **0.0.1** targets the current external-app protocol in the accompanying Open WebUI implementation; older WebUI releases without that API are not supported.
|
|
20
|
+
|
|
21
|
+
Python 3.11+. The SDK handles authentication, connection proofs, verified events, and signed runtime requests. Your app handles its own business logic and authorization.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
Once published:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
uv add 'oix[fastapi]'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
For local development against this checkout:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
uv add --editable '../sdk[fastapi]'
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The FastAPI extra is optional. The core SDK does not require a web framework.
|
|
38
|
+
|
|
39
|
+
## A minimal app
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import os
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
from fastapi import FastAPI
|
|
46
|
+
from oix import App, AppReply, Client, Event
|
|
47
|
+
from oix.fastapi import create_router
|
|
48
|
+
|
|
49
|
+
app = App(
|
|
50
|
+
id="hello",
|
|
51
|
+
name="Hello",
|
|
52
|
+
api_key=os.environ["API_KEY"],
|
|
53
|
+
private_key=Path(os.environ["APP_PRIVATE_KEY_FILE"]).read_text(),
|
|
54
|
+
scopes=["mentions:read"],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.on("mention")
|
|
59
|
+
async def hello(event: Event, client: Client) -> AppReply:
|
|
60
|
+
return AppReply(content="Hello! Only you can see this reply.")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
server = FastAPI()
|
|
64
|
+
server.include_router(create_router(app, path="/events"))
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Generate an Ed25519 key once and keep it in persistent deployment secrets:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
umask 077
|
|
71
|
+
openssl genpkey -algorithm ED25519 -out app-private-key.pem
|
|
72
|
+
export APP_PRIVATE_KEY_FILE="$PWD/app-private-key.pem"
|
|
73
|
+
export API_KEY="$(uv run python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
|
74
|
+
uv run --extra fastapi uvicorn examples.hello:server --port 8001
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Persist the API key too; do not regenerate either secret on each startup. The example reads `APP_PRIVATE_KEY_FILE`; the SDK itself takes values explicitly and does not read environment variables.
|
|
78
|
+
|
|
79
|
+
In WebUI, enable Apps, add your public HTTPS event URL and the API key, approve the scopes, then connect. Invite the app into a channel. GET on the event URL provides the public manifest; POST receives authenticated events. The public key is intentionally public.
|
|
80
|
+
|
|
81
|
+
Any route is valid, including `/manifest`. Pass `manifest=False` to `create_router` for a POST-only endpoint and enter the app details manually in WebUI. `app.manifest` supplies those details.
|
|
82
|
+
|
|
83
|
+
For localhost development, pass the exact WebUI origin in `allowed_private_origins=("http://127.0.0.1:8080",)`. The example accepts the comma-separated `OPEN_WEBUI_ALLOWED_PRIVATE_ORIGINS` setting. Separately configure WebUI's `APP_ALLOWED_PRIVATE_ORIGINS` to allow your app origin. Production callbacks require HTTPS and public destinations by default; DNS results, IP literals, redirects, response size and timeouts are checked.
|
|
84
|
+
|
|
85
|
+
## Events and replies
|
|
86
|
+
|
|
87
|
+
Register async handlers with `@app.on(...)` for:
|
|
88
|
+
|
|
89
|
+
- `mention`: explicit app mentions; text is in `event.data["text"]`.
|
|
90
|
+
- `action`: a saved message button; `event.data` contains `message_id`, `action_id`, and `context`.
|
|
91
|
+
- `operation_status`: a status request for deferred work.
|
|
92
|
+
- `message.created`, `message.updated`, `message.deleted`: ordinary channel events, including messages without mentions. Request `messages:read` for these.
|
|
93
|
+
|
|
94
|
+
All callbacks receive `(event, client)`. Events are verified against the originating WebUI **before** your handler runs. For interactions, the actor's admin status comes from current authorization, not the incoming payload. Unsigned source metadata is not exposed. Ordinary message events do not authorize commands on behalf of their authors.
|
|
95
|
+
|
|
96
|
+
Return `AppReply(content="...")` for an inline private reply, or set `visibility="channel"` to post publicly in the channel. `AppReply` also supports `update`, `download`, `error`, and `deferred`. `Action(action_id="...", label="...", context={...})` buttons are supported only on saved channel messages. Button handlers must enforce any operation-specific rules themselves.
|
|
97
|
+
|
|
98
|
+
Return `AppReply(type="deferred", operation_id=event.id)` for background work and implement an `operation_status` handler. Keep status replies private. Your app owns job storage and restart recovery; the SDK does not start a worker or persist jobs. Recheck `await client.authorize(event.execution_context)` before later sensitive work, and apply your app's own permissions.
|
|
99
|
+
|
|
100
|
+
Ordinary channel-event handlers return `None`; use the runtime client to send a message. A mention can produce both a channel event and a mention interaction. Check `event.message["author"]` to avoid responding to your own messages. Delivery is best effort, can arrive out of order, and has no replay guarantee.
|
|
101
|
+
|
|
102
|
+
## Runtime client
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from oix import AppChannelMessage
|
|
106
|
+
|
|
107
|
+
message = await client.create_message(
|
|
108
|
+
event.channel["id"], AppChannelMessage(content="Working on it...")
|
|
109
|
+
)
|
|
110
|
+
await client.update_message(
|
|
111
|
+
message["id"], AppChannelMessage(content="Done."), etag=message["etag"]
|
|
112
|
+
)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Available methods: `get_me()`, `list_channels()`, `list_messages(channel_id, limit=50, before=None, parent_id=None)`, `create_message(...)`, `update_message(...)`, `authorize(execution_context)`, and `verify_event(event_context)`. Responses are WebUI JSON dictionaries/lists. `APIError.status` exposes HTTP failures, including `412` for stale edits. Transport failures use aiohttp exceptions or `TimeoutError`. Message creation is never automatically retried.
|
|
116
|
+
|
|
117
|
+
One `App` can serve multiple independent WebUI instances. Each event supplies its WebUI origin and local installation ID; its client is bound to that pair. There is no required installation registry or single `OPEN_WEBUI_URL`. For proactive messages, save the destination URL and app ID alongside your own subscription, then call `app.client(webui_url, app_id)` after restart. API-key holders are trusted to connect instances; a shared key does not provide tenant-specific business authorization.
|
|
118
|
+
|
|
119
|
+
For another web framework, call `app.authenticate(authorization)` before reading a body, enforce a 256 KiB body limit, parse JSON, then `await app.handle_event(payload, authorization=authorization)`. Return the resulting JSON. No SDK is required on the WebUI server.
|
|
120
|
+
|
|
121
|
+
## Development and publishing
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
uv sync --extra fastapi
|
|
125
|
+
uv run ruff check .
|
|
126
|
+
uv run ruff format --check .
|
|
127
|
+
uv run pytest
|
|
128
|
+
uv build
|
|
129
|
+
uv run twine check dist/*
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`uv.lock` locks development/CI dependencies. Wheels retain normal library dependency ranges. No sibling repository is needed to build or install the package.
|
|
133
|
+
|
|
134
|
+
The GitHub workflows are adapted from the sibling **oikb** repository:
|
|
135
|
+
|
|
136
|
+
- `ci.yml`: lint, tests and package builds on PRs and pushes to `main`/`release`.
|
|
137
|
+
- `release.yml`: a push to `release` creates `v<version>` at that commit, with notes from `CHANGELOG.md`.
|
|
138
|
+
- `pypi.yml`: a push to `release` (or manual run on that branch) tests/builds, publishes missing versions with PyPI trusted publishing, and attaches the wheel, source archive and checksums to the GitHub release. Existing PyPI versions are skipped.
|
|
139
|
+
|
|
140
|
+
Before the first release, configure a PyPI pending/trusted publisher for **oix**, your GitHub owner/repository, workflow **pypi.yml**, and environment **pypi**. Create that GitHub environment and restrict publishing to the `release` branch. No long-lived PyPI API token is required. The SDK does not need oikb's Docker builds or dependency wheelhouse.
|
|
141
|
+
|
|
142
|
+
To release, update the version in `pyproject.toml`, update the changelog and lockfile, then push the reviewed commit to your `release` branch. You can instead build locally and run `uv publish` with your own publishing credentials. This checkout has not been published.
|
oix-0.0.1/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# oix — Open WebUI SDK
|
|
2
|
+
|
|
3
|
+
A small Python SDK for building external apps for Open WebUI. Version **0.0.1** targets the current external-app protocol in the accompanying Open WebUI implementation; older WebUI releases without that API are not supported.
|
|
4
|
+
|
|
5
|
+
Python 3.11+. The SDK handles authentication, connection proofs, verified events, and signed runtime requests. Your app handles its own business logic and authorization.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Once published:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
uv add 'oix[fastapi]'
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
For local development against this checkout:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
uv add --editable '../sdk[fastapi]'
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The FastAPI extra is optional. The core SDK does not require a web framework.
|
|
22
|
+
|
|
23
|
+
## A minimal app
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import os
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from fastapi import FastAPI
|
|
30
|
+
from oix import App, AppReply, Client, Event
|
|
31
|
+
from oix.fastapi import create_router
|
|
32
|
+
|
|
33
|
+
app = App(
|
|
34
|
+
id="hello",
|
|
35
|
+
name="Hello",
|
|
36
|
+
api_key=os.environ["API_KEY"],
|
|
37
|
+
private_key=Path(os.environ["APP_PRIVATE_KEY_FILE"]).read_text(),
|
|
38
|
+
scopes=["mentions:read"],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.on("mention")
|
|
43
|
+
async def hello(event: Event, client: Client) -> AppReply:
|
|
44
|
+
return AppReply(content="Hello! Only you can see this reply.")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
server = FastAPI()
|
|
48
|
+
server.include_router(create_router(app, path="/events"))
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Generate an Ed25519 key once and keep it in persistent deployment secrets:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
umask 077
|
|
55
|
+
openssl genpkey -algorithm ED25519 -out app-private-key.pem
|
|
56
|
+
export APP_PRIVATE_KEY_FILE="$PWD/app-private-key.pem"
|
|
57
|
+
export API_KEY="$(uv run python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
|
58
|
+
uv run --extra fastapi uvicorn examples.hello:server --port 8001
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Persist the API key too; do not regenerate either secret on each startup. The example reads `APP_PRIVATE_KEY_FILE`; the SDK itself takes values explicitly and does not read environment variables.
|
|
62
|
+
|
|
63
|
+
In WebUI, enable Apps, add your public HTTPS event URL and the API key, approve the scopes, then connect. Invite the app into a channel. GET on the event URL provides the public manifest; POST receives authenticated events. The public key is intentionally public.
|
|
64
|
+
|
|
65
|
+
Any route is valid, including `/manifest`. Pass `manifest=False` to `create_router` for a POST-only endpoint and enter the app details manually in WebUI. `app.manifest` supplies those details.
|
|
66
|
+
|
|
67
|
+
For localhost development, pass the exact WebUI origin in `allowed_private_origins=("http://127.0.0.1:8080",)`. The example accepts the comma-separated `OPEN_WEBUI_ALLOWED_PRIVATE_ORIGINS` setting. Separately configure WebUI's `APP_ALLOWED_PRIVATE_ORIGINS` to allow your app origin. Production callbacks require HTTPS and public destinations by default; DNS results, IP literals, redirects, response size and timeouts are checked.
|
|
68
|
+
|
|
69
|
+
## Events and replies
|
|
70
|
+
|
|
71
|
+
Register async handlers with `@app.on(...)` for:
|
|
72
|
+
|
|
73
|
+
- `mention`: explicit app mentions; text is in `event.data["text"]`.
|
|
74
|
+
- `action`: a saved message button; `event.data` contains `message_id`, `action_id`, and `context`.
|
|
75
|
+
- `operation_status`: a status request for deferred work.
|
|
76
|
+
- `message.created`, `message.updated`, `message.deleted`: ordinary channel events, including messages without mentions. Request `messages:read` for these.
|
|
77
|
+
|
|
78
|
+
All callbacks receive `(event, client)`. Events are verified against the originating WebUI **before** your handler runs. For interactions, the actor's admin status comes from current authorization, not the incoming payload. Unsigned source metadata is not exposed. Ordinary message events do not authorize commands on behalf of their authors.
|
|
79
|
+
|
|
80
|
+
Return `AppReply(content="...")` for an inline private reply, or set `visibility="channel"` to post publicly in the channel. `AppReply` also supports `update`, `download`, `error`, and `deferred`. `Action(action_id="...", label="...", context={...})` buttons are supported only on saved channel messages. Button handlers must enforce any operation-specific rules themselves.
|
|
81
|
+
|
|
82
|
+
Return `AppReply(type="deferred", operation_id=event.id)` for background work and implement an `operation_status` handler. Keep status replies private. Your app owns job storage and restart recovery; the SDK does not start a worker or persist jobs. Recheck `await client.authorize(event.execution_context)` before later sensitive work, and apply your app's own permissions.
|
|
83
|
+
|
|
84
|
+
Ordinary channel-event handlers return `None`; use the runtime client to send a message. A mention can produce both a channel event and a mention interaction. Check `event.message["author"]` to avoid responding to your own messages. Delivery is best effort, can arrive out of order, and has no replay guarantee.
|
|
85
|
+
|
|
86
|
+
## Runtime client
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from oix import AppChannelMessage
|
|
90
|
+
|
|
91
|
+
message = await client.create_message(
|
|
92
|
+
event.channel["id"], AppChannelMessage(content="Working on it...")
|
|
93
|
+
)
|
|
94
|
+
await client.update_message(
|
|
95
|
+
message["id"], AppChannelMessage(content="Done."), etag=message["etag"]
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Available methods: `get_me()`, `list_channels()`, `list_messages(channel_id, limit=50, before=None, parent_id=None)`, `create_message(...)`, `update_message(...)`, `authorize(execution_context)`, and `verify_event(event_context)`. Responses are WebUI JSON dictionaries/lists. `APIError.status` exposes HTTP failures, including `412` for stale edits. Transport failures use aiohttp exceptions or `TimeoutError`. Message creation is never automatically retried.
|
|
100
|
+
|
|
101
|
+
One `App` can serve multiple independent WebUI instances. Each event supplies its WebUI origin and local installation ID; its client is bound to that pair. There is no required installation registry or single `OPEN_WEBUI_URL`. For proactive messages, save the destination URL and app ID alongside your own subscription, then call `app.client(webui_url, app_id)` after restart. API-key holders are trusted to connect instances; a shared key does not provide tenant-specific business authorization.
|
|
102
|
+
|
|
103
|
+
For another web framework, call `app.authenticate(authorization)` before reading a body, enforce a 256 KiB body limit, parse JSON, then `await app.handle_event(payload, authorization=authorization)`. Return the resulting JSON. No SDK is required on the WebUI server.
|
|
104
|
+
|
|
105
|
+
## Development and publishing
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
uv sync --extra fastapi
|
|
109
|
+
uv run ruff check .
|
|
110
|
+
uv run ruff format --check .
|
|
111
|
+
uv run pytest
|
|
112
|
+
uv build
|
|
113
|
+
uv run twine check dist/*
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`uv.lock` locks development/CI dependencies. Wheels retain normal library dependency ranges. No sibling repository is needed to build or install the package.
|
|
117
|
+
|
|
118
|
+
The GitHub workflows are adapted from the sibling **oikb** repository:
|
|
119
|
+
|
|
120
|
+
- `ci.yml`: lint, tests and package builds on PRs and pushes to `main`/`release`.
|
|
121
|
+
- `release.yml`: a push to `release` creates `v<version>` at that commit, with notes from `CHANGELOG.md`.
|
|
122
|
+
- `pypi.yml`: a push to `release` (or manual run on that branch) tests/builds, publishes missing versions with PyPI trusted publishing, and attaches the wheel, source archive and checksums to the GitHub release. Existing PyPI versions are skipped.
|
|
123
|
+
|
|
124
|
+
Before the first release, configure a PyPI pending/trusted publisher for **oix**, your GitHub owner/repository, workflow **pypi.yml**, and environment **pypi**. Create that GitHub environment and restrict publishing to the `release` branch. No long-lived PyPI API token is required. The SDK does not need oikb's Docker builds or dependency wheelhouse.
|
|
125
|
+
|
|
126
|
+
To release, update the version in `pyproject.toml`, update the changelog and lockfile, then push the reviewed commit to your `release` branch. You can instead build locally and run `uv publish` with your own publishing credentials. This checkout has not been published.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Run: uv run --extra fastapi uvicorn examples.hello:server --port 8001"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
|
|
8
|
+
from oix import App, AppReply, Client, Event
|
|
9
|
+
from oix.fastapi import create_router
|
|
10
|
+
|
|
11
|
+
app = App(
|
|
12
|
+
id="hello",
|
|
13
|
+
name="Hello",
|
|
14
|
+
api_key=os.environ["API_KEY"],
|
|
15
|
+
private_key=Path(os.environ["APP_PRIVATE_KEY_FILE"]).read_text(),
|
|
16
|
+
scopes=["mentions:read"],
|
|
17
|
+
allowed_private_origins=tuple(
|
|
18
|
+
filter(
|
|
19
|
+
None,
|
|
20
|
+
os.getenv(
|
|
21
|
+
"OPEN_WEBUI_ALLOWED_PRIVATE_ORIGINS",
|
|
22
|
+
"",
|
|
23
|
+
).split(","),
|
|
24
|
+
)
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.on("mention")
|
|
30
|
+
async def hello(event: Event, client: Client) -> AppReply:
|
|
31
|
+
return AppReply(content="Hello! This reply is only visible to you.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
server = FastAPI()
|
|
35
|
+
server.include_router(create_router(app))
|