3lc-compute-plugin-sdk 0.2.3__tar.gz → 0.3.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 (49) hide show
  1. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/.github/workflows/release.yml +27 -1
  2. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/CHANGELOG.md +62 -0
  3. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/CLAUDE.md +10 -7
  4. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/PKG-INFO +13 -10
  5. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/README.md +12 -9
  6. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/docs/api.md +7 -13
  7. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/docs/plugin-guide.md +87 -40
  8. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/pyproject.toml +1 -1
  9. 3lc_compute_plugin_sdk-0.3.0/src/tlc_plugin_sdk/__init__.py +42 -0
  10. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/asgi_app.py +17 -9
  11. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/contract/plugin-api.d.ts +105 -27
  12. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/contract.py +29 -24
  13. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/job_context.py +48 -10
  14. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/shared/job_tracker.py +22 -2
  15. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/shared/labels.py +11 -9
  16. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/shared/modality.py +7 -27
  17. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/worker.py +5 -2
  18. 3lc_compute_plugin_sdk-0.3.0/tests/test_job_context.py +139 -0
  19. 3lc_compute_plugin_sdk-0.3.0/tests/test_js_contract_surface.py +50 -0
  20. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/uv.lock +1 -1
  21. 3lc_compute_plugin_sdk-0.2.3/src/tlc_plugin_sdk/__init__.py +0 -58
  22. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/.github/workflows/ci.yml +0 -0
  23. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/.github/workflows/docs.yml +0 -0
  24. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/.gitignore +0 -0
  25. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/LICENSE +0 -0
  26. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/docs/conf.py +0 -0
  27. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/docs/index.md +0 -0
  28. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/docs/package-lock.json +0 -0
  29. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/docs/package.json +0 -0
  30. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/docs/requirements.txt +0 -0
  31. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/py.typed +0 -0
  32. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/shared/__init__.py +0 -0
  33. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/shared/alias_override_ui.py +0 -0
  34. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/shared/alias_ui.py +0 -0
  35. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/shared/aliases.py +0 -0
  36. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/shared/config_store.py +0 -0
  37. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/shared/config_ui.py +0 -0
  38. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/shared/data_source_routes.py +0 -0
  39. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/shared/data_source_ui.py +0 -0
  40. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/shared/generic_job.py +0 -0
  41. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/shared/images.py +0 -0
  42. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/shared/model_storage.py +0 -0
  43. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/shared/naming.py +0 -0
  44. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/shared/ui_inject.py +0 -0
  45. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/src/tlc_plugin_sdk/shared/url_utils.py +0 -0
  46. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/tests/test_data_source_routes.py +0 -0
  47. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/tests/test_import_light.py +0 -0
  48. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/tests/test_url_utils.py +0 -0
  49. {3lc_compute_plugin_sdk-0.2.3 → 3lc_compute_plugin_sdk-0.3.0}/tests/test_worker_gpu_reclaim.py +0 -0
@@ -28,7 +28,7 @@ jobs:
28
28
  runs-on: ubuntu-latest
29
29
  environment: release
30
30
  permissions:
31
- contents: read
31
+ contents: write # create the GitHub Release (step below)
32
32
  id-token: write # OIDC token for PyPI Trusted Publishing
33
33
  steps:
34
34
  - uses: actions/checkout@v4
@@ -53,6 +53,32 @@ jobs:
53
53
  - name: Publish to PyPI
54
54
  run: uv publish --trusted-publishing always
55
55
 
56
+ # Publish the tagged version's changelog section as a GitHub Release (curated notes,
57
+ # not GitHub's commit list). The Release stays editable after publish for touch-up.
58
+ - name: Extract changelog section
59
+ id: changelog
60
+ run: |
61
+ VER="${GITHUB_REF_NAME#v}"
62
+ awk -v v="$VER" '$0 ~ "^## \\[" v "\\]" {f=1; next} /^## \[/ && f {exit} f' \
63
+ CHANGELOG.md > notes.md
64
+ echo "ver=$VER" >> "$GITHUB_OUTPUT"
65
+ { echo "notes<<EOF"; cat notes.md; echo EOF; } >> "$GITHUB_OUTPUT"
66
+
67
+ - name: Create GitHub Release
68
+ uses: softprops/action-gh-release@v2
69
+ with:
70
+ tag_name: ${{ github.ref_name }}
71
+ name: ${{ github.ref_name }}
72
+ body: |
73
+ ${{ steps.changelog.outputs.notes }}
74
+
75
+ ## Installation
76
+ ```bash
77
+ pip install 3lc-compute-plugin-sdk==${{ steps.changelog.outputs.ver }}
78
+ ```
79
+ draft: false
80
+ generate_release_notes: false
81
+
56
82
  # Manual (workflow_dispatch) runs → build-numbered prereleases to the legacy CloudRepo index.
57
83
  publish-cloudrepo-prerelease:
58
84
  if: github.event_name == 'workflow_dispatch'
@@ -9,6 +9,68 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  Nothing yet.
11
11
 
12
+ ## [0.3.0] - 2026-08-25
13
+
14
+ A coordinated contract wave: breaking **for plugin authors** but **not on the wire** — the
15
+ worker→host NDJSON events are unchanged, so a host keeps running a plugin venv still on 0.2.x
16
+ (skew badge only). The whole first-party fleet re-pins `>=0.3.0,<0.4.0` in the same release.
17
+
18
+ ### Added
19
+ - `JobContext.fail(message)` raises `JobFailed` (exported from `tlc_plugin_sdk`); the worker
20
+ reports it as the terminal `error` event with the **bare** message, while any other exception
21
+ keeps its `TypeName: …` prefix.
22
+ - `PluginJobs.list(pluginId?)` — `GET {compute}/api/plugins/jobs`, filtered client-side by
23
+ `plugin_id`. Use it to **seed a fragment on mount** from the durable host job list (the
24
+ fragment is torn down on navigation and `job_update` is live-only). Declared in the `.d.ts`.
25
+ - `PluginJobs.connect(namespace)` — warm the namespace socket on mount. `track()`/`on()`
26
+ connect lazily and SocketIO does not replay events to a not-yet-connected client, so a
27
+ fragment listening for custom events from the first second (or re-attaching to a job it did
28
+ not start) calls this first; `run()` already connects for you.
29
+ - The host's `/ui` handler now **auto-injects** the `PluginJobs` client into every fragment; the
30
+ client is idempotent, so a plugin that still injects it by hand keeps working.
31
+ - `ComputePlugin.compute()` ships a default (returns an error dict), so only `get_ui_fragment()`
32
+ stays abstract.
33
+ - `.d.ts` truth pass: `PluginJobUpdate.error`, `PluginFetchOptions.allowErrorStatus`,
34
+ `PluginJobsApi.list`, `TlcLocationApi.rowsSpanLocations`/`shortLabel`, and a new
35
+ **Legacy globals** ambient block (`openTablePicker`/`closeTablePicker`/`CancelJob`/`cssVar`
36
+ and the injected `_tlc*` helpers).
37
+
38
+ ### Changed
39
+ - **One contract axis.** `PY_CONTRACT` and `JS_CONTRACT` are removed; `SDK_CONTRACT_VERSION`
40
+ (the package version) is the whole contract, compared on `MAJOR.MINOR`. `/health` reports
41
+ `sdk_version` only (no `py_contract`/`js_contract`).
42
+ - `JobContext.result` takes a **positional** `url` (`ctx.result(url)`); it opens the Open
43
+ button — a run *or* a table URL. The `run_url=` keyword is gone; the emitted `result` event
44
+ still carries `run_url` (wire unchanged).
45
+ - `JobContext.progress` documents `percent=-1` as indeterminate.
46
+
47
+ ### Removed
48
+ - `shared.modality.detect_modality_from_url` (zero users). `detect_modality_from_schema`,
49
+ `classify_metrics_columns`, `labels.candidate_label_paths`, and `labels.find_label_path`
50
+ are now internal (underscore-prefixed). Public alternatives: `detect_modality_from_table`
51
+ for modality; `get_label_map` / `get_label_names` / `find_label_column` for labels.
52
+ `classify_metrics_columns` was a plugin-specific column-name heuristic with no public
53
+ replacement — inline it (its one known user did).
54
+ - The orphan `_ICON_SVG` constant; the dead `params.setdefault("url", "")` in the `/compute`
55
+ handler.
56
+
57
+ ### Migration
58
+ - `ctx.result(run_url=x)` → `ctx.result(x)`.
59
+ - Validation failures: raise, or `ctx.fail("message")` for a clean user-facing card.
60
+ - Reading `tlc_plugin_sdk.PY_CONTRACT` / `JS_CONTRACT`, or a worker's `/health`
61
+ `py_contract`/`js_contract`: use `SDK_CONTRACT_VERSION` / `sdk_version` and compare on
62
+ `MAJOR.MINOR`.
63
+ - `shared.modality.detect_modality_from_url(url)` → load the table yourself and call
64
+ `detect_modality_from_table(table)`. `classify_metrics_columns(columns)` → copy the
65
+ heuristic into your plugin.
66
+ - A fragment that kept its own always-connected socket for custom events: switch to
67
+ `PluginJobs.on(namespace, event, handler)` and call `PluginJobs.connect(namespace)` on mount
68
+ so nothing emitted before the first `track()` is missed.
69
+ - Fragments may delete their manual `inject_scripts(raw, job_tracker_script())` — the host
70
+ injects `PluginJobs` now (harmless to keep).
71
+ - The manifest never declared the SocketIO namespace or `sdk_version`; both were dead and are
72
+ documented as such. The namespace is host-derived as `/<plugin-id>`.
73
+
12
74
  ## [0.2.3] - 2026-08-21
13
75
 
14
76
  ### Changed
@@ -83,10 +83,11 @@ section is the condensed working order.
83
83
  - **Config + GPU job + SocketIO**: sam3, yolo, or timm
84
84
  - **Inline data display (no standalone page)**: `tlc_plugin_table_statistics`
85
85
  - **Action on selected resources**: `tlc_plugin_merger`
86
- 3. Read `src/tlc_plugin_sdk/contract.py` — `ComputePlugin` is an `abc.ABC` with two abstract
87
- methods (`get_ui_fragment()`, `compute()`) and an `id` attribute the host stamps from the
88
- manifest. The optional hooks (`run_job`, `initialise_runtime`, `shutdown_runtime`,
89
- `get_route_handlers`) ship as no-op defaults, so you override only what you need. There is
86
+ 3. Read `src/tlc_plugin_sdk/contract.py` — `ComputePlugin` is an `abc.ABC` with one abstract
87
+ method (`get_ui_fragment()`) and an `id` attribute the host stamps from the manifest.
88
+ `compute()` and the other hooks (`run_job`, `initialise_runtime`, `shutdown_runtime`,
89
+ `get_route_handlers`) ship as defaults (`compute()` reports that the plugin doesn't
90
+ implement it), so you override only what you need. There is
90
91
  no `register()` and **no** `get_active_jobs`/`cancel_job` — the host owns job listing and
91
92
  cancellation.
92
93
  4. Read `tlc_plugin_exporter`'s `plugin.toml` + `__init__.py` — the canonical manifest +
@@ -154,9 +155,11 @@ section is the condensed working order.
154
155
  form fills call `_tlcSyncAliasFromForm(prefix, projectId, folderId)`.
155
156
 
156
157
  **SocketIO** (if real-time updates are needed):
157
- - `socketio_namespace` is optional — it defaults to `/<plugin-id>`, which the host
158
- auto-registers at startup; declare it in `[runtime]` only to override.
159
- - Prefer the `window.PluginJobs` client over a hand-rolled socket.
158
+ - The namespace is host-derived as `/<plugin-id>` and auto-registered at startup; the manifest
159
+ does not (and never did) declare it.
160
+ - Prefer the `window.PluginJobs` client over a hand-rolled socket. It connects a namespace
161
+ lazily; call `PluginJobs.connect('/<plugin-id>')` on mount if the fragment needs custom
162
+ events from the first second (`run()` connects for you).
160
163
  - Use `ctx.emit(name, payload)` only for telemetry the generic schema can't express
161
164
  (e.g. a loss curve); never re-emit the generic lifecycle by hand.
162
165
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: 3lc-compute-plugin-sdk
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Summary: Public Python SDK for building 3LC compute-service plugins.
5
5
  Project-URL: Homepage, https://github.com/3lc-ai/3lc-compute-plugin-sdk
6
6
  Project-URL: Repository, https://github.com/3lc-ai/3lc-compute-plugin-sdk
@@ -44,9 +44,10 @@ pip install 3lc-compute-plugin-sdk # import name: tlc_plugin_sdk
44
44
 
45
45
  ## What it gives you
46
46
 
47
- - **`ComputePlugin`** — the base class you subclass. Implement `compute` / `get_ui_fragment`;
48
- job and lifecycle hooks ship as no-op defaults. There is no `register()` to call — a plugin's
49
- metadata lives in its `[tool.tlc-compute]` manifest, and the Hub discovers it from there.
47
+ - **`ComputePlugin`** — the base class you subclass. Implement `get_ui_fragment` (and `compute`
48
+ if you expose a synchronous endpoint); job and lifecycle hooks ship as safe defaults. There is
49
+ no `register()` to call — a plugin's metadata lives in its `plugin.toml` manifest (or a
50
+ `[tool.tlc-compute]` table in `pyproject.toml`), and the Hub discovers it from there.
50
51
  - **`JobContext`** — the surface a long-running job programs against: `progress` / `metric` /
51
52
  `log` / `result` for the generic job panel, `emit` for your plugin's own rich UI, and
52
53
  cooperative `cancelled`.
@@ -63,10 +64,10 @@ format, custom routes, the job model, UI fragment, checklist).
63
64
 
64
65
  ## The contract version
65
66
 
66
- `tlc_plugin_sdk.SDK_CONTRACT_VERSION` is this package's own version — one source of truth.
67
- A plugin pins the SDK (`3lc-compute-plugin-sdk>=X,<Y`) and declares the contract it targets via its
68
- manifest. The compute service implements a contract range, and the SDK version is the shared
69
- reference both sides agree on. Versions are SemVer; see **Status** below for the 0.x stability
67
+ `tlc_plugin_sdk.SDK_CONTRACT_VERSION` is this package's own version — one source of truth, one
68
+ contract axis (both the Python and the browser surface). A plugin pins the SDK
69
+ (`3lc-compute-plugin-sdk>=X,<Y`); the compute service and frontend implement a range and compare
70
+ compatibility on `MAJOR.MINOR`. Versions are SemVer; see **Status** below for the 0.x stability
70
71
  stance.
71
72
 
72
73
  ## How it fits
@@ -79,7 +80,9 @@ therefore portable across service versions and runs in its own isolated environm
79
80
 
80
81
  ## Status
81
82
 
82
- **0.2 is the released contract line.** Within 0.x the contract evolves **additively only**:
83
- symbols and schemas that exist keep working, and anything breaking waits for a major bump. In
83
+ **0.3 is the current contract line.** Within 0.x the contract still evolves — mostly additively,
84
+ but a coordinated breaking change may land on a MINOR bump while the fleet re-pins in lockstep (0.3
85
+ did: `JobContext.result` took a positional `url`, and the `PY_CONTRACT`/`JS_CONTRACT` axes
86
+ collapsed into `SDK_CONTRACT_VERSION`). Anything reshaping the core still waits for a major bump. In
84
87
  the browser bridge, `PLUGIN_API.libs.io` is a stable part of the contract; the other bundled
85
88
  libs are best-effort (see the guide).
@@ -18,9 +18,10 @@ pip install 3lc-compute-plugin-sdk # import name: tlc_plugin_sdk
18
18
 
19
19
  ## What it gives you
20
20
 
21
- - **`ComputePlugin`** — the base class you subclass. Implement `compute` / `get_ui_fragment`;
22
- job and lifecycle hooks ship as no-op defaults. There is no `register()` to call — a plugin's
23
- metadata lives in its `[tool.tlc-compute]` manifest, and the Hub discovers it from there.
21
+ - **`ComputePlugin`** — the base class you subclass. Implement `get_ui_fragment` (and `compute`
22
+ if you expose a synchronous endpoint); job and lifecycle hooks ship as safe defaults. There is
23
+ no `register()` to call — a plugin's metadata lives in its `plugin.toml` manifest (or a
24
+ `[tool.tlc-compute]` table in `pyproject.toml`), and the Hub discovers it from there.
24
25
  - **`JobContext`** — the surface a long-running job programs against: `progress` / `metric` /
25
26
  `log` / `result` for the generic job panel, `emit` for your plugin's own rich UI, and
26
27
  cooperative `cancelled`.
@@ -37,10 +38,10 @@ format, custom routes, the job model, UI fragment, checklist).
37
38
 
38
39
  ## The contract version
39
40
 
40
- `tlc_plugin_sdk.SDK_CONTRACT_VERSION` is this package's own version — one source of truth.
41
- A plugin pins the SDK (`3lc-compute-plugin-sdk>=X,<Y`) and declares the contract it targets via its
42
- manifest. The compute service implements a contract range, and the SDK version is the shared
43
- reference both sides agree on. Versions are SemVer; see **Status** below for the 0.x stability
41
+ `tlc_plugin_sdk.SDK_CONTRACT_VERSION` is this package's own version — one source of truth, one
42
+ contract axis (both the Python and the browser surface). A plugin pins the SDK
43
+ (`3lc-compute-plugin-sdk>=X,<Y`); the compute service and frontend implement a range and compare
44
+ compatibility on `MAJOR.MINOR`. Versions are SemVer; see **Status** below for the 0.x stability
44
45
  stance.
45
46
 
46
47
  ## How it fits
@@ -53,7 +54,9 @@ therefore portable across service versions and runs in its own isolated environm
53
54
 
54
55
  ## Status
55
56
 
56
- **0.2 is the released contract line.** Within 0.x the contract evolves **additively only**:
57
- symbols and schemas that exist keep working, and anything breaking waits for a major bump. In
57
+ **0.3 is the current contract line.** Within 0.x the contract still evolves — mostly additively,
58
+ but a coordinated breaking change may land on a MINOR bump while the fleet re-pins in lockstep (0.3
59
+ did: `JobContext.result` took a positional `url`, and the `PY_CONTRACT`/`JS_CONTRACT` axes
60
+ collapsed into `SDK_CONTRACT_VERSION`). Anything reshaping the core still waits for a major bump. In
58
61
  the browser bridge, `PLUGIN_API.libs.io` is a stable part of the contract; the other bundled
59
62
  libs are best-effort (see the guide).
@@ -13,29 +13,23 @@ package's own docstrings.
13
13
  :members:
14
14
 
15
15
  .. autodata:: tlc_plugin_sdk.SDK_CONTRACT_VERSION
16
-
17
- .. autodata:: tlc_plugin_sdk.PY_CONTRACT
18
-
19
- .. autodata:: tlc_plugin_sdk.JS_CONTRACT
20
16
  ```
21
17
 
22
- `SDK_CONTRACT_VERSION` is the wheel/SemVer — the dependency *pin* a plugin resolves
23
- against (`3lc-compute-plugin-sdk>=X,<Y`). `PY_CONTRACT` and `JS_CONTRACT` are finer-grained
24
- **capability markers** for runtime feature-detection: `PY_CONTRACT` covers the Python
25
- surface (`ComputePlugin` / `JobContext`), `JS_CONTRACT` the browser surface
18
+ `SDK_CONTRACT_VERSION` is the one contract axis: this package's own version — the
19
+ dependency *pin* a plugin resolves against (`3lc-compute-plugin-sdk>=X,<Y`) and the version
20
+ of both the Python surface (`ComputePlugin` / `JobContext`) and the browser surface
26
21
  (`PLUGIN_API` / `PluginJobs` / `TlcData`, declared in
27
- `tlc_plugin_sdk/contract/plugin-api.d.ts`). Both increment independently and are always
28
- `<=` the package version. See `docs/plugin-guide.md` → "Two version axes".
22
+ `tlc_plugin_sdk/contract/plugin-api.d.ts`). The host and frontend implement a range and
23
+ compare compatibility on `MAJOR.MINOR`. See `docs/plugin-guide.md` → "Version & Compatibility".
29
24
 
30
25
  The `.d.ts` ships in the wheel at
31
26
  `<site-packages>/tlc_plugin_sdk/contract/plugin-api.d.ts`; a plain-JS `ui.html` references
32
27
  it with `/// <reference types="3lc-compute-plugin-sdk/contract/plugin-api" />`.
33
28
 
34
- ## The browser contract (`JS_CONTRACT`)
29
+ ## The browser contract
35
30
 
36
31
  The browser surface a plugin's `ui.html` programs against — `PLUGIN_API`, `PluginJobs`,
37
- and the `TlcData` helper — is declared in `plugin-api.d.ts`, the source of truth for
38
- `JS_CONTRACT`.
32
+ and the `TlcData` helper — is declared in `plugin-api.d.ts`.
39
33
 
40
34
  ```{eval-rst}
41
35
  .. automodule documents every exported symbol in the file, so new contract
@@ -36,7 +36,7 @@ Browser Compute Service (port 5020)
36
36
  │ │ + ComputePlugin subclass││
37
37
  │ │ ComputePlugin (ABC) ││
38
38
  │ │ ├── get_ui_fragment() ││ ← abstract
39
- │ │ ├── compute() ││ ← abstract
39
+ │ │ ├── compute() ││ ← override (default)
40
40
  │ │ ├── id ││ ← host-stamped
41
41
  │ │ ├── run_job(ctx) ││ ← override (default)
42
42
  │ │ └── get_route_handlers()││ ← override (default)
@@ -106,7 +106,7 @@ description = "Analyzes table data quality."
106
106
  version = "1.0.0"
107
107
  min_service_version = "0.1.0" # Minimum compute service version required
108
108
  icon = "🔍" # Fallback emoji
109
- # 16x16 SVG, inline in the manifest (no _ICON_SVG import anymore):
109
+ # 16x16 SVG, inline in the manifest:
110
110
  icon_svg = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="5"/><path d="M12 12l3 3"/></svg>'
111
111
 
112
112
  [ui]
@@ -126,12 +126,21 @@ isolation = "venv" # "venv" is the only value (and the default
126
126
  entrypoint = "tlc_plugin_my_plugin:MyPlugin" # "pkg.module:ClassName"
127
127
  requires_gpu = false # drives GPU vs CPU classification
128
128
  provision_extra = "my-plugin" # umbrella extra the host provisions: `uv sync --extra <this>`
129
- # Optional SocketIO namespace for real-time updates. Defaults to "/<plugin-id>";
130
- # declare it only to override (the host registers it at startup so a UI can connect;
131
- # declaring it does NOT mean emitting custom events):
132
- # socketio_namespace = "/my-plugin"
129
+ # The plugin's SocketIO namespace is host-derived as "/<plugin-id>" and registered at
130
+ # startup — it is NOT declarable in the manifest (a plugin emits via ctx; the host owns
131
+ # the transport).
133
132
  ```
134
133
 
134
+ **Other keys the host reads** (all optional, read without importing the plugin):
135
+
136
+ - `[runtime]`: `auth_exempt_paths` (relative subpaths served without auth, scoped to the
137
+ plugin's own subtree), `training` (marks a training plugin), `python` / `venv_python`
138
+ (pin the interpreter the plugin's venv is built with).
139
+ - `[ui]`: `min_input_count` (minimum selected resources an `action` plugin needs — defaults to
140
+ `len(input_types)`; set `0` explicitly to require none), `action_param_names` (query params
141
+ passed through from the action launch), `quick_action_label` / `quick_action_description`
142
+ (dashboard quick-action copy).
143
+
135
144
  `runtime.provision_extra` is **required for every plugin distributed through an umbrella**:
136
145
  it names the extra the host installs with `uv sync --extra <that-value>`. For first-party
137
146
  plugins this is a per-plugin extra in the `3lc-compute-plugins` umbrella `pyproject.toml`.
@@ -145,11 +154,12 @@ on the CPU queue. Both are host-owned; the plugin never picks a queue or names a
145
154
  ### 3. Implement the plugin object
146
155
 
147
156
  A plugin is a **subclass of `ComputePlugin`** (imported from `tlc_plugin_sdk`) —
148
- there is no `register()` call. You must implement the two abstract methods,
149
- `get_ui_fragment()` and `compute()`; `id` is hydrated onto the instance from the manifest
150
- by the host. Optional behavior (custom routes, jobs, lifecycle hooks) ships as no-op
151
- defaults on the base, so you override only what you need and the host calls every hook
152
- directly.
157
+ there is no `register()` call. You must implement the one abstract method,
158
+ `get_ui_fragment()`; `id` is hydrated onto the instance from the manifest by the host.
159
+ Everything else — `compute()`, custom routes, jobs, lifecycle hooks — ships as a safe
160
+ default on the base, so you override only what you need and the host calls every hook
161
+ directly. (`compute()`'s default returns an error dict; implement it only if you expose a
162
+ synchronous `GET /compute` endpoint.)
153
163
 
154
164
  ```python
155
165
  """My Plugin — does something useful with tables."""
@@ -299,10 +309,10 @@ startup.
299
309
  > var API = window.PLUGIN_API; // now typed
300
310
  > ```
301
311
  >
302
- > That file is the source of truth for **`JS_CONTRACT` (0.1)** — the browser-side contract.
303
- > The 3LC Hub frontend **implements** `PLUGIN_API` when it mounts a fragment;
304
- > `window.PluginJobs` **ships from this package** (it is layered on top of the bridge, not
305
- > part of it). See "Two version axes" below.
312
+ > That file declares the browser-side contract — versioned by the single
313
+ > `SDK_CONTRACT_VERSION` (see "Version & Compatibility" below). The 3LC Hub frontend
314
+ > **implements** `PLUGIN_API` when it mounts a fragment; `window.PluginJobs` **ships from this
315
+ > package** (auto-injected by the host, layered on top of the bridge, not part of it).
306
316
 
307
317
  ### How a fragment reaches the browser
308
318
 
@@ -351,7 +361,7 @@ PLUGIN_API = {
351
361
  objects: TlcApi.objectService, // Object service methods
352
362
  authFetch: TlcApi.authFetch, // fetch() with auth headers
353
363
  data: TlcData, // Cached data (projects, tables, runs)
354
- location: TlcLocation, // Location renderers (chips/labels for project roots) — JS_CONTRACT 0.2
364
+ location: TlcLocation, // Location renderers (chips/labels for project roots) — SDK 0.2+
355
365
 
356
366
  computeFetch: TlcApi.computeFetch, // authFetch joined to the compute-service base URL
357
367
 
@@ -388,7 +398,7 @@ PLUGIN_API = {
388
398
  - **`compute` / `objects` / `data` / `computeFetch` / `navigate` / `getIcon` / `container`** are
389
399
  part of the declared surface but rarely used directly by `ui.html` (plugins reach data through
390
400
  `authFetch`); they are documented in the `.d.ts` for completeness.
391
- - **`location`** (JS_CONTRACT 0.2) exposes the host's shared location renderers (`TlcLocationApi`):
401
+ - **`location`** (SDK 0.2+) exposes the host's shared location renderers (`TlcLocationApi`):
392
402
  chips and labels for the project roots / scan URLs that tables, runs, and projects from
393
403
  `PLUGIN_API.data` resolve to (their `location` / `locations` fields, also 0.2). Every renderer
394
404
  returns `''` on single-root installs, so output can be concatenated unconditionally. Feature-detect
@@ -473,7 +483,7 @@ isolation = "venv"
473
483
  entrypoint = "tlc_plugin_my_gpu_plugin:MyGpuPlugin"
474
484
  requires_gpu = true # → routed through the shared GPU queue (1 at a time)
475
485
  provision_extra = "my-gpu-plugin" # venv deps installed via `uv sync --extra <this>`
476
- # socketio_namespace defaults to "/my-gpu-plugin"; declare only to override
486
+ # SocketIO namespace is host-derived as "/my-gpu-plugin" — not declarable here
477
487
  ```
478
488
 
479
489
  GPU jobs are serialized — only one runs at a time across every GPU plugin (YOLO, SAM3,
@@ -498,8 +508,9 @@ class MyGpuPlugin(ComputePlugin):
498
508
  ctx.progress(percent=100 * i / n, label=f"batch {i}/{n}")
499
509
  ctx.metric("loss", 0.042) # key/value card on the generic panel
500
510
 
501
- ctx.result(run_url=created_table_url) # the one "open result" link
502
- # Raise to fail the job — the host records the error and ends the stream.
511
+ ctx.result(created_table_url) # the one "open result" link (run or table URL)
512
+ # Raise to fail the job (or ctx.fail("message") for a clean, user-facing message) —
513
+ # the host records the error and ends the stream.
503
514
  ```
504
515
 
505
516
  `JobContext` surface:
@@ -513,7 +524,8 @@ class MyGpuPlugin(ComputePlugin):
513
524
  | `ctx.progress(*, percent, label="", timing=None)` | Generic progress bar. `percent=-1` = indeterminate. `timing` = `{elapsed_s, eta_s, avg_step_s, step_label}`. |
514
525
  | `ctx.metric(label, value)` | Scalar metric card on the generic panel. |
515
526
  | `ctx.log(message)` | A log line for the job. |
516
- | `ctx.result(*, run_url)` | The canonical result link (last write wins). |
527
+ | `ctx.result(url)` | The canonical result link the Open button opens — a run *or* a table URL (last write wins). |
528
+ | `ctx.fail(message)` | Fail the job with a clean, user-facing message (raises `JobFailed`; reported verbatim, no type prefix). |
517
529
  | `ctx.emit(name, payload)` | A **custom** event for the plugin's OWN rich UI (see below). |
518
530
 
519
531
  **The host owns listing and cancellation — there is nothing to implement.** Because the
@@ -539,21 +551,21 @@ can be a second consumer of that same channel for a richer, tailored view — it
539
551
  bespoke events** for the generic lifecycle (queued → running → done, %, result link,
540
552
  metrics).
541
553
 
542
- **The `window.PluginJobs` client** is a global the plugin injects into its fragment in
543
- `get_ui_fragment()`, exactly like `alias_ui_script()` — pass `job_tracker_script()` (from
544
- `tlc_plugin_sdk.shared.job_tracker`) to `inject_scripts(raw, job_tracker_script())`
545
- (see `importer/__init__.py`). It starts a job and tracks it over the generic channel:
554
+ **The `window.PluginJobs` client** is a global the **host auto-injects** into every fragment
555
+ (its `/ui` handler prepends `job_tracker_script()`), so you can call it directly — no manual
556
+ injection needed. It is idempotent (`if (window.PluginJobs) return;`), so an older plugin that
557
+ still injects it by hand via `inject_scripts(raw, job_tracker_script())` keeps working. It
558
+ starts a job and tracks it over the generic channel:
546
559
 
547
560
  ```javascript
548
- // The host registers the namespace automatically (default "/<plugin-id>");
549
- // declare socketio_namespace in plugin.toml only to override it.
561
+ // The host registers the namespace automatically as "/<plugin-id>".
550
562
  PluginJobs.run('my-plugin', { table_url: url }, {
551
563
  onUpdate: function (job) {
552
564
  // generic schema: job.status, job.progress.percent/label, job.metrics[]
553
565
  setProgress(job.progress.percent, job.progress.label);
554
566
  },
555
567
  onDone: function (job) { showResult(job.run_url); },
556
- onError: function (job) { showError(job.subtitle); }, // failure message rides subtitle
568
+ onError: function (job) { showError(job.error); }, // failure message on job.error
557
569
  });
558
570
  ```
559
571
 
@@ -562,6 +574,12 @@ the client subscribing still delivers its terminal event. (`PluginJobs.start/tra
562
574
  are the lower-level pieces.) The separate frontend's generic panel polls the same schema
563
575
  independently.
564
576
 
577
+ One timing rule to know: `track()` and `on()` open the namespace socket **lazily**, on first
578
+ use, and SocketIO does not replay server→client events to a client that was not yet
579
+ connected. `run()` connects before it posts, so the common path is safe — but a fragment that
580
+ listens for custom events from the first second, or re-attaches to a job it did not start
581
+ (seed-on-mount), should warm the socket on mount: `PluginJobs.connect('/my-plugin')`.
582
+
565
583
  **Custom events** — `ctx.emit(name, payload)` is reserved for telemetry the generic schema
566
584
  **can't** express (e.g. a training plugin's per-epoch loss curve). The host relays it
567
585
  verbatim on the plugin's namespace; the generic panel ignores it. The name `job_update` is
@@ -585,6 +603,38 @@ socket.on('epoch_metrics', function (d) { lossChart.push(d.epoch, d.loss); });
585
603
 
586
604
  ---
587
605
 
606
+ ## The job page is a launcher — the Queue is the durable view
607
+
608
+ A plugin fragment is **torn down on navigation**: the host remounts it via `innerHTML`, so
609
+ its JS state and any live SocketIO subscription are gone the moment the user leaves and comes
610
+ back. The generic **Queue & Progress** panel is the opposite — it is host-owned, polls
611
+ `GET /api/plugins/jobs`, and survives navigation. So treat your fragment as the place a job is
612
+ *launched and configured*, and the generic Queue card as the place a job is *watched*.
613
+
614
+ Two obligations follow:
615
+
616
+ **1. Seed on mount from the durable job list.** `job_update` is live-only — a long job already
617
+ running when the fragment remounts has no event to catch until it next progresses, so the
618
+ fragment would show an empty form over a job that is very much alive. On mount, ask the host
619
+ what's running and render a compact running-state instead:
620
+
621
+ ```js
622
+ PluginJobs.list('<id>').then(function (jobs) {
623
+ var live = jobs.filter(function (j) { return j.status === 'queued' || j.status === 'running'; });
624
+ if (live.length) renderRunningState(live[0]); // compact "job running — watch it in the Queue"
625
+ });
626
+ ```
627
+
628
+ **2. Keep the generic card meaningful.** If you emit custom progress for your own rich UI
629
+ (`ctx.emit`), keep `ctx.progress(percent, label, timing)` current *too* — the generic card is
630
+ the durable view, and a job that only drives a custom channel looks stalled there. Report the
631
+ one result the Open button opens with **`ctx.result(url)`** (a run or a table URL), and report
632
+ failures by **raising** (or `ctx.fail("message")` for a clean, user-facing message — it lands
633
+ on the card's `error`, without a `TypeError:`-style prefix). That is all the generic Queue
634
+ needs to render a good, navigation-proof view.
635
+
636
+ ---
637
+
588
638
  ## CSS Conventions
589
639
 
590
640
  Plugin UI fragments inherit all CSS from `main.css` (via `base.html`) and `plugin-common.css` (via `plugin_host.html`). Use these shared patterns:
@@ -758,24 +808,21 @@ plugins are unaffected either way.
758
808
 
759
809
  All versions use **SemVer** (`MAJOR.MINOR.PATCH`).
760
810
 
761
- ### Two version axes — never conflate them
811
+ ### Two kinds of version — never conflate them
762
812
 
763
813
  There are **two independent** kinds of "version" in play. Keep them separate:
764
814
 
765
- **(a) CONTRACT capability — what a plugin *programs against*.** Pinned at build/install time
766
- via the `3lc-compute-plugin-sdk` dependency. The SDK exposes three constants in `tlc_plugin_sdk`:
815
+ **(a) CONTRACT — what a plugin *programs against*.** Pinned at build/install time via the
816
+ `3lc-compute-plugin-sdk` dependency. There is **one contract axis**, `tlc_plugin_sdk.SDK_CONTRACT_VERSION`:
767
817
 
768
818
  | Constant | Covers | Value |
769
819
  |----------|--------|-------|
770
- | `SDK_CONTRACT_VERSION` | the wheel/SemVer — the actual dependency *pin* (`3lc-compute-plugin-sdk>=X,<Y`) | = package version (`0.1.0`) |
771
- | `PY_CONTRACT` | the Python surface: `ComputePlugin` / `JobContext` / `shared.*` | `0.1` |
772
- | `JS_CONTRACT` | the browser surface: `PLUGIN_API` / `PluginJobs` / `TlcData` (see `plugin-api.d.ts`) | `0.1` |
773
-
774
- `PY_CONTRACT` and `JS_CONTRACT` are **feature-detection markers** that increment *independently*
775
- as features are added to one side without the other. Both are always `<=` the package version (a
776
- capability can only exist in a shipped wheel). **Bump the package version when *either* moves.**
777
- A plugin that needs a newer capability raises its `3lc-compute-plugin-sdk` floor; it can also
778
- feature-detect at runtime by reading `tlc_plugin_sdk.PY_CONTRACT` / `JS_CONTRACT`.
820
+ | `SDK_CONTRACT_VERSION` | the whole contract — the Python surface (`ComputePlugin` / `JobContext` / `shared.*`) *and* the browser surface (`PLUGIN_API` / `PluginJobs` / `TlcData`, see `plugin-api.d.ts`); also the dependency *pin* (`3lc-compute-plugin-sdk>=X,<Y`) | = package version (e.g. `0.3.0`) |
821
+
822
+ It is this package's own version — one source of truth. A plugin that needs a newer capability
823
+ raises its `3lc-compute-plugin-sdk` floor; the host and frontend implement a range and compare
824
+ compatibility on `MAJOR.MINOR`. (Earlier 0.x lines split this into separate `PY_CONTRACT` /
825
+ `JS_CONTRACT` markers; those were removed in 0.3 — there is one axis now.)
779
826
 
780
827
  **(b) SERVICE compatibility — what a plugin *runs against*.** Negotiated at *runtime*, not pinned.
781
828
  The compute-service and frontend version independently (separate repos); a plugin declares floors
@@ -7,7 +7,7 @@ name = "3lc-compute-plugin-sdk"
7
7
  # The plugin-contract (ABI) version. This IS tlc_plugin_sdk.SDK_CONTRACT_VERSION
8
8
  # (read back via importlib.metadata) — one source of truth. SemVer MAJOR.MINOR.PATCH;
9
9
  # the 0.x line is additive-only (see README → Status).
10
- version = "0.2.3"
10
+ version = "0.3.0"
11
11
  description = "Public Python SDK for building 3LC compute-service plugins."
12
12
  requires-python = ">=3.10"
13
13
  readme = "README.md"
@@ -0,0 +1,42 @@
1
+ # Copyright 2026 3LC Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Import-light plugin SDK — the contract surface a plugin programs against.
4
+
5
+ This is the public import path for the plugin contract. Importing it must **not**
6
+ pull in the heavy server stack (litestar, python-socketio, uvicorn, the queues,
7
+ discovery): a ``venv``-mode plugin installs only this surface, not the full
8
+ service. The ``tests/test_import_light.py`` test enforces the boundary.
9
+
10
+ A plugin subclasses :class:`ComputePlugin` and implements at least
11
+ ``compute``/``get_ui_fragment``; the optional job/lifecycle hooks ship as no-op
12
+ defaults. There is no ``register()`` to call — metadata lives in the plugin
13
+ manifest, and the host discovers the plugin via its manifest ``entrypoint``.
14
+
15
+ The contract is **defined here**, in :mod:`tlc_plugin_sdk.contract`, so it lives
16
+ next to :class:`JobContext` and the venv worker on the import-light side of the SDK
17
+ boundary — nothing in this package pulls in the server stack. This is the standalone
18
+ public ``3lc-compute-plugin-sdk`` distribution: the light base a venv plugin installs (the
19
+ host ``3lc-compute`` depends on it, never the reverse). See ``CLAUDE.md``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from importlib.metadata import PackageNotFoundError
25
+ from importlib.metadata import version as _pkg_version
26
+
27
+ from tlc_plugin_sdk.contract import ComputePlugin
28
+ from tlc_plugin_sdk.job_context import JobContext, JobFailed
29
+
30
+ # The plugin contract version — one axis, one source of truth: this package's own
31
+ # version (the ``[project] version`` in pyproject), read via importlib.metadata rather
32
+ # than a separately maintained constant. It IS the contract a plugin programs against —
33
+ # both the Python surface (``ComputePlugin`` / ``JobContext`` / ``shared.*``) and the
34
+ # browser surface (``PLUGIN_API`` / ``PluginJobs`` / ``TlcData``, declared in
35
+ # ``contract/plugin-api.d.ts``) — and the thing a plugin pins (``>=X,<Y``). The host and
36
+ # frontend implement a range and compare compatibility on ``MAJOR.MINOR``.
37
+ try:
38
+ SDK_CONTRACT_VERSION = _pkg_version("3lc-compute-plugin-sdk")
39
+ except PackageNotFoundError: # running from a raw checkout that was never installed
40
+ SDK_CONTRACT_VERSION = "0.0.0"
41
+
42
+ __all__ = ["SDK_CONTRACT_VERSION", "ComputePlugin", "JobContext", "JobFailed"]
@@ -26,6 +26,9 @@ from typing import TYPE_CHECKING, Any
26
26
 
27
27
  from litestar import Litestar, Request, get
28
28
 
29
+ from tlc_plugin_sdk.shared.job_tracker import JOB_TRACKER_JS
30
+ from tlc_plugin_sdk.shared.ui_inject import inject_scripts
31
+
29
32
  if TYPE_CHECKING:
30
33
  from litestar.handlers import BaseRouteHandler
31
34
 
@@ -37,31 +40,36 @@ def _generic_handlers(plugin: ComputePlugin) -> list[BaseRouteHandler]:
37
40
 
38
41
  @get("/health", sync_to_thread=False)
39
42
  def health() -> dict[str, Any]:
40
- # The version fields are the worker half of a handshake: a venv worker imports its
43
+ # ``sdk_version`` is the worker half of a handshake: a venv worker imports its
41
44
  # *own* install of this SDK, so the host cannot know which contract is live inside
42
- # the venv unless the worker says so. The host compares them against its own and
43
- # flags skew on the plugin card. Reported from the same constants a plugin
44
- # feature-detects against, so worker and plugin can never disagree.
45
- from tlc_plugin_sdk import JS_CONTRACT, PY_CONTRACT, SDK_CONTRACT_VERSION
45
+ # the venv unless the worker says so. The host compares it against its own on
46
+ # MAJOR.MINOR and flags skew on the plugin card. One contract axis, one field.
47
+ from tlc_plugin_sdk import SDK_CONTRACT_VERSION
46
48
 
47
49
  return {
48
50
  "ok": True,
49
51
  "plugin": getattr(plugin, "id", "?"),
50
52
  "sdk_version": SDK_CONTRACT_VERSION,
51
- "py_contract": PY_CONTRACT,
52
- "js_contract": JS_CONTRACT,
53
53
  }
54
54
 
55
55
  # def + sync_to_thread: get_ui_fragment()/compute() are synchronous and may do
56
56
  # blocking work, so Litestar runs them in a threadpool, off the event loop.
57
57
  @get("/ui", media_type="text/html", sync_to_thread=True)
58
58
  def ui() -> str:
59
- return plugin.get_ui_fragment()
59
+ # Auto-inject the PluginJobs client so every fragment can drive the generic job
60
+ # channel without a manual inject_scripts() call. The client is idempotent
61
+ # (``if (window.PluginJobs) return;``), so a plugin that still injects it by hand
62
+ # is harmless. A fragment with no inline <script> has nothing to drive PluginJobs
63
+ # from — inject_scripts() raises there, so serve it unchanged.
64
+ raw = plugin.get_ui_fragment()
65
+ try:
66
+ return inject_scripts(raw, JOB_TRACKER_JS)
67
+ except ValueError:
68
+ return raw
60
69
 
61
70
  @get("/compute", sync_to_thread=True)
62
71
  def compute(request: Request[Any, Any, Any]) -> dict[str, Any]:
63
72
  params: dict[str, Any] = dict(request.query_params)
64
- params.setdefault("url", "")
65
73
  return plugin.compute(params)
66
74
 
67
75
  return [health, ui, compute]