Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3302ae35f4 | ||
|
|
11ea40485c | ||
|
|
5aa6098e5b |
No files matched your search
@@ -1,5 +0,0 @@
|
||||
# Generated by the documentation build (see publishing/build.sh)
|
||||
.site-src/
|
||||
.site-build/
|
||||
.venv-docs/
|
||||
__pycache__/
|
||||
@@ -1,52 +0,0 @@
|
||||
# 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
|
||||
@@ -1,9 +1,9 @@
|
||||
# Inference Cooperative docs
|
||||
# Inference Cooperative
|
||||
|
||||
**AI, governed together.**
|
||||
|
||||
The Inference Cooperative is a member-governed project providing more ethical AI
|
||||
inference. Members pay dues on a sliding scale and, in return, get AI
|
||||
The Inference Cooperative is a member-governed project providing private AI
|
||||
inference. Members pay dues on a sliding scale and, in return, get private AI
|
||||
chat and API access through shared, cooperatively governed infrastructure. We
|
||||
are fiscally sponsored by [Metagov](https://metagov.org), a nonprofit, and
|
||||
funded through our [Open Collective](https://opencollective.com/inference-cooperative).
|
||||
@@ -16,19 +16,17 @@ funded through our [Open Collective](https://opencollective.com/inference-cooper
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Key links
|
||||
|
||||
- **Website:** [inference.coop](https://inference.coop)
|
||||
- **AI Chat:** [chat.inference.coop](https://chat.inference.coop)
|
||||
- **Matrix chat:** [matrix.inference.coop](https://matrix.inference.coop)
|
||||
- **Member Dashboard:** [dashboard.inference.coop](https://dashboard.inference.coop)
|
||||
- **Governance forum:** [forum.inference.coop](https://forum.inference.coop)
|
||||
- **Co-op calendar:** [cloud.inference.coop](https://cloud.inference.coop/apps/calendar/p/Ltb6LAbt7NWHH7Td)
|
||||
- **Source code:** [git.inference.coop](https://git.inference.coop)
|
||||
- **Contact:** [info@inference.coop](mailto:info@inference.coop)
|
||||
- **Website:** https://inference.coop
|
||||
- **AI Chat:** https://chat.inference.coop
|
||||
- **Matrix chat:** https://matrix.inference.coop
|
||||
- **Member Dashboard:** https://dashboard.inference.coop
|
||||
- **Governance forum:** https://forum.inference.coop
|
||||
- **Source code:** https://git.inference.coop
|
||||
- **Contact:** info@inference.coop
|
||||
|
||||
## Governance & legal
|
||||
|
||||
@@ -36,3 +34,7 @@ funded through our [Open Collective](https://opencollective.com/inference-cooper
|
||||
- [Open questions](open-questions.md) — decisions currently up for member discussion.
|
||||
- [Terms of Service](terms-of-service.md)
|
||||
- [Privacy Policy](privacy-policy.md)
|
||||
|
||||
---
|
||||
|
||||
*This documentation lives in a public git repository. Contributions welcome.*
|
||||
@@ -10,32 +10,10 @@ client works.
|
||||
- **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`
|
||||
- **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
|
||||
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
|
||||
|
||||
Log in to the [Member Dashboard](https://dashboard.inference.coop) and create an
|
||||
@@ -70,25 +48,6 @@ resp = client.chat.completions.create(
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
Any tool that speaks the OpenAI API works. Where a tool asks for provider or
|
||||
|
||||
+1
-5
@@ -16,15 +16,11 @@ for our governance model, see the [Charter](charter.md).
|
||||
|
||||
The cooperative launched in September 2026, growing out of research on democratic
|
||||
digital infrastructure through Metagov's [Collective Governance for AI:
|
||||
Points of Intervention](https://metagov.org/cg-ai/). It was founded by
|
||||
[Nathan Schneider](https://nathanschneider.info), who formerly co-founded
|
||||
Social.coop, Start.coop, and the Media Economies Design Lab at the University
|
||||
of Colorado Boulder.
|
||||
Points of Intervention](https://metagov.org/cg-ai/).
|
||||
|
||||
It began with a single privacy-first provider and a deliberately small
|
||||
toolset: a chat, an API gateway, and the basics for membership and payments.
|
||||
From there it grew the way co-ops do: in response to what members asked for.
|
||||
Green-energy providers were added, along with more diverse models.
|
||||
A member dashboard made usage and spending transparent. A federated
|
||||
[Matrix](https://matrix.inference.coop) server, a
|
||||
[forum](https://forum.inference.coop), and a newsletter gave members places to
|
||||
|
||||
+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:
|
||||
|
||||
* [Code of Conduct](code-of-conduct.md)
|
||||
* [Terms of Service](terms-of-service.md)
|
||||
* [Privacy Policy](privacy-policy.md)
|
||||
* [Code of Conduct](https://git.inference.coop/co-op/docs/src/branch/main/code-of-conduct.md)
|
||||
* [Terms of Service](https://git.inference.coop/co-op/docs/src/branch/main/terms-of-service.md)
|
||||
* [Privacy Policy](https://git.inference.coop/co-op/docs/src/branch/main/privacy-policy.md)
|
||||
|
||||
## Legal entity
|
||||
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
# 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.
|
||||
@@ -7,10 +7,6 @@ private AI chat and API access in return.
|
||||
This guide covers the three things most new members want: becoming a member,
|
||||
using the chat, and getting involved in governance.
|
||||
|
||||
The co-op's meetings — including the regular member meetings — are all on the
|
||||
shared [co-op calendar](https://cloud.inference.coop/apps/calendar/p/Ltb6LAbt7NWHH7Td),
|
||||
where we share dates and details. Check it for what's coming up.
|
||||
|
||||
## 1. Become a member
|
||||
|
||||
Membership is a **sliding-scale contribution starting at $5/month** through
|
||||
@@ -30,21 +26,12 @@ account. Click it and create your login.
|
||||
> as a member. (Contributed without an Open Collective account? You're recognized
|
||||
> by the email you entered at checkout.)
|
||||
|
||||
**On payment timing:** Open Collective charges your contribution on the
|
||||
collective's billing day, not the day you joined — so your second charge can
|
||||
arrive sooner than a month after the first. When that leaves a paid month
|
||||
shorter than a month, we credit the difference automatically as non-expiring
|
||||
credits, pro-rated to the days lost. It's automatic: no need to ask, claim it,
|
||||
or contact anyone.
|
||||
|
||||
### Your membership includes
|
||||
|
||||
- **Private AI chat** through the co-op's shared gateway.
|
||||
- **API access** for your own tools and projects (see [API access](api-access.md)).
|
||||
- **A monthly usage allowance** of $15 of credit, shared between chat and API.
|
||||
- **Credit packs** — buy extra non-expiring credits when you need more.
|
||||
- **Guest access** — give a friend $2 of credit and 60 days of the co-op.
|
||||
See [Guest access](guest-access.md).
|
||||
- **A voice in governance** — how the project is run and where it goes next.
|
||||
|
||||
## 2. Use the chat
|
||||
@@ -66,13 +53,6 @@ with the account you created in step 1.
|
||||
there's a tool you'd like available, ask in #general.
|
||||
- **Voice** works in both directions: speak to the chat with the microphone
|
||||
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
|
||||
[Member Dashboard](https://dashboard.inference.coop).
|
||||
@@ -155,10 +135,6 @@ privacy, and direction happen on our **Loomio forum**:
|
||||
space and join whatever is interesting to you. New rooms appear there as
|
||||
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 [Open questions](open-questions.md) for the decisions currently on the table.
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# Guest access
|
||||
|
||||
Any member can **give a friend guest access to the co-op**: **$2 of AI usage
|
||||
for 60 days**, spent in the chat and the API. It doesn't come out of your own
|
||||
balance. You can send it once every 30 days, and an address can receive it
|
||||
only once.
|
||||
|
||||
## How to send guest access
|
||||
|
||||
On your [dashboard](https://dashboard.inference.coop), open the **Guests**
|
||||
card, enter your friend's email address (name optional), and click
|
||||
**Send invite**. There's no approval step: the account is provisioned right
|
||||
away, and your friend gets an **invitation email** from the co-op, naming
|
||||
you, with a link to set up their account. Setting it up takes about a minute.
|
||||
|
||||
New guests show as **Invited — awaiting setup** until they accept it. The
|
||||
card also lists the guests you've sent.
|
||||
|
||||
## What your guest gets
|
||||
|
||||
- **$2 of credit** for the chat and the API — a hard cap: usage stops when
|
||||
it's spent.
|
||||
- **Their own dashboard** at
|
||||
[dashboard.inference.coop](https://dashboard.inference.coop) showing what's
|
||||
left.
|
||||
- **The weekly newsletter** and these public docs.
|
||||
|
||||
Guest access is not membership — no Matrix, governance forum, or vote.
|
||||
|
||||
## When it ends
|
||||
|
||||
When **the $2 is spent or the 60 days are up**, whichever comes first, your
|
||||
friend gets an email saying so. Access stops; **the account and its data are
|
||||
kept** — and a guest can end it early with the **Cancel guest access** link
|
||||
on their dashboard, returning any unspent credit to the co-op.
|
||||
|
||||
## Becoming a member
|
||||
|
||||
Contribute on
|
||||
[Open Collective](https://opencollective.com/inference-cooperative/contribute)
|
||||
and the account converts automatically — same login, same dashboard. Any
|
||||
unspent guest credit carries over. See
|
||||
[Become a member](getting-started.md#1-become-a-member) for how membership
|
||||
works.
|
||||
+134
-128
@@ -7,11 +7,11 @@ and contributors who want to work on it.
|
||||
|
||||
Members reach the co-op through two front doors: the **chat** (LibreChat) and
|
||||
the **API** (the LiteLLM gateway). Both route through a single gateway to
|
||||
multiple LLM providers, so members can choose what they value for each task:
|
||||
architecturally private inference in **TEE-protected enclaves** (Tinfoil), or
|
||||
100% renewable energy (GreenPT, Regolo). A thin layer of custom middleware — the
|
||||
member portal — ties Open Collective membership to Cloudron accounts and LiteLLM
|
||||
keys.
|
||||
multiple LLM providers — so members choose what they value for each task:
|
||||
architecturally private inference in **TEE-protected enclaves** (Tinfoil),
|
||||
100% renewable energy (GreenPT, Regolo). A thin layer of custom middleware —
|
||||
the member portal — ties Open
|
||||
Collective membership to Cloudron accounts and LiteLLM keys.
|
||||
|
||||
```
|
||||
┌─> Tinfoil (TEE) 🔒 private
|
||||
@@ -26,132 +26,134 @@ Members ──> your own tools ────┘ gateway │
|
||||
The whole stack runs on [Cloudron](https://cloudron.io), which handles hosting,
|
||||
single sign-on, and per-app isolation.
|
||||
|
||||
## Where it runs
|
||||
|
||||
The co-op's member-facing 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 are certified to ISO 14001, ISO
|
||||
50001 and ISO 27001.
|
||||
|
||||
The co-op's **automation runs on a separate server**, also hosted on Infomaniak
|
||||
and deliberately kept apart from the member-facing applications. The steward bot
|
||||
— the automation behind the digests and the newsletter drafts — and the co-op's
|
||||
other agents live there. Keeping them on their own host means an agent problem
|
||||
stays an agent problem: it cannot reach the chat, the gateway or member data.
|
||||
|
||||
### Backups
|
||||
|
||||
Cloudron writes **encrypted** backups to Infomaniak's object storage in
|
||||
Switzerland every night and keeps the last **seven days**, so a failed upgrade
|
||||
or a lost server can be restored. The automation host backs up its own data to
|
||||
the same object storage.
|
||||
|
||||
### Domain and DNS
|
||||
|
||||
The co-op's domain is **inference.coop** — a `.coop` domain. The co-op registers
|
||||
and manages its domains with **domains.coop**, the domain provider run by the
|
||||
cooperative movement, and holds them in the co-op's own name rather than in any
|
||||
individual's. DNS itself is deliberately simple, and the co-op's own nameservers
|
||||
answer it.
|
||||
|
||||
## Components
|
||||
|
||||
| Component | Purpose | URL |
|
||||
|-----------|---------|-----|
|
||||
| **LibreChat** | Member-facing chat (models, web search, image generation, voice) | chat.inference.coop |
|
||||
| **Open WebUI** | Legacy chat instance — read-only reference (old history and file attachments) | chat-legacy.inference.coop |
|
||||
| **LiteLLM** | AI gateway — keys, metering and model routing, for chat and API | gateway.inference.coop |
|
||||
| **Member portal** | Membership middleware — onboarding, key provisioning, Loomio sync | portal.inference.coop |
|
||||
| **Member dashboard** | Your usage view and API keys | dashboard.inference.coop |
|
||||
| **Loomio** | Member governance | forum.inference.coop |
|
||||
| **Matrix (Synapse)** | Member chat server, federated, SSO login via Cloudron | matrix.inference.coop |
|
||||
| **Gitea** | Git hosting for the co-op's code and this documentation | git.inference.coop |
|
||||
| **Listmonk** | Member newsletter (synced from membership) | newsletter.inference.coop |
|
||||
| **SnappyMail** | Webmail | email.inference.coop |
|
||||
|
||||
The co-op's software behind all of these is open source — see [The
|
||||
code](#the-code) below for where each piece lives.
|
||||
| Component | Purpose | URL | Code |
|
||||
|-----------|---------|-----|------|
|
||||
| **LibreChat** | Member-facing chat (models, web search, image generation, voice) | chat.inference.coop | [co-op/librechat-custom](https://git.inference.coop/co-op/librechat-custom) |
|
||||
| **Open WebUI** | Legacy chat instance — read-only reference (old history + file attachments) | chat-legacy.inference.coop | — |
|
||||
| **LiteLLM** | AI gateway — keys, metering, model routing | gateway.inference.coop | [code/litellm](https://git.inference.coop/code/litellm) |
|
||||
| **Member Portal** | Membership middleware — onboarding, key provisioning, Loomio sync | portal.inference.coop | [code/member-portal](https://git.inference.coop/code/member-portal) |
|
||||
| **Member Dashboard** | Member-facing usage view + API key management | dashboard.inference.coop | [code/member-dashboard](https://git.inference.coop/code/member-dashboard) |
|
||||
| **Admin Panel** | Admin-only member overview, spend, balance management | panel.inference.coop | [code/admin-panel](https://git.inference.coop/code/admin-panel) |
|
||||
| **SearXNG** | Self-hosted web search (feeds the chat's search) | search.inference.coop | — |
|
||||
| **Loomio** | Member governance | forum.inference.coop | — |
|
||||
| **Matrix (Synapse)** | Member chat server (federated; SSO login via Cloudron) | matrix.inference.coop | — |
|
||||
| **Gitea** | Git hosting (code + docs) | git.inference.coop | — |
|
||||
| **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 | — |
|
||||
| **Vaultwarden** | Password manager (admin credentials) | vault.inference.coop | — |
|
||||
| **SnappyMail** | Webmail | mail.inference.coop | — |
|
||||
|
||||
Open Collective (billing and fiscal sponsorship) is the one external service in
|
||||
the flow — see
|
||||
[opencollective.com/inference-cooperative](https://opencollective.com/inference-cooperative).
|
||||
|
||||
## Mail
|
||||
|
||||
The co-op runs its own `@inference.coop` mail. **Incoming** mail arrives directly
|
||||
on the co-op's server, and webmail and mail apps reach it over the usual
|
||||
encrypted ports (IMAP and SMTP over TLS). **Outgoing** mail is sent through
|
||||
**Scaleway**, an EU-based email service, authenticated for our domain.
|
||||
|
||||
You can reach your mailbox through webmail at email.inference.coop, or add it to
|
||||
a standard mail app on your phone or computer. If mail to your `@inference.coop`
|
||||
address stops arriving, that is a fault on our side to fix.
|
||||
the flow — see [opencollective.com/inference-cooperative](https://opencollective.com/inference-cooperative).
|
||||
|
||||
## Matrix
|
||||
|
||||
Members get a federated Matrix account on the co-op's self-hosted Synapse
|
||||
(matrix.inference.coop, user IDs `@username:inference.coop`), signing in with the
|
||||
same Cloudron SSO as the chat. The co-op invites every active member to the
|
||||
private **Inference Cooperative** space (with a **General** room) automatically,
|
||||
so new members land there as they join. Federation is on, so you can also join
|
||||
from any homeserver you already use.
|
||||
Members get a federated Matrix account on our self-hosted Synapse
|
||||
(matrix.inference.coop, user IDs `@username:inference.coop`) using their
|
||||
Cloudron SSO login. The portal invites every active member — and Nathan's
|
||||
federated account — to the private **Inference Cooperative** Space (with a
|
||||
**General** room) on every membership sync, so new members land in the space
|
||||
automatically. Invites honor rate limits; external/federated members can be
|
||||
added via the portal's `MATRIX_EXTRA_MXIDS` env.
|
||||
|
||||
### Accounts you may meet
|
||||
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
|
||||
use. The `@inference-bot:inference.coop` admin account (used by the portal
|
||||
for invites) has no password — token lives in the portal env.
|
||||
|
||||
A few accounts in the space are automation rather than people:
|
||||
## How the apps run themselves
|
||||
|
||||
- **`@inferencebot` — the steward.** It posts the digests, newsletter notes and
|
||||
reminders. It holds no more power than posting needs.
|
||||
- **`@inference-admin`** — the administrator of record for the co-op's rooms
|
||||
(#general, #community, #design, #skillshare, #updates and #infrastructure) and
|
||||
their membership. It **never speaks in a member room** — a message from it is
|
||||
a mistake, not a feature.
|
||||
The portal and gateway are self-maintaining: their recurring housekeeping is
|
||||
built into the apps as scheduled tasks, not run from any member's or operator's
|
||||
personal machine.
|
||||
|
||||
The house rule: **power belongs to `@inference-admin`, voice belongs to
|
||||
`@inferencebot`.** Please don't create an account whose name differs from an
|
||||
existing one by a character or two — spotting lookalikes is exactly what this
|
||||
convention is for.
|
||||
- **Member portal** — two scheduled jobs (Cloudron scheduler):
|
||||
- *Membership sweep* (nightly, 3am) — reconciles members against the live
|
||||
Open Collective list: provisions new members who signed up while a webhook
|
||||
was missed, deactivates lapsed members, and syncs Loomio + Matrix
|
||||
membership. A missed webhook self-heals within 24 hours.
|
||||
- *Docs knowledge refresh* (nightly, 3:30am) — pulls this documentation from
|
||||
the public Git repo and updates the shared knowledge base behind the chat's
|
||||
models, so answers grounded in the docs stay current. Changes to the docs
|
||||
appear in the chat within a day, no manual step.
|
||||
- **Gateway** — an *audio keep-alive* (every 10 minutes) sends a tiny text-to-
|
||||
speech ping to the private enclave so voice mode responds quickly even after
|
||||
long quiet periods, without compromising the end-to-end encryption model.
|
||||
|
||||
## Membership
|
||||
Scheduled tasks are declared in each app's `CloudronManifest.json` (see the
|
||||
`code/` repos) and survive redeploys. If a task fails, it shows up in the
|
||||
app's logs on the admin panel; the portal also exposes a manual trigger for
|
||||
the membership sweep.
|
||||
|
||||
Membership runs through the member portal, the single source of truth for who is
|
||||
a member:
|
||||
## How membership is wired
|
||||
|
||||
Membership flows through the member portal, which is the single source of truth
|
||||
for "who is a member":
|
||||
|
||||
1. A member contributes on Open Collective.
|
||||
2. Open Collective notifies the portal, which provisions a Cloudron account, a
|
||||
LiteLLM key, and a welcome email.
|
||||
3. A nightly reconciliation against the live Open Collective list means a missed
|
||||
notification self-heals within a day.
|
||||
2. Open Collective fires a webhook to the portal, which provisions a Cloudron
|
||||
account, a LiteLLM key, and a welcome email.
|
||||
3. A nightly sweep reconciles against the live Open Collective list, so a missed
|
||||
webhook self-heals within 24 hours.
|
||||
|
||||
You may see **"Inference Co-op Bot"** listed as an admin of the collective on
|
||||
Open Collective. It is infrastructure, not a person or a member: a dedicated
|
||||
account the portal uses, **read-only**, to check the member roster and tiers. It
|
||||
cannot move money, issue refunds or change the collective — payments always come
|
||||
from a human's own contribution, and automation only reads the result. Its code
|
||||
is open source in
|
||||
[code/member-portal](https://git.inference.coop/code/member-portal), and a
|
||||
collective admin can revoke its access at any time.
|
||||
The portal database is authoritative for member emails and balances; LiteLLM's
|
||||
team budget is the source of truth for a member's allowance, shared between chat
|
||||
and API.
|
||||
|
||||
### The bot account on Open Collective
|
||||
|
||||
You may notice **"Inference Co-op Bot"** listed as an admin of the collective on
|
||||
Open Collective. This is infrastructure, not a member or a person:
|
||||
|
||||
- **What it is:** a dedicated Open Collective account owned by the co-op. It
|
||||
holds a **Personal Token** that the member portal uses to read membership
|
||||
data — the roster, tiers (needed to route credit-pack purchases correctly),
|
||||
and contributor emails (including guests, which only admins can see).
|
||||
- **What it can do:** read member and tier data via Open Collective's API.
|
||||
It cannot move money, issue refunds, or change the collective — Open
|
||||
Collective restricts those to dashboard actions by human admins. Its API
|
||||
access was verified read-mostly during the October 2026 security review.
|
||||
- **Why it has admin:** reading guest contributor emails requires admin on
|
||||
Open Collective; without it, membership reconciliation and credit-pack
|
||||
routing can't function.
|
||||
- **Who supervises it:** all of its code is open source in
|
||||
[code/member-portal](https://git.inference.coop/code/member-portal) — every
|
||||
API call it makes is documented there. Nathan Schneider (collective admin)
|
||||
oversees its use and can revoke or rotate its token at any time from the
|
||||
Open Collective dashboard.
|
||||
|
||||
Nothing on Open Collective is charged, transferred, or refunded by automation:
|
||||
payments always come from a human's own contribution action, and any automated
|
||||
system only *reads* the resulting records.
|
||||
|
||||
## The code
|
||||
|
||||
The co-op's software is open source, in the [`code`](https://git.inference.coop/code)
|
||||
and [`co-op`](https://git.inference.coop/co-op) organizations on Gitea:
|
||||
The cooperative's software is open source, in the [`code`](https://git.inference.coop/code)
|
||||
organization on Gitea:
|
||||
|
||||
- [**code/member-portal**](https://git.inference.coop/code/member-portal) — membership middleware (Python).
|
||||
- [**code/member-dashboard**](https://git.inference.coop/code/member-dashboard) — member dashboard: usage and API keys.
|
||||
- [**code/member-dashboard**](https://git.inference.coop/code/member-dashboard) — member-facing usage + API keys (Python).
|
||||
- [**code/admin-panel**](https://git.inference.coop/code/admin-panel) — admin overview (Python).
|
||||
- [**code/litellm**](https://git.inference.coop/code/litellm) — the gateway packaged as a Cloudron app.
|
||||
|
||||
Plus the `co-op` organization:
|
||||
|
||||
- [**co-op/website**](https://git.inference.coop/co-op/website) — landing page and static site.
|
||||
- [**co-op/docs**](https://git.inference.coop/co-op/docs) — this documentation.
|
||||
- [**co-op/design-assets**](https://git.inference.coop/co-op/design-assets) — logos, fonts, brand materials.
|
||||
|
||||
The chat, portal and other apps live in `code/` and `co-op/` too; everything is
|
||||
browsable at [git.inference.coop](https://git.inference.coop). Everything the co-op runs
|
||||
is open source, so as a member you can browse all of
|
||||
it and propose changes.
|
||||
## Contributing (the Git is yours, too)
|
||||
|
||||
Everything the co-op runs is open source and lives at
|
||||
[git.inference.coop](https://git.inference.coop) — the website, this
|
||||
documentation, the member portal, the dashboard, the admin panel, and the
|
||||
gateway. As a member, you can browse all of it and propose changes.
|
||||
|
||||
**Who can sign in.** Git access is tied to your membership: you log into the Git
|
||||
with the same single sign-on you use for the chat. Members are in automatically
|
||||
(through the `members` group); when a membership lapses, Git access lapses with
|
||||
it.
|
||||
(through the `members` group); when a membership lapses, Git access lapses with it.
|
||||
|
||||
**How to contribute.** Anyone can read the repos without an account. To make a
|
||||
change, fork the repo, edit in your fork, and open a **pull request**:
|
||||
@@ -162,39 +164,43 @@ change, fork the repo, edit in your fork, and open a **pull request**:
|
||||
4. Open a **pull request** back to the original repo, describing your change.
|
||||
|
||||
**All changes are reviewed.** Nothing merges directly into `main` — every change
|
||||
comes in as a pull request an admin reviews and approves first. You don't need
|
||||
to be able to create repos or organizations to contribute; you just fork, edit
|
||||
and ask for review.
|
||||
comes in as a pull request that an admin reviews and approves first. You don't
|
||||
need to be able to create repos or organizations to contribute; you just fork,
|
||||
edit, and ask for review.
|
||||
|
||||
Questions about the Git itself? Write to info@inference.coop.
|
||||
|
||||
## Models and providers
|
||||
|
||||
The co-op serves models from multiple providers — Tinfoil for architectural
|
||||
privacy, GreenPT and Regolo for renewable energy. See the [model list](models.md),
|
||||
the single source of truth for which models are available and how they're named.
|
||||
The co-op serves models from multiple providers (Tinfoil for privacy, GreenPT
|
||||
for renewable energy, PublicAI coming soon). See the [model list](models.md) for
|
||||
the current catalog — that's the single source of truth for which models are
|
||||
available and how they're named.
|
||||
|
||||
One infra detail worth knowing: a local Tinfoil proxy sidecar handles the
|
||||
enclave encryption on our side of the gateway — only Tinfoil traffic goes
|
||||
through it; the other providers are called directly.
|
||||
|
||||
## Security posture (plain language)
|
||||
|
||||
- **Member isolation.** Each API key is scoped to its owner's team and budget; a
|
||||
member can only ever act on their own keys.
|
||||
- **Separated secrets.** Chat, portal, admin and model-layer secrets are
|
||||
- **Token-gated control plane.** The portal is publicly reachable, but every
|
||||
sensitive surface requires a secret token (admin, broker, webhook). All fail
|
||||
closed — unauthenticated requests get `401`.
|
||||
- **Separated secrets.** Chat, broker, admin, and model-layer secrets are
|
||||
independent and independently rotatable, so a leak has bounded scope.
|
||||
- **No secrets in version control.** Credentials live in the hosting platform's
|
||||
environment, never in the repos.
|
||||
- **Member isolation.** Each API key is scoped to its owner's LiteLLM team and
|
||||
budget; a member can only ever act on their own keys.
|
||||
- **Rate limiting** on control-plane endpoints; the inference path is capped by
|
||||
each member's budget instead.
|
||||
- **No secrets in version control.** Credentials live in Cloudron env vars, never
|
||||
in the repos.
|
||||
- **Accepted trade-offs** (documented, not hidden): member keys are stored in
|
||||
the portal database so the co-op can serve them, and the API gateway must stay
|
||||
publicly reachable. Its admin surfaces are disabled; only the public API is
|
||||
exposed.
|
||||
plaintext in the portal database (functionally necessary), and the gateway
|
||||
must stay publicly reachable for API access (its admin UI and docs are
|
||||
disabled; only the `/v1/*` API is exposed).
|
||||
|
||||
## For operators
|
||||
|
||||
Maintaining the co-op's infrastructure — token rotation, app configuration and
|
||||
troubleshooting — is documented 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. It is the home for the operator runbook,
|
||||
the maintenance notes, incident write-ups, and the record of past server
|
||||
migrations. Anyone may read it; operators are the audience.
|
||||
|
||||
## Incident log
|
||||
|
||||
Write-ups for outages live in the operator docs, in
|
||||
[`incidents/`](https://git.inference.coop/co-op/ops-docs/src/branch/main/incidents).
|
||||
Maintaining the co-op's infrastructure — rotating Cloudron tokens, editing the
|
||||
portal's environment, and troubleshooting the provisioning pipeline — is
|
||||
documented in the [operator runbook](operator-token-runbook.md).
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,156 @@
|
||||
# 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).
|
||||
-125
@@ -1,125 +0,0 @@
|
||||
# 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 docs
|
||||
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
|
||||
- Guest access: guest-access.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,10 +25,6 @@ provider today is best on both.
|
||||
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
|
||||
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*
|
||||
(Tinfoil). GreenPT and Regolo both run on 100% renewable energy; Tinfoil's
|
||||
mix is not disclosed.
|
||||
@@ -54,10 +50,6 @@ that **you choose** which axis matters more for a given task.
|
||||
- **Cheap and cheerful** → `tinfoil/gpt-oss-120b` or `greenpt/glm-5.3-flash`.
|
||||
- **Model origins matter to you** → the Apertus models (publicly developed;
|
||||
see below).
|
||||
- **Semantic search / RAG (API)** → an embedding model: `greenpt/qwen3-embedding-8b`
|
||||
or `greenpt/green-embeddings` (renewable energy), or `tinfoil/nomic-embed-text`
|
||||
(TEE-protected — the model the co-op's own docs search runs on). API-only; see
|
||||
[Embedding models](#embedding-models) below.
|
||||
|
||||
## Current models
|
||||
|
||||
@@ -80,23 +72,19 @@ The emoji in the model name marks its **value tier** (the same icons used in the
|
||||
|
||||
| Model | Provider | Privacy | Green | Vision | Context | Pricing (in / out / cached) | Notes |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `🌱 greenpt/deepseek-v4.1-flash` | GreenPT | policy | ✅ 100% renewable | ✅ | 1M | $0.22 / $1.10 / $0.011 | **Default.** DeepSeek's multimodal reasoning model, renewable energy; TEE twin below. Base V4.1 Flash release — not GreenPT's 17 Oct 2026 "1017" snapshot (as of 2026-11-09). |
|
||||
| `🌱 greenpt/deepseek-v4.1-flash` | GreenPT | policy | ✅ 100% renewable | ✅ | 1M | $0.22 / $1.10 / $0.011 | **Default.** DeepSeek's multimodal reasoning model, renewable energy; TEE twin below. |
|
||||
| `🔒 tinfoil/deepseek-v4-1-flash` | Tinfoil | architectural | undisclosed | ✅ | 1M | $0.65 / $1.45 / $0.13 | TEE-protected version of the default; image input, tool calling, long docs/agents. |
|
||||
| `🔒 tinfoil/glm-5.3` | Tinfoil | architectural | undisclosed | — | 1M | $1.80 / $5.75 / $0.45 | GLM flagship, TEE-protected; strongest private-tier model for agentic coding and long-horizon tasks. |
|
||||
| `🔒 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` | 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 | ✅ | 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 | $0.70 / $3.94 / — | Large vision+reasoning MoE (Qwen 3.5, 122B-A10B); 262K context. |
|
||||
| `🌱 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/qwen3.5-122b` | Regolo | policy + ZDR | ✅ 100% renewable | ✅ | 262K | $1.16 / $4.87 / — | Large vision+reasoning MoE (Qwen 3.5, 122B-A10B); 262K context. |
|
||||
|
||||
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
|
||||
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
|
||||
into the infrastructure (TEE enclaves) — even the provider can't read your
|
||||
prompts. *Policy* means the provider commits, by contract and policy, not to
|
||||
@@ -118,27 +106,13 @@ model picker to avoid confusion.
|
||||
|
||||
| Model | Provider | Use | Pricing |
|
||||
|---|---|---|---|
|
||||
| `🔒 tinfoil/whisper-large-v3-turbo` | Tinfoil | Speech-to-text | $0.01 per request (flat; zero per-token) |
|
||||
| `🔒 tinfoil/voxtral-small-24b` | Tinfoil | Speech-to-text (token-priced) | $0.20 / 1M input + $0.60 / 1M output tokens |
|
||||
| `🔒 tinfoil/voxtral-tts` | Tinfoil | Text-to-speech | $0.002 per request (zero per-token) |
|
||||
| `🔒 tinfoil/whisper-large-v3-turbo` | Tinfoil | Speech-to-text | $0.05 / 1M input tokens |
|
||||
| `🔒 tinfoil/voxtral-tts` | Tinfoil | Text-to-speech | listed $0 (verify on invoice) |
|
||||
|
||||
All three run inside Tinfoil's enclaves, so voice data gets the same
|
||||
architectural privacy as the private chat models. TTS voices available:
|
||||
`neutral_female`, `neutral_male`, `casual_female/male`, `cheerful_female`,
|
||||
plus French, German, Spanish, Italian, Portuguese, Dutch, and Hindi variants.
|
||||
|
||||
Tinfoil bills these at cost, but its audio API returns **no token usage** to
|
||||
the gateway — so dashboard rows for these models read `$0.01 · 0 tokens`: the
|
||||
charge is real, the token columns stay empty.
|
||||
|
||||
`whisper-large-v3-turbo` and `voxtral-tts` charge **flat per request**.
|
||||
`voxtral-small-24b` is token-priced and the better pick for small requests:
|
||||
sent through the chat API with `input_audio` it meters exactly per token, and
|
||||
on `/v1/audio/transcriptions` (the route dictation uses) the gateway bills
|
||||
Tinfoil's measured chunk-equivalent — their audio encoder tokenizes in ~30 s
|
||||
chunks, so a short request costs ≈**$0.0001**, about 1/125th of the whisper
|
||||
floor. For audio longer than ~30 s, prefer the chat route, which meters
|
||||
exactly.
|
||||
Both run inside Tinfoil's enclaves, so voice data gets the same architectural
|
||||
privacy as the private chat models. TTS voices available: `neutral_female`,
|
||||
`neutral_male`, `casual_female/male`, `cheerful_female`, plus French, German,
|
||||
Spanish, Italian, Portuguese, Dutch, and Hindi variants.
|
||||
|
||||
```bash
|
||||
# Speech-to-text
|
||||
@@ -147,15 +121,6 @@ curl https://gateway.inference.coop/v1/audio/transcriptions \
|
||||
-F file=@your-audio.mp3 \
|
||||
-F model=tinfoil/whisper-large-v3-turbo
|
||||
|
||||
# Speech-to-text (token-priced model, chat route — meters exactly per token)
|
||||
curl https://gateway.inference.coop/v1/chat/completions \
|
||||
-H "Authorization: Bearer ***" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model": "tinfoil/voxtral-small-24b", "messages": [{"role": "user", "content": [
|
||||
{"type": "text", "text": "Transcribe this audio."},
|
||||
{"type": "input_audio", "input_audio": {"data": "<base64 audio>", "format": "wav"}}
|
||||
]}]}'
|
||||
|
||||
# Text-to-speech (returns WAV audio)
|
||||
curl https://gateway.inference.coop/v1/audio/speech \
|
||||
-H "Authorization: Bearer ***" \
|
||||
@@ -173,43 +138,10 @@ curl https://gateway.inference.coop/v1/chat/completions \
|
||||
]}]}'
|
||||
```
|
||||
|
||||
### Embedding models
|
||||
|
||||
Embeddings turn text into a numeric vector — the input to semantic search,
|
||||
clustering and RAG. They're called on `/v1/embeddings` with your co-op API key
|
||||
and are **not chat models**: they never appear in the chat picker, the API is
|
||||
the interface (the same shape as the audio models above). You choose between
|
||||
them the same way as for chat: a TEE-protected option (Tinfoil) or
|
||||
renewable-energy options (GreenPT).
|
||||
|
||||
| Model | Provider | Privacy | Green | Dimensions | Max input | Pricing |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `🌱 greenpt/green-embeddings` | GreenPT | policy | ✅ 100% renewable | 2560 (measured) | 32K tokens (per GreenPT's catalog) | $0.20 / 1M input tokens (no output charge) |
|
||||
| `🌱 greenpt/qwen3-embedding-8b` | GreenPT | policy | ✅ 100% renewable | 4096 (measured) | 32K tokens (per GreenPT's catalog) | $0.25 / 1M input tokens (no output charge) |
|
||||
| `🔒 tinfoil/nomic-embed-text` | Tinfoil | **architectural (TEE)** | — (mix not disclosed) | 768 (measured) | 8K tokens (per Tinfoil's catalog) | $0.016 / 1M input tokens (no output charge) |
|
||||
|
||||
```bash
|
||||
curl https://gateway.inference.coop/v1/embeddings \
|
||||
-H "Authorization: Bearer ***" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model": "tinfoil/nomic-embed-text", "input": "the text to embed"}'
|
||||
```
|
||||
|
||||
The response is standard OpenAI shape: `data[0].embedding` holds the vector,
|
||||
and `usage.prompt_tokens` is what meters against your allowance at the rate
|
||||
above. Embeddings bill input tokens only — there is no output charge.
|
||||
|
||||
Vectors from different models are not interchangeable — pick one per search
|
||||
index and stay with it (re-index if you switch). All three are callable with a
|
||||
member key; for reference, the co-op's own documentation search runs on
|
||||
`tinfoil/nomic-embed-text` inside the TEE.
|
||||
|
||||
### Image generation
|
||||
|
||||
`🌱 regolo/qwen-image` (Regolo, EU-hosted, zero data retention) generates
|
||||
images from a text prompt. It runs the December 2025 "2512" checkpoint of
|
||||
Qwen-Image — not the newer Qwen Image 2.1 (September 2026), which Regolo does
|
||||
not list (as of 2026-11-09). In the chat, press the **Integrations** button
|
||||
images from a text prompt. In the chat, press the **Integrations** button
|
||||
below the message box, choose **Image**, and describe what you want — the
|
||||
generated image appears in the conversation. Via the API:
|
||||
|
||||
@@ -257,42 +189,6 @@ numbers. Pricing here mirrors the `model_info` in the LiteLLM config
|
||||
(`code/litellm/config.yaml`), which is the authoritative source — the two are
|
||||
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
|
||||
|
||||
Only **Tinfoil** offers architectural privacy. The other providers are chosen
|
||||
@@ -300,8 +196,5 @@ 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
|
||||
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 —
|
||||
we commit not to. Requests made through the **API** with your own key are a
|
||||
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).
|
||||
we commit not to. The full distinction — what's architecturally private versus
|
||||
what's a policy commitment — is in the [Privacy Policy](privacy-policy.md).
|
||||
+27
-1
@@ -1,11 +1,20 @@
|
||||
# Open Questions for Member Discussion
|
||||
|
||||
A living list of some of the decisions the cooperative needs to make.
|
||||
A living list of decisions the cooperative needs to make. Each question is framed for discussion on [Loomio](https://forum.inference.coop). As questions are resolved, they move to a "Decisions" log (to be created) rather than being deleted.
|
||||
|
||||
---
|
||||
|
||||
## Privacy & Infrastructure
|
||||
|
||||
### Search: self-managed or Tinfoil's encrypted search?
|
||||
|
||||
We currently run our own [SearXNG](https://searxng.github.io/searxng/) instance (`search.inference.coop`) to power web search in the chat. Tinfoil also offers a `websearch` model — a private, encrypted search that runs inside their enclave.
|
||||
|
||||
- **Self-managed (SearXNG):** full control, no external dependency, but search queries are visible to our own infrastructure (and to the search engines SearXNG queries).
|
||||
- **Tinfoil's encrypted search:** queries are encrypted end-to-end, but it's a third-party dependency and may cost per-query.
|
||||
|
||||
**Question:** Do we prioritize self-hosting (control, no external deps) or encrypted search (stronger privacy, but a dependency on Tinfoil)?
|
||||
|
||||
### Green energy vs. privacy — when we can't have both?
|
||||
|
||||
Our inference runs through Tinfoil (TEE-protected, verifiable privacy), but Tinfoil's energy mix is not disclosed. Self-hosting on renewable-powered hardware (e.g. Hetzner hydropower, Scaleway wind+hydro) would be greener but would lose the TEE privacy guarantee at our current scale.
|
||||
@@ -15,6 +24,12 @@ Our inference runs through Tinfoil (TEE-protected, verifiable privacy), but Tinf
|
||||
|
||||
**Question:** When the two conflict, which value wins? Is there a threshold (e.g. member count, cost) at which we'd switch?
|
||||
|
||||
### Should we adopt Tinfoil's other models (embeddings, private search, audio)?
|
||||
|
||||
Tinfoil's catalog includes `nomic-embed-text` (embeddings), `websearch` (private search), and audio models (Whisper, TTS). We currently use Open WebUI's bundled local embedding model for file uploads.
|
||||
|
||||
**Question:** Do we standardize on Tinfoil for all model needs (consistency, privacy), or keep the local embedding model (no per-token cost, no dependency)?
|
||||
|
||||
### Which models should we offer?
|
||||
|
||||
We currently expose three chat models: DeepSeek V4 Flash (default), GPT-OSS 120B, and GLM-5.3 Flash. Tinfoil's full catalog also includes GLM-5.3 (full), Kimi K3, Llama 3.3 70B, Gemma 4 31B, and others.
|
||||
@@ -42,11 +57,22 @@ Members currently pay dues on a sliding scale (from $5/month) with a flat $15/mo
|
||||
|
||||
**Question:** What pricing model best balances fairness, simplicity, and the cooperative's solidarity values? How do we handle a member who consistently exceeds their budget?
|
||||
|
||||
### What happens when a member's budget runs out mid-month?
|
||||
|
||||
Members have a **flexible balance**, not a hardcoded $15/month. The balance starts at $15 but can be topped up by payments (via Open Collective or another system) through the portal's `/admin/set-balance` endpoint, which pushes the new cap to the member's LiteLLM team budget (chat + API draw from the same balance). LiteLLM enforces the cap — requests stop when it's exhausted.
|
||||
|
||||
**Question:** Should members be able to top up self-serve? Should there be a grace period? Should the co-op pool surplus to cover light users? (The *mechanism* is built — these are now governance decisions, not technical blockers.)
|
||||
|
||||
---
|
||||
|
||||
## Governance & Membership
|
||||
|
||||
### What does "member-governed" mean in practice before 50 members?
|
||||
|
||||
The charter says the General Manager (Nathan) has full governance until 50 members, then transitions to member governance. But members can already deliberate on Loomio.
|
||||
|
||||
**Question:** Which decisions should be opened to member input *now* (even if non-binding), versus reserved for the General Manager during the pilot?
|
||||
|
||||
### How do we handle institutional members?
|
||||
|
||||
AI Potluck's "government-as-contributor" framing suggests a path for institutions (universities, municipalities) to contribute compute or data without owning the whole.
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# 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.
|
||||
|
||||
## Credential inventory (draft, 2026-10-26)
|
||||
|
||||
Every credential the agents and the member-facing apps hold, compiled from the
|
||||
public code and docs, corrected against what the agent holds (PR #7 review). **No secret values appear here, only names and roles.**
|
||||
"Where it lives" and "Who rotates" are filled in where the docs say; blank
|
||||
cells are for the operator to complete. When rotating anything, check every
|
||||
row that names the same credential: several are held by more than one app.
|
||||
|
||||
### Member portal (`portal.inference.coop`)
|
||||
|
||||
| Credential | Purpose | Where it lives | Who rotates | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `CLOUDRON_TOKEN` (portal token) | Create users and groups, invite links | Portal app env | GM | See "The two-token split" above |
|
||||
| `OC_PERSONAL_TOKEN` | Read the member list from Open Collective GraphQL | Portal app env, and the vault | GM, as `info@inference.coop` | Co-op-owned since 2026-10-27: minted by `info@inference.coop`, an Admin of the collective (OC returns member emails only to admins). Scopes `collective`, `individual`, `email`, read-only; the credit-pack order lookup is an anonymous query and needs no token. No expiry set; if one is added, record it here. Rotate on suspected exposure or when the account's admin role changes. The previous personal token should be revoked |
|
||||
| `WEBHOOK_TOKEN` | Secret in the Open Collective webhook URL | Portal app env, and the webhook URL on Open Collective | GM | Rotating means re-creating the webhook on OC as well |
|
||||
| `LITELLM_MASTER_KEY` | Create gateway teams and keys for members | Portal app env (copy of the gateway's) | GM | Three holders: portal, gateway, steward agent. Rotate all together |
|
||||
| `LITELLM_DB_*` | Read spend logs from the gateway database | Portal app env | Cloudron (addon) | Postgres credentials for the gateway's database |
|
||||
| `MATRIX_BOT_TOKEN` | `@inference-bot:inference.coop` admin account: invites, space membership | Portal app env | GM | The account has no password. Paired with the steward agent's `MATRIX_LOCAL_BOT_TOKEN`: same account, separate token on a separate device (verified). Revoke or rotate both together |
|
||||
| `LOOMIO_API_KEY`, `LOOMIO_B3_KEY` | Forum (Loomio) membership sync | Portal app env | GM | |
|
||||
| `LISTMONK_API_USER`, `LISTMONK_API_TOKEN` | Newsletter list sync | Portal app env | GM | |
|
||||
| `OWUI_API_KEY`, `OWUI_JWT_SECRET` | Open WebUI knowledge refresh and SSO | Portal app env | GM | Chat moved to LibreChat in October; confirm whether still needed, and retire if not |
|
||||
| `BROKER_SECRET` | Shared secret between the portal and the member dashboard | Portal and dashboard app env | GM | Held by both; rotate together |
|
||||
| `PORTAL_SECRET` | Session signing | Portal app env | GM | |
|
||||
| `CLOUDRON_MAIL_SMTP_*` | Outbound mail | Cloudron mail addon | Cloudron | Managed by the platform |
|
||||
|
||||
### Gateway (`gateway.inference.coop`)
|
||||
|
||||
| Credential | Purpose | Where it lives | Who rotates | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `LITELLM_MASTER_KEY` | Gateway admin | `/app/data/.master_key`, exported into the litellm process by `start.sh` | GM | Also held by the portal (above), the steward agent (below) and the audio keep-alive job |
|
||||
| Salt key (`SALT_KEY_FILE`) | Encrypts provider keys stored in the gateway database | `/app/data` | GM | Losing it means re-entering every provider key |
|
||||
| `TINFOIL_API_KEY` | Provider account | Gateway app env | Provider account owner | Billing lives at the provider |
|
||||
| `GREENPT_API_KEY` | Provider account | Gateway app env | Provider account owner | Billing lives at the provider |
|
||||
| `REGOLO_API_KEY` | Provider account | Gateway app env | Provider account owner | Billing lives at the provider |
|
||||
| `CLOUDRON_POSTGRESQL_*`, `CLOUDRON_REDIS_*` | Database and cache | Cloudron addons | Cloudron | Managed by the platform |
|
||||
|
||||
### Chat (LibreChat, `chat.inference.coop`)
|
||||
|
||||
| Credential | Purpose | Where it lives | Who rotates | Notes |
|
||||
|---|---|---|---|---|
|
||||
| LibreChat encryption keys and app secrets | App secrets; the per-member key sync runs in the app's own Cloudron scheduler | App data directory | GM | Not held by the agent |
|
||||
|
||||
### Agents (on the agent host)
|
||||
|
||||
Exact file paths are deliberately not listed; "agent `.env`" is the right
|
||||
granularity for the public half. Highest privilege first.
|
||||
|
||||
| Credential | Purpose | Where it lives | Who rotates | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Gateway master key (`GW_MASTER_KEY`) | Admin over every gateway team, key and spend record | Steward agent `.env` | GM | **Third holder**, with the portal and the gateway. The row most in need of coordinated rotation |
|
||||
| `MATRIX_LOCAL_BOT_TOKEN` | The `@inference-bot:inference.coop` automation account: room administration in every co-op room | Steward agent `.env` | GM | Same account as the portal's `MATRIX_BOT_TOKEN`, but a separate token on a separate device (verified). Revoking one does not revoke the other; revoke or rotate both together |
|
||||
| Cloudron agent token | Cloudron API | Steward agent `.env` | GM | See "The two-token split" above |
|
||||
| `REGISTRY_BUILD_TOKEN` (and its username) | Image build and push for the custom apps | Steward agent `.env` | GM | Moves to CI (co-op/support#30); the agent stops holding it then |
|
||||
| Gitea token | Commits, issues, PRs as `inference-bot` | Steward agent `.env` | GM | |
|
||||
| Matrix access token (`@inferencebot:inference.coop`) | Reading and posting in rooms | Agent host, with its E2EE key store | GM | The off switch in [Working with the agent](working-with-the-agent.md) revokes this second |
|
||||
| Steward agent's gateway key (`api:info@inference.coop:inferencebots`) | The steward agent's own inference | Steward agent `.env` | GM | Today: empty model allowlist and no per-key budget; draws on the co-op account team's budget. To be scoped like the per-agent keys below. The off switch revokes this first |
|
||||
| Per-agent gateway keys (`infrastructure-bot`, later agents) | That agent's inference | That agent's `.env` | GM | Model allowlist and monthly budget enforced at the gateway |
|
||||
| `GREENPT_FALLBACK_API_KEY` | Direct-to-provider fallback inference during a gateway outage (live since 2026-10-26) | Steward agent `.env` | Provider account owner | Rotation-coupled to the gateway's `GREENPT_API_KEY`: rotate one without the other and the fallback silently breaks. A dedicated key is the cleaner end state |
|
||||
| `HERMES_CUSTOM_API_GREENPT_AI_API_KEY` | Provider key, still present | Steward agent `.env` | Provider account owner | Confirm whether still needed; retire if not |
|
||||
| Restic repository passphrase | Nightly encrypted backup of the agent's state | Agent host and the co-op vault | GM | |
|
||||
| Infomaniak Swiss Backup S3 keys (agent) | Backup target | Agent host | Infomaniak account owner | |
|
||||
|
||||
### Accounts (not tokens, but the things tokens come from)
|
||||
|
||||
| Account | Purpose | Owner today | Notes |
|
||||
|---|---|---|---|
|
||||
| Cloudron admin (`my.inference.coop`) | Everything on the main server | | |
|
||||
| Hostinger | Hosts the main Cloudron server | Personal account (co-op/support#2) | Moving to Infomaniak |
|
||||
| Infomaniak | Agent VPS, Swiss Backup S3 for Cloudron and the agent | | S3 keys for Cloudron backups rotated after setup (co-op/support#12) |
|
||||
| Open Collective (collective admins) | Dues, webhooks, the personal token above | | |
|
||||
| Provider accounts (Tinfoil, GreenPT, Regolo) | Billing and API keys | | |
|
||||
| Domain registrar and DNS for `inference.coop` | | | |
|
||||
| Gitea, Matrix, Loomio, Listmonk admin accounts | Service administration | | Cloudron SSO where applicable |
|
||||
|
||||
### Keeping this current
|
||||
|
||||
Add a row when a new integration lands, and strike one when a service is
|
||||
retired. A credential held by two apps is the thing most likely to break on
|
||||
rotation; the "Notes" column marks each one.
|
||||
@@ -1,21 +0,0 @@
|
||||
<div class="md-copyright">
|
||||
{% if config.copyright %}
|
||||
<div class="md-copyright__highlight">
|
||||
{{ config.copyright }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="md-copyright__highlight">
|
||||
Co-op homepage:
|
||||
<a href="https://inference.coop/" rel="noopener">inference.coop</a>
|
||||
</div>
|
||||
<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>
|
||||
+3
-38
@@ -1,12 +1,12 @@
|
||||
# Privacy Policy
|
||||
|
||||
**Last updated:** October 28, 2026
|
||||
**Last updated:** October 6, 2026
|
||||
|
||||
## In plain language
|
||||
|
||||
- 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 **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 **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 never train on your data, and we never sell it.
|
||||
- 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).
|
||||
- **Membership data:** your contribution status and role.
|
||||
- **Usage data:** token counts, model used, and timestamps (for metering your monthly budget).
|
||||
- **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.
|
||||
- **Content:** your prompts and the AI's responses (stored in your chat history).
|
||||
- **Billing data:** handled by Open Collective — we do not see full payment details.
|
||||
|
||||
## 3. Privacy depends on the model you choose
|
||||
@@ -43,40 +43,6 @@ 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.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
@@ -102,7 +68,6 @@ The models we serve are provided by third parties that change over time, each wi
|
||||
## 8. Retention and deletion
|
||||
|
||||
- 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).
|
||||
- You may request full deletion at any time: info@inference.coop.
|
||||
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
# 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
|
||||
@@ -1,261 +0,0 @@
|
||||
/* 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);
|
||||
|
||||
/* Header bar now matches the chat overlay's dark menu: #171717 with white
|
||||
* text (operator request 2026-11-09) — neutral dark, not green. */
|
||||
--md-primary-fg-color: #171717;
|
||||
--md-primary-fg-color--light: #171717;
|
||||
--md-primary-fg-color--dark: #171717;
|
||||
--md-primary-bg-color: #ffffff;
|
||||
--md-primary-bg-color--light: rgba(255, 255, 255, 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;
|
||||
}
|
||||
|
||||
/* The menu-bar (tab) strip. In light it is the same pale green WASH as the
|
||||
* header — #e8f5e8, the homepage's "open source" pill — not a saturated fill:
|
||||
* the deep greens are accents (links, active navigation, rules), and a large
|
||||
* --green-deep bar clashed with the rest of the light palette. A warm hairline
|
||||
* separates the wash from the cream page below it. Dark is unchanged. */
|
||||
.md-tabs {
|
||||
background-color: #e8f5e8;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
/* In dark, the tab row drops to the sheet's dark warm-green (dark-border
|
||||
* #4a5240): quieter than green-mid, white labels still clear 7:1. */
|
||||
[data-md-color-scheme="slate"] .md-tabs {
|
||||
background-color: var(--dark-border);
|
||||
border-bottom: none;
|
||||
}
|
||||
/* Search input: the white field is a light-mode choice only. In dark mode the
|
||||
* search surfaces use the pine family — input on the chips tone, light text. */
|
||||
html:not([data-md-color-scheme="slate"]) .md-search__input {
|
||||
background-color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
[data-md-color-scheme="slate"] .md-search__form,
|
||||
[data-md-color-scheme="slate"] .md-search__input {
|
||||
background-color: var(--pine-3);
|
||||
color: var(--dark-text);
|
||||
}
|
||||
[data-md-color-scheme="slate"] .md-search__form .md-icon,
|
||||
[data-md-color-scheme="slate"] .md-search__input::placeholder {
|
||||
color: var(--dark-muted);
|
||||
}
|
||||
[data-md-color-scheme="slate"] .md-search__scrollwrap,
|
||||
[data-md-color-scheme="slate"] .md-search-result__article {
|
||||
background-color: var(--pine-2);
|
||||
}
|
||||
[data-md-color-scheme="slate"] .md-search-result__meta {
|
||||
color: var(--dark-muted);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/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/"
|
||||
@@ -1,36 +0,0 @@
|
||||
# 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
|
||||
@@ -1,93 +0,0 @@
|
||||
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.
@@ -1,144 +0,0 @@
|
||||
"""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)
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,70 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,3 +0,0 @@
|
||||
# Pinned build toolchain for the documentation site.
|
||||
# Reproducible: `pip install -r publishing/requirements.txt && mkdocs build`
|
||||
mkdocs-material==9.8.0
|
||||
@@ -1,3 +0,0 @@
|
||||
# Documentation site crawler rules
|
||||
User-agent: *
|
||||
Allow: /
|
||||
@@ -1,76 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,29 +0,0 @@
|
||||
# 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
|
||||
+2
-1
@@ -87,7 +87,8 @@ Privacy is a core value, but it is **not uniform across the service** — and th
|
||||
|
||||
## 9. Governance
|
||||
|
||||
The Co-op's governance proceeds according to the content of the current [Charter](charter.md).
|
||||
- Decisions are made through **Loomio** and governed by the [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
|
||||
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# 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