Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98cd517b8a | ||
|
|
c5fd1ba96f | ||
|
|
9b5eff63fb | ||
|
|
08ad0c42ee | ||
|
|
4f07c2d3ac | ||
|
|
492a64fa43 | ||
|
|
e0b9c0cd7e | ||
|
|
c95bae161b | ||
|
|
a81249eda2 | ||
|
|
1c863ddb95 | ||
|
|
49ce027c5b | ||
|
|
4e44793aaf | ||
|
|
bf356cd9a3 | ||
|
|
9b66409343 | ||
|
|
4069ee12f6 | ||
|
|
3cc32708d9 | ||
|
|
f1be37a725 | ||
|
|
5b432079b7 | ||
|
|
8b1f6d4a65 | ||
|
|
387269e245 | ||
|
|
2d3291d493 | ||
|
|
fa1f38ced2 | ||
|
|
44f040c27f | ||
|
|
62158c16cc | ||
|
|
cdb62fc247 | ||
|
|
7c9c8ef578 | ||
|
|
1fec2b16eb | ||
|
|
f0ed33371c | ||
|
|
a0e7c8d405 | ||
|
|
6b41382bef | ||
|
|
deb53eabc8 | ||
|
|
27415161f9 | ||
|
|
ddbbe01801 | ||
|
|
2fdda5a201 | ||
|
|
be2ebd1bc4 | ||
|
|
fbfca9624e | ||
|
|
e0732af1fd | ||
|
|
6cf50e6a5d | ||
|
|
f6dcde3cbb | ||
|
|
3ee59e2dcc | ||
|
|
95c6258594 | ||
|
|
682b5530be | ||
|
|
f27621d4de | ||
|
|
8cff4871dd |
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
# Generated by the documentation build (see publishing/build.sh)
|
||||||
|
.site-src/
|
||||||
|
.site-build/
|
||||||
|
.venv-docs/
|
||||||
|
__pycache__/
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Woodpecker CI pipeline for co-op/docs — THIN on purpose.
|
||||||
|
#
|
||||||
|
# It does NOT re-implement the build. The repository's publishing layer is the
|
||||||
|
# single source of truth and this pipeline only wires it up:
|
||||||
|
#
|
||||||
|
# publishing/requirements.txt pinned MkDocs + Material toolchain
|
||||||
|
# publishing/build.sh stage Markdown -> .site-src/, render .site-build/
|
||||||
|
# publishing/deploy.sh push .site-build/ into the Cloudron Surfer app
|
||||||
|
#
|
||||||
|
# Any change to how the site is built belongs in publishing/, never here.
|
||||||
|
#
|
||||||
|
# Run model: MANUAL ONLY for now. The Gitea webhook that fires this on push is
|
||||||
|
# deliberately NOT wired — the poll watcher (docs-deploy-poll.service on the
|
||||||
|
# agent host) remains the live auto-deploy path until a manual run has proven
|
||||||
|
# the pipeline. Once it has, add `push` to the `when` block below and register
|
||||||
|
# the webhook in Gitea.
|
||||||
|
#
|
||||||
|
# Backend note (IMPORTANT — this is why there are no container images here):
|
||||||
|
# The co-op's Woodpecker agent runs as a Cloudron app, which has no Docker
|
||||||
|
# socket, so it uses Woodpecker's `local` backend. Under `local` each step is
|
||||||
|
# run as a shell command INSIDE the agent container, and the step's `image:`
|
||||||
|
# field selects the SHELL, not a container image. Every tool the steps need is
|
||||||
|
# therefore baked into the agent image (co-op/woodpecker-agent):
|
||||||
|
# python3 + the pinned MkDocs toolchain (venv at /opt/docs-venv, mirroring
|
||||||
|
# publishing/requirements.txt), node + the cloudron CLI, bash, git, tar.
|
||||||
|
# If the toolchain pin changes, rebuild that image — see its Dockerfile.
|
||||||
|
#
|
||||||
|
# Secrets required (Woodpecker repo secret, name `cloudron_token`):
|
||||||
|
# cloudron_token = the Cloudron API token used by publishing/deploy.sh
|
||||||
|
# Do not put credentials in this file.
|
||||||
|
|
||||||
|
when:
|
||||||
|
- event: manual
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: build
|
||||||
|
image: bash
|
||||||
|
commands:
|
||||||
|
# Reuses the toolchain pre-installed in the agent image; build.sh only
|
||||||
|
# creates/installs a venv if the one given is missing, which it is not.
|
||||||
|
- publishing/build.sh /opt/docs-venv
|
||||||
|
|
||||||
|
- name: deploy
|
||||||
|
image: bash
|
||||||
|
commands:
|
||||||
|
# deploy.sh drives the Cloudron `cloudron` CLI (a node program) via
|
||||||
|
# `cloudron exec`; node, the CLI, bash, git and tar are all in the agent
|
||||||
|
# image. The build it publishes is the shared workspace .site-build/.
|
||||||
|
- publishing/deploy.sh
|
||||||
|
environment:
|
||||||
|
CLOUDRON_TOKEN:
|
||||||
|
from_secret: cloudron_token
|
||||||
@@ -16,17 +16,18 @@ funded through our [Open Collective](https://opencollective.com/inference-cooper
|
|||||||
- **Want API access?** → [API access](api-access.md) — use our models programmatically.
|
- **Want API access?** → [API access](api-access.md) — use our models programmatically.
|
||||||
- **Want community chat?** → [Getting started · Matrix](getting-started.md) — our federated Matrix server, including how to connect from any client and how to use an existing Matrix account.
|
- **Want community chat?** → [Getting started · Matrix](getting-started.md) — our federated Matrix server, including how to connect from any client and how to use an existing Matrix account.
|
||||||
- **Curious how it works?** → [Infrastructure](infrastructure.md) — a map of the stack and the codebase.
|
- **Curious how it works?** → [Infrastructure](infrastructure.md) — a map of the stack and the codebase.
|
||||||
|
- **Wondering about the bot?** → [Working with the agent](working-with-the-agent.md) — what the co-op's agents do, who can instruct them, and what always needs a human.
|
||||||
- **Need help or have an idea?** → [Support & feature requests](https://git.inference.coop/co-op/support) — open an issue.
|
- **Need help or have an idea?** → [Support & feature requests](https://git.inference.coop/co-op/support) — open an issue.
|
||||||
|
|
||||||
## Key links
|
## Key links
|
||||||
|
|
||||||
- **Website:** https://inference.coop
|
- **Website:** [inference.coop](https://inference.coop)
|
||||||
- **AI Chat:** https://chat.inference.coop
|
- **AI Chat:** [chat.inference.coop](https://chat.inference.coop)
|
||||||
- **Matrix chat:** https://matrix.inference.coop
|
- **Matrix chat:** [matrix.inference.coop](https://matrix.inference.coop)
|
||||||
- **Member Dashboard:** https://dashboard.inference.coop
|
- **Member Dashboard:** [dashboard.inference.coop](https://dashboard.inference.coop)
|
||||||
- **Governance forum:** https://forum.inference.coop
|
- **Governance forum:** [forum.inference.coop](https://forum.inference.coop)
|
||||||
- **Source code:** https://git.inference.coop
|
- **Source code:** [git.inference.coop](https://git.inference.coop)
|
||||||
- **Contact:** info@inference.coop
|
- **Contact:** [info@inference.coop](mailto:info@inference.coop)
|
||||||
|
|
||||||
## Governance & legal
|
## Governance & legal
|
||||||
|
|
||||||
@@ -34,7 +35,3 @@ funded through our [Open Collective](https://opencollective.com/inference-cooper
|
|||||||
- [Open questions](open-questions.md) — decisions currently up for member discussion.
|
- [Open questions](open-questions.md) — decisions currently up for member discussion.
|
||||||
- [Terms of Service](terms-of-service.md)
|
- [Terms of Service](terms-of-service.md)
|
||||||
- [Privacy Policy](privacy-policy.md)
|
- [Privacy Policy](privacy-policy.md)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*This documentation lives in a public git repository. Contributions welcome.*
|
|
||||||
@@ -10,10 +10,32 @@ client works.
|
|||||||
- **Auth:** a bearer API key (create one in the [Member Dashboard](https://dashboard.inference.coop))
|
- **Auth:** a bearer API key (create one in the [Member Dashboard](https://dashboard.inference.coop))
|
||||||
- **Format:** OpenAI chat completions — `POST https://gateway.inference.coop/v1/chat/completions`
|
- **Format:** OpenAI chat completions — `POST https://gateway.inference.coop/v1/chat/completions`
|
||||||
- **Models:** see the [model list](models.md).
|
- **Models:** see the [model list](models.md).
|
||||||
|
- **Not only chat:** the gateway also serves a decision endpoint,
|
||||||
|
`POST https://gateway.inference.coop/v1/systemone` — see
|
||||||
|
[Decisions](models.md#decisions). It takes a different request shape, so
|
||||||
|
it is not reachable through an OpenAI SDK's `chat.completions` call.
|
||||||
|
|
||||||
API usage draws from the **same monthly allowance as chat** — there's no separate
|
API usage draws from the **same monthly allowance as chat** — there's no separate
|
||||||
quota.
|
quota.
|
||||||
|
|
||||||
|
## What the co-op stores about your requests
|
||||||
|
|
||||||
|
Usage records, and nothing else: the model, token counts, cost, and which key made
|
||||||
|
the request — that is how your monthly metering works. **Your prompts and the
|
||||||
|
responses are not stored on our side.** The API path keeps no conversation, so there
|
||||||
|
is no transcript to delete later.
|
||||||
|
|
||||||
|
Two things worth being precise about:
|
||||||
|
|
||||||
|
- The **chat interface** is different — it saves your conversations so you can revisit
|
||||||
|
them. If you want the API's behaviour there instead, use a **temporary chat**.
|
||||||
|
- The **provider** is a separate question. Whether a model's operator retains anything
|
||||||
|
is set by that provider, not by us, and it varies by model — the
|
||||||
|
[model list](models.md) distinguishes policy-based zero retention from
|
||||||
|
enclave-based protection.
|
||||||
|
|
||||||
|
Full detail, including what we can and cannot see: [privacy policy](privacy-policy.md).
|
||||||
|
|
||||||
## Get a key
|
## Get a key
|
||||||
|
|
||||||
Log in to the [Member Dashboard](https://dashboard.inference.coop) and create an
|
Log in to the [Member Dashboard](https://dashboard.inference.coop) and create an
|
||||||
@@ -48,6 +70,25 @@ resp = client.chat.completions.create(
|
|||||||
Any OpenAI-compatible client works the same way: set the base URL to
|
Any OpenAI-compatible client works the same way: set the base URL to
|
||||||
`https://gateway.inference.coop/v1` and the API key to your member key.
|
`https://gateway.inference.coop/v1` and the API key to your member key.
|
||||||
|
|
||||||
|
## Decisions (Metis)
|
||||||
|
|
||||||
|
Alongside the chat models, the gateway exposes GreenPT's **Metis** decision model
|
||||||
|
at `POST https://gateway.inference.coop/v1/systemone`. It is the same key and the
|
||||||
|
same base URL — only the path and the request body differ. For what it is, the
|
||||||
|
three question types, and a full `curl` example, see
|
||||||
|
[Decisions in the model list](models.md#decisions).
|
||||||
|
|
||||||
|
Two practical notes:
|
||||||
|
|
||||||
|
- **It is not an OpenAI chat endpoint.** You send a `state` plus typed `questions`
|
||||||
|
and receive probabilities; there is nothing for an OpenAI SDK's
|
||||||
|
`chat.completions` to parse. Post the body directly (`curl`, `requests`,
|
||||||
|
`httpx`, or an SDK's generic POST).
|
||||||
|
- **Its usage is metered like any other call.** Each decision is billed at
|
||||||
|
**$0.12 per 1M input tokens**, figured from the exact token count GreenPT
|
||||||
|
returns (Metis generates no prose, so there is no output cost). Decisions draw
|
||||||
|
on the same monthly allowance as chat.
|
||||||
|
|
||||||
## Coding agents and other OpenAI-compatible clients
|
## Coding agents and other OpenAI-compatible clients
|
||||||
|
|
||||||
Any tool that speaks the OpenAI API works. Where a tool asks for provider or
|
Any tool that speaks the OpenAI API works. Where a tool asks for provider or
|
||||||
|
|||||||
+3
-3
@@ -41,9 +41,9 @@ We regard disputes as an opportunity to learn and grow. Members should try to wo
|
|||||||
|
|
||||||
This charter is the core governing document. Members are also bound by the following agreements:
|
This charter is the core governing document. Members are also bound by the following agreements:
|
||||||
|
|
||||||
* [Code of Conduct](https://git.inference.coop/co-op/docs/src/branch/main/code-of-conduct.md)
|
* [Code of Conduct](code-of-conduct.md)
|
||||||
* [Terms of Service](https://git.inference.coop/co-op/docs/src/branch/main/terms-of-service.md)
|
* [Terms of Service](terms-of-service.md)
|
||||||
* [Privacy Policy](https://git.inference.coop/co-op/docs/src/branch/main/privacy-policy.md)
|
* [Privacy Policy](privacy-policy.md)
|
||||||
|
|
||||||
## Legal entity
|
## Legal entity
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Energy and carbon
|
||||||
|
|
||||||
|
The co-op shows members how much energy its inference uses, and what carbon that
|
||||||
|
represents. This page says where those numbers come from and where they are weak.
|
||||||
|
The [model list](models.md) covers which providers we use and their energy
|
||||||
|
sources; this covers how we count.
|
||||||
|
|
||||||
|
## What we measure
|
||||||
|
|
||||||
|
**Output tokens, per model, per day** — read from our own gateway logs. Nothing
|
||||||
|
is required of your client, and no per-member figure is published: the dashboards
|
||||||
|
show co-op totals.
|
||||||
|
|
||||||
|
We count **output tokens only**, deliberately. That is the unit
|
||||||
|
[GreenPT's published metric](https://greenpt.com/blog/introducing-a-new-ai-metric-to-drive-sustainability)
|
||||||
|
uses ("mWh per 100 tokens"); input tokens are excluded so comparisons hold across
|
||||||
|
prompts, workloads and models. It also means these figures are **not** total
|
||||||
|
compute.
|
||||||
|
|
||||||
|
## How tokens become energy and carbon
|
||||||
|
|
||||||
|
```
|
||||||
|
energy = output tokens × (Wh per 1,000 output tokens)
|
||||||
|
carbon = energy × grid intensity of the electricity (gCO2e per kWh)
|
||||||
|
```
|
||||||
|
|
||||||
|
Both factors are held per provider in one table, with the source recorded for
|
||||||
|
each row, in the portal's
|
||||||
|
[`metrics_snapshot.py`](https://git.inference.coop/code/member-portal/src/branch/main/metrics_snapshot.py).
|
||||||
|
|
||||||
|
### Measured: GreenPT
|
||||||
|
|
||||||
|
GreenPT returns an `impact` object with every call — energy in watt-milliseconds,
|
||||||
|
emissions in µgCO2e — so these are fitted from their own numbers rather than
|
||||||
|
imported from a third party. Measured 2026-10-28 by regressing energy against
|
||||||
|
output tokens across prompt sizes (R² ≥ 0.999; there is a real per-request
|
||||||
|
overhead, because their energy derives from inference *time*):
|
||||||
|
|
||||||
|
| Model | Wh per 1,000 output tokens | Implied grid intensity |
|
||||||
|
|---|---|---|
|
||||||
|
| `greenpt/deepseek-v4.1-flash` | 0.36 | ~193 gCO2e/kWh |
|
||||||
|
| `greenpt/glm-5.3-flash` | 1.08 | ~193 gCO2e/kWh |
|
||||||
|
| `greenpt/green-r` | 0.55 | ~24 gCO2e/kWh |
|
||||||
|
|
||||||
|
`green-r` is roughly an order of magnitude cleaner per unit of energy, which is
|
||||||
|
where GreenPT's claim of up to 40% lower CO2 per query comes from.
|
||||||
|
|
||||||
|
### Estimated: everyone else
|
||||||
|
|
||||||
|
Providers that do not disclose impact — Tinfoil, Regolo, PublicAI — use a
|
||||||
|
**documented order-of-magnitude proxy**: 0.5 Wh per 1,000 output tokens at an EU
|
||||||
|
grid average of 250 gCO2e/kWh. It exists so a day's total is never quietly
|
||||||
|
missing models. It is an estimate, and it is labelled as one everywhere it
|
||||||
|
appears. Refining a factor is a data edit, not a code change.
|
||||||
|
|
||||||
|
## What this does not cover
|
||||||
|
|
||||||
|
- **It is a floor, not a total.** Embedding, audio and image requests carry no
|
||||||
|
completion tokens, so their compute is not counted at all. This is the largest
|
||||||
|
known gap.
|
||||||
|
- **Estimates for non-disclosing providers.** The proxy is an order-of-magnitude
|
||||||
|
figure, not a measurement of what a given provider actually draws.
|
||||||
|
- **GreenPT's energy is itself estimated** — from time and documented hardware
|
||||||
|
power — so our per-token figures are an efficiency ratio, not a per-call meter
|
||||||
|
reading.
|
||||||
|
- **Training emissions are not included**, for any model we serve. Nobody in this
|
||||||
|
market can verify them yet.
|
||||||
|
|
||||||
|
## Where to see the numbers
|
||||||
|
|
||||||
|
The [member dashboard](https://dashboard.inference.coop) has them under
|
||||||
|
"Co-op usage" — by day, alongside membership, requests and spend. Operators see
|
||||||
|
the same series on the admin panel's Trends tab.
|
||||||
|
|
||||||
|
## Units, for the curious
|
||||||
|
|
||||||
|
`Wh` is a watt-hour (a 100 W bulb for one hour is 100 Wh). `kWh` is 1,000 Wh.
|
||||||
|
`gCO2e` is grams of carbon-dioxide equivalent. Converting between them is where
|
||||||
|
mistakes hide: µgCO2e per mWh and gCO2e per kWh are the same ratio, and Wh to kWh
|
||||||
|
is a factor of 1,000.
|
||||||
@@ -53,6 +53,13 @@ with the account you created in step 1.
|
|||||||
there's a tool you'd like available, ask in #general.
|
there's a tool you'd like available, ask in #general.
|
||||||
- **Voice** works in both directions: speak to the chat with the microphone
|
- **Voice** works in both directions: speak to the chat with the microphone
|
||||||
icon, and have replies read aloud.
|
icon, and have replies read aloud.
|
||||||
|
- **Temporary chats** — for conversations you don't want kept on our servers
|
||||||
|
at all. Click the **Temporary Chat** toggle (in the chat header) and the
|
||||||
|
conversation is never saved: it disappears when you leave the page, and
|
||||||
|
nothing lands in your history or our database. To make this your default,
|
||||||
|
open **Settings → Conversations** and turn on **Default Temporary Chat**.
|
||||||
|
For anything sensitive, we recommend a temporary chat — ideally paired with
|
||||||
|
a 🔒 enclave model. See [Privacy](privacy-policy.md#4-what-we-can-see-an-honest-caveat).
|
||||||
|
|
||||||
Your usage draws from your monthly allowance; you can see it on the
|
Your usage draws from your monthly allowance; you can see it on the
|
||||||
[Member Dashboard](https://dashboard.inference.coop).
|
[Member Dashboard](https://dashboard.inference.coop).
|
||||||
@@ -135,6 +142,10 @@ privacy, and direction happen on our **Loomio forum**:
|
|||||||
space and join whatever is interesting to you. New rooms appear there as
|
space and join whatever is interesting to you. New rooms appear there as
|
||||||
the co-op grows.
|
the co-op grows.
|
||||||
|
|
||||||
|
- The updates feed is posted by the co-op's automation account. See
|
||||||
|
[Working with the agent](working-with-the-agent.md) for the agents behind
|
||||||
|
it, what they do, and how members interact with them.
|
||||||
|
|
||||||
- See the [Charter](charter.md) for how governance is structured.
|
- See the [Charter](charter.md) for how governance is structured.
|
||||||
- See [Open questions](open-questions.md) for the decisions currently on the table.
|
- See [Open questions](open-questions.md) for the decisions currently on the table.
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
# 2026-10-02 — gateway outage after a security-upgrade rebuild
|
|
||||||
|
|
||||||
*Written by InferenceBot on the `incident/2026-10-02-gateway-cve-upgrade`
|
|
||||||
branch on 2026-10-02 (05:35 UTC, a minute after service returned; the commit's local timestamp reads 2026-10-01 23:35 −06:00); moved to `main` with the substance unchanged so it's
|
|
||||||
findable.*
|
|
||||||
|
|
||||||
**Impact.** The gateway (chat and API) was down about 45 minutes,
|
|
||||||
04:50–05:34 UTC. No data loss.
|
|
||||||
|
|
||||||
**What happened.** A Cloudron update triggered by a member's CVE report
|
|
||||||
rebuilt the app image. The Dockerfile pinned the floating
|
|
||||||
`main-v1.74.0-stable` tag, which the registry had silently moved to newer
|
|
||||||
(v1.84.0-era) bits, so the rebuild changed the running version and kicked
|
|
||||||
off a full Prisma database migration that takes 10–15 minutes per boot with
|
|
||||||
internal timeouts and retries. The migration ran at Cloudron's default
|
|
||||||
256 MB memory cap and was OOM-killed; the app crash-looped on a missing
|
|
||||||
migrations ledger until the memory limit and `LITELLM_MIGRATION_DIR` were
|
|
||||||
fixed.
|
|
||||||
|
|
||||||
**Also during the incident.** The domain's authoritative nameservers
|
|
||||||
(yoursrs.com) were intermittently unreachable, briefly failing resolution of
|
|
||||||
some hostnames (git, portal). Transient, registrar-side, self-recovered; no
|
|
||||||
records were wrong. The upgrade had also silently reverted CORS to `*`.
|
|
||||||
|
|
||||||
**Repairs.** Version pinned to the exact tag in `code/litellm`;
|
|
||||||
`memoryLimit` set to 2 GB in the manifest; `LITELLM_MIGRATION_DIR` pointed
|
|
||||||
at the persistent volume; `LITELLM_CORS_ORIGINS` set to the chat origin;
|
|
||||||
packaging synced to mirror the live deployment. The upgrade rules learned
|
|
||||||
here are in the `code/litellm` README under "Updating LiteLLM".
|
|
||||||
|
|
||||||
**What it fixed on the security side.** CVE-2026-35029 (authorization bypass
|
|
||||||
on `/config/update`, fixed in 1.83.0) and CVE-2026-59822 (MCP session auth
|
|
||||||
bypass, fixed in 1.84.0, listed in CISA's Known Exploited Vulnerabilities
|
|
||||||
catalog). The gateway has since moved to v1.103.2.
|
|
||||||
+109
-6
@@ -26,6 +26,36 @@ Members ──> your own tools ────┘ gateway │
|
|||||||
The whole stack runs on [Cloudron](https://cloudron.io), which handles hosting,
|
The whole stack runs on [Cloudron](https://cloudron.io), which handles hosting,
|
||||||
single sign-on, and per-app isolation.
|
single sign-on, and per-app isolation.
|
||||||
|
|
||||||
|
## Where it runs
|
||||||
|
|
||||||
|
The co-op's services run on a server the co-op owns and pays for directly, at
|
||||||
|
**Infomaniak** — a Swiss sovereign cloud provider whose data centres are in
|
||||||
|
Geneva, run on renewable energy, and certified to ISO 14001, ISO 50001 and
|
||||||
|
ISO 27001. The stack moved there from a personal Hostinger account on
|
||||||
|
**2026-11-02**; nothing about how members use the co-op changed in the move.
|
||||||
|
|
||||||
|
One thing runs elsewhere: the co-op's **steward bot** — the automation behind the
|
||||||
|
digests and the newsletter drafts — runs on a separate machine, not on the
|
||||||
|
Cloudron server, and was not part of the migration.
|
||||||
|
|
||||||
|
### DNS
|
||||||
|
|
||||||
|
`inference.coop` is managed by hand at **domains.coop** (nameservers
|
||||||
|
`ns1/ns2.yoursrs.com`). The zone is deliberately small — **eleven records**. Most
|
||||||
|
of the co-op hangs off just a few of them: the apex (`inference.coop`), `www`,
|
||||||
|
and a wildcard (`*.inference.coop`) point at the server, so each application
|
||||||
|
hostname — chat, portal, dashboard, gateway, git, newsletter, vault and the rest
|
||||||
|
— is served by the wildcard rather than having a record of its own. The DNS
|
||||||
|
change sheet listing all eleven records is public, in
|
||||||
|
[`co-op/runbooks`](https://git.inference.coop/co-op/runbooks).
|
||||||
|
|
||||||
|
### Backups
|
||||||
|
|
||||||
|
Cloudron writes **encrypted** backups to **Infomaniak's object storage** (the
|
||||||
|
"Infomaniak Encrypted S3" site, in Switzerland) every night, and keeps the last
|
||||||
|
**seven days**. The steward-bot host backs up its own selected home directories
|
||||||
|
to the same object storage.
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
| Component | Purpose | URL | Code |
|
| Component | Purpose | URL | Code |
|
||||||
@@ -43,11 +73,41 @@ single sign-on, and per-app isolation.
|
|||||||
| **Surfer** | Static hosting (landing page, thanks page) | inference.coop | [co-op/website](https://git.inference.coop/co-op/website) |
|
| **Surfer** | Static hosting (landing page, thanks page) | inference.coop | [co-op/website](https://git.inference.coop/co-op/website) |
|
||||||
| **Listmonk** | Member newsletter (auto-synced from membership) | newsletter.inference.coop | — |
|
| **Listmonk** | Member newsletter (auto-synced from membership) | newsletter.inference.coop | — |
|
||||||
| **Vaultwarden** | Password manager (admin credentials) | vault.inference.coop | — |
|
| **Vaultwarden** | Password manager (admin credentials) | vault.inference.coop | — |
|
||||||
| **SnappyMail** | Webmail | mail.inference.coop | — |
|
| **SnappyMail** | Webmail | email.inference.coop | — |
|
||||||
|
|
||||||
Open Collective (billing and fiscal sponsorship) is the one external service in
|
Open Collective (billing and fiscal sponsorship) is the one external service in
|
||||||
the flow — see [opencollective.com/inference-cooperative](https://opencollective.com/inference-cooperative).
|
the flow — see [opencollective.com/inference-cooperative](https://opencollective.com/inference-cooperative).
|
||||||
|
|
||||||
|
## Mail
|
||||||
|
|
||||||
|
The `inference.coop` mail domain is the co-op's, and the mail itself runs on the
|
||||||
|
co-op's Cloudron server.
|
||||||
|
|
||||||
|
**Incoming** mail is received directly by that server — the domain's MX record
|
||||||
|
points at it, it accepts mail on the standard SMTP port, and webmail and mail
|
||||||
|
apps reach it over the usual encrypted ports (IMAP and SMTP over TLS). If mail
|
||||||
|
to your `@inference.coop` address stops arriving, that is a fault on our side to
|
||||||
|
fix, not a limitation of how the co-op is set up.
|
||||||
|
|
||||||
|
**Outgoing** mail travels by two different paths, on purpose:
|
||||||
|
|
||||||
|
- The **newsletter** (Listmonk) sends through **Scaleway Transactional Email**,
|
||||||
|
an EU-based SMTP relay, rather than from our own server. Our domain is
|
||||||
|
authenticated to that relay with its own DKIM key, and a real message has been
|
||||||
|
checked end to end: SPF, DKIM and DMARC all pass under a `p=reject` policy —
|
||||||
|
so anything claiming to be from us that the relay hasn't authenticated is
|
||||||
|
rejected outright rather than quietly filed away.
|
||||||
|
- The **member portal's** transactional email — welcome messages, budget
|
||||||
|
notices, the confirmation the nightly membership sweep sends — still sends from
|
||||||
|
the co-op's own server, through the mail service Cloudron runs for us.
|
||||||
|
|
||||||
|
Both paths send as **Inference Cooperative `<info@inference.coop>`**.
|
||||||
|
|
||||||
|
Why split them: sending the newsletter through a relay lets a provider with
|
||||||
|
established sending IPs carry a list this size, instead of leaning on one
|
||||||
|
freshly-migrated server's reputation. Receiving we keep in-house, on our own
|
||||||
|
hardware.
|
||||||
|
|
||||||
## Matrix
|
## Matrix
|
||||||
|
|
||||||
Members get a federated Matrix account on our self-hosted Synapse
|
Members get a federated Matrix account on our self-hosted Synapse
|
||||||
@@ -60,8 +120,44 @@ added via the portal's `MATRIX_EXTRA_MXIDS` env.
|
|||||||
|
|
||||||
Federation is enabled (delegated via `.well-known/matrix/server` on the bare
|
Federation is enabled (delegated via `.well-known/matrix/server` on the bare
|
||||||
domain, port 443), so members can also join from any homeserver they already
|
domain, port 443), so members can also join from any homeserver they already
|
||||||
use. The `@inference-bot:inference.coop` admin account (used by the portal
|
use.
|
||||||
for invites) has no password — token lives in the portal env.
|
|
||||||
|
### Which account is which
|
||||||
|
|
||||||
|
Several automated accounts touch this homeserver, and two of them once differed
|
||||||
|
by a single character — which caused real confusion, including a bot appearing to
|
||||||
|
announce itself. The distinction is deliberate:
|
||||||
|
|
||||||
|
- **`@inferencebot:inference.coop` — the steward.** The bot members actually meet:
|
||||||
|
it posts digests, newsletter notes and reminders. It is *not* a Synapse server
|
||||||
|
admin, and holds no room power beyond what it needs to post.
|
||||||
|
- **`@inference-admin:inference.coop` — the room and server administrator.** A
|
||||||
|
Synapse server admin, used for provisioning rooms, managing membership, and
|
||||||
|
retiring rooms. It **never speaks in a member room**; a message from it is a
|
||||||
|
mistake, not a feature.
|
||||||
|
- **`@infrastructure-bot:inference.coop` — the infrastructure working group's bot.**
|
||||||
|
Answers infrastructure questions in **#infrastructure** when mentioned.
|
||||||
|
- **`@ops_audit:inference.coop`** — a server-admin account created for the October
|
||||||
|
2026 security review. Retained only until its tokens are revoked.
|
||||||
|
|
||||||
|
**House rule: power belongs to `@inference-admin`, voice belongs to `@inferencebot`.**
|
||||||
|
Please don't create another account whose name differs from an existing one by a
|
||||||
|
character or two; that is the specific confusion this section exists to undo.
|
||||||
|
|
||||||
|
**`@inference-bot:inference.coop` is retired (2026-10-29).** It was the original
|
||||||
|
room-administration account (it created the space and most of the rooms) and held a
|
||||||
|
Synapse server-admin flag. Its room power went to `@inference-admin`, which is now
|
||||||
|
administrator of record for #general, #community, #design, #skillshare, #updates and
|
||||||
|
#infrastructure, **and holds the portal's member-invite credential**
|
||||||
|
(`MATRIX_BOT_TOKEN`) under its own token. The old account's admin flag was dropped,
|
||||||
|
it was deactivated, and every device and token was deleted. Nothing should use it
|
||||||
|
again.
|
||||||
|
|
||||||
|
Two loose ends, recorded so nobody re-investigates them from scratch: it remains
|
||||||
|
joined to two rooms it cannot leave (they reject every write — `500` on leave), and
|
||||||
|
those rooms are also why they can't be deleted; and the old account's *name* is still
|
||||||
|
the tempting one, so check `whoami` rather than the name before trusting any account
|
||||||
|
that looks like it.
|
||||||
|
|
||||||
## How the apps run themselves
|
## How the apps run themselves
|
||||||
|
|
||||||
@@ -203,9 +299,16 @@ through it; the other providers are called directly.
|
|||||||
|
|
||||||
Maintaining the co-op's infrastructure — rotating Cloudron tokens, editing the
|
Maintaining the co-op's infrastructure — rotating Cloudron tokens, editing the
|
||||||
portal's environment, and troubleshooting the provisioning pipeline — is
|
portal's environment, and troubleshooting the provisioning pipeline — is
|
||||||
documented in the [operator runbook](operator-token-runbook.md).
|
documented in the [operator
|
||||||
|
runbook](https://git.inference.coop/co-op/ops-docs/src/branch/main/operator-token-runbook.md).
|
||||||
|
All operator-facing material (that runbook, the maintenance notes, and incident
|
||||||
|
write-ups) lives in the git-only
|
||||||
|
[`co-op/ops-docs`](https://git.inference.coop/co-op/ops-docs) repository, which
|
||||||
|
is deliberately not published on this site.
|
||||||
|
|
||||||
## Incident log
|
## Incident log
|
||||||
|
|
||||||
Write-ups for outages live in [`incidents/`](incidents/). One so far:
|
Write-ups for outages live in the operator docs, in
|
||||||
[2026-10-02 — gateway outage after a security-upgrade rebuild](incidents/2026-10-02-gateway-cve-upgrade.md).
|
[`incidents/`](https://git.inference.coop/co-op/ops-docs/src/branch/main/incidents).
|
||||||
|
One so far:
|
||||||
|
[2026-10-02 — gateway outage after a security-upgrade rebuild](https://git.inference.coop/co-op/ops-docs/src/branch/main/incidents/2026-10-02-gateway-cve-upgrade.md).
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Chat task models (titles, tags, suggestions)
|
|
||||||
|
|
||||||
Both chat front doors generate small helper outputs — conversation titles,
|
|
||||||
tags, retrieval queries — with a model separate from the one answering the
|
|
||||||
member. These tasks are pinned to a small, inexpensive model so they never
|
|
||||||
bill against the member's chosen (larger) model.
|
|
||||||
|
|
||||||
## Open WebUI (chat.inference.coop)
|
|
||||||
|
|
||||||
- Title generation and tag generation are **enabled**; follow-up reply
|
|
||||||
suggestions and input autocomplete are **disabled** (October 2026).
|
|
||||||
- The task model (`task.model.default` in the Postgres `config` table) is
|
|
||||||
pinned to `greenpt/glm-5.3-flash` — a small, inexpensive model — so titles
|
|
||||||
and tags always run on it regardless of which model the member is chatting
|
|
||||||
with. Before October 2026 it was empty, which meant helper tasks ran on the
|
|
||||||
member's selected model.
|
|
||||||
- Config lives in the app's Postgres database (`config` table, key/value rows
|
|
||||||
with JSON values) and is read at startup: change a row, then restart the
|
|
||||||
app (`cloudron restart --app <id>`; note `restart` takes no `--timeout`).
|
|
||||||
|
|
||||||
## LibreChat (librechat-test.inference.coop)
|
|
||||||
|
|
||||||
- Title generation is configured in `/app/data/librechat.yaml`:
|
|
||||||
`titleConvo: true`, `titleModel: greenpt/glm-5.3-flash`,
|
|
||||||
`titleMethod: completion` — the same small GreenPT model.
|
|
||||||
- LibreChat v0.8.8 has no reply-suggestion feature; there is nothing to
|
|
||||||
disable on that front.
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
# LibreChat sandbox maintenance
|
|
||||||
|
|
||||||
The LibreChat evaluation instance at **librechat-test.inference.coop**
|
|
||||||
(Cloudron app `4023ccbf-d57a-4500-97df-14de9cf3516e`, appstore package
|
|
||||||
`ai.librechat.cloudronapp@1.4.1`, which bundles **LibreChat v0.8.8**).
|
|
||||||
|
|
||||||
The single source of truth for the deployment is the repo
|
|
||||||
**[co-op/librechat-custom](https://git.inference.coop/co-op/librechat-custom)**
|
|
||||||
— its README is the full runbook (config deploy, image build/deploy, rollback,
|
|
||||||
env vars, branding overlay, known state). This doc is the operator-facing
|
|
||||||
summary.
|
|
||||||
|
|
||||||
## Architecture: what lives where
|
|
||||||
|
|
||||||
- **App config** — `/app/data/librechat.yaml` (persistent volume, mirrored
|
|
||||||
byte-identically in the repo). Holds the "Inference Co-op" custom endpoint
|
|
||||||
(gateway), the 8-model chat picker, speech, web search, and the two MCP
|
|
||||||
servers. Volume edits deploy via `cloudron exec` (base64), never via the
|
|
||||||
image — the image never touches `/app/data`.
|
|
||||||
- **Env file** — `/app/data/env` (persistent volume). **`start.sh` sources it
|
|
||||||
AFTER the process env, so it wins over `cloudron env`** — that is where
|
|
||||||
`APP_TITLE="Inference Co-op"`, `ALLOW_EMAIL_LOGIN=false` (password login
|
|
||||||
removed; Cloudron SSO is the only login path), and
|
|
||||||
`SCHEDULES_SINGLE_PROCESS=true` must live.
|
|
||||||
- **Other env vars** — via `cloudron env`: `TTS_API_KEY`/`STT_API_KEY`
|
|
||||||
(dedicated LiteLLM virtual key, alias `librechat-speech`, $5/30d, audio
|
|
||||||
models only — never the gateway master key), `IMAGE_GEN_OAI_BASEURL` +
|
|
||||||
`IMAGE_GEN_OAI_MODEL` (agent image tool → gateway), `SEARXNG_INSTANCE_URL`.
|
|
||||||
- **MCP servers** — `/app/data/mcp/server.py` (docs search, `search_docs`,
|
|
||||||
starts at boot) and `/app/data/mcp/image_server.py` (image generation,
|
|
||||||
`generate_image`, `startup: false`); both mirrored in the repo under
|
|
||||||
`mcp/`, stdlib-only Python.
|
|
||||||
- **Branding overlay** (logo, favicons, PWA icons, `<title>`, `custom.css`,
|
|
||||||
`custom2.js`) — baked into a pinned overlay image `FROM` the official
|
|
||||||
appstore image. Deployed tag (last verified 2026-10-19):
|
|
||||||
`registry.inference.coop/co-op/librechat-custom:20261019-fc9b11e`.
|
|
||||||
- **Branding CSS/JS** — `custom.css` overrides LibreChat's Tailwind theme
|
|
||||||
variables on `html.light`/`html.dark` using the HOMEPAGE's canonical
|
|
||||||
palette (`--cream #faf8f5`, `--wash #eef4ea`, `--green-deep #5b8c5a`,
|
|
||||||
`--green-mid #6ba86b`, `--ink #2d3327`, `--muted #6b7a62`) and brands
|
|
||||||
the loading flash (`#loading-container`); the login logo is the green
|
|
||||||
pine-dots vector (white-plate variant was invisible on cream — the
|
|
||||||
'flash on load' glitch), cache-busted via `?v=3` in the client bundle.
|
|
||||||
and hides the register/sign-up prompt via
|
|
||||||
`p:has(> a[href*="register"])`; `custom2.js` is a MutationObserver that
|
|
||||||
hides the prompt when React renders it (the auth form renders after
|
|
||||||
`load`). The `interface.theme` block in the yaml remains
|
|
||||||
forward-compatible only (inert on v0.8.8).
|
|
||||||
|
|
||||||
## Image generation (two member-facing paths)
|
|
||||||
|
|
||||||
1. **MCP tool (default, no key ever)**: the `inference-image` MCP server
|
|
||||||
exposes `generate_image` in the composer's **+** palette — no agent setup,
|
|
||||||
no key prompt. Per-member billing via `customUserVars.IC_API_KEY` (seeded
|
|
||||||
from the portal) + `env: IC_API_KEY: "{{IC_API_KEY}}"`; a system-key
|
|
||||||
fallback (`system-image-fallback`, $5/30d) covers the rare empty-value
|
|
||||||
case. The `customUserVars` gate blocks a not-yet-seeded member until the
|
|
||||||
next sync (≤10 min) — accepted as a short delay over billing everything
|
|
||||||
to the system account.
|
|
||||||
2. **Agent tool**: LibreChat's built-in "OpenAI Image Tools"
|
|
||||||
(`IMAGE_GEN_OAI_BASEURL`/`IMAGE_GEN_OAI_MODEL` → gateway; members paste
|
|
||||||
their own key — v0.8.8 limitation).
|
|
||||||
|
|
||||||
## Update policy
|
|
||||||
|
|
||||||
- The app is **image-sourced** (the `--force` update moved it off the
|
|
||||||
appstore source), so Cloudron will not auto-update it. This is deliberate —
|
|
||||||
same lockdown model as LiteLLM.
|
|
||||||
- **Image deploys**: build `--no-cache`, write the new tag into
|
|
||||||
`CloudronManifest.json.dockerImage` (the only editable field), commit, then
|
|
||||||
`cloudron update --app <id> --force`. **`--image` alone is IGNORED** when
|
|
||||||
the manifest sits in the deploy directory. **The remote builder's cache can
|
|
||||||
serve stale COPY layers even when file content changed** (six consecutive
|
|
||||||
"Build successful" runs did on 2026-10-19) — so after every image deploy,
|
|
||||||
verify: the Cloudron API's `manifest.dockerImage`, container-file md5 vs
|
|
||||||
repo md5, the served HTML, and the rendered DOM. Full checklist in the
|
|
||||||
repo README.
|
|
||||||
- **When a new appstore package ships**: pull its manifest copy into the repo
|
|
||||||
(`GET /api/v1/apps/<id>` → `.manifest`), repoint `dockerImage` to the
|
|
||||||
rebuilt overlay, bump the Dockerfile `FROM` to the new `manifest.dockerImage`
|
|
||||||
(pinned), rebuild, verify in the sandbox, then update.
|
|
||||||
- **Rollback**: repoint `CloudronManifest.json.dockerImage` to the previous
|
|
||||||
tag and rerun `cloudron update --force` (or the original appstore image).
|
|
||||||
Cloudron also snapshots the app before every image update.
|
|
||||||
|
|
||||||
## Hard-won pitfalls (do not re-learn these)
|
|
||||||
|
|
||||||
- `cloudron update` deploys **`manifest.dockerImage`** from the manifest in
|
|
||||||
the deploy directory — the `--image` flag alone is ignored (and the update
|
|
||||||
output still prints the `--image` value, so it *looks* right). The repo
|
|
||||||
manifest is a byte-identical copy of the OFFICIAL appstore manifest except
|
|
||||||
`dockerImage`; editing anything else in it (or hand-writing one that omits
|
|
||||||
the addons) strips `postgresql`/`mongodb`/`oidc`/`sendmail`/`localstorage`
|
|
||||||
from the app and crash-loops it (`mkdir: Read-only file system`). That is
|
|
||||||
what broke the sandbox on 2026-10-18.
|
|
||||||
- The remote builder's layer cache can pin COPY steps to old file content
|
|
||||||
even across "successful" rebuilds — use `--no-cache` after asset edits and
|
|
||||||
confirm by comparing container-file md5 against the repo file.
|
|
||||||
- Deploy `librechat.yaml` only via base64 round-trip. A `sed` insert once
|
|
||||||
broke the YAML indentation and LibreChat **silently fell back to defaults**
|
|
||||||
(all custom branding/models/MCP gone) with a single log line.
|
|
||||||
- Reading large files from the container over `cloudron exec` with a plain
|
|
||||||
`cat` resets the connection (`ECONNRESET`) — pipe through
|
|
||||||
`gzip | base64 | fold` and decode locally.
|
|
||||||
- `cloudron env set` alone does NOT disable the password form —
|
|
||||||
`ALLOW_EMAIL_LOGIN` must be flipped in `/app/data/env` (precedence above).
|
|
||||||
- Image generation in LibreChat is the **MCP tool** (composer **+** palette);
|
|
||||||
the agent "OpenAI Image Tools" path also exists. Image models in the chat
|
|
||||||
picker were the source of the old 500 errors and are deliberately excluded.
|
|
||||||
- No `branding:` block exists in LibreChat's config schema, and the login
|
|
||||||
page has no `.login-page`/`.primary` classes to target — theming happens by
|
|
||||||
overriding the Tailwind CSS variables on `html.light`/`html.dark`
|
|
||||||
(`--surface-primary`, `--accent-primary`, …) from the overlay `custom.css`.
|
|
||||||
|
|
||||||
|
|
||||||
## Per-member key auto-load (no API-key prompts)
|
|
||||||
|
|
||||||
Members never enter an API key. The portal provisions each member's LiteLLM
|
|
||||||
key at onboarding; the sandbox seeds it into LibreChat's user-key store:
|
|
||||||
|
|
||||||
- `GET /admin/librechat/keys` on the portal returns active members' chat keys
|
|
||||||
(dedicated `X-Sync-Token` auth — `LIBRECHAT_SYNC_TOKEN` on both apps).
|
|
||||||
- `/app/data/sync-keys.js` (in the LibreChat container, mirrored in the repo
|
|
||||||
as `sync-keys.js`) fetches that feed and seeds the `keys` collection
|
|
||||||
(chat endpoint, `apiKey: "user_provided"`) and `pluginauths`
|
|
||||||
(`mcp:inference-image` → `IC_API_KEY`, injected per-user via
|
|
||||||
`customUserVars` so image generation bills to the member's own team).
|
|
||||||
- Members without a sandbox account are skipped until they first log in; run
|
|
||||||
the sync after onboarding waves and on a schedule (the command is in the
|
|
||||||
repo README). Idempotent.
|
|
||||||
- Encryption matches LibreChat's own (`CREDS_KEY`/`CREDS_IV`, read from
|
|
||||||
`/proc/1/environ` — exec sessions don't inherit the app env).
|
|
||||||
|
|
||||||
> Terminal output redacts values after `apiKey:` as `***` even for harmless
|
|
||||||
> strings (`user_provided`, `${TTS_API_KEY}`). Probe lengths/counts instead
|
|
||||||
> of trusting redacted output.
|
|
||||||
|
|
||||||
## Verification (after any change)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cloudron logs --app 4023ccbf-d57a-4500-97df-14de9cf3516e --lines 60 | \
|
|
||||||
grep -iE 'custom config|error|invalid|MCP|schedules'
|
|
||||||
curl -s https://librechat-test.inference.coop/api/config # appTitle, emailLoginEnabled
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `Custom config file loaded`, `[MCP][inference-docs]` initialized
|
|
||||||
with tool `search_docs`, `inference-image` registered (its `Tools: undefined`
|
|
||||||
boot line is expected — per-user startup), `[schedules] engine started`,
|
|
||||||
`appTitle: Inference Co-op`, `emailLoginEnabled: false`. The login page shows
|
|
||||||
the co-op logo, the co-op favicon, a single "Login with Inference
|
|
||||||
Cooperative" (Cloudron SSO) path with no email/password form, **no
|
|
||||||
"Don't have an account?" line**, and the cream/green co-op color scheme.
|
|
||||||
|
|
||||||
After an IMAGE deploy, additionally run the four-step verification in the
|
|
||||||
repo README (API `manifest.dockerImage`, container md5 vs repo md5, served
|
|
||||||
HTML tags, rendered DOM colors).
|
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
# MkDocs configuration for the member-readable documentation site.
|
||||||
|
#
|
||||||
|
# The documentation source stays exactly where it is — Markdown files at the
|
||||||
|
# repository root. This is a *publishing* layer over that source, not an edit
|
||||||
|
# of it. docs_dir is the repo root, so no document has to move for the
|
||||||
|
# generator to find it.
|
||||||
|
#
|
||||||
|
# Build: see publishing/build.sh
|
||||||
|
# Deploy: see publishing/deploy.sh
|
||||||
|
#
|
||||||
|
# Constraints baked in here:
|
||||||
|
# * theme.font: false -> Material must NOT pull Google Fonts.
|
||||||
|
# * fonts are self-hosted (publishing/fonts/) and OFL-licensed (Figtree).
|
||||||
|
# * no CDN / third-party host appears anywhere in the output.
|
||||||
|
|
||||||
|
site_name: Inference Cooperative
|
||||||
|
site_description: >-
|
||||||
|
Member-readable documentation for the Inference Cooperative: how to join and
|
||||||
|
use the service, the model catalogue, API access, governance and policies.
|
||||||
|
site_url: https://docs.inference.coop/
|
||||||
|
# MkDocs requires the docs directory to be a *child* of the config's directory
|
||||||
|
# and refuses to read the repository root directly. So the build stages the
|
||||||
|
# Markdown into .site-src/ (a generated, gitignored mirror of the documents)
|
||||||
|
# and renders that. No document is moved or rewritten in the repository — the
|
||||||
|
# staging copy exists only during the build.
|
||||||
|
docs_dir: .site-src
|
||||||
|
# Build output.
|
||||||
|
site_dir: .site-build
|
||||||
|
use_directory_urls: true
|
||||||
|
|
||||||
|
theme:
|
||||||
|
name: material
|
||||||
|
# Theme overrides. Used only to add a "documentation source" link to the
|
||||||
|
# footer on *every* page (overrides/partials/copyright.html) without putting
|
||||||
|
# it in the Markdown sources. No styling is changed here.
|
||||||
|
custom_dir: overrides
|
||||||
|
language: en
|
||||||
|
# Disable the theme's built-in Google Fonts loader. Without this, Material
|
||||||
|
# requests Roboto from fonts.googleapis.com / fonts.gstatic.com.
|
||||||
|
font: false
|
||||||
|
logo: publishing/logo.svg
|
||||||
|
favicon: publishing/logo.svg
|
||||||
|
palette:
|
||||||
|
# Light is listed first, so a fresh visitor gets the bright theme.
|
||||||
|
- media: "(prefers-color-scheme: light)"
|
||||||
|
scheme: default
|
||||||
|
primary: custom
|
||||||
|
accent: custom
|
||||||
|
toggle:
|
||||||
|
icon: material/weather-night
|
||||||
|
name: Switch to dark mode
|
||||||
|
- media: "(prefers-color-scheme: dark)"
|
||||||
|
scheme: slate
|
||||||
|
primary: custom
|
||||||
|
accent: custom
|
||||||
|
toggle:
|
||||||
|
icon: material/weather-sunny
|
||||||
|
name: Switch to light mode
|
||||||
|
features:
|
||||||
|
- navigation.tabs
|
||||||
|
- navigation.sections
|
||||||
|
- navigation.top
|
||||||
|
- navigation.indexes
|
||||||
|
- search.suggest
|
||||||
|
- search.highlight
|
||||||
|
- content.code.copy
|
||||||
|
- toc.integrate
|
||||||
|
|
||||||
|
nav:
|
||||||
|
- Home: README.md
|
||||||
|
- Getting started: getting-started.md
|
||||||
|
- Models: models.md
|
||||||
|
- API access: api-access.md
|
||||||
|
- Energy & carbon: energy.md
|
||||||
|
- Infrastructure: infrastructure.md
|
||||||
|
- Background: background.md
|
||||||
|
- Working with the agent: working-with-the-agent.md
|
||||||
|
- Governance:
|
||||||
|
- Charter: charter.md
|
||||||
|
- Code of conduct: code-of-conduct.md
|
||||||
|
- Open questions: open-questions.md
|
||||||
|
- Terms of Service: terms-of-service.md
|
||||||
|
- Privacy Policy: privacy-policy.md
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
# Built-in, offline search — the index is a JSON file in the build output.
|
||||||
|
- search
|
||||||
|
|
||||||
|
hooks:
|
||||||
|
# Rewrites directory-style links (e.g. [incidents/](incidents/)) to the
|
||||||
|
# landing pages the build generates for them. Publishing-layer logic only.
|
||||||
|
- publishing/hooks.py
|
||||||
|
|
||||||
|
markdown_extensions:
|
||||||
|
- admonition
|
||||||
|
- attr_list
|
||||||
|
- md_in_html
|
||||||
|
- tables
|
||||||
|
- toc:
|
||||||
|
permalink: true
|
||||||
|
- pymdownx.details
|
||||||
|
- pymdownx.superfences
|
||||||
|
- pymdownx.tasklist:
|
||||||
|
custom_checkbox: true
|
||||||
|
|
||||||
|
extra_css:
|
||||||
|
# Co-op brand: palette from the website + self-hosted Figtree (SIL OFL 1.1).
|
||||||
|
- publishing/brand.css
|
||||||
|
- publishing/typography.css
|
||||||
|
|
||||||
|
# Keep build/tooling files out of the rendered site.
|
||||||
|
exclude_docs: |
|
||||||
|
.git/
|
||||||
|
*.sh
|
||||||
|
overrides/
|
||||||
|
publishing/README.md
|
||||||
|
publishing/requirements.txt
|
||||||
|
|
||||||
|
# Links to git.inference.coop (e.g. code-of-conduct.md in charter.md) are
|
||||||
|
# intentional absolute URLs; do not warn about them.
|
||||||
|
validation:
|
||||||
|
links:
|
||||||
|
absolute_links: ignore
|
||||||
|
unrecognized_links: ignore
|
||||||
@@ -25,6 +25,10 @@ provider today is best on both.
|
|||||||
policy protects you only as far as the provider keeps its word. Both are
|
policy protects you only as far as the provider keeps its word. Both are
|
||||||
better than a provider with neither — and Regolo adds a written
|
better than a provider with neither — and Regolo adds a written
|
||||||
zero-data-retention commitment on top of its policy.
|
zero-data-retention commitment on top of its policy.
|
||||||
|
How the co-op measures its own energy use and carbon emissions is on the
|
||||||
|
[energy and carbon page](energy.md) — including what those figures cover and what
|
||||||
|
they miss.
|
||||||
|
|
||||||
- **Green energy:** *100% renewable* (GreenPT, Regolo) beats *undisclosed*
|
- **Green energy:** *100% renewable* (GreenPT, Regolo) beats *undisclosed*
|
||||||
(Tinfoil). GreenPT and Regolo both run on 100% renewable energy; Tinfoil's
|
(Tinfoil). GreenPT and Regolo both run on 100% renewable energy; Tinfoil's
|
||||||
mix is not disclosed.
|
mix is not disclosed.
|
||||||
@@ -78,13 +82,17 @@ The emoji in the model name marks its **value tier** (the same icons used in the
|
|||||||
| `🔒 tinfoil/gpt-oss-120b` | Tinfoil | architectural | undisclosed | — | 131K | $0.15 / $0.60 / — | Lightweight open-weight reasoning; cheapest private tier. |
|
| `🔒 tinfoil/gpt-oss-120b` | Tinfoil | architectural | undisclosed | — | 131K | $0.15 / $0.60 / — | Lightweight open-weight reasoning; cheapest private tier. |
|
||||||
| `🌱 greenpt/glm-5.3-flash` | GreenPT | policy | ✅ 100% renewable | — | 1M | $0.11 / $0.44 / $0.022 | Fast GLM model, renewable energy. |
|
| `🌱 greenpt/glm-5.3-flash` | GreenPT | policy | ✅ 100% renewable | — | 1M | $0.11 / $0.44 / $0.022 | Fast GLM model, renewable energy. |
|
||||||
| `🌱 greenpt/glm-5.3` | GreenPT | policy | ✅ 100% renewable | — | 1M | $1.10 / $4.40 / $0.275 | GLM flagship — strongest agentic model in the green tier. |
|
| `🌱 greenpt/glm-5.3` | GreenPT | policy | ✅ 100% renewable | — | 1M | $1.10 / $4.40 / $0.275 | GLM flagship — strongest agentic model in the green tier. |
|
||||||
| `🌱 regolo/apertus-70b` | Regolo | policy + ZDR | ✅ 100% renewable | ✅ | 65K | $0.46 / $2.44 / — | Fully open sovereign model (Apache-2.0), large/capable, image understanding. |
|
| `🌱 regolo/apertus-70b` | Regolo | policy + ZDR | ✅ 100% renewable | ✅ | 262K | $1.16 / $3.94 / — | Fully open sovereign model (Apache-2.0), large/capable, image understanding. |
|
||||||
| `🌱 regolo/qwen3.5-122b` | Regolo | policy + ZDR | ✅ 100% renewable | ✅ | 262K | $1.16 / $4.87 / — | Large vision+reasoning MoE (Qwen 3.5, 122B-A10B); 262K context. |
|
| `🌱 regolo/qwen3.5-122b` | Regolo | policy + ZDR | ✅ 100% renewable | ✅ | 262K | $0.70 / $3.94 / — | Large vision+reasoning MoE (Qwen 3.5, 122B-A10B); 262K context. |
|
||||||
|
|
||||||
Vision is verified working through the co-op gateway (base64 image input via
|
Vision is verified working through the co-op gateway (base64 image input via
|
||||||
`/v1/chat/completions`, standard OpenAI format). In the chat UI, just attach
|
`/v1/chat/completions`, standard OpenAI format). In the chat UI, just attach
|
||||||
an image to your message while using a ✅ model.
|
an image to your message while using a ✅ model.
|
||||||
|
|
||||||
|
Context figures are measured the same way, through the gateway. Where a
|
||||||
|
provider's public catalog advertises a smaller limit than the model actually
|
||||||
|
accepts, the measured figure is the one shown here.
|
||||||
|
|
||||||
**Reading the Privacy column:** *architectural* means the protection is built
|
**Reading the Privacy column:** *architectural* means the protection is built
|
||||||
into the infrastructure (TEE enclaves) — even the provider can't read your
|
into the infrastructure (TEE enclaves) — even the provider can't read your
|
||||||
prompts. *Policy* means the provider commits, by contract and policy, not to
|
prompts. *Policy* means the provider commits, by contract and policy, not to
|
||||||
@@ -189,6 +197,42 @@ numbers. Pricing here mirrors the `model_info` in the LiteLLM config
|
|||||||
(`code/litellm/config.yaml`), which is the authoritative source — the two are
|
(`code/litellm/config.yaml`), which is the authoritative source — the two are
|
||||||
kept in sync.
|
kept in sync.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
Most models here generate text. **Metis**, from GreenPT, makes *decisions*
|
||||||
|
instead: you give it a `state` (the situation, in plain text) plus up to 32
|
||||||
|
typed questions, and it returns **probabilities, choices, and scores** — never
|
||||||
|
prose. Use it when you want a calibrated judgement rather than a written
|
||||||
|
answer: "should we…?", "which option…?", "how confident is that?".
|
||||||
|
|
||||||
|
It's on the gateway at `/v1/systemone`, called with your normal co-op API key
|
||||||
|
(the same key you use for chat):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://gateway.inference.coop/v1/systemone \
|
||||||
|
-H "Authorization: Bearer ***" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"model": "metis",
|
||||||
|
"state": "Your situation, described in plain text.",
|
||||||
|
"questions": {
|
||||||
|
"go": {"type": "noul", "instructions": "Should we proceed?",
|
||||||
|
"criteria": {"true": "proceed", "false": "do not proceed"}},
|
||||||
|
"which": {"type": "choice", "instructions": "Which factor matters most?",
|
||||||
|
"criteria": {"cost": "price", "speed": "latency"}},
|
||||||
|
"confidence": {"type": "score", "instructions": "How confident are you?",
|
||||||
|
"criteria": ["very low", "low", "medium", "high", "very high"]}
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Metis is hosted by **GreenPT** — the same policy-based privacy, EU hosting, and
|
||||||
|
100% renewable energy as the 🌱 chat models. **Decisions count toward your
|
||||||
|
monthly allowance**, like every other call: each request is metered at
|
||||||
|
**$0.12 per 1M input tokens**, computed from the exact token count GreenPT
|
||||||
|
reports back, and appears in your usage record alongside your chat calls.
|
||||||
|
(Metis generates no prose, so there is no output charge.)
|
||||||
|
|
||||||
## Limitations on privacy
|
## Limitations on privacy
|
||||||
|
|
||||||
Only **Tinfoil** offers architectural privacy. The other providers are chosen
|
Only **Tinfoil** offers architectural privacy. The other providers are chosen
|
||||||
@@ -196,5 +240,8 @@ for their value (renewable energy, public models) but do not run in enclaves —
|
|||||||
prompts and responses pass through them in the ordinary way. Your **chat
|
prompts and responses pass through them in the ordinary way. Your **chat
|
||||||
history** is also stored on our server so you can revisit it, and that stored
|
history** is also stored on our server so you can revisit it, and that stored
|
||||||
history is not encrypted in a way that prevents us from technically reading it —
|
history is not encrypted in a way that prevents us from technically reading it —
|
||||||
we commit not to. The full distinction — what's architecturally private versus
|
we commit not to. Requests made through the **API** with your own key are a
|
||||||
what's a policy commitment — is in the [Privacy Policy](privacy-policy.md).
|
different matter: those are **not stored on our side at all**, beyond the usage
|
||||||
|
record needed for metering ([API access](api-access.md)). The full distinction —
|
||||||
|
what's architecturally private versus what's a policy commitment — is in the
|
||||||
|
[Privacy Policy](privacy-policy.md).
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
# Operator runbook — Cloudron tokens
|
|
||||||
|
|
||||||
How the co-op's Cloudron credentials are organized, and how to rotate them
|
|
||||||
without breaking the stack.
|
|
||||||
|
|
||||||
## The two-token split
|
|
||||||
|
|
||||||
There are **two separate** Cloudron API tokens, deliberately decoupled so
|
|
||||||
rotating one doesn't break the other:
|
|
||||||
|
|
||||||
| Token | Purpose | Where it lives |
|
|
||||||
|-------|---------|----------------|
|
|
||||||
| **Agent token** | Inferencebot's own Cloudron access | The agent's private `.env` (gitignored) |
|
|
||||||
| **Portal token** | The member portal's Cloudron access (create users, set groups, invite links) | Cloudron's env store, under the portal app's `CLOUDRON_TOKEN` |
|
|
||||||
|
|
||||||
The portal needs only a handful of Cloudron capabilities (user/group management,
|
|
||||||
invite links). It is the most exposed app (public webhook + broker endpoints),
|
|
||||||
so it must **not** hold the agent's token — a portal compromise shouldn't reach
|
|
||||||
the agent's wider Cloudron access.
|
|
||||||
|
|
||||||
## Rotating a token
|
|
||||||
|
|
||||||
### Portal token
|
|
||||||
|
|
||||||
1. In `my.inference.coop` → **Settings → API Tokens**, create a **"Read and Write"** token.
|
|
||||||
2. Set it as the portal's `CLOUDRON_TOKEN` env var (custom apps have no env-var
|
|
||||||
UI — use the Cloudron CLI or the API; see the next section).
|
|
||||||
3. Verify: the portal can reach `GET /api/v1/users` with the new token (HTTP 200).
|
|
||||||
|
|
||||||
### Agent token
|
|
||||||
|
|
||||||
1. Create a fresh token in `Settings → API Tokens`.
|
|
||||||
2. Put it in the agent's `.env` as `CLOUDRON_TOKEN`.
|
|
||||||
3. Verify against `/api/v1/cloudron/status`.
|
|
||||||
|
|
||||||
**The trap this split exists to prevent:** the portal keeps its *own copy* of a
|
|
||||||
Cloudron token. If the agent's token is rotated without also updating the portal
|
|
||||||
copy, the portal's Cloudron calls start returning `401` — which breaks
|
|
||||||
provisioning (new members can't be created) and the member dashboard (its
|
|
||||||
usage/keys data is resolved through the portal's Cloudron calls).
|
|
||||||
|
|
||||||
## Chat image: spaCy model for document parsing (EPUB/DOCX)
|
|
||||||
|
|
||||||
Open WebUI's document parser (`unstructured`) lazily downloads the spaCy model
|
|
||||||
`en_core_web_sm` on first EPUB/DOCX parse — but the app directory is read-only
|
|
||||||
under Cloudron, so that install always fails ("Errno 30 Read-only file
|
|
||||||
system"). Fixed 2026-10-14 (issue #20) by pre-installing the model into
|
|
||||||
persistent storage:
|
|
||||||
|
|
||||||
- `/app/data/spacy_model/` holds the unpacked wheel
|
|
||||||
(SHA-verified `en_core_web_sm-3.8.0-py3-none-any.whl` from the pinned URL in
|
|
||||||
`unstructured/nlp/tokenize.py`).
|
|
||||||
- App env has `PYTHONPATH=/app/data/spacy_model` so `import en_core_web_sm`
|
|
||||||
resolves and the lazy download never triggers.
|
|
||||||
|
|
||||||
**Restore caveat:** a backup restore from before 2026-10-14 will NOT contain
|
|
||||||
the model dir — re-create it (download the pinned wheel, unzip into
|
|
||||||
`/app/data/spacy_model/`, verify `spacy.load("en_core_web_sm")` works in the
|
|
||||||
container). The `PYTHONPATH` env survives restores (it's in app config).
|
|
||||||
|
|
||||||
## Scheduled tasks (scheduler addon)
|
|
||||||
|
|
||||||
The portal and gateway run their own housekeeping via the Cloudron scheduler
|
|
||||||
addon, declared in each app's `CloudronManifest.json` under `addons.scheduler`:
|
|
||||||
|
|
||||||
- **portal**: `membership_sweep` (nightly 3am, `/app/code/sweep.sh` →
|
|
||||||
`POST /admin/sweep` with the `WEBHOOK_TOKEN` header) and `rag_refresh`
|
|
||||||
(nightly 3:30am, `/app/code/rag_refresh.py` — pulls docs from the Gitea raw
|
|
||||||
API and refreshes the chat knowledge base; needs `OWUI_API_KEY` +
|
|
||||||
`DOCS_KNOWLEDGE_ID` in the app env).
|
|
||||||
- **gateway**: `audio_keepalive` (every 10 minutes, `/app/code/audio_keepalive.py`
|
|
||||||
— pings the Tinfoil audio models locally to avoid the ~24s TEE cold-start;
|
|
||||||
reads the master key from `/app/data/.master_key`, which `start.sh` writes —
|
|
||||||
the env var `LITELLM_MASTER_KEY` is only exported into the litellm process).
|
|
||||||
|
|
||||||
**After any manifest change, verify the scheduler survived the deploy:** the
|
|
||||||
build log must show `setupAddons: setting up addon scheduler with options
|
|
||||||
{...}` listing your task(s), and each script must succeed when run manually
|
|
||||||
inside the container (`cloudron exec --app <id> -- <command>`). The scheduler
|
|
||||||
has historically gone missing from the manifest during edits — a deploy without
|
|
||||||
the setupAddons line means nightly reconciliation is NOT running.
|
|
||||||
|
|
||||||
## Custom apps have no env-var UI
|
|
||||||
|
|
||||||
The member portal is a *custom* app (packaged by us), not an App Store app.
|
|
||||||
Custom apps do **not** expose environment variables in the Cloudron dashboard.
|
|
||||||
Env vars for custom apps are set via:
|
|
||||||
|
|
||||||
- **Cloudron CLI** (on your workstation, *not* the server): `cloudron env set --app portal.inference.coop CLOUDRON_TOKEN=...`
|
|
||||||
- **Cloudron API**: `POST /apps/:appId/configure/env`
|
|
||||||
|
|
||||||
## `configure/env` is a full replace
|
|
||||||
|
|
||||||
`POST /apps/:appId/configure/env` with body `{"env": {KEY: value, ...}}`
|
|
||||||
**replaces the entire environment** — it does not merge. Sending a partial map
|
|
||||||
(e.g. a single test variable) wipes every other variable.
|
|
||||||
|
|
||||||
- To change **one** variable, read the current env first (`GET /api/v1/apps/:id`
|
|
||||||
→ `.env`), merge in memory, and POST the full map back.
|
|
||||||
- Never probe the endpoint with a throwaway body against a live app.
|
|
||||||
- If the env is wiped, it is recoverable from a Cloudron app backup:
|
|
||||||
`GET /api/v1/apps/:appId/backups` → newest entry → `GET .../backups/:backupId/download`
|
|
||||||
(a `.tar.gz` containing `config.json`, whose `.env` field holds the original map).
|
|
||||||
|
|
||||||
## Reference
|
|
||||||
|
|
||||||
- Cloudron CLI: `npm install -g cloudron` (install on your workstation, not the server), then `cloudron login my.inference.coop`.
|
|
||||||
- Cloudron API docs: `docs.cloudron.io/api/`.
|
|
||||||
|
|
||||||
## Open Collective: the bot token needs the `email` scope (Sept 2026)
|
|
||||||
|
|
||||||
The portal identifies members by email, read via the bot's OC personal token
|
|
||||||
(`OC_PERSONAL_TOKEN`) as `collective.members.nodes[].account.email` with the
|
|
||||||
`... on Individual { email }` inline fragment.
|
|
||||||
|
|
||||||
**The `email` scope is load-bearing.** The bot's personal token must be minted
|
|
||||||
with the `email` scope. Without it, the GraphQL `email` field returns `null` for
|
|
||||||
*every* member — including the bot's own `me { email }` — even though:
|
|
||||||
|
|
||||||
- the bot still authenticates and reports `isAdmin: true`, and
|
|
||||||
- the OC **admin CSV export still shows emails** (it's a separate, non-GraphQL
|
|
||||||
path; its `contributorEmail` column is not a GraphQL field).
|
|
||||||
|
|
||||||
**Symptoms when the scope is missing:** `fetch_members()` filters out empty
|
|
||||||
emails and returns `[]`, so the webhook can't provision anyone by email and the
|
|
||||||
nightly sweep goes into its empty-list fail-safe (skips, doesn't deactivate).
|
|
||||||
A new member's webhook fires cleanly but no "Sent email to …" log line follows,
|
|
||||||
and no Cloudron user is created. (This is easy to misread as a "guest with no
|
|
||||||
email" problem — it is not; verify the scope first.)
|
|
||||||
|
|
||||||
**Diagnosis:** run `me { email }` with the bot token. `null` here while
|
|
||||||
`isAdmin: true` → the token is missing the `email` scope (or the scope was
|
|
||||||
revoked). Fix: re-add the `email` scope to the token on Open Collective
|
|
||||||
(Settings → Developers → Personal Tokens). No token rotation, no re-granting of
|
|
||||||
admin — just the scope.
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<div class="md-copyright">
|
||||||
|
{% if config.copyright %}
|
||||||
|
<div class="md-copyright__highlight">
|
||||||
|
{{ config.copyright }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="md-copyright__highlight">
|
||||||
|
Documentation source:
|
||||||
|
<a href="https://git.inference.coop/co-op/docs" target="_blank" rel="noopener">co-op/docs on Gitea</a>
|
||||||
|
</div>
|
||||||
|
{% if not config.extra.generator == false %}
|
||||||
|
Made with
|
||||||
|
<a href="https://squidfunk.github.io/mkdocs-material/" target="_blank" rel="noopener">
|
||||||
|
Material for MkDocs
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
+38
-3
@@ -1,12 +1,12 @@
|
|||||||
# Privacy Policy
|
# Privacy Policy
|
||||||
|
|
||||||
**Last updated:** October 6, 2026
|
**Last updated:** October 28, 2026
|
||||||
|
|
||||||
## In plain language
|
## In plain language
|
||||||
|
|
||||||
- We collect only what we need: your email, name, and basic usage data.
|
- We collect only what we need: your email, name, and basic usage data.
|
||||||
- We offer a **changing variety of models with different privacy and value affordances** — some run in encrypted enclaves we cannot read, others differ. **You are responsible for assessing each model's properties and picking the one that fits your needs.**
|
- We offer a **changing variety of models with different privacy and value affordances** — some run in encrypted enclaves we cannot read, others differ. **You are responsible for assessing each model's properties and picking the one that fits your needs.**
|
||||||
- We **do** store your chat history on our server (so you can revisit it). We promise not to read it, but this is a promise, not a technical guarantee.
|
- We **do** store your chat history on our server (so you can revisit it). We promise not to read it, but this is a promise, not a technical guarantee. **Temporary chats** are the exception: nothing is stored at all, and you can make them your default — see §4 if you'd like sensitive conversations to leave nothing behind.
|
||||||
- We never train on your data, and we never sell it.
|
- We never train on your data, and we never sell it.
|
||||||
- You can export or delete your data at any time.
|
- You can export or delete your data at any time.
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ The Inference Cooperative is a member-governed project. We are **not** an ad-sup
|
|||||||
- **Account data:** your email and name (from Open Collective).
|
- **Account data:** your email and name (from Open Collective).
|
||||||
- **Membership data:** your contribution status and role.
|
- **Membership data:** your contribution status and role.
|
||||||
- **Usage data:** token counts, model used, and timestamps (for metering your monthly budget).
|
- **Usage data:** token counts, model used, and timestamps (for metering your monthly budget).
|
||||||
- **Content:** your prompts and the AI's responses (stored in your chat history).
|
- **Content:** your prompts and the AI's responses. Whether we keep them depends on how you use the service: in the **chat interface** they are saved as your chat history, while through the **API** they are not stored at all. See §4.
|
||||||
- **Billing data:** handled by Open Collective — we do not see full payment details.
|
- **Billing data:** handled by Open Collective — we do not see full payment details.
|
||||||
|
|
||||||
## 3. Privacy depends on the model you choose
|
## 3. Privacy depends on the model you choose
|
||||||
@@ -43,6 +43,40 @@ Your **chat history is stored on our server** so you can revisit past conversati
|
|||||||
|
|
||||||
We commit not to read it. But this is a **policy commitment**, not an architectural guarantee. We're transparent about this distinction because it matters.
|
We commit not to read it. But this is a **policy commitment**, not an architectural guarantee. We're transparent about this distinction because it matters.
|
||||||
|
|
||||||
|
Note the boundary precisely: enclave models protect your conversation **while it is being processed** — the model provider and we cannot read the exchange at inference time. Stored history is a separate leg: once a conversation is saved, it sits on our server in a form we can technically read. So for sensitive material, the strongest combination available today is a **Temporary Chat with an enclave model** — protected in transit and at inference, and never stored afterwards.
|
||||||
|
|
||||||
|
### If you use the API, nothing you send is stored
|
||||||
|
|
||||||
|
This is the part that surprises people, so it is worth stating plainly. If you call
|
||||||
|
the co-op's API with your own key and your own client, there is no conversation on
|
||||||
|
our side to save. Our gateway records **that** a request happened and what it cost:
|
||||||
|
the model, the token counts, the cost, and which key made the request. It does not
|
||||||
|
record **what you asked or what came back**.
|
||||||
|
|
||||||
|
We measured this rather than asserting it. A request with a distinctive marker was
|
||||||
|
sent through the gateway and then searched for: it appears nowhere in the gateway's
|
||||||
|
logs, the gateway's configuration enables no content logging, and the request records
|
||||||
|
themselves carry usage fields only. So the API path keeps no text.
|
||||||
|
|
||||||
|
Two boundaries on that statement, because it would be easy to read it as more than
|
||||||
|
it is:
|
||||||
|
|
||||||
|
- **We can still tell that a given key made a request**, and therefore which member
|
||||||
|
it belongs to. That link is how metering works — it is not something we hide.
|
||||||
|
- **"Not stored by us" is not the same as "not stored."** The provider's handling is
|
||||||
|
a separate question from ours, and it depends on the model you choose — see §3. Not
|
||||||
|
being stored on our side says nothing about the far end of the request.
|
||||||
|
|
||||||
|
### Temporary chats (recommended for anything sensitive)
|
||||||
|
|
||||||
|
The chat has a **Temporary Chat** mode designed exactly for this:
|
||||||
|
|
||||||
|
- Click the **Temporary Chat** toggle in the chat header, and the conversation is **never saved** — it doesn't appear in your history, it isn't written to our database, and it disappears when you leave the page.
|
||||||
|
- To make this the default for every new conversation, open **Settings → Conversations** and enable **Default Temporary Chat**. The preference is stored in your browser, so it's yours to change at any time.
|
||||||
|
- The trade-off is that there's nothing to revisit: no history, no search, no sync across devices. Use it whenever that trade is right for you — you can mix saved and temporary conversations freely.
|
||||||
|
|
||||||
|
Temporary chats change what we store, not who processes your request: the model you choose still receives your messages to generate a reply, under the properties described in §3.
|
||||||
|
|
||||||
### Operational logs
|
### Operational logs
|
||||||
|
|
||||||
Our server software keeps standard operational logs (dates, error messages, and — for account management like provisioning and welcome emails — the email addresses involved). These logs exist to run and debug the service, are kept briefly, and are not used to profile members.
|
Our server software keeps standard operational logs (dates, error messages, and — for account management like provisioning and welcome emails — the email addresses involved). These logs exist to run and debug the service, are kept briefly, and are not used to profile members.
|
||||||
@@ -68,6 +102,7 @@ The models we serve are provided by third parties that change over time, each wi
|
|||||||
## 8. Retention and deletion
|
## 8. Retention and deletion
|
||||||
|
|
||||||
- Chat history is retained until you delete it.
|
- Chat history is retained until you delete it.
|
||||||
|
- **Temporary chats are never retained** — they are not written to our database at all (see §4).
|
||||||
- On leaving, your account is deactivated (data preserved for reactivation).
|
- On leaving, your account is deactivated (data preserved for reactivation).
|
||||||
- You may request full deletion at any time: info@inference.coop.
|
- You may request full deletion at any time: info@inference.coop.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Publishing the documentation site
|
||||||
|
|
||||||
|
The Markdown in this repository is the single source of truth. This directory
|
||||||
|
is the publishing layer that turns it into a member-readable static site and
|
||||||
|
puts it on the co-op's docs Surfer app at https://docs.inference.coop/.
|
||||||
|
Documents are never edited here — a change
|
||||||
|
to a document is a normal commit or pull request against the Markdown at the
|
||||||
|
repository root.
|
||||||
|
|
||||||
|
## What is built
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `../mkdocs.yml` | Site configuration (MkDocs + Material for MkDocs) |
|
||||||
|
| `brand.css` | Co-op palette + self-hosted Figtree webfont (SIL OFL 1.1) |
|
||||||
|
| `fonts/` | Figtree woff2 files and the OFL licence text |
|
||||||
|
| `logo.svg` | Co-op logo (shared with the website repo) |
|
||||||
|
| `build.sh` | Stage the Markdown into `.site-src/`, render to `.site-build/` |
|
||||||
|
| `hooks.py` | MkDocs hooks: directory-link rewriting + content-hashed stylesheet URLs |
|
||||||
|
| `deploy.sh` | Push the build into the Surfer app container |
|
||||||
|
| `poll-watcher.sh` | Polls Gitea main every 60s; deploys on change (agent host) |
|
||||||
|
| `docs-deploy-poll.service` | systemd unit for the poller |
|
||||||
|
| `webhook-receiver.py` | Gitea webhook → pull → build → deploy (agent host) |
|
||||||
|
| `webhook-receiver.service` | systemd unit for the receiver |
|
||||||
|
| `requirements.txt` | Pinned Python build toolchain |
|
||||||
|
|
||||||
|
Nothing external is loaded by the site: no CDN, no third-party font service.
|
||||||
|
Fonts are served from this repository's own output.
|
||||||
|
|
||||||
|
### Cache-proof stylesheets
|
||||||
|
|
||||||
|
`brand.css` and `typography.css` are listed under `extra_css`, so at a fixed URL
|
||||||
|
they would be vulnerable to a stale browser cache after a deploy — a warm cache
|
||||||
|
keeps serving the old file because the URL never changes. `hooks.py`'s
|
||||||
|
`on_post_build` fixes this at build time: it copies each stylesheet to
|
||||||
|
`<name>.<sha256-8>.css` in the output and rewrites the `<link>` in every built
|
||||||
|
page to the hashed name. A change to a stylesheet's content therefore always
|
||||||
|
produces a new URL. The authored sources keep their names and locations; the
|
||||||
|
hashed copies exist only in the build output. The hashed copy sits in the same
|
||||||
|
directory as the original, so the `@font-face` `url("fonts/…")` references keep
|
||||||
|
resolving. The deploy is unchanged.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
publishing/build.sh # creates .venv-docs on first run
|
||||||
|
```
|
||||||
|
|
||||||
|
Output lands in `.site-build/` (gitignored). `mkdocs build --strict` is what
|
||||||
|
CI-style runs should use, so broken links fail the build.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```sh
|
||||||
|
CLOUDRON_TOKEN=... publishing/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Writes the build into the **docs Surfer app** (Cloudron app `docs.inference.coop`,
|
||||||
|
`io.cloudron.surfer`) at:
|
||||||
|
|
||||||
|
- `/app/data/public/` — served at **https://docs.inference.coop/**
|
||||||
|
|
||||||
|
That is now the one canonical location. The docs used to be a pair of
|
||||||
|
directories inside the *marketing* Surfer app (`inference.coop`) as well, at
|
||||||
|
`/app/data/public-docs/` and `/app/data/public/docs/`; both were deleted when
|
||||||
|
this app took over, so `https://inference.coop/docs/` no longer serves the docs
|
||||||
|
(nothing on the marketing site linked to it).
|
||||||
|
|
||||||
|
Override the destination with `SURFER_APP` (the app's Cloudron location) and
|
||||||
|
`DOCS_DEST` (path inside the container) if you ever need to.
|
||||||
|
|
||||||
|
## Automatic rebuilds (push to main)
|
||||||
|
|
||||||
|
Two mechanisms keep the site in step with `main`; both run on the agent host
|
||||||
|
and deploy into the docs Cloudron app above.
|
||||||
|
|
||||||
|
**Polling (running now).** `docs-deploy-poll.service` polls Gitea every 60
|
||||||
|
seconds and deploys whenever `main` moves. A push appears on the site within
|
||||||
|
about a minute, with no human action. The Gitea webhook alone was not enough
|
||||||
|
because the agent host's cloud firewall only admits inbound SSH — a webhook
|
||||||
|
POST is silently dropped — while outbound access to Gitea works.
|
||||||
|
|
||||||
|
The service builds from a **dedicated clean clone** at
|
||||||
|
`/home/ubuntu/.docs-publisher`, not from anyone's working copy, so uncommitted
|
||||||
|
edits in a working clone can never block a deploy. Edit, commit, push — that
|
||||||
|
is the whole deployment story.
|
||||||
|
|
||||||
|
**Webhook (wired, waiting on one port).** A Gitea webhook is registered on
|
||||||
|
`co-op/docs` (push events, branch `main`) pointing at the receiver below. The
|
||||||
|
moment the Infomaniak panel allows inbound TCP to the agent host — port 8787
|
||||||
|
from the Cloudron box 179.237.86.116 is enough — start the receiver and pushes
|
||||||
|
deploy instantly instead of within a minute:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl enable --now docs-deploy-webhook
|
||||||
|
tail -f /home/ubuntu/docs-deploy.log # what both mechanisms are doing
|
||||||
|
```
|
||||||
|
|
||||||
|
The receiver authenticates webhooks with an HMAC-SHA256 shared secret
|
||||||
|
(`DEPLOY_WEBHOOK_SECRET` in the agent host's env file) and only accepts pushes
|
||||||
|
to `main` of `co-op/docs` from the Cloudron box. Woodpecker CI (Cloudron
|
||||||
|
catalog) could do the same job inside Cloudron, at the cost of running another
|
||||||
|
app; the agent-host mechanisms were chosen because they add no new Cloudron app.
|
||||||
|
|
||||||
|
Each deploy writes `meta.json` into the site with the commit it was built
|
||||||
|
from, so a deploy can be verified from the served site itself:
|
||||||
|
https://docs.inference.coop/meta.json
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
/* Inference Cooperative brand layer for the documentation site.
|
||||||
|
*
|
||||||
|
* Design-system source of truth: the co-op's chat/dashboard/portal/panel
|
||||||
|
* palette, carried in two places and kept identical there —
|
||||||
|
* repos/librechat-custom/assets/custom.css (html.light / html.dark RGB triples)
|
||||||
|
* repos/member-dashboard/app/main.py (the dashboard's :root palette)
|
||||||
|
* This file reuses those exact values so the documentation reads as the same
|
||||||
|
* organisation in both schemes, not a default Material theme.
|
||||||
|
*
|
||||||
|
* Standing constraint: everything here is local. No third-party font service,
|
||||||
|
* no CDN, no other external host.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* -- the co-op's light palette (dashboard/portal/panel design system) -- */
|
||||||
|
--cream: #faf8f5; /* page background */
|
||||||
|
--white: #ffffff; /* cards */
|
||||||
|
--ink: #2d3327; /* primary text (warm, not near-black) */
|
||||||
|
--muted: #6b7a62; /* secondary text */
|
||||||
|
--light-muted: #94a08c; /* tertiary text */
|
||||||
|
--green-deep: #5b8c5a;
|
||||||
|
--green-mid: #6ba86b;
|
||||||
|
--green-light: #7ab87a;
|
||||||
|
--green-link: #4e7a4d; /* dashboard link green — 4.7:1 on cream */
|
||||||
|
--green-ink: #2f5c30;
|
||||||
|
--border: #e4e0d8; /* warm neutral border (design-system value) */
|
||||||
|
--wash: #eaf3ea; /* the dashboard's green wash, used sparingly */
|
||||||
|
--chip: #f4f2ec; /* warm chip / code background */
|
||||||
|
--hover: #f0ede6; /* warm hover */
|
||||||
|
--bar-track: #eef0ea; /* neutral track for meters */
|
||||||
|
--earth-red: #a04b3a;
|
||||||
|
--earth-red-wash: #f3e6e1;
|
||||||
|
--bronze: #a97a4a;
|
||||||
|
--bronze-wash: #f1e8da;
|
||||||
|
|
||||||
|
/* -- the co-op's dark palette: the deep-pine family from the chat overlay -- */
|
||||||
|
--pine: #22271f; /* 34 39 31 — page background */
|
||||||
|
--pine-2: #2a3027; /* 42 48 39 — raised surface (footer, cards) */
|
||||||
|
--pine-3: #343b30; /* 52 59 48 — chips / code background */
|
||||||
|
--pine-hover: #2d3327; /* --ink reused as the hover band */
|
||||||
|
--dark-border: #4a5240; /* 74 82 64 — warm dark border */
|
||||||
|
--dark-text: #e8f5e8; /* 232 245 232 — light warm text */
|
||||||
|
--dark-muted: #94a08c; /* 148 160 140 — muted text */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Material for MkDocs: map the palette onto the theme ---------- */
|
||||||
|
|
||||||
|
/* Light scheme: the header is a pale green WASH with dark text, not a saturated green
|
||||||
|
* bar. The colour is the homepage's "open source" pill (#e8f5e8). The deep greens stay
|
||||||
|
* for ACCENTS -- links, active navigation, rules -- rather than as large background fills.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root,
|
||||||
|
[data-md-color-scheme="default"] {
|
||||||
|
--md-default-bg-color: var(--cream);
|
||||||
|
--md-default-fg-color: var(--ink);
|
||||||
|
--md-default-fg-color--light: var(--muted);
|
||||||
|
--md-default-fg-color--lighter: var(--light-muted);
|
||||||
|
--md-default-fg-color--lightest: var(--border);
|
||||||
|
|
||||||
|
--md-primary-fg-color: #e8f5e8;
|
||||||
|
--md-primary-fg-color--light: #eef4ea;
|
||||||
|
--md-primary-fg-color--dark: var(--green-deep);
|
||||||
|
--md-primary-bg-color: var(--ink);
|
||||||
|
--md-primary-bg-color--light: rgba(250, 248, 245, 0.7);
|
||||||
|
|
||||||
|
--md-accent-fg-color: var(--earth-red);
|
||||||
|
--md-typeset-a-color: var(--green-link);
|
||||||
|
--md-typeset-color: var(--ink);
|
||||||
|
--md-typeset-table-color: var(--border);
|
||||||
|
|
||||||
|
--md-code-bg-color: var(--chip);
|
||||||
|
--md-code-fg-color: var(--ink);
|
||||||
|
|
||||||
|
--md-footer-bg-color: var(--wash);
|
||||||
|
--md-footer-fg-color: var(--ink);
|
||||||
|
--md-footer-fg-color--light: var(--muted);
|
||||||
|
|
||||||
|
--md-admonition-bg-color: var(--wash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark scheme: the co-op's own deep pine, not Material's cold blue-grey slate.
|
||||||
|
* The old page background measured #1e2129 (a blue-grey); this scheme is the
|
||||||
|
* chat overlay's dark palette (--surface-primary #22271f and its family), so a
|
||||||
|
* dark screen reads as the co-op's green identity, not stock Material. */
|
||||||
|
[data-md-color-scheme="slate"] {
|
||||||
|
--md-default-bg-color: var(--pine);
|
||||||
|
--md-default-fg-color: var(--dark-text);
|
||||||
|
--md-default-fg-color--light: var(--dark-muted);
|
||||||
|
--md-default-fg-color--lighter: #6f7a68;
|
||||||
|
--md-default-fg-color--lightest: rgba(148, 160, 140, 0.12);
|
||||||
|
|
||||||
|
/* The header was an unreadably dark green. Green-mid with near-black pine
|
||||||
|
* text keeps the bar clearly green, clearly distinct from the page, and
|
||||||
|
* legible (measured below). */
|
||||||
|
--md-primary-fg-color: var(--green-mid);
|
||||||
|
--md-primary-fg-color--light: var(--green-light);
|
||||||
|
--md-primary-fg-color--dark: var(--green-deep);
|
||||||
|
--md-primary-bg-color: #16201a;
|
||||||
|
--md-primary-bg-color--light: rgba(22, 32, 26, 0.7);
|
||||||
|
|
||||||
|
--md-accent-fg-color: var(--green-light);
|
||||||
|
--md-typeset-a-color: var(--green-light);
|
||||||
|
--md-typeset-color: var(--dark-text);
|
||||||
|
--md-typeset-table-color: var(--dark-border);
|
||||||
|
|
||||||
|
--md-code-bg-color: var(--pine-3);
|
||||||
|
--md-code-fg-color: var(--dark-text);
|
||||||
|
|
||||||
|
--md-footer-bg-color: var(--pine-2);
|
||||||
|
--md-footer-fg-color: var(--dark-text);
|
||||||
|
--md-footer-fg-color--light: var(--dark-muted);
|
||||||
|
|
||||||
|
--md-admonition-bg-color: var(--pine-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Typography: Figtree, self-hosted (SIL OFL 1.1) ---------- */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Figtree";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("fonts/figtree-400.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Figtree";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("fonts/figtree-500.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Figtree";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("fonts/figtree-600.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Figtree";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("fonts/figtree-700.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--md-text-font-family: "Figtree", system-ui, -apple-system, "Segoe UI",
|
||||||
|
Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--md-code-font-family: ui-monospace, "SFMono-Regular", "Cascadia Mono",
|
||||||
|
"Segoe UI Mono", Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Friendly details ---------- */
|
||||||
|
|
||||||
|
/* Links read like the website's: brand green, semibold, underline on hover. */
|
||||||
|
.md-typeset a {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.md-typeset a:hover,
|
||||||
|
.md-typeset a:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rounder cards and tables; a warm neutral border, never a near-black one. */
|
||||||
|
.md-typeset .grid.cards > ul > li,
|
||||||
|
.md-typeset .grid > .card,
|
||||||
|
.md-typeset table:not([class]) {
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
}
|
||||||
|
.md-typeset table:not([class]) {
|
||||||
|
border: 1px solid var(--md-typeset-table-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header: co-op green with the cream wordmark. */
|
||||||
|
.md-header {
|
||||||
|
box-shadow: none;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
[data-md-color-scheme="slate"] .md-header {
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
.md-header .md-header__title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs and the search field pick up the brand green. */
|
||||||
|
.md-tabs {
|
||||||
|
background-color: var(--green-deep);
|
||||||
|
}
|
||||||
|
/* In dark, keep the tab row the same green as the header bar so the text on it
|
||||||
|
* clears 4.5:1 against green-mid (green-deep against near-black text is only
|
||||||
|
* ~4.2:1 — marginal for the small tab labels). */
|
||||||
|
[data-md-color-scheme="slate"] .md-tabs {
|
||||||
|
background-color: var(--green-mid);
|
||||||
|
}
|
||||||
|
.md-search__input {
|
||||||
|
background-color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admonitions get the co-op's washes instead of stock greys. */
|
||||||
|
.md-typeset .admonition,
|
||||||
|
.md-typeset details {
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
}
|
||||||
|
.md-typeset .admonition.tip,
|
||||||
|
.md-typeset .admonition.note {
|
||||||
|
border-color: var(--green-mid);
|
||||||
|
}
|
||||||
|
.md-typeset .admonition.warning {
|
||||||
|
border-color: var(--earth-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Typography: make Figtree actually apply ----------
|
||||||
|
* Material renders body and headings with var(--md-text-font-family) and defines that
|
||||||
|
* variable on <body>, so a :root definition loses to it by proximity in the DOM -- the
|
||||||
|
* same way the colours lost to the palette. Declare the font on the elements themselves
|
||||||
|
* as well. Sizing lives in publishing/typography.css, which loads after this file.
|
||||||
|
*/
|
||||||
|
body,
|
||||||
|
.md-typeset,
|
||||||
|
.md-nav,
|
||||||
|
.md-header,
|
||||||
|
.md-tabs {
|
||||||
|
font-family: "Figtree", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
|
||||||
|
Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-typeset h1,
|
||||||
|
.md-typeset h2,
|
||||||
|
.md-typeset h3,
|
||||||
|
.md-typeset h4,
|
||||||
|
.md-typeset h5,
|
||||||
|
.md-typeset h6,
|
||||||
|
.md-header__title {
|
||||||
|
font-family: "Figtree", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
|
||||||
|
Arial, sans-serif;
|
||||||
|
}
|
||||||
Executable
+82
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the member-readable documentation site from this repository.
|
||||||
|
#
|
||||||
|
# The repository is the single source of truth. This script:
|
||||||
|
# 1. stages a mirror of the documents into .site-src/ (generated, gitignored),
|
||||||
|
# because MkDocs cannot read the repository root directly;
|
||||||
|
# 2. renders .site-src/ to .site-build/ with MkDocs + Material.
|
||||||
|
#
|
||||||
|
# No document is moved, renamed, or rewritten — the staging copy is a build
|
||||||
|
# artifact and disappears with `--clean`.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# publishing/build.sh # create/use .venv-docs and build
|
||||||
|
# publishing/build.sh /path/to/venv # use an existing venv
|
||||||
|
#
|
||||||
|
# In CI (Woodpecker), install the toolchain and run MkDocs directly:
|
||||||
|
# pip install -r publishing/requirements.txt && mkdocs build --strict
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
|
||||||
|
VENV="${1:-$REPO_DIR/.venv-docs}"
|
||||||
|
SITE_SRC="$REPO_DIR/.site-src"
|
||||||
|
|
||||||
|
# 1. Stage the documents (Markdown at the root, maintenance/, incidents/) plus
|
||||||
|
# the publishing assets (fonts, CSS, icon). Exclude tooling and build output.
|
||||||
|
rm -rf "$SITE_SRC"
|
||||||
|
mkdir -p "$SITE_SRC"
|
||||||
|
tar cf - \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='.site-src' \
|
||||||
|
--exclude='.site-build' \
|
||||||
|
--exclude='.venv-docs' \
|
||||||
|
--exclude='*.sh' \
|
||||||
|
--exclude='*.py' \
|
||||||
|
--exclude='publishing/webhook-receiver.py' \
|
||||||
|
--exclude='publishing/webhook-receiver.service' \
|
||||||
|
--exclude='mkdocs.yml' \
|
||||||
|
--exclude='.gitignore' \
|
||||||
|
--exclude='requirements.txt' \
|
||||||
|
. | tar xf - -C "$SITE_SRC"
|
||||||
|
|
||||||
|
# A file that must sit at the site root (crawler rules), not under publishing/.
|
||||||
|
cp publishing/robots.txt "$SITE_SRC/robots.txt"
|
||||||
|
|
||||||
|
# The documents sometimes link to a directory (`incidents/`) the way Gitea
|
||||||
|
# renders it. A static site has no directory listing, so generate a small
|
||||||
|
# index page for any documented subdirectory that lacks one. This is a build
|
||||||
|
# artifact — the documents themselves are never touched.
|
||||||
|
(cd "$SITE_SRC" && find . -mindepth 1 -type d ! -path './publishing*' | while read -r dir; do
|
||||||
|
if compgen -G "$dir"/*.md >/dev/null && [ ! -f "$dir/index.md" ]; then
|
||||||
|
dir_name="$(basename "$dir")"
|
||||||
|
title="$(echo "$dir_name" | sed 's/-/ /g' | sed 's/^./\u&/')"
|
||||||
|
{
|
||||||
|
echo "# $title"
|
||||||
|
echo
|
||||||
|
echo "Landing page for this directory, generated by the publishing layer — the"
|
||||||
|
echo "documents themselves live untouched in the repository."
|
||||||
|
echo
|
||||||
|
for f in "$dir"/*.md; do
|
||||||
|
[ "$(basename "$f")" = "index.md" ] && continue
|
||||||
|
heading="$(grep -m1 '^# ' "$f" | sed 's/^# //' || true)"
|
||||||
|
echo "- [${heading:-$(basename "$f" .md)}]($(basename "$f"))"
|
||||||
|
done
|
||||||
|
} > "$dir/index.md"
|
||||||
|
fi
|
||||||
|
done)
|
||||||
|
|
||||||
|
# 2. Build.
|
||||||
|
if [ ! -x "$VENV/bin/mkdocs" ]; then
|
||||||
|
echo "Creating build venv at $VENV"
|
||||||
|
python3 -m venv "$VENV"
|
||||||
|
"$VENV/bin/pip" install --quiet --upgrade pip
|
||||||
|
"$VENV/bin/pip" install --quiet -r "$REPO_DIR/publishing/requirements.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building site -> $REPO_DIR/.site-build"
|
||||||
|
"$VENV/bin/mkdocs" build --clean
|
||||||
|
|
||||||
|
echo "Done: $REPO_DIR/.site-build"
|
||||||
Executable
+87
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Publish the built documentation site to the docs Surfer app.
|
||||||
|
#
|
||||||
|
# The docs have their OWN Cloudron app now:
|
||||||
|
#
|
||||||
|
# Cloudron app: docs.inference.coop (io.cloudron.surfer, id 85b7ce06-e2af-49a4-bd4f-a9c2e692e3ab)
|
||||||
|
# served at: https://docs.inference.coop/
|
||||||
|
# on disk: /app/data/public (Surfer's "Default" site)
|
||||||
|
#
|
||||||
|
# They used to be a directory inside the marketing Surfer app
|
||||||
|
# (inference.coop, id 55f5f904-...), reachable at /app/data/public-docs and
|
||||||
|
# /app/data/public/docs. Both of those copies were deleted once this app was
|
||||||
|
# serving, so there is exactly one canonical location and nothing to drift.
|
||||||
|
#
|
||||||
|
# Mechanism (evidence: Cloudron app "Surfer" io.cloudron.surfer 7.4.1, and
|
||||||
|
# repos/website/deploy.sh, which deploys the marketing site the same way):
|
||||||
|
# pipe a tar of the build output into the container via `cloudron exec`, which
|
||||||
|
# needs only the Cloudron API token — no WebDAV token, SFTP credentials or SSH.
|
||||||
|
# The tar is spooled to a file inside the container first; piping it straight
|
||||||
|
# into `tar xf -` twice would leave the second extraction with an empty stdin.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# CLOUDRON_TOKEN=... publishing/deploy.sh
|
||||||
|
#
|
||||||
|
# Env:
|
||||||
|
# CLOUDRON_TOKEN (required) Cloudron API token
|
||||||
|
# CLOUDRON_SERVER default my.inference.coop
|
||||||
|
# SURFER_APP default docs.inference.coop (the docs app's location)
|
||||||
|
# DOCS_DEST default /app/data/public (Surfer's Default site root)
|
||||||
|
# SITE_DIR default <repo>/.site-build
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
SITE_DIR="${SITE_DIR:-$REPO_DIR/.site-build}"
|
||||||
|
SERVER="${CLOUDRON_SERVER:-my.inference.coop}"
|
||||||
|
APP="${SURFER_APP:-docs.inference.coop}"
|
||||||
|
DEST="${DOCS_DEST:-/app/data/public}"
|
||||||
|
|
||||||
|
CLOUDRON_BIN="${CLOUDRON_BIN:-$(command -v cloudron || true)}"
|
||||||
|
if [ -z "$CLOUDRON_BIN" ] && [ -x "$HOME/.local/bin/cloudron" ]; then
|
||||||
|
CLOUDRON_BIN="$HOME/.local/bin/cloudron"
|
||||||
|
fi
|
||||||
|
: "${CLOUDRON_BIN:?cloudron CLI not found (set CLOUDRON_BIN or add it to PATH)}"
|
||||||
|
|
||||||
|
# The cloudron CLI is a node script (`#!/usr/bin/env node`); under systemd the
|
||||||
|
# PATH may not contain the host's node, so look in the usual places.
|
||||||
|
if ! command -v node >/dev/null 2>&1; then
|
||||||
|
for dir in "$HOME"/.hermes/tools/node*/bin /usr/local/bin; do
|
||||||
|
if [ -x "$dir/node" ]; then export PATH="$dir:$PATH"; break; fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
: "${CLOUDRON_TOKEN:?CLOUDRON_TOKEN is required (the Cloudron API token)}"
|
||||||
|
|
||||||
|
if [ ! -f "$SITE_DIR/index.html" ]; then
|
||||||
|
echo "No build found at $SITE_DIR — run publishing/build.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build metadata: written into the output so a deploy can be verified from the
|
||||||
|
# served site itself (which push produced it, and when).
|
||||||
|
COMMIT="$(git -C "$REPO_DIR" rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||||
|
COMMIT_SHORT="$(git -C "$REPO_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||||
|
BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
cat > "$SITE_DIR/meta.json" <<EOF
|
||||||
|
{
|
||||||
|
"commit": "$COMMIT",
|
||||||
|
"commit_short": "$COMMIT_SHORT",
|
||||||
|
"built_at": "$BUILT_AT",
|
||||||
|
"generator": "mkdocs-material (publishing/build.sh)",
|
||||||
|
"source": "https://git.inference.coop/co-op/docs"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Deploying $SITE_DIR (commit $COMMIT_SHORT) -> $SERVER:$APP"
|
||||||
|
echo " $DEST (docs.inference.coop)"
|
||||||
|
|
||||||
|
tar cf - -C "$SITE_DIR" . | \
|
||||||
|
"$CLOUDRON_BIN" --server "$SERVER" --token "$CLOUDRON_TOKEN" exec --app "$APP" -- \
|
||||||
|
sh -c "set -e
|
||||||
|
cat > /tmp/docs-site.tar
|
||||||
|
rm -rf '$DEST' && mkdir -p '$DEST' && tar xf /tmp/docs-site.tar -C '$DEST'
|
||||||
|
rm -f /tmp/docs-site.tar"
|
||||||
|
|
||||||
|
echo "Done:"
|
||||||
|
echo " https://docs.inference.coop/"
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# systemd units for the docs deploy automation (agent host).
|
||||||
|
#
|
||||||
|
# Two mechanisms, one job — keep https://docs.inference.coop/ in step with
|
||||||
|
# co-op/docs main:
|
||||||
|
#
|
||||||
|
# docs-deploy-poll.service ALWAYS ON. Polls Gitea every 60s; deploys on
|
||||||
|
# any change to main. Works with the agent
|
||||||
|
# host's current firewall (outbound only).
|
||||||
|
#
|
||||||
|
# docs-deploy-webhook.service INSTALLED, INACTIVE UNTIL THE PORT IS OPEN.
|
||||||
|
# Instant push -> deploy once the Infomaniak
|
||||||
|
# panel allows inbound TCP to the agent host
|
||||||
|
# (the Gitea webhook is already registered).
|
||||||
|
#
|
||||||
|
# Install both:
|
||||||
|
# sudo cp publishing/poll-watcher.sh publishing/webhook-receiver.py /home/ubuntu/repos/docs/publishing/
|
||||||
|
# sudo cp publishing/docs-deploy-poll.service publishing/webhook-receiver.service /etc/systemd/system/
|
||||||
|
# sudo systemctl daemon-reload
|
||||||
|
# sudo systemctl enable --now docs-deploy-poll
|
||||||
|
# sudo systemctl enable --now docs-deploy-webhook # when the port is open
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Inference docs: poll Gitea main and deploy to Surfer
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=ubuntu
|
||||||
|
WorkingDirectory=/home/ubuntu/.docs-publisher
|
||||||
|
Environment=DOCS_REPO_DIR=/home/ubuntu/.docs-publisher
|
||||||
|
ExecStart=/bin/bash /home/ubuntu/.docs-publisher/publishing/poll-watcher.sh
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2022 The Figtree Project Authors (https://github.com/erikdkennedy/figtree)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,144 @@
|
|||||||
|
"""MkDocs hooks for the publishing layer.
|
||||||
|
|
||||||
|
Two jobs, both publishing-layer only — neither edits the documents or the
|
||||||
|
authored stylesheets in the repository.
|
||||||
|
|
||||||
|
1. Directory-style links. The documents sometimes link to a directory the way
|
||||||
|
Gitea renders it, e.g. infrastructure.md contains [`incidents/`](incidents/).
|
||||||
|
On a static site with directory URLs, that href resolves relative to the
|
||||||
|
*page* (giving /infrastructure/incidents/ and a 404). This hook rewrites such
|
||||||
|
links to the generated landing page for that directory (build.sh creates
|
||||||
|
<dir>/index.md for documented directories that lack one).
|
||||||
|
|
||||||
|
2. Content-hashed stylesheet URLs. The site's own stylesheets (brand.css,
|
||||||
|
typography.css) are listed in mkdocs.yml under extra_css, so every page
|
||||||
|
links them at a FIXED URL (publishing/brand.css). A fixed URL means a browser
|
||||||
|
with a warm cache keeps serving the old stylesheet after a deploy — the URL
|
||||||
|
never changes even when the content does. Material's own assets
|
||||||
|
(assets/stylesheets/main.<hash>.min.css) are content-hashed and immune; ours
|
||||||
|
were not. This hook gives ours the same property: at build time it copies
|
||||||
|
each to <name>.<sha256-8>.css in the output and rewrites the links in the
|
||||||
|
built HTML, so any change to a stylesheet's content necessarily yields a new
|
||||||
|
URL. The authored sources keep their names and locations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import posixpath
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from mkdocs.utils import get_relative_url
|
||||||
|
|
||||||
|
_HREF = re.compile(r'(href=)("([^"]+)/")')
|
||||||
|
|
||||||
|
|
||||||
|
def on_page_content(html, page, config, files):
|
||||||
|
index_urls = {}
|
||||||
|
for f in files:
|
||||||
|
src = f.src_path
|
||||||
|
if src.endswith("/index.md"):
|
||||||
|
index_urls[posixpath.dirname(src)] = f.url
|
||||||
|
|
||||||
|
page_dir = posixpath.dirname(page.file.src_path)
|
||||||
|
|
||||||
|
def fix(match):
|
||||||
|
href = match.group(3)
|
||||||
|
if href.startswith(("http://", "https://", "/", "#")):
|
||||||
|
return match.group(0)
|
||||||
|
target = posixpath.normpath(posixpath.join(page_dir, href))
|
||||||
|
if target in (".", "..") or target not in index_urls:
|
||||||
|
return match.group(0)
|
||||||
|
return f'{match.group(1)}"{get_relative_url(index_urls[target], page.file.url)}"'
|
||||||
|
|
||||||
|
return _HREF.sub(fix, html)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Content-hashed stylesheet URLs ----------------------------------------
|
||||||
|
|
||||||
|
# Material's own asset names use 8 hex characters (main.57a0d79d.min.css); match
|
||||||
|
# that so the site's naming is uniform and the hash is short but collision-safe
|
||||||
|
# for a handful of stylesheets.
|
||||||
|
_HASH_LEN = 8
|
||||||
|
# A hashed sibling of brand.css is brand.<8 hex>.css.
|
||||||
|
_HASHED_SIBLING = re.compile(r"^(?P<stem>.+)\.(?P<hash>[0-9a-f]{%d})(?P<ext>\.[^.]+)$" % _HASH_LEN)
|
||||||
|
|
||||||
|
|
||||||
|
def _hashed_links(config, files=None):
|
||||||
|
"""Build each extra_css file to a content-hashed name in the site output.
|
||||||
|
|
||||||
|
Returns {original-relative-path: hashed-relative-path}. The hashed copy
|
||||||
|
lands in the *same directory* as the original, so relative references
|
||||||
|
inside the CSS (e.g. brand.css's @font-face url("fonts/figtree-400.woff2"))
|
||||||
|
keep resolving. Stale hashed siblings from an earlier build are removed, so
|
||||||
|
the output does not accumulate files across builds.
|
||||||
|
"""
|
||||||
|
site_dir = config["site_dir"]
|
||||||
|
replacements = {}
|
||||||
|
|
||||||
|
for css in config.get("extra_css") or []:
|
||||||
|
rel = css.lstrip("/").replace("\\", "/")
|
||||||
|
original = os.path.join(site_dir, *rel.split("/"))
|
||||||
|
if not os.path.isfile(original):
|
||||||
|
continue
|
||||||
|
|
||||||
|
with open(original, "rb") as fh:
|
||||||
|
digest = hashlib.sha256(fh.read()).hexdigest()[:_HASH_LEN]
|
||||||
|
|
||||||
|
stem, ext = posixpath.splitext(rel)
|
||||||
|
hashed_rel = f"{stem}.{digest}{ext}"
|
||||||
|
hashed = os.path.join(site_dir, *hashed_rel.split("/"))
|
||||||
|
|
||||||
|
# Copy to the hashed name (a no-op if it is already correct).
|
||||||
|
if os.path.abspath(hashed) != os.path.abspath(original):
|
||||||
|
shutil.copyfile(original, hashed)
|
||||||
|
|
||||||
|
# Drop stale hashed siblings of the original (same stem/ext, any hash).
|
||||||
|
directory = os.path.dirname(original)
|
||||||
|
base_stem, base_ext = posixpath.splitext(os.path.basename(rel))
|
||||||
|
keep = os.path.basename(hashed)
|
||||||
|
for name in os.listdir(directory):
|
||||||
|
m = _HASHED_SIBLING.match(name)
|
||||||
|
if not m or name == keep:
|
||||||
|
continue
|
||||||
|
if m.group("stem") == base_stem and m.group("ext") == base_ext:
|
||||||
|
os.remove(os.path.join(directory, name))
|
||||||
|
|
||||||
|
replacements[rel] = hashed_rel
|
||||||
|
|
||||||
|
return replacements
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_stylesheet_links(site_dir, replacements):
|
||||||
|
"""Point every <link href="…brand.css"> at the hashed file, in place.
|
||||||
|
|
||||||
|
The href is relative to each page (root pages use "publishing/brand.css",
|
||||||
|
nested pages "../publishing/brand.css"), so match any prefix up to the
|
||||||
|
known relative path and swap only the path tail, preserving the page's
|
||||||
|
own relative prefix.
|
||||||
|
"""
|
||||||
|
patterns = [
|
||||||
|
(re.compile(r'(href="[^"]*)' + re.escape(orig) + r'(")'), hashed)
|
||||||
|
for orig, hashed in replacements.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
for root, _dirs, filenames in os.walk(site_dir):
|
||||||
|
for name in filenames:
|
||||||
|
if not name.endswith(".html"):
|
||||||
|
continue
|
||||||
|
path = os.path.join(root, name)
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
html = fh.read()
|
||||||
|
new = html
|
||||||
|
for pattern, hashed in patterns:
|
||||||
|
new = pattern.sub(lambda m, h=hashed: m.group(1) + h + m.group(2), new)
|
||||||
|
if new != html:
|
||||||
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(new)
|
||||||
|
|
||||||
|
|
||||||
|
def on_post_build(config):
|
||||||
|
replacements = _hashed_links(config)
|
||||||
|
if replacements:
|
||||||
|
_rewrite_stylesheet_links(config["site_dir"], replacements)
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130 130">
|
||||||
|
<rect width="130" height="130" rx="24" fill="#faf8f5"/>
|
||||||
|
<circle cx="65" cy="14" r="5" fill="#5b8c5a"/>
|
||||||
|
<circle cx="58" cy="30" r="6" fill="#7ab87a"/>
|
||||||
|
<circle cx="74" cy="30" r="4" fill="#6ba86b"/>
|
||||||
|
<circle cx="51.5" cy="46" r="6.5" fill="#5b8c5a"/>
|
||||||
|
<circle cx="65" cy="46" r="5" fill="#7ab87a"/>
|
||||||
|
<circle cx="81" cy="46" r="4" fill="#6ba86b"/>
|
||||||
|
<circle cx="44" cy="62" r="7" fill="#6ba86b"/>
|
||||||
|
<circle cx="61" cy="62" r="5.5" fill="#5b8c5a"/>
|
||||||
|
<circle cx="76" cy="62" r="4.5" fill="#7ab87a"/>
|
||||||
|
<circle cx="89.5" cy="62" r="3.5" fill="#6ba86b"/>
|
||||||
|
<circle cx="36.5" cy="78" r="7.5" fill="#5b8c5a"/>
|
||||||
|
<circle cx="54" cy="78" r="6" fill="#7ab87a"/>
|
||||||
|
<circle cx="70" cy="78" r="5" fill="#6ba86b"/>
|
||||||
|
<circle cx="84" cy="78" r="4" fill="#5b8c5a"/>
|
||||||
|
<circle cx="97.5" cy="78" r="3.5" fill="#7ab87a"/>
|
||||||
|
<circle cx="44" cy="94" r="6.5" fill="#6ba86b"/>
|
||||||
|
<circle cx="61" cy="94" r="5.5" fill="#5b8c5a"/>
|
||||||
|
<circle cx="76" cy="94" r="4.5" fill="#7ab87a"/>
|
||||||
|
<circle cx="89.5" cy="94" r="3.5" fill="#6ba86b"/>
|
||||||
|
<circle cx="51.5" cy="110" r="5.5" fill="#5b8c5a"/>
|
||||||
|
<circle cx="65" cy="110" r="4.5" fill="#7ab87a"/>
|
||||||
|
<circle cx="81" cy="110" r="3.5" fill="#6ba86b"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
Executable
+70
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Poll Gitea for new commits on main and deploy the docs site when it moves.
|
||||||
|
#
|
||||||
|
# Why polling: the agent host's cloud firewall only admits inbound SSH, so a
|
||||||
|
# Gitea webhook cannot reach the receiver (publishing/webhook-receiver.py) —
|
||||||
|
# every port is silently dropped. Outbound to git.inference.coop works, so the
|
||||||
|
# agent host polls instead: a push to main is picked up within ~a minute and
|
||||||
|
# deployed with no human action. When the operator opens a TCP port to the
|
||||||
|
# agent host in the Infomaniak panel, the webhook takes over (its unit is
|
||||||
|
# already installed); until then this watcher is the automation.
|
||||||
|
#
|
||||||
|
# Runs on the agent host as a systemd service (docs-deploy-poll.service).
|
||||||
|
# Deploy steps are serialised with the same lock the webhook receiver uses, so
|
||||||
|
# the two mechanisms can never deploy concurrently.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
REPO_DIR="${DOCS_REPO_DIR:-/home/ubuntu/repos/docs}"
|
||||||
|
ENV_FILE="${DOCS_ENV_FILE:-/home/ubuntu/.hermes/.env}"
|
||||||
|
LOG_FILE="${DOCS_DEPLOY_LOG:-/home/ubuntu/docs-deploy.log}"
|
||||||
|
LOCK_FILE="${DOCS_DEPLOY_LOCK:-/home/ubuntu/.docs-deploy.lock}"
|
||||||
|
STATE_FILE="${DOCS_DEPLOY_STATE:-/home/ubuntu/.docs-deploy.state}"
|
||||||
|
INTERVAL="${DOCS_POLL_INTERVAL:-60}"
|
||||||
|
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
|
||||||
|
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [poll] $*" | tee -a "$LOG_FILE" >&2; }
|
||||||
|
|
||||||
|
# The Cloudron token travels by environment only; it is read from the env file
|
||||||
|
# and never echoed.
|
||||||
|
CLOUDRON_TOKEN="$(sed -n 's/^CLOUDRON_TOKEN=//p' "$ENV_FILE" | head -1 | tr -d '"' | tr -d "'")"
|
||||||
|
export CLOUDRON_TOKEN
|
||||||
|
if [ -z "$CLOUDRON_TOKEN" ]; then
|
||||||
|
log "FATAL: CLOUDRON_TOKEN not found in $ENV_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "watcher started: polling $REPO_DIR every ${INTERVAL}s"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
if ! git fetch --quiet origin main 2>>"$LOG_FILE"; then
|
||||||
|
log "git fetch failed; will retry"
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
REMOTE="$(git rev-parse origin/main 2>/dev/null || echo none)"
|
||||||
|
# Compare against the commit last DEPLOYED (not the clone's HEAD): a push
|
||||||
|
# made from this very clone would otherwise look like "no change".
|
||||||
|
LAST_DEPLOYED="$(cat "$STATE_FILE" 2>/dev/null || echo none)"
|
||||||
|
|
||||||
|
if [ "$LAST_DEPLOYED" != "$REMOTE" ]; then
|
||||||
|
log "main moved: ${LAST_DEPLOYED:0:12} -> ${REMOTE:0:12}; deploying"
|
||||||
|
# flock -n shares the receiver's lock: one deploy at a time, ever.
|
||||||
|
if flock -n "$LOCK_FILE" bash -c '
|
||||||
|
set -e
|
||||||
|
cd "$1"
|
||||||
|
git pull --ff-only origin main
|
||||||
|
bash publishing/build.sh
|
||||||
|
bash publishing/deploy.sh
|
||||||
|
' _ "$REPO_DIR" >>"$LOG_FILE" 2>&1; then
|
||||||
|
printf '%s\n' "$REMOTE" > "$STATE_FILE"
|
||||||
|
log "deploy finished for ${REMOTE:0:12}"
|
||||||
|
else
|
||||||
|
log "deploy FAILED for ${REMOTE:0:12} (see log above)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
done
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Pinned build toolchain for the documentation site.
|
||||||
|
# Reproducible: `pip install -r publishing/requirements.txt && mkdocs build`
|
||||||
|
mkdocs-material==9.8.0
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Documentation site crawler rules
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/* Typography for the co-op documentation site.
|
||||||
|
*
|
||||||
|
* Loaded after publishing/brand.css on purpose: the font has to be declared on the
|
||||||
|
* elements themselves. Material renders body and headings with
|
||||||
|
* var(--md-text-font-family), which it defines on <body>, so a :root-level value
|
||||||
|
* loses to it by DOM proximity no matter how the cascade is ordered.
|
||||||
|
*
|
||||||
|
* Figtree is self-hosted -- see the @font-face rules in brand.css. No CDN, no
|
||||||
|
* third-party font service.
|
||||||
|
*/
|
||||||
|
|
||||||
|
body,
|
||||||
|
.md-typeset,
|
||||||
|
.md-nav,
|
||||||
|
.md-header,
|
||||||
|
.md-tabs,
|
||||||
|
.md-footer {
|
||||||
|
font-family: "Figtree", system-ui, -apple-system, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-typeset h1,
|
||||||
|
.md-typeset h2,
|
||||||
|
.md-typeset h3,
|
||||||
|
.md-typeset h4,
|
||||||
|
.md-typeset h5,
|
||||||
|
.md-typeset h6,
|
||||||
|
.md-header__title,
|
||||||
|
.md-nav__title {
|
||||||
|
font-family: "Figtree", system-ui, -apple-system, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-typeset h1,
|
||||||
|
.md-typeset h2,
|
||||||
|
.md-typeset h3,
|
||||||
|
.md-typeset h4,
|
||||||
|
.md-header__title {
|
||||||
|
/* Never break a word mid-way; wrap at the space, and let the browser balance the
|
||||||
|
two lines rather than leaving one word on its own. */
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: normal;
|
||||||
|
hyphens: none;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fluid scale. Fixed rem sizes do not shrink on a phone -- Material keeps a 20px root
|
||||||
|
* even at 360px wide -- so a 2.6rem h1 stays 52px and "Cooperative" (297px at that
|
||||||
|
* size) runs out of room on anything narrower than ~370px and breaks mid-word.
|
||||||
|
* These track the viewport instead and land at the same desktop size.
|
||||||
|
*/
|
||||||
|
.md-typeset h1 { font-size: clamp(1.6rem, 1.5rem + 3.2vw, 2.6rem); font-weight: 700; letter-spacing: -0.02em; }
|
||||||
|
.md-typeset h2 { font-size: clamp(1.3rem, 1.1rem + 1.6vw, 1.7rem); font-weight: 600; }
|
||||||
|
.md-typeset h3 { font-size: clamp(1.1rem, 1rem + 0.7vw, 1.25rem); font-weight: 600; }
|
||||||
|
.md-header__title { font-size: clamp(0.85rem, 0.7rem + 0.8vw, 0.95rem); font-weight: 600; }
|
||||||
|
|
||||||
|
/* Long prose stays readable; measured in characters rather than pixels. */
|
||||||
|
.md-typeset { line-height: 1.65; }
|
||||||
|
|
||||||
|
/* Colour is scheme-specific, so it stays out of the rules above.
|
||||||
|
* Light: the co-op's warm ink (#2d3327), never a neutral near-black.
|
||||||
|
* Dark: the chat overlay's light warm text (#e8f5e8) on the deep-pine page. */
|
||||||
|
[data-md-color-scheme="default"] .md-typeset h1,
|
||||||
|
[data-md-color-scheme="default"] .md-typeset h2,
|
||||||
|
[data-md-color-scheme="default"] .md-typeset h3,
|
||||||
|
[data-md-color-scheme="default"] .md-typeset h4 {
|
||||||
|
color: #2d3327;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-color-scheme="slate"] .md-typeset h1,
|
||||||
|
[data-md-color-scheme="slate"] .md-typeset h2,
|
||||||
|
[data-md-color-scheme="slate"] .md-typeset h3,
|
||||||
|
[data-md-color-scheme="slate"] .md-typeset h4 {
|
||||||
|
color: #e8f5e8;
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Gitea webhook receiver that rebuilds and republishes the docs site.
|
||||||
|
|
||||||
|
Flow: a push to main in co-op/docs fires a Gitea webhook -> this receiver
|
||||||
|
verifies it -> pulls the latest main -> runs publishing/build.sh ->
|
||||||
|
publishing/deploy.sh (which writes the build into the Surfer app).
|
||||||
|
|
||||||
|
Runs on the agent host (no new Cloudron app). Standard library only.
|
||||||
|
|
||||||
|
Security model:
|
||||||
|
* Only POSTs from the Cloudron box (git.inference.coop's host) are accepted.
|
||||||
|
* The payload must carry a valid HMAC-SHA256 signature (X-Gitea-Signature,
|
||||||
|
shared secret DEPLOY_WEBHOOK_SECRET). The secret itself is never sent.
|
||||||
|
* Only pushes to refs/heads/main of co-op/docs trigger a deploy.
|
||||||
|
* The receiver binds 0.0.0.0; an iptables rule installed by the systemd unit
|
||||||
|
narrows the port to the Cloudron box as well (defence in depth).
|
||||||
|
|
||||||
|
Secrets are read from /home/ubuntu/.hermes/.env and are never logged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
REPO_DIR = os.environ.get("DOCS_REPO_DIR", "/home/ubuntu/repos/docs")
|
||||||
|
ENV_FILE = os.environ.get("DOCS_ENV_FILE", "/home/ubuntu/.hermes/.env")
|
||||||
|
LOG_FILE = os.environ.get("DOCS_DEPLOY_LOG", "/home/ubuntu/docs-deploy.log")
|
||||||
|
LOCK_FILE = os.environ.get("DOCS_DEPLOY_LOCK", "/home/ubuntu/.docs-deploy.lock")
|
||||||
|
PORT = int(os.environ.get("DOCS_WEBHOOK_PORT", "8787"))
|
||||||
|
EXPECT_REPO = "co-op/docs"
|
||||||
|
EXPECT_BRANCH = "refs/heads/main"
|
||||||
|
ALLOWED_SOURCES = [
|
||||||
|
ipaddress.ip_network(n)
|
||||||
|
for n in os.environ.get(
|
||||||
|
"DOCS_WEBHOOK_SOURCES",
|
||||||
|
# The Cloudron box (hosts git.inference.coop) plus loopback for local tests.
|
||||||
|
"179.237.86.116/32,2001:1600:18:206::89/128,127.0.0.1/32,::1/128",
|
||||||
|
).split(",")
|
||||||
|
if n.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
log = logging.getLogger("docs-deploy")
|
||||||
|
deploy_lock_file = open(LOCK_FILE, "a")
|
||||||
|
|
||||||
|
|
||||||
|
def load_env_file(path):
|
||||||
|
"""Return KEY=VALUE pairs from the agent host's env file (values stay local)."""
|
||||||
|
values = {}
|
||||||
|
try:
|
||||||
|
with open(path) as handle:
|
||||||
|
for line in handle:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
values[key.strip()] = value.strip().strip('"').strip("'")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
_FILE_ENV = load_env_file(ENV_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def secret(name):
|
||||||
|
return os.environ.get(name) or _FILE_ENV.get(name) or ""
|
||||||
|
|
||||||
|
|
||||||
|
def source_allowed(address):
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(address.split("%")[0])
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return any(ip in net for net in ALLOWED_SOURCES)
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd, timeout, cwd=None):
|
||||||
|
log.info("exec: %s", " ".join(cmd))
|
||||||
|
# Hand the build/deploy scripts the credentials they need (from the env
|
||||||
|
# file, if not already set). Values are passed by environment only and are
|
||||||
|
# never logged.
|
||||||
|
env = dict(os.environ)
|
||||||
|
for key in ("CLOUDRON_TOKEN", "CLOUDRON_SERVER", "SURFER_APP", "GIT_ASKPASS"):
|
||||||
|
if not env.get(key) and secret(key):
|
||||||
|
env[key] = secret(key)
|
||||||
|
env.setdefault("GIT_ASKPASS", "/home/ubuntu/.hermes/scripts/git_askpass.sh")
|
||||||
|
env.setdefault("PATH", os.environ.get("PATH", ""))
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd, cwd=cwd or REPO_DIR, capture_output=True, text=True, timeout=timeout,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
if result.stdout.strip():
|
||||||
|
log.info("stdout: %s", result.stdout.strip()[-2000:])
|
||||||
|
if result.returncode != 0 and result.stderr.strip():
|
||||||
|
log.error("stderr: %s", result.stderr.strip()[-2000:])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def deploy(commit_hint=""):
|
||||||
|
"""Pull main, rebuild, republish. Returns (ok, log_tail)."""
|
||||||
|
try:
|
||||||
|
fcntl.flock(deploy_lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError:
|
||||||
|
return False, "another deploy is already running; skipped"
|
||||||
|
|
||||||
|
tail = []
|
||||||
|
try:
|
||||||
|
steps = [
|
||||||
|
(["git", "pull", "--ff-only", "origin", "main"], 120),
|
||||||
|
(["bash", "publishing/build.sh"], 900),
|
||||||
|
(["bash", "publishing/deploy.sh"], 600),
|
||||||
|
]
|
||||||
|
for cmd, timeout in steps:
|
||||||
|
result = run(cmd, timeout)
|
||||||
|
out = (result.stdout or "") + (result.stderr or "")
|
||||||
|
tail.append(f"$ {' '.join(cmd)} -> exit {result.returncode}")
|
||||||
|
tail.extend(out.strip().splitlines()[-12:])
|
||||||
|
if result.returncode != 0:
|
||||||
|
log.error("step failed: %s", " ".join(cmd))
|
||||||
|
return False, "\n".join(tail)
|
||||||
|
log.info("deploy complete%s", f" ({commit_hint})" if commit_hint else "")
|
||||||
|
return True, "\n".join(tail)
|
||||||
|
except Exception as exc: # noqa: BLE001 - report, never crash the receiver
|
||||||
|
log.exception("deploy failed")
|
||||||
|
tail.append(f"error: {exc}")
|
||||||
|
return False, "\n".join(tail)
|
||||||
|
finally:
|
||||||
|
fcntl.flock(deploy_lock_file, fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "docs-deploy/1.0"
|
||||||
|
|
||||||
|
def log_message(self, format, *args): # noqa: A002 - stdlib signature
|
||||||
|
log.info("%s - %s", self.client_address[0], format % args)
|
||||||
|
|
||||||
|
def _json(self, code, payload):
|
||||||
|
body = json.dumps(payload).encode()
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == "/healthz":
|
||||||
|
return self._json(200, {"status": "ok"})
|
||||||
|
return self._json(404, {"error": "not found"})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
peer = self.client_address[0]
|
||||||
|
if not source_allowed(peer):
|
||||||
|
log.warning("rejected: source %s not allowed", peer)
|
||||||
|
return self._json(403, {"error": "forbidden"})
|
||||||
|
|
||||||
|
if self.headers.get("X-Gitea-Event") != "push":
|
||||||
|
return self._json(400, {"error": "not a push event"})
|
||||||
|
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
if length <= 0 or length > 1_000_000:
|
||||||
|
return self._json(400, {"error": "bad payload size"})
|
||||||
|
body = self.rfile.read(length)
|
||||||
|
|
||||||
|
signature = self.headers.get("X-Gitea-Signature") or ""
|
||||||
|
expected = hmac.new(
|
||||||
|
secret("DEPLOY_WEBHOOK_SECRET").encode(), body, hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
if not signature or not hmac.compare_digest(signature, expected):
|
||||||
|
log.warning("rejected: bad signature from %s", peer)
|
||||||
|
return self._json(403, {"error": "invalid signature"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(body)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return self._json(400, {"error": "invalid json"})
|
||||||
|
|
||||||
|
repo = (payload.get("repository") or {}).get("full_name", "")
|
||||||
|
ref = payload.get("ref", "")
|
||||||
|
commit = (payload.get("after") or "")[:12]
|
||||||
|
pusher = ((payload.get("pusher") or {}).get("login")) or "unknown"
|
||||||
|
if repo != EXPECT_REPO or ref != EXPECT_BRANCH:
|
||||||
|
log.info("ignored: push to %s %s (not %s %s)", repo, ref, EXPECT_REPO, EXPECT_BRANCH)
|
||||||
|
return self._json(200, {"status": "ignored", "reason": "not main on co-op/docs"})
|
||||||
|
|
||||||
|
log.info("push received: %s %s commit=%s pusher=%s", repo, ref, commit, pusher)
|
||||||
|
threading.Thread(target=self._run_deploy, args=(commit,), daemon=True).start()
|
||||||
|
return self._json(202, {"status": "accepted", "commit": commit})
|
||||||
|
|
||||||
|
def _run_deploy(self, commit):
|
||||||
|
ok, tail = deploy(commit)
|
||||||
|
log.info("deploy result for %s: %s", commit, "ok" if ok else "FAILED")
|
||||||
|
log.info("tail:\n%s", tail[-1500:])
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(LOG_FILE),
|
||||||
|
logging.StreamHandler(sys.stdout),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if not secret("DEPLOY_WEBHOOK_SECRET"):
|
||||||
|
log.error("DEPLOY_WEBHOOK_SECRET is not set; refusing to start")
|
||||||
|
sys.exit(1)
|
||||||
|
if not secret("CLOUDRON_TOKEN"):
|
||||||
|
log.warning("CLOUDRON_TOKEN not set; deploys will fail")
|
||||||
|
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
|
||||||
|
log.info("listening on 0.0.0.0:%d, repo %s", PORT, REPO_DIR)
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# systemd unit for the docs deploy webhook receiver (agent host).
|
||||||
|
#
|
||||||
|
# Install:
|
||||||
|
# sudo cp publishing/webhook-receiver.service /etc/systemd/system/
|
||||||
|
# sudo systemctl daemon-reload && sudo systemctl enable --now docs-deploy-webhook
|
||||||
|
#
|
||||||
|
# The ExecStartPre lines narrow the webhook port to the Cloudron box (which
|
||||||
|
# hosts git.inference.coop); the receiver additionally checks the source address
|
||||||
|
# and the payload signature, so a scanner hitting the port gets nothing.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Inference docs: Gitea webhook -> build -> Surfer deploy
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=ubuntu
|
||||||
|
WorkingDirectory=/home/ubuntu/.docs-publisher
|
||||||
|
Environment=DOCS_REPO_DIR=/home/ubuntu/.docs-publisher
|
||||||
|
ExecStartPre=+/bin/sh -c '/usr/sbin/iptables -C INPUT -i lo -p tcp --dport 8787 -j ACCEPT 2>/dev/null || /usr/sbin/iptables -I INPUT 1 -i lo -p tcp --dport 8787 -j ACCEPT'
|
||||||
|
ExecStartPre=+/bin/sh -c '/usr/sbin/iptables -C INPUT -p tcp --dport 8787 -s 179.237.86.116 -j ACCEPT 2>/dev/null || /usr/sbin/iptables -I INPUT 2 -p tcp --dport 8787 -s 179.237.86.116 -j ACCEPT'
|
||||||
|
ExecStartPre=+/bin/sh -c '/usr/sbin/iptables -C INPUT -p tcp --dport 8787 ! -s 179.237.86.116 -j DROP 2>/dev/null || /usr/sbin/iptables -I INPUT 3 -p tcp --dport 8787 ! -s 179.237.86.116 -j DROP'
|
||||||
|
ExecStartPre=+/bin/sh -c '/usr/sbin/ip6tables -C INPUT -p tcp --dport 8787 -j DROP 2>/dev/null || /usr/sbin/ip6tables -I INPUT -p tcp --dport 8787 -j DROP'
|
||||||
|
ExecStart=/home/ubuntu/.docs-publisher/.venv-docs/bin/python /home/ubuntu/.docs-publisher/publishing/webhook-receiver.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
+1
-2
@@ -87,8 +87,7 @@ Privacy is a core value, but it is **not uniform across the service** — and th
|
|||||||
|
|
||||||
## 9. Governance
|
## 9. Governance
|
||||||
|
|
||||||
- Decisions are made through **Loomio** and governed by the [Charter](charter.md).
|
The Co-op's governance proceeds according to the content of the current [Charter](charter.md).
|
||||||
- During the pilot phase, the General Manager holds final discretion; this transitions to full member governance as the project grows.
|
|
||||||
|
|
||||||
## 10. Disclaimers, liability, and indemnity
|
## 10. Disclaimers, liability, and indemnity
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Working with the agent
|
||||||
|
|
||||||
|
The co-op runs a small number of agents to help steward its infrastructure,
|
||||||
|
each isolated from the others: its own OS user, its own installation, and its
|
||||||
|
own gateway key with a model allowlist and a budget enforced at the gateway.
|
||||||
|
The first, **inferencebot**, stewards the co-op's services. The second,
|
||||||
|
**infrastructure-bot**, lives in #infrastructure and answers only when
|
||||||
|
mentioned. They run on co-op infrastructure under co-op identities, use the
|
||||||
|
co-op's own gateway for inference, and their skills, scripts and configuration
|
||||||
|
are mirrored to a Gitea repository nightly. These norms apply to all of them.
|
||||||
|
This page is for members who read their output or give them instructions.
|
||||||
|
|
||||||
|
## Two handles that look alike
|
||||||
|
|
||||||
|
- `@inference-bot:inference.coop` (with a hyphen) is the co-op's automation
|
||||||
|
account. It administers rooms, sends invitations and posts the updates feed.
|
||||||
|
It does not take instructions.
|
||||||
|
- `@inferencebot:inference.coop` (no hyphen) is the steward agent that
|
||||||
|
admitted members can instruct.
|
||||||
|
|
||||||
|
## Where they speak and listen
|
||||||
|
|
||||||
|
- **#inferencebot-updates** is read-only: daily digests, model changes,
|
||||||
|
upgrades, maintenance, outages. Posts are anonymized.
|
||||||
|
- **Instructing an agent** is limited to the members on that agent's
|
||||||
|
allowlist of Matrix IDs, kept in the agent's own configuration, and only in
|
||||||
|
the rooms where it is configured to answer; most require a mention. The
|
||||||
|
General Manager decides who is on the list, and the list is published here
|
||||||
|
once it is settled.
|
||||||
|
|
||||||
|
## What to ask for
|
||||||
|
|
||||||
|
- Operational work that a runbook already describes, or that ends in a PR, an
|
||||||
|
issue or a post members can see.
|
||||||
|
- Research and summaries that would otherwise land in an issue.
|
||||||
|
- Status: what is running, what failed, what changed.
|
||||||
|
|
||||||
|
## What not to ask in a shared room
|
||||||
|
|
||||||
|
- Anything involving member-identifying data. That work happens in a session a
|
||||||
|
named steward opened, and the result is reported without the data.
|
||||||
|
- Anything that moves money or changes who is a member. Those go through the
|
||||||
|
portal and a human.
|
||||||
|
- Credentials, in either direction.
|
||||||
|
|
||||||
|
## What they do without asking
|
||||||
|
|
||||||
|
Read, monitor, draft, open issues and PRs, post updates, run the scheduled jobs
|
||||||
|
listed in [Infrastructure](infrastructure.md), and deploy bug and security
|
||||||
|
fixes immediately, reporting each one in #inferencebot-updates.
|
||||||
|
|
||||||
|
## What always needs a human
|
||||||
|
|
||||||
|
- Member-visible, destructive or risky changes to the gateway, the portal or
|
||||||
|
the chat: confirmed before they deploy.
|
||||||
|
- Changing the model catalog, prices or budgets.
|
||||||
|
- Minting or rotating credentials.
|
||||||
|
- Anything a runbook marks "confirm".
|
||||||
|
|
||||||
|
This matches the standing policy the agents operate under today. A stricter
|
||||||
|
norm is a policy decision for the [forum](https://forum.inference.coop).
|
||||||
|
|
||||||
|
## Attribution and the off switch
|
||||||
|
|
||||||
|
Every instruction an agent acts on is attributable to the member who gave it,
|
||||||
|
in the room where it was given. If an agent misbehaves, the operator, or anyone
|
||||||
|
holding gateway-admin and Matrix account access, can stop it: revoke its
|
||||||
|
gateway key, then its Matrix access token. Stewards generally do not hold that
|
||||||
|
access today, so say what you saw in #general and the operator acts. Stopping
|
||||||
|
is not restarting: the backup and the git mirror restore state, not uptime,
|
||||||
|
and someone has to start the agent again. The steps, and the steps for
|
||||||
|
rotating everything an agent holds, are in the agent's runbook; the credential
|
||||||
|
inventory in the [operator token
|
||||||
|
runbook](https://git.inference.coop/co-op/ops-docs/src/branch/main/operator-token-runbook.md)
|
||||||
|
says what that is.
|
||||||
|
|
||||||
|
Changes to this page are policy: propose them in the
|
||||||
|
[forum](https://forum.inference.coop).
|
||||||
Reference in new issue
Block a user