Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/languagetool-extension.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: LanguageTool offline proofreading contract

on:
pull_request:
paths:
- 'ods/extensions/library/services/languagetool/**'
- 'ods/tests/test_languagetool_extension.py'
- '.github/workflows/languagetool-extension.yml'
workflow_dispatch:

permissions:
contents: read

jobs:
proofreading:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.12'
- name: Install boundary test dependencies
run: python -m pip install pytest==8.3.5 httpx==0.28.1 PyYAML==6.0.3
- name: Verify offline spelling, grammar and request limits
env:
ODS_TEST_LANGUAGETOOL_DOCKER: '1'
run: python -m pytest -q ods/tests/test_languagetool_extension.py
75 changes: 74 additions & 1 deletion ods/config/extensions-catalog.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"generated_at": "2026-09-13T16:53:56Z",
"generated_at": "2026-09-14T09:55:30Z",
"schema_version": "1.0.0",
"extensions": [
{
Expand Down Expand Up @@ -1937,6 +1937,57 @@
"catalog_source": "builtin",
"configuration_scope": "declared-environment-keys"
},
{
"id": "languagetool",
"name": "LanguageTool",
"description": "Local spelling and grammar checks for writing tools and automation.",
"category": "optional",
"gpu_backends": [
"all"
],
"compose_file": "compose.yaml",
"depends_on": [],
"port": 8081,
"external_port_default": 7826,
"health_endpoint": "/v2/languages",
"env_vars": [
{
"key": "LANGUAGETOOL_PORT",
"default": "7826",
"description": "Published port for the local LanguageTool HTTP API"
}
],
"tags": [
"writing",
"grammar",
"spelling",
"local",
"api"
],
"features": [
{
"id": "local-proofreading",
"name": "Local Proofreading API",
"description": "Check spelling and grammar using local dictionaries and rules",
"icon": "FileText",
"category": "productivity",
"requirements": {
"services": [
"languagetool"
],
"vram_gb": 0,
"disk_gb": 3
},
"enabled_services_all": [
"languagetool"
],
"setup_time": "~2-5 minutes",
"priority": 32
}
],
"startup_timeout": 300,
"configuration_scope": "declared-environment-keys"
},
{
"id": "librechat",
"name": "LibreChat",
Expand Down Expand Up @@ -3156,6 +3207,28 @@
"catalog_source": "builtin",
"configuration_scope": "declared-environment-keys"
},
{
"id": "pixel-inference",
"name": "Pixel inference sharing (development)",
"description": "Disabled by default. Owner-provisioned, inference-only device credentials; loopback transport only. Does not provide Pixel agent or computer access. Private state must be provisioned before service activation. Native Windows private-state support and guided setup are not yet implemented.",
"category": "optional",
"gpu_backends": [
"all"
],
"compose_file": "compose.yaml",
"depends_on": [
"model-router"
],
"port": 8093,
"external_port_default": 4005,
"health_endpoint": "/health",
"env_vars": [],
"tags": [],
"features": [],
"startup_check": false,
"catalog_source": "builtin",
"configuration_scope": "declared-environment-keys"
},
{
"id": "qdrant",
"name": "Qdrant (Vector DB)",
Expand Down
3 changes: 2 additions & 1 deletion ods/extensions/library/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ODS Extensions Library

**35 service extensions being tested for ODS. 17 are already in production — these are next.**
**36 service extensions being tested for ODS. 17 are already in production — these are next.**

Each extension is a self-contained directory with a `manifest.yaml` (service metadata), `compose.yaml` (Docker Compose fragment), and optional Dockerfiles, workflows, and documentation. Drop any of these into your ODS's `extensions/services/` directory and run `ods enable <service-id>`.

Expand Down Expand Up @@ -74,6 +74,7 @@ Each extension is a self-contained directory with a `manifest.yaml` (service met
| [`gitea/`](services/gitea/) | Gitea — lightweight self-hosted Git | CPU |
| [`baserow/`](services/baserow/) | Baserow — open-source Airtable alternative | CPU |
| [`miniflux/`](services/miniflux/) | Miniflux — RSS/Atom reading and research API | CPU |
| [`languagetool/`](services/languagetool/) | LanguageTool — local spelling and grammar API | CPU |
| [`ntfy/`](services/ntfy/) | ntfy — authenticated local workflow notifications | CPU |
| [`sillytavern/`](services/sillytavern/) | SillyTavern — advanced roleplay/chat frontend | AMD, NVIDIA, Apple |

Expand Down
66 changes: 66 additions & 0 deletions ods/extensions/library/services/languagetool/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# LanguageTool

Use local spelling and grammar checks in a writing client or an ODS automation.
This is the open-source LanguageTool 6.8 HTTP API, packaged by the community
`meyay/languagetool` image (6.8-10), which the LanguageTool project lists among its
Docker options. The exact multi-architecture image is pinned by digest. It is not
the hosted premium service and has no browser editor or account system.

## Start and check text

Install LanguageTool from the Extensions library. The default endpoint on the
ODS host is `http://127.0.0.1:7826/v2`; `LANGUAGETOOL_PORT` changes the published
port. The Extensions page treats it as an API service rather than offering a
browser-editor link.

```bash
# List supported language codes
curl --fail --show-error http://127.0.0.1:7826/v2/languages

# Check a sentence without sending it to the hosted LanguageTool service
curl --fail --show-error http://127.0.0.1:7826/v2/check \
--data-urlencode 'language=en-US' \
--data-urlencode 'text=This is a sentnce.'
```

The response includes rule matches, text offsets and suggested replacements.
Use `http://languagetool:8081/v2` from another container on `ods-network`.
An editor must support a custom LanguageTool server URL; point it at the address
reachable from that editor's machine. Some clients require HTTPS, in which case
configure a protected TLS reverse proxy. No specific browser add-on or editor
integration is enabled by installing this recipe.

## Limits and local operation

The service uses bundled language rules/dictionaries. FastText and automatic
n-gram downloads are disabled, so it can start and check explicit languages
without fetching additional models. Optional statistical models, premium rules
and hosted AI rewriting are not included. Language support and rule coverage
vary; obtain supported codes from `/v2/languages` and specify a language for
predictable results.

Requests are limited to 20,000 characters, with two checking threads, a 1.5 GiB
Java heap and a 2 GiB container limit. Split longer documents into suitable chunks.
The service runs as UID/GID 783 with a read-only filesystem; a temporary executable
`/tmp` is required by the image's Java/native-library startup. It has no persistent
database, custom word-list volume or result history in this recipe.

The API has no authentication and binds to loopback by default. Only allow
trusted clients to access it; add access control and TLS before exposing it
outside the host. The image runs locally, but clients must actually use this
endpoint to avoid sending their text to a hosted service. Disabling the extension
stops checks; re-enabling starts a fresh process with the same bundled rules.

## Verification and rollback

The opt-in boundary test runs the exact installed recipe on an isolated Docker
network without external connectivity. It checks language discovery, a real
spelling suggestion, grammar correction, request limits and a repeated check
after recreation. This does not certify every supported language or integration.
Native ARM/macOS/Windows activation and editor/browser clients require separate
verification. Rollback restores the prior image pin or removes the optional
extension; there is no database migration.

Upstream: [LanguageTool and its Docker options](https://github.com/languagetool-org/languagetool),
[HTTP server](https://dev.languagetool.org/http-server),
[image source and configuration](https://github.com/meyayl/docker-languagetool/tree/6.8-10).
37 changes: 37 additions & 0 deletions ods/extensions/library/services/languagetool/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
services:
languagetool:
image: meyay/languagetool:6.8-10@sha256:d9f15384ea3febbe9b24ed9cfc05e912c5154d031354397e5de5e699cb15cd6b
container_name: ods-languagetool
restart: unless-stopped
user: "783:783"
read_only: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
tmpfs:
- /tmp:rw,exec,nosuid,size=256m,mode=1777
environment:
LISTEN_PORT: "8081"
DISABLE_FASTTEXT: "true"
download_ngrams_for_langs: none
JAVA_XMS: 256m
JAVA_XMX: 1536m
LOG_LEVEL: WARN
langtool_maxTextLength: "20000"
langtool_maxCheckThreads: "2"
ports:
- "${BIND_ADDRESS:-127.0.0.1}:${LANGUAGETOOL_PORT:-7826}:8081"
healthcheck:
test: [CMD, /entrypoint, healthcheck]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
networks: [ods-network]
deploy:
resources:
limits:
cpus: "2.0"
memory: 2G
networks:
ods-network:
external: true
41 changes: 41 additions & 0 deletions ods/extensions/library/services/languagetool/manifest.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
schema_version: ods.services.v1
compatibility:
ods_min: "2.6.0"
service:
id: languagetool
name: LanguageTool
description: "Local spelling and grammar checks for writing tools and automation."
aliases: [grammar-checker]
container_name: ods-languagetool
container_uid: 783
default_host: languagetool
port: 8081
external_port_env: LANGUAGETOOL_PORT
external_port_default: 7826
health: /v2/languages
ui_path: /v2/languages
external_link: false
startup_timeout: 300
type: docker
gpu_backends: [all]
compose_file: compose.yaml
category: optional
depends_on: []
tags: [writing, grammar, spelling, local, api]
env_vars:
- key: LANGUAGETOOL_PORT
default: "7826"
description: Published port for the local LanguageTool HTTP API
features:
- id: local-proofreading
name: Local Proofreading API
description: "Check spelling and grammar using local dictionaries and rules"
icon: FileText
category: productivity
requirements:
services: [languagetool]
vram_gb: 0
disk_gb: 3
enabled_services_all: [languagetool]
setup_time: "~2-5 minutes"
priority: 32
2 changes: 1 addition & 1 deletion ods/extensions/services/dashboard/src/pages/Extensions.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import './extensions-refined.css'
export { getTemplateStatus }

// API/backend services with no user-facing web UI — show badge instead of port link.
const HEADLESS_EXTENSIONS = new Set(['embeddings', 'tts', 'whisper', 'privacy-shield'])
const HEADLESS_EXTENSIONS = new Set(['embeddings', 'tts', 'whisper', 'privacy-shield', 'languagetool'])

// Auth: nginx injects "Authorization: Bearer ${DASHBOARD_API_KEY}" via
// proxy_set_header for all /api/ requests (see nginx.conf). All fetches
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { afterEach, expect, it, vi } from 'vitest'
import { screen } from '@testing-library/react'
import { render } from '../test/test-utils'
import Extensions from './Extensions'
import catalog from '../../../../../config/extensions-catalog.json'

afterEach(() => vi.unstubAllGlobals())

it('identifies the installed LanguageTool recipe as an API service', async () => {
const entry = catalog.extensions.find(item => item.id === 'languagetool')
vi.stubGlobal('fetch', vi.fn(async (url) => {
if (String(url).includes('/api/extensions/catalog')) return {
ok: true, json: async () => ({
agent_available: true,
extensions: [{ ...entry, status: 'enabled', source: 'user' }],
summary: { total: 1, installed: 1 },
}),
}
if (String(url).includes('/api/templates')) return { ok: true, json: async () => ({ templates: [] }) }
throw new Error(`Unexpected fetch: ${url}`)
}))
render(<Extensions compact />)
expect(await screen.findByText('LanguageTool')).toBeVisible()
expect(screen.getByText('API service')).toBeVisible()
expect(screen.queryByRole('link', { name: /7826/ })).toBeNull()
})
Loading
Loading