Compare commits

...
67 Commits
Author SHA1 Message Date
inference-bot 2422432b85 Dark mode: search form wrapper also dark (the input's parent was still light) 2026-11-09 18:20:09 +00:00
inference-bot 5402b9e758 Dark mode: darker tab stripe (dark-border green) and darker pine search surfaces 2026-11-09 18:15:50 +00:00
inference-bot 96b1201f72 guest-access.md: dashboard self-serve invites replace the email path, note awaiting-setup state and credit return on cancel 2026-11-09 18:04:33 +00:00
inference-bot 852e52dc35 models.md: pin exact upstream versions of the default chat model and the image model
- greenpt/deepseek-v4.1-flash routes to GreenPT's base deepseek-v4.1-flash
  id, not the deepseek-v4.1-flash-1017 snapshot (17 Oct 2026) that GreenPT
  lists separately. Confirmed from the gateway registry mapping and
  GreenPT's model card / model list, 2026-11-09.
- regolo/qwen-image serves Regolo's single Qwen-Image id, which Regolo's
  catalog describes as the (2512) checkpoint — the December 2025 update,
  not the newer Qwen Image 2.1 (2026-09-20). Regolo does not list 2.1.
2026-11-09 15:44:44 +00:00
inference-bot ac75c504f6 Dark-mode search input: deep green instead of pine (stands out less) 2026-11-09 06:33:26 +00:00
inference-admin c6d03c8f85 Removing some open questions that have been closed 2026-11-09 06:28:34 +00:00
inference-bot fb14577626 Dark-mode search input: dark background with light text (was white box, light-on-light) 2026-11-09 06:27:23 +00:00
inference-bot 9119e6726a Dark header bar: #171717 with white text, matching the chat overlay's dark menu 2026-11-09 06:24:40 +00:00
inference-bot 70c87e47c2 Guest access: $5 -> $2 of credits; trim page to essentials 2026-11-08 20:12:28 +00:00
inference-bot 342c353ebc Rename 'Gift access' to 'Guest access' — pure terminology rename, page and nav moved to guest-access.md 2026-11-08 02:30:27 +00:00
inference-bot 0786d97286 Add 'Gift access' — documenting the member gift-account feature 2026-11-08 02:23:42 +00:00
inference-bot af4f96ad13 models: three member-facing embedding models (nomic un-scoped, green-embeddings added)
- tinfoil/nomic-embed-text is now member-facing ($0.016/1M per Tinfoil's
  catalog, TEE-protected, 768-dim, 8K ctx) — the model the co-op's own docs
  search runs on inside the TEE.
- greenpt/green-embeddings added ($0.20/1M, 2560-dim measured, 32K ctx) — the
  only genuinely distinct other embedding model in GreenPT's catalog.
- Embedding table now lists all three with pricing; quick-guide bullet updated.
2026-11-06 22:39:07 +00:00
inference-bot b263e125c4 models: correct audio pricing rows; add tinfoil/voxtral-small-24b
- whisper-large-v3-turbo: $0.01 per request (flat, zero per-token) — the old
  $0.05/1M-input row was stale (co-op/support#48)
- voxtral-tts: $0.002 per request (was 'listed $0 (verify on invoice)')
- new: tinfoil/voxtral-small-24b, Tinfoil's token-priced STT model
  ($0.20/1M in + $0.60/1M out), with the chunk-equivalent note for the
  transcription route and a chat-with-input_audio example
- note why dashboard audio rows read '$0.01 · 0 tokens' (Tinfoil returns no
  token usage for audio)
2026-11-06 22:33:42 +00:00
inference-bot fa4ba8d989 models: document greenpt/qwen3-embedding-8b — the member-facing embeddings API
New gateway model (code/litellm 54925ca): /v1/embeddings, GreenPT-hosted,
4096-dim (measured through the gateway), $0.25/1M input from GreenPT's own
pricing, 32K input cap (provider catalog). Not a chat model — API-only like
the audio models, hidden from the chat picker. Also states plainly that
Tinfoil's nomic-embed-text is the operator-only docs-RAG embedding, since a
member read its absence from the model list as removal.
2026-11-06 20:35:04 +00:00
inference-bot 2a89e6a19d Add the co-op meeting calendar to the homepage and Getting Started 2026-11-06 17:44:34 +00:00
inference-admin 5eacc1c116 Made explicit Nathan Schneider's founding role 2026-11-05 04:47:21 +00:00
inference-admin df722f81b0 A little tightening 2026-11-04 21:33:35 +00:00
docs-bot 278c1fda1a infrastructure: make the page member-facing; lighten the light-mode menu-bar stripe
- condense the page to what a member needs: where services run (Infomaniak),
  what backups mean to them, the .coop domain via domains.coop, mail via
  Scaleway, the Matrix space and the accounts they may meet
- drop operator detail (migration narration, DNS record inventory, scheduled-task
  internals, admin-only components, internal account/credential identifiers) to
  co-op/ops-docs; fix a heading glitch where a line began with '#infrastructure'
- brand.css: light-mode .md-tabs uses the header's pale wash #e8f5e8 (was the
  saturated --green-deep) with a warm hairline; dark scheme unchanged
2026-11-04 21:29:06 +00:00
inference-bot 869d16cc65 docs: note on Open Collective payment timing and automatic credit 2026-11-04 21:15:41 +00:00
inference-admin 07d5a60377 Adjusted framing from "private" to "ethical" 2026-11-04 20:54:48 +00:00
inference-bot 33b5cf73e8 docs site: label the site as 'docs' and link the co-op homepage
Make clear this is the documentation, not the co-op homepage:
- site_name -> 'Inference Cooperative docs' (drives the <title> and the
  header brand on every page)
- homepage h1 -> 'Inference Cooperative docs'
- footer gains a 'Co-op homepage' link to https://inference.coop/, added
  in the existing copyright.html override alongside the Gitea source link
2026-11-04 20:42:07 +00:00
inference-bot 98cd517b8a docs: rebase onto current main -- add the page to the nav and repoint the operator-runbook link
ci/woodpecker/manual/woodpecker Pipeline was successful
- mkdocs.yml: list 'Working with the agent' in the nav (a page not in the
  nav is hard to find).
- working-with-the-agent.md: the operator token runbook moved out of the
  published site into the git-only co-op/ops-docs, so the relative link
  (operator-token-runbook.md) would 404; point it at the runbook's new home
  in ops-docs, as infrastructure.md already does. Wording unchanged.
2026-11-04 01:17:47 +00:00
solo.ist c5fd1ba96f working-with-the-agent: apply the agent's review (fleet, two handles, allowlist mechanism, deploy policy as practiced, off-switch access caveat)
- The co-op now runs more than one isolated agent; the page covers all of
  them and names infrastructure-bot.
- Disambiguates @inference-bot (automation account) from @inferencebot
  (the steward agent members instruct).
- Names the mechanism behind "admitted members": a per-agent allowlist
  plus mention-required rooms.
- Restates the deploy norm to match standing practice (bug and security
  fixes deploy immediately and are reported; member-visible or risky
  changes are confirmed first) and points a stricter norm at the forum.
- Off switch: who can actually reach it, and that stopping is not
  restarting.
2026-11-04 01:17:33 +00:00
solo.ist 9b5eff63fb docs: add "Working with the agent" (norms for reading and instructing inferencebot)
A first draft of the member-facing norms listed as a design goal on
co-op/support#17: where the agent speaks and listens, what to ask it for,
what not to ask it in a shared room, what it does without confirmation,
what always needs a human, and how it is stopped. Linked from the README
index and the Matrix section of getting-started. Nothing here changes how
the agent is configured.
2026-11-04 01:17:33 +00:00
inference-bot 08ad0c42ee ci: run the docs pipeline on the agent's local backend (no container images) 2026-11-04 01:08:17 +00:00
docs-agent 4f07c2d3ac docs site: content-hash the site's own stylesheets (cache-proof URLs)
brand.css and typography.css are referenced at fixed URLs via extra_css, so a
warm browser cache could keep serving the previous stylesheet after a deploy.
Add on_post_build to publishing/hooks.py: at build time copy each extra_css
file to <name>.<sha256-8>.css in the output and rewrite the <link> in the built
HTML. Any content change now yields a new URL. Sources keep their names; hashed
copies exist only in the build output and stale ones are pruned on rebuild.
2026-11-04 00:23:28 +00:00
inference-bot 492a64fa43 docs site: gentler header -- a pale wash instead of a saturated green bar
The light scheme filled the header and tabs with green-deep, which read as heavy. They now
use the homepage's "open source" pill colour #e8f5e8 with ink text, and the deeper greens
are kept for accents -- links, active navigation and rules -- rather than as background
fills. The dark scheme is unchanged.
2026-11-03 23:57:36 +00:00
inference-bot e0b9c0cd7e docs: clickable Key links, footer source link, move operator material to co-op/ops-docs
- README: render the Key links as real Markdown links (mailto: for the
  contact address); drop the 'lives in a public git repository' note now
  that the footer carries the source link.
- mkdocs.yml: add a theme override (custom_dir: overrides) that puts a
  'Documentation source' link in the footer of every page.
- Move the operator runbook, maintenance notes and incident write-up out
  of the published site into the git-only co-op/ops-docs repository;
  remove them from the nav and repoint cross-references at Gitea.
2026-11-03 23:30:58 +00:00
inference-bot c95bae161b charter: link agreements to site-relative paths, not Gitea raw URLs 2026-11-03 21:47:34 +00:00
inference-bot a81249eda2 publishing: deploy the docs to their own Surfer app at docs.inference.coop
The docs now have a dedicated Cloudron app (io.cloudron.surfer,
location docs.inference.coop, id 85b7ce06-e2af-49a4-bd4f-a9c2e692e3ab)
serving /app/data/public. deploy.sh writes there and nowhere else; the
two copies inside the marketing Surfer app (public-docs and
public/docs) have been deleted, so there is one canonical location.

SURFER_APP now defaults to docs.inference.coop and DOCS_DEST to
/app/data/public.
2026-11-03 21:39:41 +00:00
co-op agent 1c863ddb95 docs site: gentler colours from the co-op design system, dark mode off Material slate
Light: warm neutrals (#e4e0d8 borders, #f4f2ec code, #6b7a62 muted) instead of
near-blacks; links use the dashboard's #4e7a4d (4.7:1 on cream).

Dark: replace Material's cold blue-grey slate (#1e2129 page) with the chat
overlay's deep-pine palette (#22271f page, #e8f5e8 text, #7ab87a accent), so a
dark screen reads as the co-op's green identity rather than stock Material.
2026-11-03 21:34:46 +00:00
inference-bot 49ce027c5b ci: thin Woodpecker pipeline that runs the existing publishing scripts
Add .woodpecker.yml — install the pinned toolchain, run publishing/build.sh,
then publishing/deploy.sh. No build logic is duplicated; the publishing layer
stays the source of truth. Manual runs only for now: the push webhook is not
wired until the Woodpecker instance is restricted, connected to Gitea and
proven by a manual run.
2026-11-03 21:34:36 +00:00
inference-bot 4e44793aaf docs site: scale headings with the viewport so the title stops breaking mid-word
At 360px wide the h1 was still 52px, because Material keeps a 20px root on mobile, so
"Cooperative" (297px at that size) left only ~30px of slack before breaking mid-word.

Headings now use clamp() to track the viewport and reach the same desktop sizes, with
word-break: normal, hyphens: none and text-wrap: balance so wrapping happens at the
space and the two lines are balanced rather than one word sitting alone.
2026-11-03 20:50:27 +00:00
inference-bot bf356cd9a3 docs site: legible header in dark mode, typography in its own stylesheet
Dark mode: the slate scheme set the header to #3f6b3e, a dark green on a dark page, so
the bar disappeared into the background. It is now green-mid with near-black text.

Typography moves to publishing/typography.css, loaded after brand.css, and declares
Figtree on the elements themselves -- Material defines --md-text-font-family on <body>,
so a :root value loses to it by DOM proximity. Heading colours are scoped to the light
scheme, since ink is unreadable on the dark one.
2026-11-03 20:36:45 +00:00
inference-bot 9b66409343 docs site: make Figtree actually apply, and size headings like the website
Material emits its own font variables and defines --md-text-font-family on <body>,
so brand.css's :root value lost to it by DOM proximity -- the same failure mode as the
palette, and the reason the site rendered entirely in the system font stack with
Material's default 32px/300 headings.

theme.font: false stops Material supplying typography, and brand.css now declares the
font on body, the navigation, the header and every heading, with heading sizes closer to
the website. Figtree is still self-hosted from publishing/fonts.
2026-11-03 20:35:20 +00:00
inference-bot 4069ee12f6 docs site: use Material's custom palette so the co-op colours actually apply
The theme was configured with primary: teal and accent: deep orange, so every page
shipped data-md-color-primary="teal" / data-md-color-accent="deep-orange" and Material
set --md-primary-fg-color and friends on <body>. Those variables then beat brand.css,
which sets them on <html>, because a value on the nearer ancestor wins for descendants
regardless of specificity. The result: cream background and ink from brand.css applied,
but the header, links and accents stayed Material's defaults.

primary/accent are now "custom", Material emits no palette of its own, and the co-op
variables in publishing/brand.css take effect.
2026-11-03 20:22:45 +00:00
inference-bot 3cc32708d9 docs site: deploy from a dedicated clean clone; poll against last deployed commit
- docs-deploy-poll.service and the receiver now build from
  /home/ubuntu/.docs-publisher, so uncommitted edits in a working clone can
  never block a deploy
- the poller compares origin/main against the last DEPLOYED commit (state
  file), so a push made from the deploy clone itself also triggers a deploy
2026-11-03 19:48:34 +00:00
inference-bot f1be37a725 docs site: drop host names from a brand.css comment so the no-external-host grep reads clean 2026-11-03 19:43:00 +00:00
inference-bot 5b432079b7 docs site: ignore __pycache__ 2026-11-03 19:40:39 +00:00
inference-bot 8b1f6d4a65 docs site: release the deploy lock after each deploy
The receiver took the deploy lock but never released it, which starved the
poller (flock -n failed instantly on every cycle).
2026-11-03 19:40:36 +00:00
inference-bot 387269e245 docs site: rewrite directory-style links to their landing pages
A MkDocs hook turns links like [incidents/](incidents/) — which Gitea
renders as a directory listing but a static site would resolve against the
page URL — into links to the generated landing page for that directory.
2026-11-03 19:37:54 +00:00
inference-bot 2d3291d493 docs site: generate landing pages for documented directories
infrastructure.md links to incidents/ the way Gitea renders directories.
A static site has no directory listing, so the build now generates a small
landing page (with each document's own title) for any documented directory
that lacks an index. Build artifact only — no document is touched.
2026-11-03 19:35:45 +00:00
inference-bot fa1f38ced2 docs site: serve a robots.txt at the site root 2026-11-03 19:31:12 +00:00
inference-bot 44f040c27f docs site: add push-to-main polling deployer (agent host)
The agent host's cloud firewall only admits inbound SSH, so the registered
Gitea webhook cannot reach the receiver. docs-deploy-poll.service polls
Gitea every 60s over outbound HTTPS and deploys whenever main moves; the
webhook stays wired for the moment a port is opened in the Infomaniak panel.
Both serialise on the same deploy lock.
2026-11-03 19:29:39 +00:00
inference-bot 62158c16cc docs site: add MkDocs publishing layer (build, deploy, push-to-main webhook)
Renders the repository's Markdown (which stays where it is) into a
member-readable static site with Material for MkDocs: co-op brand colours
and logo, self-hosted OFL Figtree, offline search, no external hosts.

- publishing/build.sh   stage .site-src, render .site-build (mkdocs --strict)
- publishing/deploy.sh  write the build into the Surfer app container
- publishing/webhook-receiver.py + .service   Gitea push hook on main
                        triggers pull -> build -> deploy (agent host, no new
                        Cloudron app)
2026-11-03 19:26:21 +00:00
inference-bot cdb62fc247 docs: Metis decisions now count toward the allowance
Metering on /v1/systemone is live: each call is billed at $0.12 per 1M
input tokens, computed from the exact token count GreenPT returns and
attributed to the calling member key. Replace the "not yet metered"
warnings in models.md and api-access.md with the accurate position.

(metering verified 2026-11-03; code/litellm patch_passthrough_token_cost.py)
2026-11-03 19:03:27 +00:00
inference-bot 7c9c8ef578 docs: Metis is not yet metered; document the decisions endpoint in API access
models.md claimed decisions count toward the member allowance. Verified today that a
/v1/systemone call registers no spend against the calling key, so the claim was wrong.
States the gap plainly instead. api-access.md now names the decisions route, the fact
that it is not an OpenAI chat endpoint, and the metering caveat.
2026-11-03 18:47:26 +00:00
inference-bot 1fec2b16eb models: document Metis, the GreenPT decision model on /v1/systemone
Add a Decisions section: Metis returns probabilities/choices/scores rather than
text, is called at /v1/systemone with a member's normal gateway key, is
GreenPT-hosted (policy-based privacy, EU, renewable), and is metered
approximately (flat per-call estimate, not exact tokens).
2026-11-03 18:32:31 +00:00
inference-admin f0ed33371c infrastructure: co-op now self-hosts on Infomaniak; document mail, DNS and backups
- New 'Where it runs' section: services moved to the co-op's own Infomaniak
  server (Swiss sovereign cloud, Geneva, renewable-powered, ISO 14001/50001/27001)
  from a personal Hostinger account on 2026-11-02; note the steward bot runs on a
  separate machine and was not part of the migration.
- DNS subsection: managed manually at domains.coop (nameservers yoursrs.com); the
  zone is only 11 records, with the apex A/AAAA and a wildcard A/AAAA carrying
  every application hostname.
- Backups subsection: encrypted nightly backups to Infomaniak Encrypted S3, kept
  seven days; the steward-bot host backs up selected home directories to the same
  object storage.
- New 'Mail' section: inbound mail is received directly by the box; the newsletter
  (Listmonk) sends via the Scaleway Transactional Email relay (SPF, DKIM and DMARC
  pass at p=reject), while the member portal still sends through the Cloudron mail
  service on the box. Both send as info@inference.coop.
- Correct the webmail URL: mail.inference.coop -> email.inference.coop (the
  Cloudron mail app vhost; mail.inference.coop is not a configured vhost).
2026-11-02 08:17:18 +00:00
inference-admin a0e7c8d405 Removed reference to pilot phase in ToS 2026-11-01 21:56:05 +00:00
inference-bot 6b41382bef models: Regolo's 2026-11-01 prices — apertus-70b $1.16/$3.94, qwen3.5-122b $0.70/$3.94 2026-11-01 00:44:07 +00:00
inference-bot deb53eabc8 Merge #9: incidents: add the 2026-10-02 gateway outage write-up
Reviewed by dckc (review 6, APPROVED) and cross-checked against the gateway upgrade
runbook. Merged by push: this repo's approval gate is restricted to the Owners team,
which currently holds only machine accounts, and Gitea will not accept their API
reviews as approvals — see the PR discussion.
2026-10-30 15:03:53 +00:00
inference-bot 27415161f9 Revert "Merge #9: add the 2026-10-02 gateway outage write-up"
This reverts commit ddbbe01801, reversing
changes made to 2fdda5a201.
2026-10-30 15:03:50 +00:00
inference-bot ddbbe01801 Merge #9: add the 2026-10-02 gateway outage write-up
Reviewed by dckc (review 6, APPROVED) and cross-checked against the gateway upgrade
runbook. Merged by push: this repo's approval gate is restricted to the Owners team,
which currently holds only machine accounts, and Gitea will not accept their API
reviews as approvals — see the PR discussion.
2026-10-30 15:03:23 +00:00
inference-bot 2fdda5a201 privacy: state what the API path actually stores (and add it where members read)
The policy described chat history and enclave models carefully but never addressed
the API path, and §2 read as though all content is retained — which is wrong for
members using their own keys.

Verified before writing, rather than restating policy language: a request with a
distinctive marker was sent through the gateway and searched for afterwards. It
appears nowhere in the gateway's logs, `litellm_settings` enables no content
logging, and the request records carry usage fields only (model, tokens, cost, key).

- privacy-policy.md §2: content retention now depends on the surface, with a pointer.
- privacy-policy.md §4: new subsection on API requests, including the two limits that
  stop it being read as more than it says — we can still link a request to a key and
  therefore a member, and "not stored by us" says nothing about the provider.
- api-access.md: a "what we store" section, since that is the page an API user reads
  before worrying about it.
- models.md: the privacy-limitations section now distinguishes chat from API.
2026-10-29 18:21:01 +00:00
inference-bot be2ebd1bc4 infrastructure: @inference-bot is retired, not 'being retired'
Records the completed state: room power and the portal's invite credential now both
belong to @inference-admin; the old account's admin flag is dropped, it is
deactivated, and its devices and tokens are deleted. Notes the two corrupt rooms it
cannot leave, so the next person does not re-investigate.
2026-10-29 18:10:38 +00:00
inference-bot fbfca9624e infrastructure: say which Matrix account is which, and start retiring @inference-bot
Four automated accounts touch the homeserver and two of them differed by one
character, which caused real confusion (a bot appearing to announce itself; the
steward unable to post in a room it had been asked to report to). This records
the distinction rather than leaving it to be rediscovered.

Power belongs to @inference-admin, voice belongs to @inferencebot, and
@inference-admin never speaks in a member room. @inference-bot is being retired:
its room power was transferred to @inference-admin, which is now administrator of
record for #general, #community, #design, #skillshare, #updates and
#infrastructure.

Also records the one live dependency that blocks finishing: the portal's member
invites still authenticate as the old account via MATRIX_BOT_TOKEN, so that has
to be repointed before its credentials are revoked. Without this written down the
next person would revoke the token and silently break member invites.

Verified while writing: the portal's token hashes to the old account's device
VYQDYBXULA, which is also the unexplained python-httpx client seen using that
account today.
2026-10-29 17:45:04 +00:00
inference-bot e0732af1fd models: apertus-70b is 262K context, not 65K — measured, not copied
The provider's published catalog says this model takes 30K input and has no
vision support. Both of those are wrong. Sent through our own gateway:

- an image returns the right answer ("Red" for a solid red PNG)
- prompts of 32K, 70K, 130K and 262K input tokens are all accepted, with
  Regolo's own usage accounting reporting prompt_tokens=262021

Our own description of the v1.5 generation — image understanding and a 262K
context window — matches what the model demonstrably does, which is why the
figure goes to 262K. The old 65K undersold the model by roughly 4x.

Regolo's meta-model for qwen3.5-122b says 120K; it processed 262,023 tokens and
still followed the instruction at the end, so that row's 262K is confirmed as
written and is left alone.

Adds a line under the table recording that context figures here are measured
through the gateway rather than copied from provider catalogs, because that
difference is exactly what caused a member to report this as an error.
2026-10-29 17:11:17 +00:00
inference-bot 6cf50e6a5d docs: link the energy and carbon page from the model list 2026-10-29 04:16:48 +00:00
inference-bot f6dcde3cbb docs: how the co-op estimates energy and carbon (method, factors, and limits) 2026-10-29 04:16:36 +00:00
Nathan Schneider 3ee59e2dcc privacy: document temporary chats (incl. as default) and sharpen the honest caveat
- §4: the exact boundary — enclave models protect inference, stored
  history is operator-readable; recommend Temporary Chat + enclave model
  for sensitive material. New subsection on Temporary Chat (per-
  conversation toggle, Default Temporary Chat setting, trade-offs,
  what it does and does not change).
- §8: temporary chats are never retained.
- getting-started: add Temporary Chat to the chat feature list with
  the Settings → Conversations default toggle.
No engineering change; documents what the deployed chat (1.4.5) already
supports. Member request 2026-10-28.
2026-10-28 17:48:12 -06:00
inference-bot 95c6258594 runbook: how to confirm the webhook URL match, and what the watch job does and does not cover 2026-10-28 14:19:37 +00:00
inference-bot 682b5530be runbook: correct the WEBHOOK_TOKEN rotation state (rotated 2026-10-27; URL match unverified) 2026-10-28 14:03:56 +00:00
inference-bot f27621d4de runbook: admin/webhook token split, metrics_snapshot task, correct sweep auth 2026-10-28 13:55:28 +00:00
solo.istandClaude Fable 5.1 29f0d2674a incidents: fix the 2026-10-02 byline date and the outage duration (review)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 01:34:15 -04:00
solo.ist cdb705d89e incidents: add the 2026-10-02 gateway outage write-up (from the unmerged incident branch)
The write-up has sat on incident/2026-10-02-gateway-cve-upgrade since the
outage and that branch is now far behind main. This lifts just the incident
text and the CVE note into incidents/, attributed to InferenceBot with the
substance unchanged, and links it from infrastructure.md under a new
Incident log section.
2026-10-26 02:11:33 -04:00
inference-bot 8cff4871dd Document gateway v1.84.0 pin, CORS env rename, and 2026-10-02 outage incident 2026-10-01 23:35:39 -06:00
38 changed files with 2019 additions and 514 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Generated by the documentation build (see publishing/build.sh)
.site-src/
.site-build/
.venv-docs/
__pycache__/
+52
View File
@@ -0,0 +1,52 @@
# Woodpecker CI pipeline for co-op/docs — THIN on purpose.
#
# It does NOT re-implement the build. The repository's publishing layer is the
# single source of truth and this pipeline only wires it up:
#
# publishing/requirements.txt pinned MkDocs + Material toolchain
# publishing/build.sh stage Markdown -> .site-src/, render .site-build/
# publishing/deploy.sh push .site-build/ into the Cloudron Surfer app
#
# Any change to how the site is built belongs in publishing/, never here.
#
# Run model: MANUAL ONLY for now. The Gitea webhook that fires this on push is
# deliberately NOT wired — the poll watcher (docs-deploy-poll.service on the
# agent host) remains the live auto-deploy path until a manual run has proven
# the pipeline. Once it has, add `push` to the `when` block below and register
# the webhook in Gitea.
#
# Backend note (IMPORTANT — this is why there are no container images here):
# The co-op's Woodpecker agent runs as a Cloudron app, which has no Docker
# socket, so it uses Woodpecker's `local` backend. Under `local` each step is
# run as a shell command INSIDE the agent container, and the step's `image:`
# field selects the SHELL, not a container image. Every tool the steps need is
# therefore baked into the agent image (co-op/woodpecker-agent):
# python3 + the pinned MkDocs toolchain (venv at /opt/docs-venv, mirroring
# publishing/requirements.txt), node + the cloudron CLI, bash, git, tar.
# If the toolchain pin changes, rebuild that image — see its Dockerfile.
#
# Secrets required (Woodpecker repo secret, name `cloudron_token`):
# cloudron_token = the Cloudron API token used by publishing/deploy.sh
# Do not put credentials in this file.
when:
- event: manual
steps:
- name: build
image: bash
commands:
# Reuses the toolchain pre-installed in the agent image; build.sh only
# creates/installs a venv if the one given is missing, which it is not.
- publishing/build.sh /opt/docs-venv
- name: deploy
image: bash
commands:
# deploy.sh drives the Cloudron `cloudron` CLI (a node program) via
# `cloudron exec`; node, the CLI, bash, git and tar are all in the agent
# image. The build it publishes is the shared workspace .site-build/.
- publishing/deploy.sh
environment:
CLOUDRON_TOKEN:
from_secret: cloudron_token
+12 -14
View File
@@ -1,9 +1,9 @@
# Inference Cooperative # Inference Cooperative docs
**AI, governed together.** **AI, governed together.**
The Inference Cooperative is a member-governed project providing private AI The Inference Cooperative is a member-governed project providing more ethical AI
inference. Members pay dues on a sliding scale and, in return, get private AI inference. Members pay dues on a sliding scale and, in return, get AI
chat and API access through shared, cooperatively governed infrastructure. We chat and API access through shared, cooperatively governed infrastructure. We
are fiscally sponsored by [Metagov](https://metagov.org), a nonprofit, and are fiscally sponsored by [Metagov](https://metagov.org), a nonprofit, and
funded through our [Open Collective](https://opencollective.com/inference-cooperative). funded through our [Open Collective](https://opencollective.com/inference-cooperative).
@@ -16,17 +16,19 @@ funded through our [Open Collective](https://opencollective.com/inference-cooper
- **Want API access?** → [API access](api-access.md) — use our models programmatically. - **Want API access?** → [API access](api-access.md) — use our models programmatically.
- **Want community chat?** → [Getting started · Matrix](getting-started.md) — our federated Matrix server, including how to connect from any client and how to use an existing Matrix account. - **Want community chat?** → [Getting started · Matrix](getting-started.md) — our federated Matrix server, including how to connect from any client and how to use an existing Matrix account.
- **Curious how it works?** → [Infrastructure](infrastructure.md) — a map of the stack and the codebase. - **Curious how it works?** → [Infrastructure](infrastructure.md) — a map of the stack and the codebase.
- **Wondering about the bot?** → [Working with the agent](working-with-the-agent.md) — what the co-op's agents do, who can instruct them, and what always needs a human.
- **Need help or have an idea?** → [Support & feature requests](https://git.inference.coop/co-op/support) — open an issue. - **Need help or have an idea?** → [Support & feature requests](https://git.inference.coop/co-op/support) — open an issue.
## Key links ## Key links
- **Website:** https://inference.coop - **Website:** [inference.coop](https://inference.coop)
- **AI Chat:** https://chat.inference.coop - **AI Chat:** [chat.inference.coop](https://chat.inference.coop)
- **Matrix chat:** https://matrix.inference.coop - **Matrix chat:** [matrix.inference.coop](https://matrix.inference.coop)
- **Member Dashboard:** https://dashboard.inference.coop - **Member Dashboard:** [dashboard.inference.coop](https://dashboard.inference.coop)
- **Governance forum:** https://forum.inference.coop - **Governance forum:** [forum.inference.coop](https://forum.inference.coop)
- **Source code:** https://git.inference.coop - **Co-op calendar:** [cloud.inference.coop](https://cloud.inference.coop/apps/calendar/p/Ltb6LAbt7NWHH7Td)
- **Contact:** info@inference.coop - **Source code:** [git.inference.coop](https://git.inference.coop)
- **Contact:** [info@inference.coop](mailto:info@inference.coop)
## Governance & legal ## Governance & legal
@@ -34,7 +36,3 @@ funded through our [Open Collective](https://opencollective.com/inference-cooper
- [Open questions](open-questions.md) — decisions currently up for member discussion. - [Open questions](open-questions.md) — decisions currently up for member discussion.
- [Terms of Service](terms-of-service.md) - [Terms of Service](terms-of-service.md)
- [Privacy Policy](privacy-policy.md) - [Privacy Policy](privacy-policy.md)
---
*This documentation lives in a public git repository. Contributions welcome.*
+41
View File
@@ -10,10 +10,32 @@ client works.
- **Auth:** a bearer API key (create one in the [Member Dashboard](https://dashboard.inference.coop)) - **Auth:** a bearer API key (create one in the [Member Dashboard](https://dashboard.inference.coop))
- **Format:** OpenAI chat completions — `POST https://gateway.inference.coop/v1/chat/completions` - **Format:** OpenAI chat completions — `POST https://gateway.inference.coop/v1/chat/completions`
- **Models:** see the [model list](models.md). - **Models:** see the [model list](models.md).
- **Not only chat:** the gateway also serves a decision endpoint,
`POST https://gateway.inference.coop/v1/systemone` — see
[Decisions](models.md#decisions). It takes a different request shape, so
it is not reachable through an OpenAI SDK's `chat.completions` call.
API usage draws from the **same monthly allowance as chat** — there's no separate API usage draws from the **same monthly allowance as chat** — there's no separate
quota. quota.
## What the co-op stores about your requests
Usage records, and nothing else: the model, token counts, cost, and which key made
the request — that is how your monthly metering works. **Your prompts and the
responses are not stored on our side.** The API path keeps no conversation, so there
is no transcript to delete later.
Two things worth being precise about:
- The **chat interface** is different — it saves your conversations so you can revisit
them. If you want the API's behaviour there instead, use a **temporary chat**.
- The **provider** is a separate question. Whether a model's operator retains anything
is set by that provider, not by us, and it varies by model — the
[model list](models.md) distinguishes policy-based zero retention from
enclave-based protection.
Full detail, including what we can and cannot see: [privacy policy](privacy-policy.md).
## Get a key ## Get a key
Log in to the [Member Dashboard](https://dashboard.inference.coop) and create an Log in to the [Member Dashboard](https://dashboard.inference.coop) and create an
@@ -48,6 +70,25 @@ resp = client.chat.completions.create(
Any OpenAI-compatible client works the same way: set the base URL to Any OpenAI-compatible client works the same way: set the base URL to
`https://gateway.inference.coop/v1` and the API key to your member key. `https://gateway.inference.coop/v1` and the API key to your member key.
## Decisions (Metis)
Alongside the chat models, the gateway exposes GreenPT's **Metis** decision model
at `POST https://gateway.inference.coop/v1/systemone`. It is the same key and the
same base URL — only the path and the request body differ. For what it is, the
three question types, and a full `curl` example, see
[Decisions in the model list](models.md#decisions).
Two practical notes:
- **It is not an OpenAI chat endpoint.** You send a `state` plus typed `questions`
and receive probabilities; there is nothing for an OpenAI SDK's
`chat.completions` to parse. Post the body directly (`curl`, `requests`,
`httpx`, or an SDK's generic POST).
- **Its usage is metered like any other call.** Each decision is billed at
**$0.12 per 1M input tokens**, figured from the exact token count GreenPT
returns (Metis generates no prose, so there is no output cost). Decisions draw
on the same monthly allowance as chat.
## Coding agents and other OpenAI-compatible clients ## Coding agents and other OpenAI-compatible clients
Any tool that speaks the OpenAI API works. Where a tool asks for provider or Any tool that speaks the OpenAI API works. Where a tool asks for provider or
+5 -1
View File
@@ -16,11 +16,15 @@ for our governance model, see the [Charter](charter.md).
The cooperative launched in September 2026, growing out of research on democratic The cooperative launched in September 2026, growing out of research on democratic
digital infrastructure through Metagov's [Collective Governance for AI: digital infrastructure through Metagov's [Collective Governance for AI:
Points of Intervention](https://metagov.org/cg-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.
It began with a single privacy-first provider and a deliberately small 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. 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. 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 A member dashboard made usage and spending transparent. A federated
[Matrix](https://matrix.inference.coop) server, a [Matrix](https://matrix.inference.coop) server, a
[forum](https://forum.inference.coop), and a newsletter gave members places to [forum](https://forum.inference.coop), and a newsletter gave members places to
+3 -3
View File
@@ -41,9 +41,9 @@ We regard disputes as an opportunity to learn and grow. Members should try to wo
This charter is the core governing document. Members are also bound by the following agreements: This charter is the core governing document. Members are also bound by the following agreements:
* [Code of Conduct](https://git.inference.coop/co-op/docs/src/branch/main/code-of-conduct.md) * [Code of Conduct](code-of-conduct.md)
* [Terms of Service](https://git.inference.coop/co-op/docs/src/branch/main/terms-of-service.md) * [Terms of Service](terms-of-service.md)
* [Privacy Policy](https://git.inference.coop/co-op/docs/src/branch/main/privacy-policy.md) * [Privacy Policy](privacy-policy.md)
## Legal entity ## Legal entity
+80
View File
@@ -0,0 +1,80 @@
# Energy and carbon
The co-op shows members how much energy its inference uses, and what carbon that
represents. This page says where those numbers come from and where they are weak.
The [model list](models.md) covers which providers we use and their energy
sources; this covers how we count.
## What we measure
**Output tokens, per model, per day** — read from our own gateway logs. Nothing
is required of your client, and no per-member figure is published: the dashboards
show co-op totals.
We count **output tokens only**, deliberately. That is the unit
[GreenPT's published metric](https://greenpt.com/blog/introducing-a-new-ai-metric-to-drive-sustainability)
uses ("mWh per 100 tokens"); input tokens are excluded so comparisons hold across
prompts, workloads and models. It also means these figures are **not** total
compute.
## How tokens become energy and carbon
```
energy = output tokens × (Wh per 1,000 output tokens)
carbon = energy × grid intensity of the electricity (gCO2e per kWh)
```
Both factors are held per provider in one table, with the source recorded for
each row, in the portal's
[`metrics_snapshot.py`](https://git.inference.coop/code/member-portal/src/branch/main/metrics_snapshot.py).
### Measured: GreenPT
GreenPT returns an `impact` object with every call — energy in watt-milliseconds,
emissions in µgCO2e — so these are fitted from their own numbers rather than
imported from a third party. Measured 2026-10-28 by regressing energy against
output tokens across prompt sizes (R² ≥ 0.999; there is a real per-request
overhead, because their energy derives from inference *time*):
| Model | Wh per 1,000 output tokens | Implied grid intensity |
|---|---|---|
| `greenpt/deepseek-v4.1-flash` | 0.36 | ~193 gCO2e/kWh |
| `greenpt/glm-5.3-flash` | 1.08 | ~193 gCO2e/kWh |
| `greenpt/green-r` | 0.55 | ~24 gCO2e/kWh |
`green-r` is roughly an order of magnitude cleaner per unit of energy, which is
where GreenPT's claim of up to 40% lower CO2 per query comes from.
### Estimated: everyone else
Providers that do not disclose impact — Tinfoil, Regolo, PublicAI — use a
**documented order-of-magnitude proxy**: 0.5 Wh per 1,000 output tokens at an EU
grid average of 250 gCO2e/kWh. It exists so a day's total is never quietly
missing models. It is an estimate, and it is labelled as one everywhere it
appears. Refining a factor is a data edit, not a code change.
## What this does not cover
- **It is a floor, not a total.** Embedding, audio and image requests carry no
completion tokens, so their compute is not counted at all. This is the largest
known gap.
- **Estimates for non-disclosing providers.** The proxy is an order-of-magnitude
figure, not a measurement of what a given provider actually draws.
- **GreenPT's energy is itself estimated** — from time and documented hardware
power — so our per-token figures are an efficiency ratio, not a per-call meter
reading.
- **Training emissions are not included**, for any model we serve. Nobody in this
market can verify them yet.
## Where to see the numbers
The [member dashboard](https://dashboard.inference.coop) has them under
"Co-op usage" — by day, alongside membership, requests and spend. Operators see
the same series on the admin panel's Trends tab.
## Units, for the curious
`Wh` is a watt-hour (a 100 W bulb for one hour is 100 Wh). `kWh` is 1,000 Wh.
`gCO2e` is grams of carbon-dioxide equivalent. Converting between them is where
mistakes hide: µgCO2e per mWh and gCO2e per kWh are the same ratio, and Wh to kWh
is a factor of 1,000.
+24
View File
@@ -7,6 +7,10 @@ private AI chat and API access in return.
This guide covers the three things most new members want: becoming a member, This guide covers the three things most new members want: becoming a member,
using the chat, and getting involved in governance. 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 ## 1. Become a member
Membership is a **sliding-scale contribution starting at $5/month** through Membership is a **sliding-scale contribution starting at $5/month** through
@@ -26,12 +30,21 @@ account. Click it and create your login.
> as a member. (Contributed without an Open Collective account? You're recognized > as a member. (Contributed without an Open Collective account? You're recognized
> by the email you entered at checkout.) > 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 ### Your membership includes
- **Private AI chat** through the co-op's shared gateway. - **Private AI chat** through the co-op's shared gateway.
- **API access** for your own tools and projects (see [API access](api-access.md)). - **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. - **A monthly usage allowance** of $15 of credit, shared between chat and API.
- **Credit packs** — buy extra non-expiring credits when you need more. - **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. - **A voice in governance** — how the project is run and where it goes next.
## 2. Use the chat ## 2. Use the chat
@@ -53,6 +66,13 @@ with the account you created in step 1.
there's a tool you'd like available, ask in #general. there's a tool you'd like available, ask in #general.
- **Voice** works in both directions: speak to the chat with the microphone - **Voice** works in both directions: speak to the chat with the microphone
icon, and have replies read aloud. icon, and have replies read aloud.
- **Temporary chats** — for conversations you don't want kept on our servers
at all. Click the **Temporary Chat** toggle (in the chat header) and the
conversation is never saved: it disappears when you leave the page, and
nothing lands in your history or our database. To make this your default,
open **Settings → Conversations** and turn on **Default Temporary Chat**.
For anything sensitive, we recommend a temporary chat — ideally paired with
a 🔒 enclave model. See [Privacy](privacy-policy.md#4-what-we-can-see-an-honest-caveat).
Your usage draws from your monthly allowance; you can see it on the Your usage draws from your monthly allowance; you can see it on the
[Member Dashboard](https://dashboard.inference.coop). [Member Dashboard](https://dashboard.inference.coop).
@@ -135,6 +155,10 @@ privacy, and direction happen on our **Loomio forum**:
space and join whatever is interesting to you. New rooms appear there as space and join whatever is interesting to you. New rooms appear there as
the co-op grows. the co-op grows.
- The updates feed is posted by the co-op's automation account. See
[Working with the agent](working-with-the-agent.md) for the agents behind
it, what they do, and how members interact with them.
- See the [Charter](charter.md) for how governance is structured. - See the [Charter](charter.md) for how governance is structured.
- See [Open questions](open-questions.md) for the decisions currently on the table. - See [Open questions](open-questions.md) for the decisions currently on the table.
+44
View File
@@ -0,0 +1,44 @@
# 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.
+128 -134
View File
@@ -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 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 the **API** (the LiteLLM gateway). Both route through a single gateway to
multiple LLM providers — so members choose what they value for each task: multiple LLM providers, so members can choose what they value for each task:
architecturally private inference in **TEE-protected enclaves** (Tinfoil), architecturally private inference in **TEE-protected enclaves** (Tinfoil), or
100% renewable energy (GreenPT, Regolo). A thin layer of custom middleware — 100% renewable energy (GreenPT, Regolo). A thin layer of custom middleware — the
the member portal — ties Open member portal — ties Open Collective membership to Cloudron accounts and LiteLLM
Collective membership to Cloudron accounts and LiteLLM keys. keys.
``` ```
┌─> Tinfoil (TEE) 🔒 private ┌─> Tinfoil (TEE) 🔒 private
@@ -26,134 +26,132 @@ Members ──> your own tools ────┘ gateway │
The whole stack runs on [Cloudron](https://cloudron.io), which handles hosting, The whole stack runs on [Cloudron](https://cloudron.io), which handles hosting,
single sign-on, and per-app isolation. single sign-on, and per-app isolation.
## Where it runs
The co-op's 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 ## Components
| Component | Purpose | URL | Code | | Component | Purpose | URL |
|-----------|---------|-----|------| |-----------|---------|-----|
| **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) | | **LibreChat** | Member-facing chat (models, web search, image generation, voice) | chat.inference.coop |
| **Open WebUI** | Legacy chat instance — read-only reference (old history + file attachments) | chat-legacy.inference.coop | — | | **Open WebUI** | Legacy chat instance — read-only reference (old history and file attachments) | chat-legacy.inference.coop |
| **LiteLLM** | AI gateway — keys, metering, model routing | gateway.inference.coop | [code/litellm](https://git.inference.coop/code/litellm) | | **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 | [code/member-portal](https://git.inference.coop/code/member-portal) | | **Member portal** | Membership middleware — onboarding, key provisioning, Loomio sync | portal.inference.coop |
| **Member Dashboard** | Member-facing usage view + API key management | dashboard.inference.coop | [code/member-dashboard](https://git.inference.coop/code/member-dashboard) | | **Member dashboard** | Your usage view and API keys | dashboard.inference.coop |
| **Admin Panel** | Admin-only member overview, spend, balance management | panel.inference.coop | [code/admin-panel](https://git.inference.coop/code/admin-panel) | | **Loomio** | Member governance | forum.inference.coop |
| **SearXNG** | Self-hosted web search (feeds the chat's search) | search.inference.coop | — | | **Matrix (Synapse)** | Member chat server, federated, SSO login via Cloudron | matrix.inference.coop |
| **Loomio** | Member governance | forum.inference.coop | — | | **Gitea** | Git hosting for the co-op's code and this documentation | git.inference.coop |
| **Matrix (Synapse)** | Member chat server (federated; SSO login via Cloudron) | matrix.inference.coop | — | | **Listmonk** | Member newsletter (synced from membership) | newsletter.inference.coop |
| **Gitea** | Git hosting (code + docs) | git.inference.coop | — | | **SnappyMail** | Webmail | email.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 | — | The co-op's software behind all of these is open source — see [The
| **Vaultwarden** | Password manager (admin credentials) | vault.inference.coop | — | code](#the-code) below for where each piece lives.
| **SnappyMail** | Webmail | mail.inference.coop | — |
Open Collective (billing and fiscal sponsorship) is the one external service in Open Collective (billing and fiscal sponsorship) is the one external service in
the flow — see [opencollective.com/inference-cooperative](https://opencollective.com/inference-cooperative). the flow — see
[opencollective.com/inference-cooperative](https://opencollective.com/inference-cooperative).
## Mail
The 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.
## Matrix ## Matrix
Members get a federated Matrix account on our self-hosted Synapse Members get a federated Matrix account on the co-op's self-hosted Synapse
(matrix.inference.coop, user IDs `@username:inference.coop`) using their (matrix.inference.coop, user IDs `@username:inference.coop`), signing in with the
Cloudron SSO login. The portal invites every active member — and Nathan's same Cloudron SSO as the chat. The co-op invites every active member to the
federated account — to the private **Inference Cooperative** Space (with a private **Inference Cooperative** space (with a **General** room) automatically,
**General** room) on every membership sync, so new members land in the space so new members land there as they join. Federation is on, so you can also join
automatically. Invites honor rate limits; external/federated members can be from any homeserver you already use.
added via the portal's `MATRIX_EXTRA_MXIDS` env.
Federation is enabled (delegated via `.well-known/matrix/server` on the bare ### Accounts you may meet
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.
## How the apps run themselves A few accounts in the space are automation rather than people:
The portal and gateway are self-maintaining: their recurring housekeeping is - **`@inferencebot` — the steward.** It posts the digests, newsletter notes and
built into the apps as scheduled tasks, not run from any member's or operator's reminders. It holds no more power than posting needs.
personal machine. - **`@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.
- **Member portal** — two scheduled jobs (Cloudron scheduler): The house rule: **power belongs to `@inference-admin`, voice belongs to
- *Membership sweep* (nightly, 3am) — reconciles members against the live `@inferencebot`.** Please don't create an account whose name differs from an
Open Collective list: provisions new members who signed up while a webhook existing one by a character or two — spotting lookalikes is exactly what this
was missed, deactivates lapsed members, and syncs Loomio + Matrix convention is for.
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.
Scheduled tasks are declared in each app's `CloudronManifest.json` (see the ## Membership
`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.
## How membership is wired Membership runs through the member portal, the single source of truth for who is
a member:
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. 1. A member contributes on Open Collective.
2. Open Collective fires a webhook to the portal, which provisions a Cloudron 2. Open Collective notifies the portal, which provisions a Cloudron account, a
account, a LiteLLM key, and a welcome email. LiteLLM key, and a welcome email.
3. A nightly sweep reconciles against the live Open Collective list, so a missed 3. A nightly reconciliation against the live Open Collective list means a missed
webhook self-heals within 24 hours. notification self-heals within a day.
The portal database is authoritative for member emails and balances; LiteLLM's You may see **"Inference Co-op Bot"** listed as an admin of the collective on
team budget is the source of truth for a member's allowance, shared between chat Open Collective. It is infrastructure, not a person or a member: a dedicated
and API. 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
### The bot account on Open Collective from a human's own contribution, and automation only reads the result. Its code
is open source in
You may notice **"Inference Co-op Bot"** listed as an admin of the collective on [code/member-portal](https://git.inference.coop/code/member-portal), and a
Open Collective. This is infrastructure, not a member or a person: collective admin can revoke its access at any time.
- **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 code
The cooperative's software is open source, in the [`code`](https://git.inference.coop/code) The co-op's software is open source, in the [`code`](https://git.inference.coop/code)
organization on Gitea: and [`co-op`](https://git.inference.coop/co-op) organizations on Gitea:
- [**code/member-portal**](https://git.inference.coop/code/member-portal) — membership middleware (Python). - [**code/member-portal**](https://git.inference.coop/code/member-portal) — membership middleware (Python).
- [**code/member-dashboard**](https://git.inference.coop/code/member-dashboard) — member-facing usage + API keys (Python). - [**code/member-dashboard**](https://git.inference.coop/code/member-dashboard) — member dashboard: usage and API keys.
- [**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. - [**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/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/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.
## Contributing (the Git is yours, too) 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
Everything the co-op runs is open source and lives at is open source, so as a member you can browse all of
[git.inference.coop](https://git.inference.coop) — the website, this it and propose changes.
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 **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 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 **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**: change, fork the repo, edit in your fork, and open a **pull request**:
@@ -164,43 +162,39 @@ 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. 4. Open a **pull request** back to the original repo, describing your change.
**All changes are reviewed.** Nothing merges directly into `main` — every change **All changes are reviewed.** Nothing merges directly into `main` — every change
comes in as a pull request that an admin reviews and approves first. You don't comes in as a pull request an admin reviews and approves first. You don't need
need to be able to create repos or organizations to contribute; you just fork, to be able to create repos or organizations to contribute; you just fork, edit
edit, and ask for review. and ask for review.
Questions about the Git itself? Write to info@inference.coop.
## Models and providers ## Models and providers
The co-op serves models from multiple providers (Tinfoil for privacy, GreenPT The co-op serves models from multiple providers — Tinfoil for architectural
for renewable energy, PublicAI coming soon). See the [model list](models.md) for privacy, GreenPT and Regolo for renewable energy. See the [model list](models.md),
the current catalog — that's the single source of truth for which models are the single source of truth for which models are available and how they're named.
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) ## Security posture (plain language)
- **Token-gated control plane.** The portal is publicly reachable, but every - **Member isolation.** Each API key is scoped to its owner's team and budget; a
sensitive surface requires a secret token (admin, broker, webhook). All fail member can only ever act on their own keys.
closed — unauthenticated requests get `401`. - **Separated secrets.** Chat, portal, admin and model-layer secrets are
- **Separated secrets.** Chat, broker, admin, and model-layer secrets are
independent and independently rotatable, so a leak has bounded scope. independent and independently rotatable, so a leak has bounded scope.
- **Member isolation.** Each API key is scoped to its owner's LiteLLM team and - **No secrets in version control.** Credentials live in the hosting platform's
budget; a member can only ever act on their own keys. environment, never in the repos.
- **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 - **Accepted trade-offs** (documented, not hidden): member keys are stored in
plaintext in the portal database (functionally necessary), and the gateway the portal database so the co-op can serve them, and the API gateway must stay
must stay publicly reachable for API access (its admin UI and docs are publicly reachable. Its admin surfaces are disabled; only the public API is
disabled; only the `/v1/*` API is exposed). exposed.
## For operators ## For operators
Maintaining the co-op's infrastructure — rotating Cloudron tokens, editing the Maintaining the co-op's infrastructure — token rotation, app configuration and
portal's environment, and troubleshooting the provisioning pipeline — is troubleshooting — is documented in the git-only
documented in the [operator runbook](operator-token-runbook.md). [`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).
-27
View File
@@ -1,27 +0,0 @@
# Chat task models (titles, tags, suggestions)
Both chat front doors generate small helper outputs — conversation titles,
tags, retrieval queries — with a model separate from the one answering the
member. These tasks are pinned to a small, inexpensive model so they never
bill against the member's chosen (larger) model.
## Open WebUI (chat.inference.coop)
- Title generation and tag generation are **enabled**; follow-up reply
suggestions and input autocomplete are **disabled** (October 2026).
- The task model (`task.model.default` in the Postgres `config` table) is
pinned to `greenpt/glm-5.3-flash` — a small, inexpensive model — so titles
and tags always run on it regardless of which model the member is chatting
with. Before October 2026 it was empty, which meant helper tasks ran on the
member's selected model.
- Config lives in the app's Postgres database (`config` table, key/value rows
with JSON values) and is read at startup: change a row, then restart the
app (`cloudron restart --app <id>`; note `restart` takes no `--timeout`).
## LibreChat (librechat-test.inference.coop)
- Title generation is configured in `/app/data/librechat.yaml`:
`titleConvo: true`, `titleModel: greenpt/glm-5.3-flash`,
`titleMethod: completion` — the same small GreenPT model.
- LibreChat v0.8.8 has no reply-suggestion feature; there is nothing to
disable on that front.
-156
View File
@@ -1,156 +0,0 @@
# LibreChat sandbox maintenance
The LibreChat evaluation instance at **librechat-test.inference.coop**
(Cloudron app `4023ccbf-d57a-4500-97df-14de9cf3516e`, appstore package
`ai.librechat.cloudronapp@1.4.1`, which bundles **LibreChat v0.8.8**).
The single source of truth for the deployment is the repo
**[co-op/librechat-custom](https://git.inference.coop/co-op/librechat-custom)**
— its README is the full runbook (config deploy, image build/deploy, rollback,
env vars, branding overlay, known state). This doc is the operator-facing
summary.
## Architecture: what lives where
- **App config** — `/app/data/librechat.yaml` (persistent volume, mirrored
byte-identically in the repo). Holds the "Inference Co-op" custom endpoint
(gateway), the 8-model chat picker, speech, web search, and the two MCP
servers. Volume edits deploy via `cloudron exec` (base64), never via the
image — the image never touches `/app/data`.
- **Env file** — `/app/data/env` (persistent volume). **`start.sh` sources it
AFTER the process env, so it wins over `cloudron env`** — that is where
`APP_TITLE="Inference Co-op"`, `ALLOW_EMAIL_LOGIN=false` (password login
removed; Cloudron SSO is the only login path), and
`SCHEDULES_SINGLE_PROCESS=true` must live.
- **Other env vars** — via `cloudron env`: `TTS_API_KEY`/`STT_API_KEY`
(dedicated LiteLLM virtual key, alias `librechat-speech`, $5/30d, audio
models only — never the gateway master key), `IMAGE_GEN_OAI_BASEURL` +
`IMAGE_GEN_OAI_MODEL` (agent image tool → gateway), `SEARXNG_INSTANCE_URL`.
- **MCP servers** — `/app/data/mcp/server.py` (docs search, `search_docs`,
starts at boot) and `/app/data/mcp/image_server.py` (image generation,
`generate_image`, `startup: false`); both mirrored in the repo under
`mcp/`, stdlib-only Python.
- **Branding overlay** (logo, favicons, PWA icons, `<title>`, `custom.css`,
`custom2.js`) — baked into a pinned overlay image `FROM` the official
appstore image. Deployed tag (last verified 2026-10-19):
`registry.inference.coop/co-op/librechat-custom:20261019-fc9b11e`.
- **Branding CSS/JS** — `custom.css` overrides LibreChat's Tailwind theme
variables on `html.light`/`html.dark` using the HOMEPAGE's canonical
palette (`--cream #faf8f5`, `--wash #eef4ea`, `--green-deep #5b8c5a`,
`--green-mid #6ba86b`, `--ink #2d3327`, `--muted #6b7a62`) and brands
the loading flash (`#loading-container`); the login logo is the green
pine-dots vector (white-plate variant was invisible on cream — the
'flash on load' glitch), cache-busted via `?v=3` in the client bundle.
and hides the register/sign-up prompt via
`p:has(> a[href*="register"])`; `custom2.js` is a MutationObserver that
hides the prompt when React renders it (the auth form renders after
`load`). The `interface.theme` block in the yaml remains
forward-compatible only (inert on v0.8.8).
## Image generation (two member-facing paths)
1. **MCP tool (default, no key ever)**: the `inference-image` MCP server
exposes `generate_image` in the composer's **+** palette — no agent setup,
no key prompt. Per-member billing via `customUserVars.IC_API_KEY` (seeded
from the portal) + `env: IC_API_KEY: "{{IC_API_KEY}}"`; a system-key
fallback (`system-image-fallback`, $5/30d) covers the rare empty-value
case. The `customUserVars` gate blocks a not-yet-seeded member until the
next sync (≤10 min) — accepted as a short delay over billing everything
to the system account.
2. **Agent tool**: LibreChat's built-in "OpenAI Image Tools"
(`IMAGE_GEN_OAI_BASEURL`/`IMAGE_GEN_OAI_MODEL` → gateway; members paste
their own key — v0.8.8 limitation).
## Update policy
- The app is **image-sourced** (the `--force` update moved it off the
appstore source), so Cloudron will not auto-update it. This is deliberate —
same lockdown model as LiteLLM.
- **Image deploys**: build `--no-cache`, write the new tag into
`CloudronManifest.json.dockerImage` (the only editable field), commit, then
`cloudron update --app <id> --force`. **`--image` alone is IGNORED** when
the manifest sits in the deploy directory. **The remote builder's cache can
serve stale COPY layers even when file content changed** (six consecutive
"Build successful" runs did on 2026-10-19) — so after every image deploy,
verify: the Cloudron API's `manifest.dockerImage`, container-file md5 vs
repo md5, the served HTML, and the rendered DOM. Full checklist in the
repo README.
- **When a new appstore package ships**: pull its manifest copy into the repo
(`GET /api/v1/apps/<id>` → `.manifest`), repoint `dockerImage` to the
rebuilt overlay, bump the Dockerfile `FROM` to the new `manifest.dockerImage`
(pinned), rebuild, verify in the sandbox, then update.
- **Rollback**: repoint `CloudronManifest.json.dockerImage` to the previous
tag and rerun `cloudron update --force` (or the original appstore image).
Cloudron also snapshots the app before every image update.
## Hard-won pitfalls (do not re-learn these)
- `cloudron update` deploys **`manifest.dockerImage`** from the manifest in
the deploy directory — the `--image` flag alone is ignored (and the update
output still prints the `--image` value, so it *looks* right). The repo
manifest is a byte-identical copy of the OFFICIAL appstore manifest except
`dockerImage`; editing anything else in it (or hand-writing one that omits
the addons) strips `postgresql`/`mongodb`/`oidc`/`sendmail`/`localstorage`
from the app and crash-loops it (`mkdir: Read-only file system`). That is
what broke the sandbox on 2026-10-18.
- The remote builder's layer cache can pin COPY steps to old file content
even across "successful" rebuilds — use `--no-cache` after asset edits and
confirm by comparing container-file md5 against the repo file.
- Deploy `librechat.yaml` only via base64 round-trip. A `sed` insert once
broke the YAML indentation and LibreChat **silently fell back to defaults**
(all custom branding/models/MCP gone) with a single log line.
- Reading large files from the container over `cloudron exec` with a plain
`cat` resets the connection (`ECONNRESET`) — pipe through
`gzip | base64 | fold` and decode locally.
- `cloudron env set` alone does NOT disable the password form —
`ALLOW_EMAIL_LOGIN` must be flipped in `/app/data/env` (precedence above).
- Image generation in LibreChat is the **MCP tool** (composer **+** palette);
the agent "OpenAI Image Tools" path also exists. Image models in the chat
picker were the source of the old 500 errors and are deliberately excluded.
- No `branding:` block exists in LibreChat's config schema, and the login
page has no `.login-page`/`.primary` classes to target — theming happens by
overriding the Tailwind CSS variables on `html.light`/`html.dark`
(`--surface-primary`, `--accent-primary`, …) from the overlay `custom.css`.
## Per-member key auto-load (no API-key prompts)
Members never enter an API key. The portal provisions each member's LiteLLM
key at onboarding; the sandbox seeds it into LibreChat's user-key store:
- `GET /admin/librechat/keys` on the portal returns active members' chat keys
(dedicated `X-Sync-Token` auth — `LIBRECHAT_SYNC_TOKEN` on both apps).
- `/app/data/sync-keys.js` (in the LibreChat container, mirrored in the repo
as `sync-keys.js`) fetches that feed and seeds the `keys` collection
(chat endpoint, `apiKey: "user_provided"`) and `pluginauths`
(`mcp:inference-image` → `IC_API_KEY`, injected per-user via
`customUserVars` so image generation bills to the member's own team).
- Members without a sandbox account are skipped until they first log in; run
the sync after onboarding waves and on a schedule (the command is in the
repo README). Idempotent.
- Encryption matches LibreChat's own (`CREDS_KEY`/`CREDS_IV`, read from
`/proc/1/environ` — exec sessions don't inherit the app env).
> Terminal output redacts values after `apiKey:` as `***` even for harmless
> strings (`user_provided`, `${TTS_API_KEY}`). Probe lengths/counts instead
> of trusting redacted output.
## Verification (after any change)
```bash
cloudron logs --app 4023ccbf-d57a-4500-97df-14de9cf3516e --lines 60 | \
grep -iE 'custom config|error|invalid|MCP|schedules'
curl -s https://librechat-test.inference.coop/api/config # appTitle, emailLoginEnabled
```
Expected: `Custom config file loaded`, `[MCP][inference-docs]` initialized
with tool `search_docs`, `inference-image` registered (its `Tools: undefined`
boot line is expected — per-user startup), `[schedules] engine started`,
`appTitle: Inference Co-op`, `emailLoginEnabled: false`. The login page shows
the co-op logo, the co-op favicon, a single "Login with Inference
Cooperative" (Cloudron SSO) path with no email/password form, **no
"Don't have an account?" line**, and the cream/green co-op color scheme.
After an IMAGE deploy, additionally run the four-step verification in the
repo README (API `manifest.dockerImage`, container md5 vs repo md5, served
HTML tags, rendered DOM colors).
+125
View File
@@ -0,0 +1,125 @@
# 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
+119 -12
View File
@@ -25,6 +25,10 @@ provider today is best on both.
policy protects you only as far as the provider keeps its word. Both are policy protects you only as far as the provider keeps its word. Both are
better than a provider with neither — and Regolo adds a written better than a provider with neither — and Regolo adds a written
zero-data-retention commitment on top of its policy. zero-data-retention commitment on top of its policy.
How the co-op measures its own energy use and carbon emissions is on the
[energy and carbon page](energy.md) — including what those figures cover and what
they miss.
- **Green energy:** *100% renewable* (GreenPT, Regolo) beats *undisclosed* - **Green energy:** *100% renewable* (GreenPT, Regolo) beats *undisclosed*
(Tinfoil). GreenPT and Regolo both run on 100% renewable energy; Tinfoil's (Tinfoil). GreenPT and Regolo both run on 100% renewable energy; Tinfoil's
mix is not disclosed. mix is not disclosed.
@@ -50,6 +54,10 @@ that **you choose** which axis matters more for a given task.
- **Cheap and cheerful** → `tinfoil/gpt-oss-120b` or `greenpt/glm-5.3-flash`. - **Cheap and cheerful** → `tinfoil/gpt-oss-120b` or `greenpt/glm-5.3-flash`.
- **Model origins matter to you** → the Apertus models (publicly developed; - **Model origins matter to you** → the Apertus models (publicly developed;
see below). 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 ## Current models
@@ -72,19 +80,23 @@ 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 | | 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. | | `🌱 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). |
| `🔒 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/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/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. | | `🔒 tinfoil/gpt-oss-120b` | Tinfoil | architectural | undisclosed | — | 131K | $0.15 / $0.60 / — | Lightweight open-weight reasoning; cheapest private tier. |
| `🌱 greenpt/glm-5.3-flash` | GreenPT | policy | ✅ 100% renewable | — | 1M | $0.11 / $0.44 / $0.022 | Fast GLM model, renewable energy. | | `🌱 greenpt/glm-5.3-flash` | GreenPT | policy | ✅ 100% renewable | — | 1M | $0.11 / $0.44 / $0.022 | Fast GLM model, renewable energy. |
| `🌱 greenpt/glm-5.3` | GreenPT | policy | ✅ 100% renewable | — | 1M | $1.10 / $4.40 / $0.275 | GLM flagship — strongest agentic model in the green tier. | | `🌱 greenpt/glm-5.3` | GreenPT | policy | ✅ 100% renewable | — | 1M | $1.10 / $4.40 / $0.275 | GLM flagship — strongest agentic model in the green tier. |
| `🌱 regolo/apertus-70b` | Regolo | policy + ZDR | ✅ 100% renewable | ✅ | 65K | $0.46 / $2.44 / — | Fully open sovereign model (Apache-2.0), large/capable, image understanding. | | `🌱 regolo/apertus-70b` | Regolo | policy + ZDR | ✅ 100% renewable | ✅ | 262K | $1.16 / $3.94 / — | Fully open sovereign model (Apache-2.0), large/capable, image understanding. |
| `🌱 regolo/qwen3.5-122b` | Regolo | policy + ZDR | ✅ 100% renewable | ✅ | 262K | $1.16 / $4.87 / — | Large vision+reasoning MoE (Qwen 3.5, 122B-A10B); 262K context. | | `🌱 regolo/qwen3.5-122b` | Regolo | policy + ZDR | ✅ 100% renewable | ✅ | 262K | $0.70 / $3.94 / — | Large vision+reasoning MoE (Qwen 3.5, 122B-A10B); 262K context. |
Vision is verified working through the co-op gateway (base64 image input via Vision is verified working through the co-op gateway (base64 image input via
`/v1/chat/completions`, standard OpenAI format). In the chat UI, just attach `/v1/chat/completions`, standard OpenAI format). In the chat UI, just attach
an image to your message while using a ✅ model. an image to your message while using a ✅ model.
Context figures are measured the same way, through the gateway. Where a
provider's public catalog advertises a smaller limit than the model actually
accepts, the measured figure is the one shown here.
**Reading the Privacy column:** *architectural* means the protection is built **Reading the Privacy column:** *architectural* means the protection is built
into the infrastructure (TEE enclaves) — even the provider can't read your into the infrastructure (TEE enclaves) — even the provider can't read your
prompts. *Policy* means the provider commits, by contract and policy, not to prompts. *Policy* means the provider commits, by contract and policy, not to
@@ -106,13 +118,27 @@ model picker to avoid confusion.
| Model | Provider | Use | Pricing | | Model | Provider | Use | Pricing |
|---|---|---|---| |---|---|---|---|
| `🔒 tinfoil/whisper-large-v3-turbo` | Tinfoil | Speech-to-text | $0.05 / 1M input tokens | | `🔒 tinfoil/whisper-large-v3-turbo` | Tinfoil | Speech-to-text | $0.01 per request (flat; zero per-token) |
| `🔒 tinfoil/voxtral-tts` | Tinfoil | Text-to-speech | listed $0 (verify on invoice) | | `🔒 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) |
Both run inside Tinfoil's enclaves, so voice data gets the same architectural All three run inside Tinfoil's enclaves, so voice data gets the same
privacy as the private chat models. TTS voices available: `neutral_female`, architectural privacy as the private chat models. TTS voices available:
`neutral_male`, `casual_female/male`, `cheerful_female`, plus French, German, `neutral_female`, `neutral_male`, `casual_female/male`, `cheerful_female`,
Spanish, Italian, Portuguese, Dutch, and Hindi variants. 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.
```bash ```bash
# Speech-to-text # Speech-to-text
@@ -121,6 +147,15 @@ curl https://gateway.inference.coop/v1/audio/transcriptions \
-F file=@your-audio.mp3 \ -F file=@your-audio.mp3 \
-F model=tinfoil/whisper-large-v3-turbo -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) # Text-to-speech (returns WAV audio)
curl https://gateway.inference.coop/v1/audio/speech \ curl https://gateway.inference.coop/v1/audio/speech \
-H "Authorization: Bearer ***" \ -H "Authorization: Bearer ***" \
@@ -138,10 +173,43 @@ 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 ### Image generation
`🌱 regolo/qwen-image` (Regolo, EU-hosted, zero data retention) generates `🌱 regolo/qwen-image` (Regolo, EU-hosted, zero data retention) generates
images from a text prompt. In the chat, press the **Integrations** button 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
below the message box, choose **Image**, and describe what you want — the below the message box, choose **Image**, and describe what you want — the
generated image appears in the conversation. Via the API: generated image appears in the conversation. Via the API:
@@ -189,6 +257,42 @@ numbers. Pricing here mirrors the `model_info` in the LiteLLM config
(`code/litellm/config.yaml`), which is the authoritative source — the two are (`code/litellm/config.yaml`), which is the authoritative source — the two are
kept in sync. kept in sync.
## Decisions
Most models here generate text. **Metis**, from GreenPT, makes *decisions*
instead: you give it a `state` (the situation, in plain text) plus up to 32
typed questions, and it returns **probabilities, choices, and scores** — never
prose. Use it when you want a calibrated judgement rather than a written
answer: "should we…?", "which option…?", "how confident is that?".
It's on the gateway at `/v1/systemone`, called with your normal co-op API key
(the same key you use for chat):
```bash
curl https://gateway.inference.coop/v1/systemone \
-H "Authorization: Bearer ***" \
-H "Content-Type: application/json" \
-d '{
"model": "metis",
"state": "Your situation, described in plain text.",
"questions": {
"go": {"type": "noul", "instructions": "Should we proceed?",
"criteria": {"true": "proceed", "false": "do not proceed"}},
"which": {"type": "choice", "instructions": "Which factor matters most?",
"criteria": {"cost": "price", "speed": "latency"}},
"confidence": {"type": "score", "instructions": "How confident are you?",
"criteria": ["very low", "low", "medium", "high", "very high"]}
}
}'
```
Metis is hosted by **GreenPT** — the same policy-based privacy, EU hosting, and
100% renewable energy as the 🌱 chat models. **Decisions count toward your
monthly allowance**, like every other call: each request is metered at
**$0.12 per 1M input tokens**, computed from the exact token count GreenPT
reports back, and appears in your usage record alongside your chat calls.
(Metis generates no prose, so there is no output charge.)
## Limitations on privacy ## Limitations on privacy
Only **Tinfoil** offers architectural privacy. The other providers are chosen Only **Tinfoil** offers architectural privacy. The other providers are chosen
@@ -196,5 +300,8 @@ for their value (renewable energy, public models) but do not run in enclaves —
prompts and responses pass through them in the ordinary way. Your **chat prompts and responses pass through them in the ordinary way. Your **chat
history** is also stored on our server so you can revisit it, and that stored history** is also stored on our server so you can revisit it, and that stored
history is not encrypted in a way that prevents us from technically reading it — history is not encrypted in a way that prevents us from technically reading it —
we commit not to. The full distinction — what's architecturally private versus we commit not to. Requests made through the **API** with your own key are a
what's a policy commitment — is in the [Privacy Policy](privacy-policy.md). different matter: those are **not stored on our side at all**, beyond the usage
record needed for metering ([API access](api-access.md)). The full distinction —
what's architecturally private versus what's a policy commitment — is in the
[Privacy Policy](privacy-policy.md).
+1 -27
View File
@@ -1,20 +1,11 @@
# Open Questions for Member Discussion # Open Questions for Member Discussion
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. A living list of some of the decisions the cooperative needs to make.
--- ---
## Privacy & Infrastructure ## 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? ### 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. 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.
@@ -24,12 +15,6 @@ 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? **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? ### 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. 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.
@@ -57,22 +42,11 @@ 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? **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 ## 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? ### 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. AI Potluck's "government-as-contributor" framing suggests a path for institutions (universities, municipalities) to contribute compute or data without owning the whole.
-135
View File
@@ -1,135 +0,0 @@
# Operator runbook — Cloudron tokens
How the co-op's Cloudron credentials are organized, and how to rotate them
without breaking the stack.
## The two-token split
There are **two separate** Cloudron API tokens, deliberately decoupled so
rotating one doesn't break the other:
| Token | Purpose | Where it lives |
|-------|---------|----------------|
| **Agent token** | Inferencebot's own Cloudron access | The agent's private `.env` (gitignored) |
| **Portal token** | The member portal's Cloudron access (create users, set groups, invite links) | Cloudron's env store, under the portal app's `CLOUDRON_TOKEN` |
The portal needs only a handful of Cloudron capabilities (user/group management,
invite links). It is the most exposed app (public webhook + broker endpoints),
so it must **not** hold the agent's token — a portal compromise shouldn't reach
the agent's wider Cloudron access.
## Rotating a token
### Portal token
1. In `my.inference.coop` → **Settings → API Tokens**, create a **"Read and Write"** token.
2. Set it as the portal's `CLOUDRON_TOKEN` env var (custom apps have no env-var
UI — use the Cloudron CLI or the API; see the next section).
3. Verify: the portal can reach `GET /api/v1/users` with the new token (HTTP 200).
### Agent token
1. Create a fresh token in `Settings → API Tokens`.
2. Put it in the agent's `.env` as `CLOUDRON_TOKEN`.
3. Verify against `/api/v1/cloudron/status`.
**The trap this split exists to prevent:** the portal keeps its *own copy* of a
Cloudron token. If the agent's token is rotated without also updating the portal
copy, the portal's Cloudron calls start returning `401` — which breaks
provisioning (new members can't be created) and the member dashboard (its
usage/keys data is resolved through the portal's Cloudron calls).
## Chat image: spaCy model for document parsing (EPUB/DOCX)
Open WebUI's document parser (`unstructured`) lazily downloads the spaCy model
`en_core_web_sm` on first EPUB/DOCX parse — but the app directory is read-only
under Cloudron, so that install always fails ("Errno 30 Read-only file
system"). Fixed 2026-10-14 (issue #20) by pre-installing the model into
persistent storage:
- `/app/data/spacy_model/` holds the unpacked wheel
(SHA-verified `en_core_web_sm-3.8.0-py3-none-any.whl` from the pinned URL in
`unstructured/nlp/tokenize.py`).
- App env has `PYTHONPATH=/app/data/spacy_model` so `import en_core_web_sm`
resolves and the lazy download never triggers.
**Restore caveat:** a backup restore from before 2026-10-14 will NOT contain
the model dir — re-create it (download the pinned wheel, unzip into
`/app/data/spacy_model/`, verify `spacy.load("en_core_web_sm")` works in the
container). The `PYTHONPATH` env survives restores (it's in app config).
## Scheduled tasks (scheduler addon)
The portal and gateway run their own housekeeping via the Cloudron scheduler
addon, declared in each app's `CloudronManifest.json` under `addons.scheduler`:
- **portal**: `membership_sweep` (nightly 3am, `/app/code/sweep.sh` →
`POST /admin/sweep` with the `WEBHOOK_TOKEN` header) and `rag_refresh`
(nightly 3:30am, `/app/code/rag_refresh.py` — pulls docs from the Gitea raw
API and refreshes the chat knowledge base; needs `OWUI_API_KEY` +
`DOCS_KNOWLEDGE_ID` in the app env).
- **gateway**: `audio_keepalive` (every 10 minutes, `/app/code/audio_keepalive.py`
— pings the Tinfoil audio models locally to avoid the ~24s TEE cold-start;
reads the master key from `/app/data/.master_key`, which `start.sh` writes —
the env var `LITELLM_MASTER_KEY` is only exported into the litellm process).
**After any manifest change, verify the scheduler survived the deploy:** the
build log must show `setupAddons: setting up addon scheduler with options
{...}` listing your task(s), and each script must succeed when run manually
inside the container (`cloudron exec --app <id> -- <command>`). The scheduler
has historically gone missing from the manifest during edits — a deploy without
the setupAddons line means nightly reconciliation is NOT running.
## Custom apps have no env-var UI
The member portal is a *custom* app (packaged by us), not an App Store app.
Custom apps do **not** expose environment variables in the Cloudron dashboard.
Env vars for custom apps are set via:
- **Cloudron CLI** (on your workstation, *not* the server): `cloudron env set --app portal.inference.coop CLOUDRON_TOKEN=...`
- **Cloudron API**: `POST /apps/:appId/configure/env`
## `configure/env` is a full replace
`POST /apps/:appId/configure/env` with body `{"env": {KEY: value, ...}}`
**replaces the entire environment** — it does not merge. Sending a partial map
(e.g. a single test variable) wipes every other variable.
- To change **one** variable, read the current env first (`GET /api/v1/apps/:id`
→ `.env`), merge in memory, and POST the full map back.
- Never probe the endpoint with a throwaway body against a live app.
- If the env is wiped, it is recoverable from a Cloudron app backup:
`GET /api/v1/apps/:appId/backups` → newest entry → `GET .../backups/:backupId/download`
(a `.tar.gz` containing `config.json`, whose `.env` field holds the original map).
## Reference
- Cloudron CLI: `npm install -g cloudron` (install on your workstation, not the server), then `cloudron login my.inference.coop`.
- Cloudron API docs: `docs.cloudron.io/api/`.
## Open Collective: the bot token needs the `email` scope (Sept 2026)
The portal identifies members by email, read via the bot's OC personal token
(`OC_PERSONAL_TOKEN`) as `collective.members.nodes[].account.email` with the
`... on Individual { email }` inline fragment.
**The `email` scope is load-bearing.** The bot's personal token must be minted
with the `email` scope. Without it, the GraphQL `email` field returns `null` for
*every* member — including the bot's own `me { email }` — even though:
- the bot still authenticates and reports `isAdmin: true`, and
- the OC **admin CSV export still shows emails** (it's a separate, non-GraphQL
path; its `contributorEmail` column is not a GraphQL field).
**Symptoms when the scope is missing:** `fetch_members()` filters out empty
emails and returns `[]`, so the webhook can't provision anyone by email and the
nightly sweep goes into its empty-list fail-safe (skips, doesn't deactivate).
A new member's webhook fires cleanly but no "Sent email to …" log line follows,
and no Cloudron user is created. (This is easy to misread as a "guest with no
email" problem — it is not; verify the scope first.)
**Diagnosis:** run `me { email }` with the bot token. `null` here while
`isAdmin: true` → the token is missing the `email` scope (or the scope was
revoked). Fix: re-add the `email` scope to the token on Open Collective
(Settings → Developers → Personal Tokens). No token rotation, no re-granting of
admin — just the scope.
+21
View File
@@ -0,0 +1,21 @@
<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>
+38 -3
View File
@@ -1,12 +1,12 @@
# Privacy Policy # Privacy Policy
**Last updated:** October 6, 2026 **Last updated:** October 28, 2026
## In plain language ## In plain language
- We collect only what we need: your email, name, and basic usage data. - We collect only what we need: your email, name, and basic usage data.
- We offer a **changing variety of models with different privacy and value affordances** — some run in encrypted enclaves we cannot read, others differ. **You are responsible for assessing each model's properties and picking the one that fits your needs.** - We offer a **changing variety of models with different privacy and value affordances** — some run in encrypted enclaves we cannot read, others differ. **You are responsible for assessing each model's properties and picking the one that fits your needs.**
- We **do** store your chat history on our server (so you can revisit it). We promise not to read it, but this is a promise, not a technical guarantee. - We **do** store your chat history on our server (so you can revisit it). We promise not to read it, but this is a promise, not a technical guarantee. **Temporary chats** are the exception: nothing is stored at all, and you can make them your default — see §4 if you'd like sensitive conversations to leave nothing behind.
- We never train on your data, and we never sell it. - We never train on your data, and we never sell it.
- You can export or delete your data at any time. - You can export or delete your data at any time.
@@ -23,7 +23,7 @@ The Inference Cooperative is a member-governed project. We are **not** an ad-sup
- **Account data:** your email and name (from Open Collective). - **Account data:** your email and name (from Open Collective).
- **Membership data:** your contribution status and role. - **Membership data:** your contribution status and role.
- **Usage data:** token counts, model used, and timestamps (for metering your monthly budget). - **Usage data:** token counts, model used, and timestamps (for metering your monthly budget).
- **Content:** your prompts and the AI's responses (stored in your chat history). - **Content:** your prompts and the AI's responses. Whether we keep them depends on how you use the service: in the **chat interface** they are saved as your chat history, while through the **API** they are not stored at all. See §4.
- **Billing data:** handled by Open Collective — we do not see full payment details. - **Billing data:** handled by Open Collective — we do not see full payment details.
## 3. Privacy depends on the model you choose ## 3. Privacy depends on the model you choose
@@ -43,6 +43,40 @@ Your **chat history is stored on our server** so you can revisit past conversati
We commit not to read it. But this is a **policy commitment**, not an architectural guarantee. We're transparent about this distinction because it matters. We commit not to read it. But this is a **policy commitment**, not an architectural guarantee. We're transparent about this distinction because it matters.
Note the boundary precisely: enclave models protect your conversation **while it is being processed** — the model provider and we cannot read the exchange at inference time. Stored history is a separate leg: once a conversation is saved, it sits on our server in a form we can technically read. So for sensitive material, the strongest combination available today is a **Temporary Chat with an enclave model** — protected in transit and at inference, and never stored afterwards.
### If you use the API, nothing you send is stored
This is the part that surprises people, so it is worth stating plainly. If you call
the co-op's API with your own key and your own client, there is no conversation on
our side to save. Our gateway records **that** a request happened and what it cost:
the model, the token counts, the cost, and which key made the request. It does not
record **what you asked or what came back**.
We measured this rather than asserting it. A request with a distinctive marker was
sent through the gateway and then searched for: it appears nowhere in the gateway's
logs, the gateway's configuration enables no content logging, and the request records
themselves carry usage fields only. So the API path keeps no text.
Two boundaries on that statement, because it would be easy to read it as more than
it is:
- **We can still tell that a given key made a request**, and therefore which member
it belongs to. That link is how metering works — it is not something we hide.
- **"Not stored by us" is not the same as "not stored."** The provider's handling is
a separate question from ours, and it depends on the model you choose — see §3. Not
being stored on our side says nothing about the far end of the request.
### Temporary chats (recommended for anything sensitive)
The chat has a **Temporary Chat** mode designed exactly for this:
- Click the **Temporary Chat** toggle in the chat header, and the conversation is **never saved** — it doesn't appear in your history, it isn't written to our database, and it disappears when you leave the page.
- To make this the default for every new conversation, open **Settings → Conversations** and enable **Default Temporary Chat**. The preference is stored in your browser, so it's yours to change at any time.
- The trade-off is that there's nothing to revisit: no history, no search, no sync across devices. Use it whenever that trade is right for you — you can mix saved and temporary conversations freely.
Temporary chats change what we store, not who processes your request: the model you choose still receives your messages to generate a reply, under the properties described in §3.
### Operational logs ### Operational logs
Our server software keeps standard operational logs (dates, error messages, and — for account management like provisioning and welcome emails — the email addresses involved). These logs exist to run and debug the service, are kept briefly, and are not used to profile members. Our server software keeps standard operational logs (dates, error messages, and — for account management like provisioning and welcome emails — the email addresses involved). These logs exist to run and debug the service, are kept briefly, and are not used to profile members.
@@ -68,6 +102,7 @@ The models we serve are provided by third parties that change over time, each wi
## 8. Retention and deletion ## 8. Retention and deletion
- Chat history is retained until you delete it. - Chat history is retained until you delete it.
- **Temporary chats are never retained** — they are not written to our database at all (see §4).
- On leaving, your account is deactivated (data preserved for reactivation). - On leaving, your account is deactivated (data preserved for reactivation).
- You may request full deletion at any time: info@inference.coop. - You may request full deletion at any time: info@inference.coop.
+107
View File
@@ -0,0 +1,107 @@
# Publishing the documentation site
The Markdown in this repository is the single source of truth. This directory
is the publishing layer that turns it into a member-readable static site and
puts it on the co-op's docs Surfer app at https://docs.inference.coop/.
Documents are never edited here — a change
to a document is a normal commit or pull request against the Markdown at the
repository root.
## What is built
| File | Purpose |
|---|---|
| `../mkdocs.yml` | Site configuration (MkDocs + Material for MkDocs) |
| `brand.css` | Co-op palette + self-hosted Figtree webfont (SIL OFL 1.1) |
| `fonts/` | Figtree woff2 files and the OFL licence text |
| `logo.svg` | Co-op logo (shared with the website repo) |
| `build.sh` | Stage the Markdown into `.site-src/`, render to `.site-build/` |
| `hooks.py` | MkDocs hooks: directory-link rewriting + content-hashed stylesheet URLs |
| `deploy.sh` | Push the build into the Surfer app container |
| `poll-watcher.sh` | Polls Gitea main every 60s; deploys on change (agent host) |
| `docs-deploy-poll.service` | systemd unit for the poller |
| `webhook-receiver.py` | Gitea webhook → pull → build → deploy (agent host) |
| `webhook-receiver.service` | systemd unit for the receiver |
| `requirements.txt` | Pinned Python build toolchain |
Nothing external is loaded by the site: no CDN, no third-party font service.
Fonts are served from this repository's own output.
### Cache-proof stylesheets
`brand.css` and `typography.css` are listed under `extra_css`, so at a fixed URL
they would be vulnerable to a stale browser cache after a deploy — a warm cache
keeps serving the old file because the URL never changes. `hooks.py`'s
`on_post_build` fixes this at build time: it copies each stylesheet to
`<name>.<sha256-8>.css` in the output and rewrites the `<link>` in every built
page to the hashed name. A change to a stylesheet's content therefore always
produces a new URL. The authored sources keep their names and locations; the
hashed copies exist only in the build output. The hashed copy sits in the same
directory as the original, so the `@font-face` `url("fonts/…")` references keep
resolving. The deploy is unchanged.
## Build
```sh
publishing/build.sh # creates .venv-docs on first run
```
Output lands in `.site-build/` (gitignored). `mkdocs build --strict` is what
CI-style runs should use, so broken links fail the build.
## Deploy
```sh
CLOUDRON_TOKEN=... publishing/deploy.sh
```
Writes the build into the **docs Surfer app** (Cloudron app `docs.inference.coop`,
`io.cloudron.surfer`) at:
- `/app/data/public/` — served at **https://docs.inference.coop/**
That is now the one canonical location. The docs used to be a pair of
directories inside the *marketing* Surfer app (`inference.coop`) as well, at
`/app/data/public-docs/` and `/app/data/public/docs/`; both were deleted when
this app took over, so `https://inference.coop/docs/` no longer serves the docs
(nothing on the marketing site linked to it).
Override the destination with `SURFER_APP` (the app's Cloudron location) and
`DOCS_DEST` (path inside the container) if you ever need to.
## Automatic rebuilds (push to main)
Two mechanisms keep the site in step with `main`; both run on the agent host
and deploy into the docs Cloudron app above.
**Polling (running now).** `docs-deploy-poll.service` polls Gitea every 60
seconds and deploys whenever `main` moves. A push appears on the site within
about a minute, with no human action. The Gitea webhook alone was not enough
because the agent host's cloud firewall only admits inbound SSH — a webhook
POST is silently dropped — while outbound access to Gitea works.
The service builds from a **dedicated clean clone** at
`/home/ubuntu/.docs-publisher`, not from anyone's working copy, so uncommitted
edits in a working clone can never block a deploy. Edit, commit, push — that
is the whole deployment story.
**Webhook (wired, waiting on one port).** A Gitea webhook is registered on
`co-op/docs` (push events, branch `main`) pointing at the receiver below. The
moment the Infomaniak panel allows inbound TCP to the agent host — port 8787
from the Cloudron box 179.237.86.116 is enough — start the receiver and pushes
deploy instantly instead of within a minute:
```sh
sudo systemctl enable --now docs-deploy-webhook
tail -f /home/ubuntu/docs-deploy.log # what both mechanisms are doing
```
The receiver authenticates webhooks with an HMAC-SHA256 shared secret
(`DEPLOY_WEBHOOK_SECRET` in the agent host's env file) and only accepts pushes
to `main` of `co-op/docs` from the Cloudron box. Woodpecker CI (Cloudron
catalog) could do the same job inside Cloudron, at the cost of running another
app; the agent-host mechanisms were chosen because they add no new Cloudron app.
Each deploy writes `meta.json` into the site with the commit it was built
from, so a deploy can be verified from the served site itself:
https://docs.inference.coop/meta.json
+261
View File
@@ -0,0 +1,261 @@
/* 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;
}
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Build the member-readable documentation site from this repository.
#
# The repository is the single source of truth. This script:
# 1. stages a mirror of the documents into .site-src/ (generated, gitignored),
# because MkDocs cannot read the repository root directly;
# 2. renders .site-src/ to .site-build/ with MkDocs + Material.
#
# No document is moved, renamed, or rewritten — the staging copy is a build
# artifact and disappears with `--clean`.
#
# Usage:
# publishing/build.sh # create/use .venv-docs and build
# publishing/build.sh /path/to/venv # use an existing venv
#
# In CI (Woodpecker), install the toolchain and run MkDocs directly:
# pip install -r publishing/requirements.txt && mkdocs build --strict
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_DIR"
VENV="${1:-$REPO_DIR/.venv-docs}"
SITE_SRC="$REPO_DIR/.site-src"
# 1. Stage the documents (Markdown at the root, maintenance/, incidents/) plus
# the publishing assets (fonts, CSS, icon). Exclude tooling and build output.
rm -rf "$SITE_SRC"
mkdir -p "$SITE_SRC"
tar cf - \
--exclude='.git' \
--exclude='.site-src' \
--exclude='.site-build' \
--exclude='.venv-docs' \
--exclude='*.sh' \
--exclude='*.py' \
--exclude='publishing/webhook-receiver.py' \
--exclude='publishing/webhook-receiver.service' \
--exclude='mkdocs.yml' \
--exclude='.gitignore' \
--exclude='requirements.txt' \
. | tar xf - -C "$SITE_SRC"
# A file that must sit at the site root (crawler rules), not under publishing/.
cp publishing/robots.txt "$SITE_SRC/robots.txt"
# The documents sometimes link to a directory (`incidents/`) the way Gitea
# renders it. A static site has no directory listing, so generate a small
# index page for any documented subdirectory that lacks one. This is a build
# artifact — the documents themselves are never touched.
(cd "$SITE_SRC" && find . -mindepth 1 -type d ! -path './publishing*' | while read -r dir; do
if compgen -G "$dir"/*.md >/dev/null && [ ! -f "$dir/index.md" ]; then
dir_name="$(basename "$dir")"
title="$(echo "$dir_name" | sed 's/-/ /g' | sed 's/^./\u&/')"
{
echo "# $title"
echo
echo "Landing page for this directory, generated by the publishing layer — the"
echo "documents themselves live untouched in the repository."
echo
for f in "$dir"/*.md; do
[ "$(basename "$f")" = "index.md" ] && continue
heading="$(grep -m1 '^# ' "$f" | sed 's/^# //' || true)"
echo "- [${heading:-$(basename "$f" .md)}]($(basename "$f"))"
done
} > "$dir/index.md"
fi
done)
# 2. Build.
if [ ! -x "$VENV/bin/mkdocs" ]; then
echo "Creating build venv at $VENV"
python3 -m venv "$VENV"
"$VENV/bin/pip" install --quiet --upgrade pip
"$VENV/bin/pip" install --quiet -r "$REPO_DIR/publishing/requirements.txt"
fi
echo "Building site -> $REPO_DIR/.site-build"
"$VENV/bin/mkdocs" build --clean
echo "Done: $REPO_DIR/.site-build"
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Publish the built documentation site to the docs Surfer app.
#
# The docs have their OWN Cloudron app now:
#
# Cloudron app: docs.inference.coop (io.cloudron.surfer, id 85b7ce06-e2af-49a4-bd4f-a9c2e692e3ab)
# served at: https://docs.inference.coop/
# on disk: /app/data/public (Surfer's "Default" site)
#
# They used to be a directory inside the marketing Surfer app
# (inference.coop, id 55f5f904-...), reachable at /app/data/public-docs and
# /app/data/public/docs. Both of those copies were deleted once this app was
# serving, so there is exactly one canonical location and nothing to drift.
#
# Mechanism (evidence: Cloudron app "Surfer" io.cloudron.surfer 7.4.1, and
# repos/website/deploy.sh, which deploys the marketing site the same way):
# pipe a tar of the build output into the container via `cloudron exec`, which
# needs only the Cloudron API token — no WebDAV token, SFTP credentials or SSH.
# The tar is spooled to a file inside the container first; piping it straight
# into `tar xf -` twice would leave the second extraction with an empty stdin.
#
# Usage:
# CLOUDRON_TOKEN=... publishing/deploy.sh
#
# Env:
# CLOUDRON_TOKEN (required) Cloudron API token
# CLOUDRON_SERVER default my.inference.coop
# SURFER_APP default docs.inference.coop (the docs app's location)
# DOCS_DEST default /app/data/public (Surfer's Default site root)
# SITE_DIR default <repo>/.site-build
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SITE_DIR="${SITE_DIR:-$REPO_DIR/.site-build}"
SERVER="${CLOUDRON_SERVER:-my.inference.coop}"
APP="${SURFER_APP:-docs.inference.coop}"
DEST="${DOCS_DEST:-/app/data/public}"
CLOUDRON_BIN="${CLOUDRON_BIN:-$(command -v cloudron || true)}"
if [ -z "$CLOUDRON_BIN" ] && [ -x "$HOME/.local/bin/cloudron" ]; then
CLOUDRON_BIN="$HOME/.local/bin/cloudron"
fi
: "${CLOUDRON_BIN:?cloudron CLI not found (set CLOUDRON_BIN or add it to PATH)}"
# The cloudron CLI is a node script (`#!/usr/bin/env node`); under systemd the
# PATH may not contain the host's node, so look in the usual places.
if ! command -v node >/dev/null 2>&1; then
for dir in "$HOME"/.hermes/tools/node*/bin /usr/local/bin; do
if [ -x "$dir/node" ]; then export PATH="$dir:$PATH"; break; fi
done
fi
: "${CLOUDRON_TOKEN:?CLOUDRON_TOKEN is required (the Cloudron API token)}"
if [ ! -f "$SITE_DIR/index.html" ]; then
echo "No build found at $SITE_DIR — run publishing/build.sh first." >&2
exit 1
fi
# Build metadata: written into the output so a deploy can be verified from the
# served site itself (which push produced it, and when).
COMMIT="$(git -C "$REPO_DIR" rev-parse HEAD 2>/dev/null || echo unknown)"
COMMIT_SHORT="$(git -C "$REPO_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown)"
BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
cat > "$SITE_DIR/meta.json" <<EOF
{
"commit": "$COMMIT",
"commit_short": "$COMMIT_SHORT",
"built_at": "$BUILT_AT",
"generator": "mkdocs-material (publishing/build.sh)",
"source": "https://git.inference.coop/co-op/docs"
}
EOF
echo "Deploying $SITE_DIR (commit $COMMIT_SHORT) -> $SERVER:$APP"
echo " $DEST (docs.inference.coop)"
tar cf - -C "$SITE_DIR" . | \
"$CLOUDRON_BIN" --server "$SERVER" --token "$CLOUDRON_TOKEN" exec --app "$APP" -- \
sh -c "set -e
cat > /tmp/docs-site.tar
rm -rf '$DEST' && mkdir -p '$DEST' && tar xf /tmp/docs-site.tar -C '$DEST'
rm -f /tmp/docs-site.tar"
echo "Done:"
echo " https://docs.inference.coop/"
+36
View File
@@ -0,0 +1,36 @@
# systemd units for the docs deploy automation (agent host).
#
# Two mechanisms, one job — keep https://docs.inference.coop/ in step with
# co-op/docs main:
#
# docs-deploy-poll.service ALWAYS ON. Polls Gitea every 60s; deploys on
# any change to main. Works with the agent
# host's current firewall (outbound only).
#
# docs-deploy-webhook.service INSTALLED, INACTIVE UNTIL THE PORT IS OPEN.
# Instant push -> deploy once the Infomaniak
# panel allows inbound TCP to the agent host
# (the Gitea webhook is already registered).
#
# Install both:
# sudo cp publishing/poll-watcher.sh publishing/webhook-receiver.py /home/ubuntu/repos/docs/publishing/
# sudo cp publishing/docs-deploy-poll.service publishing/webhook-receiver.service /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable --now docs-deploy-poll
# sudo systemctl enable --now docs-deploy-webhook # when the port is open
[Unit]
Description=Inference docs: poll Gitea main and deploy to Surfer
After=network-online.target
Wants=network-online.target
[Service]
User=ubuntu
WorkingDirectory=/home/ubuntu/.docs-publisher
Environment=DOCS_REPO_DIR=/home/ubuntu/.docs-publisher
ExecStart=/bin/bash /home/ubuntu/.docs-publisher/publishing/poll-watcher.sh
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Figtree Project Authors (https://github.com/erikdkennedy/figtree)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+144
View File
@@ -0,0 +1,144 @@
"""MkDocs hooks for the publishing layer.
Two jobs, both publishing-layer only — neither edits the documents or the
authored stylesheets in the repository.
1. Directory-style links. The documents sometimes link to a directory the way
Gitea renders it, e.g. infrastructure.md contains [`incidents/`](incidents/).
On a static site with directory URLs, that href resolves relative to the
*page* (giving /infrastructure/incidents/ and a 404). This hook rewrites such
links to the generated landing page for that directory (build.sh creates
<dir>/index.md for documented directories that lack one).
2. Content-hashed stylesheet URLs. The site's own stylesheets (brand.css,
typography.css) are listed in mkdocs.yml under extra_css, so every page
links them at a FIXED URL (publishing/brand.css). A fixed URL means a browser
with a warm cache keeps serving the old stylesheet after a deploy — the URL
never changes even when the content does. Material's own assets
(assets/stylesheets/main.<hash>.min.css) are content-hashed and immune; ours
were not. This hook gives ours the same property: at build time it copies
each to <name>.<sha256-8>.css in the output and rewrites the links in the
built HTML, so any change to a stylesheet's content necessarily yields a new
URL. The authored sources keep their names and locations.
"""
import hashlib
import os
import posixpath
import re
import shutil
from mkdocs.utils import get_relative_url
_HREF = re.compile(r'(href=)("([^"]+)/")')
def on_page_content(html, page, config, files):
index_urls = {}
for f in files:
src = f.src_path
if src.endswith("/index.md"):
index_urls[posixpath.dirname(src)] = f.url
page_dir = posixpath.dirname(page.file.src_path)
def fix(match):
href = match.group(3)
if href.startswith(("http://", "https://", "/", "#")):
return match.group(0)
target = posixpath.normpath(posixpath.join(page_dir, href))
if target in (".", "..") or target not in index_urls:
return match.group(0)
return f'{match.group(1)}"{get_relative_url(index_urls[target], page.file.url)}"'
return _HREF.sub(fix, html)
# --- Content-hashed stylesheet URLs ----------------------------------------
# Material's own asset names use 8 hex characters (main.57a0d79d.min.css); match
# that so the site's naming is uniform and the hash is short but collision-safe
# for a handful of stylesheets.
_HASH_LEN = 8
# A hashed sibling of brand.css is brand.<8 hex>.css.
_HASHED_SIBLING = re.compile(r"^(?P<stem>.+)\.(?P<hash>[0-9a-f]{%d})(?P<ext>\.[^.]+)$" % _HASH_LEN)
def _hashed_links(config, files=None):
"""Build each extra_css file to a content-hashed name in the site output.
Returns {original-relative-path: hashed-relative-path}. The hashed copy
lands in the *same directory* as the original, so relative references
inside the CSS (e.g. brand.css's @font-face url("fonts/figtree-400.woff2"))
keep resolving. Stale hashed siblings from an earlier build are removed, so
the output does not accumulate files across builds.
"""
site_dir = config["site_dir"]
replacements = {}
for css in config.get("extra_css") or []:
rel = css.lstrip("/").replace("\\", "/")
original = os.path.join(site_dir, *rel.split("/"))
if not os.path.isfile(original):
continue
with open(original, "rb") as fh:
digest = hashlib.sha256(fh.read()).hexdigest()[:_HASH_LEN]
stem, ext = posixpath.splitext(rel)
hashed_rel = f"{stem}.{digest}{ext}"
hashed = os.path.join(site_dir, *hashed_rel.split("/"))
# Copy to the hashed name (a no-op if it is already correct).
if os.path.abspath(hashed) != os.path.abspath(original):
shutil.copyfile(original, hashed)
# Drop stale hashed siblings of the original (same stem/ext, any hash).
directory = os.path.dirname(original)
base_stem, base_ext = posixpath.splitext(os.path.basename(rel))
keep = os.path.basename(hashed)
for name in os.listdir(directory):
m = _HASHED_SIBLING.match(name)
if not m or name == keep:
continue
if m.group("stem") == base_stem and m.group("ext") == base_ext:
os.remove(os.path.join(directory, name))
replacements[rel] = hashed_rel
return replacements
def _rewrite_stylesheet_links(site_dir, replacements):
"""Point every <link href="…brand.css"> at the hashed file, in place.
The href is relative to each page (root pages use "publishing/brand.css",
nested pages "../publishing/brand.css"), so match any prefix up to the
known relative path and swap only the path tail, preserving the page's
own relative prefix.
"""
patterns = [
(re.compile(r'(href="[^"]*)' + re.escape(orig) + r'(")'), hashed)
for orig, hashed in replacements.items()
]
for root, _dirs, filenames in os.walk(site_dir):
for name in filenames:
if not name.endswith(".html"):
continue
path = os.path.join(root, name)
with open(path, "r", encoding="utf-8") as fh:
html = fh.read()
new = html
for pattern, hashed in patterns:
new = pattern.sub(lambda m, h=hashed: m.group(1) + h + m.group(2), new)
if new != html:
with open(path, "w", encoding="utf-8") as fh:
fh.write(new)
def on_post_build(config):
replacements = _hashed_links(config)
if replacements:
_rewrite_stylesheet_links(config["site_dir"], replacements)
+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130 130">
<rect width="130" height="130" rx="24" fill="#faf8f5"/>
<circle cx="65" cy="14" r="5" fill="#5b8c5a"/>
<circle cx="58" cy="30" r="6" fill="#7ab87a"/>
<circle cx="74" cy="30" r="4" fill="#6ba86b"/>
<circle cx="51.5" cy="46" r="6.5" fill="#5b8c5a"/>
<circle cx="65" cy="46" r="5" fill="#7ab87a"/>
<circle cx="81" cy="46" r="4" fill="#6ba86b"/>
<circle cx="44" cy="62" r="7" fill="#6ba86b"/>
<circle cx="61" cy="62" r="5.5" fill="#5b8c5a"/>
<circle cx="76" cy="62" r="4.5" fill="#7ab87a"/>
<circle cx="89.5" cy="62" r="3.5" fill="#6ba86b"/>
<circle cx="36.5" cy="78" r="7.5" fill="#5b8c5a"/>
<circle cx="54" cy="78" r="6" fill="#7ab87a"/>
<circle cx="70" cy="78" r="5" fill="#6ba86b"/>
<circle cx="84" cy="78" r="4" fill="#5b8c5a"/>
<circle cx="97.5" cy="78" r="3.5" fill="#7ab87a"/>
<circle cx="44" cy="94" r="6.5" fill="#6ba86b"/>
<circle cx="61" cy="94" r="5.5" fill="#5b8c5a"/>
<circle cx="76" cy="94" r="4.5" fill="#7ab87a"/>
<circle cx="89.5" cy="94" r="3.5" fill="#6ba86b"/>
<circle cx="51.5" cy="110" r="5.5" fill="#5b8c5a"/>
<circle cx="65" cy="110" r="4.5" fill="#7ab87a"/>
<circle cx="81" cy="110" r="3.5" fill="#6ba86b"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Poll Gitea for new commits on main and deploy the docs site when it moves.
#
# Why polling: the agent host's cloud firewall only admits inbound SSH, so a
# Gitea webhook cannot reach the receiver (publishing/webhook-receiver.py) —
# every port is silently dropped. Outbound to git.inference.coop works, so the
# agent host polls instead: a push to main is picked up within ~a minute and
# deployed with no human action. When the operator opens a TCP port to the
# agent host in the Infomaniak panel, the webhook takes over (its unit is
# already installed); until then this watcher is the automation.
#
# Runs on the agent host as a systemd service (docs-deploy-poll.service).
# Deploy steps are serialised with the same lock the webhook receiver uses, so
# the two mechanisms can never deploy concurrently.
set -uo pipefail
REPO_DIR="${DOCS_REPO_DIR:-/home/ubuntu/repos/docs}"
ENV_FILE="${DOCS_ENV_FILE:-/home/ubuntu/.hermes/.env}"
LOG_FILE="${DOCS_DEPLOY_LOG:-/home/ubuntu/docs-deploy.log}"
LOCK_FILE="${DOCS_DEPLOY_LOCK:-/home/ubuntu/.docs-deploy.lock}"
STATE_FILE="${DOCS_DEPLOY_STATE:-/home/ubuntu/.docs-deploy.state}"
INTERVAL="${DOCS_POLL_INTERVAL:-60}"
cd "$REPO_DIR"
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [poll] $*" | tee -a "$LOG_FILE" >&2; }
# The Cloudron token travels by environment only; it is read from the env file
# and never echoed.
CLOUDRON_TOKEN="$(sed -n 's/^CLOUDRON_TOKEN=//p' "$ENV_FILE" | head -1 | tr -d '"' | tr -d "'")"
export CLOUDRON_TOKEN
if [ -z "$CLOUDRON_TOKEN" ]; then
log "FATAL: CLOUDRON_TOKEN not found in $ENV_FILE"
exit 1
fi
log "watcher started: polling $REPO_DIR every ${INTERVAL}s"
while true; do
if ! git fetch --quiet origin main 2>>"$LOG_FILE"; then
log "git fetch failed; will retry"
sleep "$INTERVAL"
continue
fi
REMOTE="$(git rev-parse origin/main 2>/dev/null || echo none)"
# Compare against the commit last DEPLOYED (not the clone's HEAD): a push
# made from this very clone would otherwise look like "no change".
LAST_DEPLOYED="$(cat "$STATE_FILE" 2>/dev/null || echo none)"
if [ "$LAST_DEPLOYED" != "$REMOTE" ]; then
log "main moved: ${LAST_DEPLOYED:0:12} -> ${REMOTE:0:12}; deploying"
# flock -n shares the receiver's lock: one deploy at a time, ever.
if flock -n "$LOCK_FILE" bash -c '
set -e
cd "$1"
git pull --ff-only origin main
bash publishing/build.sh
bash publishing/deploy.sh
' _ "$REPO_DIR" >>"$LOG_FILE" 2>&1; then
printf '%s\n' "$REMOTE" > "$STATE_FILE"
log "deploy finished for ${REMOTE:0:12}"
else
log "deploy FAILED for ${REMOTE:0:12} (see log above)"
fi
fi
sleep "$INTERVAL"
done
+3
View File
@@ -0,0 +1,3 @@
# Pinned build toolchain for the documentation site.
# Reproducible: `pip install -r publishing/requirements.txt && mkdocs build`
mkdocs-material==9.8.0
+3
View File
@@ -0,0 +1,3 @@
# Documentation site crawler rules
User-agent: *
Allow: /
+76
View File
@@ -0,0 +1,76 @@
/* Typography for the co-op documentation site.
*
* Loaded after publishing/brand.css on purpose: the font has to be declared on the
* elements themselves. Material renders body and headings with
* var(--md-text-font-family), which it defines on <body>, so a :root-level value
* loses to it by DOM proximity no matter how the cascade is ordered.
*
* Figtree is self-hosted -- see the @font-face rules in brand.css. No CDN, no
* third-party font service.
*/
body,
.md-typeset,
.md-nav,
.md-header,
.md-tabs,
.md-footer {
font-family: "Figtree", system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
}
.md-typeset h1,
.md-typeset h2,
.md-typeset h3,
.md-typeset h4,
.md-typeset h5,
.md-typeset h6,
.md-header__title,
.md-nav__title {
font-family: "Figtree", system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
letter-spacing: -0.01em;
}
.md-typeset h1,
.md-typeset h2,
.md-typeset h3,
.md-typeset h4,
.md-header__title {
/* Never break a word mid-way; wrap at the space, and let the browser balance the
two lines rather than leaving one word on its own. */
overflow-wrap: break-word;
word-break: normal;
hyphens: none;
text-wrap: balance;
}
/* Fluid scale. Fixed rem sizes do not shrink on a phone -- Material keeps a 20px root
* even at 360px wide -- so a 2.6rem h1 stays 52px and "Cooperative" (297px at that
* size) runs out of room on anything narrower than ~370px and breaks mid-word.
* These track the viewport instead and land at the same desktop size.
*/
.md-typeset h1 { font-size: clamp(1.6rem, 1.5rem + 3.2vw, 2.6rem); font-weight: 700; letter-spacing: -0.02em; }
.md-typeset h2 { font-size: clamp(1.3rem, 1.1rem + 1.6vw, 1.7rem); font-weight: 600; }
.md-typeset h3 { font-size: clamp(1.1rem, 1rem + 0.7vw, 1.25rem); font-weight: 600; }
.md-header__title { font-size: clamp(0.85rem, 0.7rem + 0.8vw, 0.95rem); font-weight: 600; }
/* Long prose stays readable; measured in characters rather than pixels. */
.md-typeset { line-height: 1.65; }
/* Colour is scheme-specific, so it stays out of the rules above.
* Light: the co-op's warm ink (#2d3327), never a neutral near-black.
* Dark: the chat overlay's light warm text (#e8f5e8) on the deep-pine page. */
[data-md-color-scheme="default"] .md-typeset h1,
[data-md-color-scheme="default"] .md-typeset h2,
[data-md-color-scheme="default"] .md-typeset h3,
[data-md-color-scheme="default"] .md-typeset h4 {
color: #2d3327;
}
[data-md-color-scheme="slate"] .md-typeset h1,
[data-md-color-scheme="slate"] .md-typeset h2,
[data-md-color-scheme="slate"] .md-typeset h3,
[data-md-color-scheme="slate"] .md-typeset h4 {
color: #e8f5e8;
}
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""Gitea webhook receiver that rebuilds and republishes the docs site.
Flow: a push to main in co-op/docs fires a Gitea webhook -> this receiver
verifies it -> pulls the latest main -> runs publishing/build.sh ->
publishing/deploy.sh (which writes the build into the Surfer app).
Runs on the agent host (no new Cloudron app). Standard library only.
Security model:
* Only POSTs from the Cloudron box (git.inference.coop's host) are accepted.
* The payload must carry a valid HMAC-SHA256 signature (X-Gitea-Signature,
shared secret DEPLOY_WEBHOOK_SECRET). The secret itself is never sent.
* Only pushes to refs/heads/main of co-op/docs trigger a deploy.
* The receiver binds 0.0.0.0; an iptables rule installed by the systemd unit
narrows the port to the Cloudron box as well (defence in depth).
Secrets are read from /home/ubuntu/.hermes/.env and are never logged.
"""
import fcntl
import hashlib
import hmac
import ipaddress
import json
import logging
import os
import subprocess
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
REPO_DIR = os.environ.get("DOCS_REPO_DIR", "/home/ubuntu/repos/docs")
ENV_FILE = os.environ.get("DOCS_ENV_FILE", "/home/ubuntu/.hermes/.env")
LOG_FILE = os.environ.get("DOCS_DEPLOY_LOG", "/home/ubuntu/docs-deploy.log")
LOCK_FILE = os.environ.get("DOCS_DEPLOY_LOCK", "/home/ubuntu/.docs-deploy.lock")
PORT = int(os.environ.get("DOCS_WEBHOOK_PORT", "8787"))
EXPECT_REPO = "co-op/docs"
EXPECT_BRANCH = "refs/heads/main"
ALLOWED_SOURCES = [
ipaddress.ip_network(n)
for n in os.environ.get(
"DOCS_WEBHOOK_SOURCES",
# The Cloudron box (hosts git.inference.coop) plus loopback for local tests.
"179.237.86.116/32,2001:1600:18:206::89/128,127.0.0.1/32,::1/128",
).split(",")
if n.strip()
]
log = logging.getLogger("docs-deploy")
deploy_lock_file = open(LOCK_FILE, "a")
def load_env_file(path):
"""Return KEY=VALUE pairs from the agent host's env file (values stay local)."""
values = {}
try:
with open(path) as handle:
for line in handle:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
values[key.strip()] = value.strip().strip('"').strip("'")
except OSError:
pass
return values
_FILE_ENV = load_env_file(ENV_FILE)
def secret(name):
return os.environ.get(name) or _FILE_ENV.get(name) or ""
def source_allowed(address):
try:
ip = ipaddress.ip_address(address.split("%")[0])
except ValueError:
return False
return any(ip in net for net in ALLOWED_SOURCES)
def run(cmd, timeout, cwd=None):
log.info("exec: %s", " ".join(cmd))
# Hand the build/deploy scripts the credentials they need (from the env
# file, if not already set). Values are passed by environment only and are
# never logged.
env = dict(os.environ)
for key in ("CLOUDRON_TOKEN", "CLOUDRON_SERVER", "SURFER_APP", "GIT_ASKPASS"):
if not env.get(key) and secret(key):
env[key] = secret(key)
env.setdefault("GIT_ASKPASS", "/home/ubuntu/.hermes/scripts/git_askpass.sh")
env.setdefault("PATH", os.environ.get("PATH", ""))
result = subprocess.run(
cmd, cwd=cwd or REPO_DIR, capture_output=True, text=True, timeout=timeout,
env=env,
)
if result.stdout.strip():
log.info("stdout: %s", result.stdout.strip()[-2000:])
if result.returncode != 0 and result.stderr.strip():
log.error("stderr: %s", result.stderr.strip()[-2000:])
return result
def deploy(commit_hint=""):
"""Pull main, rebuild, republish. Returns (ok, log_tail)."""
try:
fcntl.flock(deploy_lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return False, "another deploy is already running; skipped"
tail = []
try:
steps = [
(["git", "pull", "--ff-only", "origin", "main"], 120),
(["bash", "publishing/build.sh"], 900),
(["bash", "publishing/deploy.sh"], 600),
]
for cmd, timeout in steps:
result = run(cmd, timeout)
out = (result.stdout or "") + (result.stderr or "")
tail.append(f"$ {' '.join(cmd)} -> exit {result.returncode}")
tail.extend(out.strip().splitlines()[-12:])
if result.returncode != 0:
log.error("step failed: %s", " ".join(cmd))
return False, "\n".join(tail)
log.info("deploy complete%s", f" ({commit_hint})" if commit_hint else "")
return True, "\n".join(tail)
except Exception as exc: # noqa: BLE001 - report, never crash the receiver
log.exception("deploy failed")
tail.append(f"error: {exc}")
return False, "\n".join(tail)
finally:
fcntl.flock(deploy_lock_file, fcntl.LOCK_UN)
class Handler(BaseHTTPRequestHandler):
server_version = "docs-deploy/1.0"
def log_message(self, format, *args): # noqa: A002 - stdlib signature
log.info("%s - %s", self.client_address[0], format % args)
def _json(self, code, payload):
body = json.dumps(payload).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/healthz":
return self._json(200, {"status": "ok"})
return self._json(404, {"error": "not found"})
def do_POST(self):
peer = self.client_address[0]
if not source_allowed(peer):
log.warning("rejected: source %s not allowed", peer)
return self._json(403, {"error": "forbidden"})
if self.headers.get("X-Gitea-Event") != "push":
return self._json(400, {"error": "not a push event"})
length = int(self.headers.get("Content-Length") or 0)
if length <= 0 or length > 1_000_000:
return self._json(400, {"error": "bad payload size"})
body = self.rfile.read(length)
signature = self.headers.get("X-Gitea-Signature") or ""
expected = hmac.new(
secret("DEPLOY_WEBHOOK_SECRET").encode(), body, hashlib.sha256
).hexdigest()
if not signature or not hmac.compare_digest(signature, expected):
log.warning("rejected: bad signature from %s", peer)
return self._json(403, {"error": "invalid signature"})
try:
payload = json.loads(body)
except json.JSONDecodeError:
return self._json(400, {"error": "invalid json"})
repo = (payload.get("repository") or {}).get("full_name", "")
ref = payload.get("ref", "")
commit = (payload.get("after") or "")[:12]
pusher = ((payload.get("pusher") or {}).get("login")) or "unknown"
if repo != EXPECT_REPO or ref != EXPECT_BRANCH:
log.info("ignored: push to %s %s (not %s %s)", repo, ref, EXPECT_REPO, EXPECT_BRANCH)
return self._json(200, {"status": "ignored", "reason": "not main on co-op/docs"})
log.info("push received: %s %s commit=%s pusher=%s", repo, ref, commit, pusher)
threading.Thread(target=self._run_deploy, args=(commit,), daemon=True).start()
return self._json(202, {"status": "accepted", "commit": commit})
def _run_deploy(self, commit):
ok, tail = deploy(commit)
log.info("deploy result for %s: %s", commit, "ok" if ok else "FAILED")
log.info("tail:\n%s", tail[-1500:])
def main():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[
logging.FileHandler(LOG_FILE),
logging.StreamHandler(sys.stdout),
],
)
if not secret("DEPLOY_WEBHOOK_SECRET"):
log.error("DEPLOY_WEBHOOK_SECRET is not set; refusing to start")
sys.exit(1)
if not secret("CLOUDRON_TOKEN"):
log.warning("CLOUDRON_TOKEN not set; deploys will fail")
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
log.info("listening on 0.0.0.0:%d, repo %s", PORT, REPO_DIR)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()
+29
View File
@@ -0,0 +1,29 @@
# systemd unit for the docs deploy webhook receiver (agent host).
#
# Install:
# sudo cp publishing/webhook-receiver.service /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now docs-deploy-webhook
#
# The ExecStartPre lines narrow the webhook port to the Cloudron box (which
# hosts git.inference.coop); the receiver additionally checks the source address
# and the payload signature, so a scanner hitting the port gets nothing.
[Unit]
Description=Inference docs: Gitea webhook -> build -> Surfer deploy
After=network-online.target
Wants=network-online.target
[Service]
User=ubuntu
WorkingDirectory=/home/ubuntu/.docs-publisher
Environment=DOCS_REPO_DIR=/home/ubuntu/.docs-publisher
ExecStartPre=+/bin/sh -c '/usr/sbin/iptables -C INPUT -i lo -p tcp --dport 8787 -j ACCEPT 2>/dev/null || /usr/sbin/iptables -I INPUT 1 -i lo -p tcp --dport 8787 -j ACCEPT'
ExecStartPre=+/bin/sh -c '/usr/sbin/iptables -C INPUT -p tcp --dport 8787 -s 179.237.86.116 -j ACCEPT 2>/dev/null || /usr/sbin/iptables -I INPUT 2 -p tcp --dport 8787 -s 179.237.86.116 -j ACCEPT'
ExecStartPre=+/bin/sh -c '/usr/sbin/iptables -C INPUT -p tcp --dport 8787 ! -s 179.237.86.116 -j DROP 2>/dev/null || /usr/sbin/iptables -I INPUT 3 -p tcp --dport 8787 ! -s 179.237.86.116 -j DROP'
ExecStartPre=+/bin/sh -c '/usr/sbin/ip6tables -C INPUT -p tcp --dport 8787 -j DROP 2>/dev/null || /usr/sbin/ip6tables -I INPUT -p tcp --dport 8787 -j DROP'
ExecStart=/home/ubuntu/.docs-publisher/.venv-docs/bin/python /home/ubuntu/.docs-publisher/publishing/webhook-receiver.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+1 -2
View File
@@ -87,8 +87,7 @@ Privacy is a core value, but it is **not uniform across the service** — and th
## 9. Governance ## 9. Governance
- Decisions are made through **Loomio** and governed by the [Charter](charter.md). The Co-op's governance proceeds according to the content of the current [Charter](charter.md).
- During the pilot phase, the General Manager holds final discretion; this transitions to full member governance as the project grows.
## 10. Disclaimers, liability, and indemnity ## 10. Disclaimers, liability, and indemnity
+78
View File
@@ -0,0 +1,78 @@
# Working with the agent
The co-op runs a small number of agents to help steward its infrastructure,
each isolated from the others: its own OS user, its own installation, and its
own gateway key with a model allowlist and a budget enforced at the gateway.
The first, **inferencebot**, stewards the co-op's services. The second,
**infrastructure-bot**, lives in #infrastructure and answers only when
mentioned. They run on co-op infrastructure under co-op identities, use the
co-op's own gateway for inference, and their skills, scripts and configuration
are mirrored to a Gitea repository nightly. These norms apply to all of them.
This page is for members who read their output or give them instructions.
## Two handles that look alike
- `@inference-bot:inference.coop` (with a hyphen) is the co-op's automation
account. It administers rooms, sends invitations and posts the updates feed.
It does not take instructions.
- `@inferencebot:inference.coop` (no hyphen) is the steward agent that
admitted members can instruct.
## Where they speak and listen
- **#inferencebot-updates** is read-only: daily digests, model changes,
upgrades, maintenance, outages. Posts are anonymized.
- **Instructing an agent** is limited to the members on that agent's
allowlist of Matrix IDs, kept in the agent's own configuration, and only in
the rooms where it is configured to answer; most require a mention. The
General Manager decides who is on the list, and the list is published here
once it is settled.
## What to ask for
- Operational work that a runbook already describes, or that ends in a PR, an
issue or a post members can see.
- Research and summaries that would otherwise land in an issue.
- Status: what is running, what failed, what changed.
## What not to ask in a shared room
- Anything involving member-identifying data. That work happens in a session a
named steward opened, and the result is reported without the data.
- Anything that moves money or changes who is a member. Those go through the
portal and a human.
- Credentials, in either direction.
## What they do without asking
Read, monitor, draft, open issues and PRs, post updates, run the scheduled jobs
listed in [Infrastructure](infrastructure.md), and deploy bug and security
fixes immediately, reporting each one in #inferencebot-updates.
## What always needs a human
- Member-visible, destructive or risky changes to the gateway, the portal or
the chat: confirmed before they deploy.
- Changing the model catalog, prices or budgets.
- Minting or rotating credentials.
- Anything a runbook marks "confirm".
This matches the standing policy the agents operate under today. A stricter
norm is a policy decision for the [forum](https://forum.inference.coop).
## Attribution and the off switch
Every instruction an agent acts on is attributable to the member who gave it,
in the room where it was given. If an agent misbehaves, the operator, or anyone
holding gateway-admin and Matrix account access, can stop it: revoke its
gateway key, then its Matrix access token. Stewards generally do not hold that
access today, so say what you saw in #general and the operator acts. Stopping
is not restarting: the backup and the git mirror restore state, not uptime,
and someone has to start the agent again. The steps, and the steps for
rotating everything an agent holds, are in the agent's runbook; the credential
inventory in the [operator token
runbook](https://git.inference.coop/co-op/ops-docs/src/branch/main/operator-token-runbook.md)
says what that is.
Changes to this page are policy: propose them in the
[forum](https://forum.inference.coop).