From b23eab54db665221dc5aec7d6b3822e9699e6e38 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:02:06 +0800 Subject: [PATCH 1/2] feat(core): server deployment on the Core page, the home page and the Core docs Core page (/core) - Headline and description in the approved register: an AI API gateway engine for desktops and servers, MIT-licensed crates and the twcore binary, which runs inside ThinkWatch Lite or as a systemd service on a Linux server. No "server edition": ThinkWatch Enterprise, which uses tw-dialect, tw-guard and tw-breaker. - Capabilities: routing with format conversion, mid-stream failover and the circuit breaker, cost accounting against the public price table that is refreshed daily (unknown when a request cannot be priced), outbound redaction, tool-call inspection within the five guards, and the encrypted control plane (local socket or Windows loopback port, optional remote port, Noise handshake). - New "Install and deploy" section (#install): the Linux one-line install with a copy button and a pinned-version variant, the follow-up commands (remote enable, systemctl, control-key, twcore upgrade), the prebuilt binaries of the latest release for each platform with their checksums and the Linux archives, and cargo build. - Crate layers from the 16 crates on main, grouped by role; no crate depends on a group below its own, and the group ThinkWatch Enterprise depends on is marked. - The hero shows the installed twcore (init, check, serve), not cargo run. - The twcore JSON-LD gains installUrl, and its description is required: a missing meta.twcoreDescription fails the build. Home page - Meta and body say ThinkWatch Enterprise. Core is what both products share and also runs on a Linux server; its strip shows the install command with a copy button and a link to the server guide instead of cargo run. - Compare table: Core is released with prebuilt binaries. Lite band: pricing without the retired subscription split, the five guards and the scan of client configurations, the remote core. "Apple silicon" throughout. Core docs - Overview, quick start, crate layers, and development and tests rewritten against the current Core: prebuilt binaries and the install script, pointing a client at the gateway (Claude Code, OpenAI-format clients, links to the Lite docs and the configuration reference), the daily price table refresh, pull requests to main, the control-plane rule, releases. - New pages: Configuration reference (/docs/core/configuration) and Server deployment (/docs/core/server-deployment), published from docs/config*.md and docs/server*.md in ThinkWatch-Core rather than copied. Publishing the Core documents - src/lib/core-docs.mjs and a content collection (src/lib/core-docs-loader.ts) fetch the four files from the latest Core release at build time over raw.githubusercontent.com with the site's generic User-Agent, render them with the site's markdown settings, drop the language-switch line, and point links at this site (another published document) or at GitHub at the same tag. CORE_DOCS_REF previews another ref. - src/data/core-docs is a committed copy, used when GitHub cannot be reached; the build warns (and annotates the pull request in Actions) when it differs from the latest release, and `pnpm core-docs` refreshes it. Pages are dated by the git history of that copy, and "Edit on GitHub" points at the Core repository. Also: markdown tables scroll sideways inside a wrapper instead of being cut off on narrow screens (every doc). Co-Authored-By: Claude Opus 5.5 --- astro.config.mjs | 22 + package.json | 1 + scripts/update-core-docs.mjs | 18 + src/components/CopyCommand.astro | 58 ++ src/components/home/CoreStrip.astro | 21 +- src/components/pages/CorePage.astro | 156 +++- src/content.config.ts | 16 +- src/content/docs-core/en/crate-layers.md | 55 +- src/content/docs-core/en/development.md | 32 +- src/content/docs-core/en/overview.md | 27 +- src/content/docs-core/en/quick-start.md | 68 +- src/content/docs-core/zh-CN/crate-layers.md | 53 +- src/content/docs-core/zh-CN/development.md | 32 +- src/content/docs-core/zh-CN/overview.md | 27 +- src/content/docs-core/zh-CN/quick-start.md | 64 +- src/content/docs/_meta.ts | 38 +- src/data/core-docs/config.md | 871 ++++++++++++++++++++ src/data/core-docs/config.zh-CN.md | 757 +++++++++++++++++ src/data/core-docs/manifest.json | 10 + src/data/core-docs/server.md | 239 ++++++ src/data/core-docs/server.zh-CN.md | 175 ++++ src/i18n/pages/core.ts | 208 +++-- src/i18n/pages/home.ts | 50 +- src/layouts/DocsLayout.astro | 8 +- src/lib/core-docs-loader.ts | 73 ++ src/lib/core-docs.mjs | 190 +++++ src/lib/lastmod.ts | 7 +- src/lib/structured-data.ts | 13 +- src/pages/docs/_DocArticle.astro | 18 +- src/pages/docs/_lib.ts | 20 +- src/styles/global.css | 16 + 31 files changed, 3128 insertions(+), 215 deletions(-) create mode 100644 scripts/update-core-docs.mjs create mode 100644 src/components/CopyCommand.astro create mode 100644 src/data/core-docs/config.md create mode 100644 src/data/core-docs/config.zh-CN.md create mode 100644 src/data/core-docs/manifest.json create mode 100644 src/data/core-docs/server.md create mode 100644 src/data/core-docs/server.zh-CN.md create mode 100644 src/lib/core-docs-loader.ts create mode 100644 src/lib/core-docs.mjs diff --git a/astro.config.mjs b/astro.config.mjs index a2e204b..f0db395 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -8,6 +8,27 @@ import rehypeAutolinkHeadings from 'rehype-autolink-headings'; import { lastModified, pageReleases, pageSources } from './src/lib/lastmod.ts'; import { getReleases } from './src/lib/github.ts'; +/** + * Wraps every markdown table in a container that scrolls sideways. The page + * clips horizontal overflow (global.css), so a table wider than a phone screen + * would otherwise be cut off rather than scroll. + */ +function rehypeScrollingTables() { + /** @param {any} node */ + const visit = (node) => { + if (!Array.isArray(node.children)) return; + node.children = node.children.map((/** @type {any} */ child) => { + if (child.type === 'element' && child.tagName === 'table') { + // Focusable, like the code blocks, so that it can be scrolled from the keyboard. + return { type: 'element', tagName: 'div', properties: { className: ['table-scroll'], tabIndex: 0 }, children: [child] }; + } + visit(child); + return child; + }); + }; + return (/** @type {any} */ tree) => visit(tree); +} + // https://astro.build/config export default defineConfig({ site: 'https://thinkwat.ch', @@ -87,6 +108,7 @@ export default defineConfig({ wrap: false, }, rehypePlugins: [ + rehypeScrollingTables, rehypeSlug, [ rehypeAutolinkHeadings, diff --git a/package.json b/package.json index 4ac8384..bfb5581 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "pagefind": "pagefind --site dist --output-path dist/pagefind", "preview": "astro preview", "releases": "node scripts/update-releases.mjs", + "core-docs": "node scripts/update-core-docs.mjs", "favicons": "node scripts/generate-favicons.mjs", "astro": "astro" }, diff --git a/scripts/update-core-docs.mjs b/scripts/update-core-docs.mjs new file mode 100644 index 0000000..680a59c --- /dev/null +++ b/scripts/update-core-docs.mjs @@ -0,0 +1,18 @@ +// Refresh src/data/core-docs, the committed copy of the ThinkWatch Core +// documents this site publishes (the configuration reference and the server +// deployment guide), from the latest Core release. The build always fetches +// the documents itself and uses this copy only when GitHub cannot be reached; +// it warns when the copy no longer matches. See src/lib/core-docs.mjs. +// +// Run with: pnpm core-docs (GITHUB_TOKEN is used when set; CORE_DOCS_REF +// takes another tag). +import { fileURLToPath } from "node:url"; + +// Paths in core-docs.mjs are relative to the project root. +process.chdir(fileURLToPath(new URL("..", import.meta.url))); +const { coreDocsRef, fetchCoreDocs, SNAPSHOT_DIR, writeSnapshot } = await import("../src/lib/core-docs.mjs"); + +const ref = await coreDocsRef(); +const docs = await fetchCoreDocs(ref); +await writeSnapshot(docs); +console.log(`✓ Wrote ${SNAPSHOT_DIR} from ThinkWatch Core ${ref} (${Object.keys(docs.files).join(", ")})`); diff --git a/src/components/CopyCommand.astro b/src/components/CopyCommand.astro new file mode 100644 index 0000000..21f7b33 --- /dev/null +++ b/src/components/CopyCommand.astro @@ -0,0 +1,58 @@ +--- +// A shell command with a copy button: the command scrolls, the button stays. +// Used for the twcore install command on the Core page and on the home page. +import { getLang, t } from "~/i18n"; + +interface Props { + command: string; + /** Colour and size of the command text */ + class?: string; +} + +const { command, class: className = "text-[#e6ebf2] text-[15px] sm:text-base" } = Astro.props; +const ui = t(getLang(Astro)).common; +--- + +
+ + $ {command} + + +
+ + + + diff --git a/src/components/home/CoreStrip.astro b/src/components/home/CoreStrip.astro index 56e4007..272999c 100644 --- a/src/components/home/CoreStrip.astro +++ b/src/components/home/CoreStrip.astro @@ -1,16 +1,31 @@ --- +// ThinkWatch Core on the home page: what the two products share, and the +// command that installs twcore on a Linux server (see the /core page). +import CopyCommand from "~/components/CopyCommand.astro"; import { getLang, localePath } from "~/i18n"; import { homeCopy } from "~/i18n/pages/home"; const lang = getLang(Astro); const c = homeCopy[lang].core; + +const installCommand = + "curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh"; ---
-
-

+

+

{c.a}{c.name}{c.b}

- cargo run -p twcore -- serve +
+

{c.commandLabel}

+ + + {c.docs} + +
diff --git a/src/components/pages/CorePage.astro b/src/components/pages/CorePage.astro index 40f8505..7e6d761 100644 --- a/src/components/pages/CorePage.astro +++ b/src/components/pages/CorePage.astro @@ -1,11 +1,14 @@ --- // The /core page: ThinkWatch Core, the MIT-licensed Rust crates and the twcore -// binary shared by ThinkWatch Lite and ThinkWatch Enterprise. +// binary. twcore is the gateway inside ThinkWatch Lite and also runs on its own +// on a Linux server; ThinkWatch Enterprise depends on three of the crates. import Base from "~/layouts/Base.astro"; import SiteHeader from "~/components/SiteHeader.astro"; import SiteFooter from "~/components/SiteFooter.astro"; +import CopyCommand from "~/components/CopyCommand.astro"; import { getLang, localePath } from "~/i18n"; import { coreCopy } from "~/i18n/pages/core"; +import { getLatestCoreRelease, getReleases } from "~/lib/github"; import { coreLd } from "~/lib/structured-data"; const lang = getLang(Astro); @@ -14,15 +17,48 @@ const zh = lang === "zh-CN"; const repo = "https://github.com/ThinkWatchProject/ThinkWatch-Core"; const docsHref = localePath(lang, "/docs/core"); +const serverDocsHref = localePath(lang, "/docs/core/server-deployment"); +const configDocsHref = localePath(lang, "/docs/core/configuration"); +const layersDocsHref = localePath(lang, "/docs/core/crate-layers"); const jsonLd = await coreLd(lang); +// The server install script, scripts/install.sh in the Core repository. +const installScript = "https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh"; +const installCommand = `curl -fsSL ${installScript} | sudo sh`; +// The service unit runs core as this user, with this data directory. +const asServiceUser = "sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore …"; + +// Prebuilt binaries. The file names are fixed by Core's release workflow +// (twcore upgrade and the install script depend on them as well). Links point +// at the latest release read at build time, or at GitHub's redirect to the +// latest release when GitHub could not be reached; the version then comes from +// the committed list of releases (src/data/releases.json). +const release = await getLatestCoreRelease(); +const tag = release?.tag ?? (await getReleases()).find((r) => r.product === "core")?.tag; +const version = tag?.replace(/^v/, ""); +const download = (file: string) => release?.assets[file] ?? `${repo}/releases/latest/download/${file}`; +const binaries: { label: string; file: string; archive?: string }[] = [ + { label: c.install.platforms.mac, file: "twcore-aarch64-apple-darwin" }, + { label: c.install.platforms.winX64, file: "twcore-x86_64-pc-windows-msvc.exe" }, + { label: c.install.platforms.winArm, file: "twcore-aarch64-pc-windows-msvc.exe" }, + { label: c.install.platforms.linuxX64, file: "twcore-x86_64-unknown-linux-gnu", archive: "twcore-x86_64-unknown-linux-gnu.tar.gz" }, + { label: c.install.platforms.linuxArm, file: "twcore-aarch64-unknown-linux-gnu", archive: "twcore-aarch64-unknown-linux-gnu.tar.gz" }, +]; +const sourceCommands = [ + "git clone https://github.com/ThinkWatchProject/ThinkWatch-Core.git", + "cd ThinkWatch-Core", + "cargo build --release -p twcore", +]; + const delay = (i: number) => `animation-delay: ${i * 90}ms;`; const eyebrow = "font-mono text-[11px] uppercase tracking-[0.08em] text-[var(--color-muted)] sm:text-[13px]"; +const label = "m-0 font-mono text-[13px] uppercase tracking-[0.08em] text-[var(--color-muted)]"; const h2 = zh ? "font-display m-0 text-[26px] leading-[1.35] sm:text-[32px] lg:text-[36px]" : "font-display m-0 text-[30px] leading-[1.15] sm:text-[36px] lg:text-[40px]"; const textLink = "inline-flex min-h-11 items-center text-[17px] font-medium text-[var(--color-brand-1)] transition-colors hover:text-[var(--color-brand-2)]"; +const fileLink = "break-all text-[var(--color-brand-1)] transition-colors hover:text-[var(--color-brand-2)]"; --- @@ -36,24 +72,36 @@ const textLink =

- {c.hero.titleA}{c.hero.titleHighlight} + {zh ? ( + // Kept on one line, so that the break falls before the highlight + // rather than leaving "的" at the start of the second line. + {c.hero.titleA.trimEnd()}{c.hero.titleA.endsWith(" ") && " "} + ) : ( + c.hero.titleA + )}{c.hero.titleHighlight}

-

+

{c.hero.sub}

- @@ -93,7 +141,7 @@ const textLink =

{c.does.eyebrow}

-
+
{c.does.items.map((item) => (

{item.title}

@@ -104,24 +152,99 @@ const textLink =
+
+
+
+
+

{c.install.eyebrow}

+

{c.install.title}

+ {c.install.body.map((text) => ( +

{text}

+ ))} + +
+ +
+

{c.install.scriptLabel}

+ +

{c.install.pinNote}

+ +

{c.install.nextLabel}

+
+
    + {c.install.next.map((row) => ( +
  • + {row.cmd} + {row.note} +
  • + ))} +
+
+
+

{c.install.serviceUser}

+ {asServiceUser} +
+
+
+ +
+
+

{c.install.binariesTitle}

+

{c.install.binariesBody}

+ +

+ {version && `${c.install.version} ${version} · `} + {c.install.releases} +

+
+ +
+

{c.install.sourceTitle}

+

{c.install.sourceBody}

+
+
    + {sourceCommands.map((cmd) => ( +
  • $ {cmd}
  • + ))} +
+
+
+
+
+
+

{c.layers.eyebrow}

    - {c.layers.rows.map((row, i) => ( + {c.layers.rows.map((row) => (
  1. {row.name} {row.crates} - {i === 0 ? ( - + {row.mark === "shared" ? ( + {c.layers.sharedTag} - ) : i >= 2 ? ( + ) : row.mark === "local" ? ( {c.layers.localNote} ) : ( @@ -130,6 +253,7 @@ const textLink = ))}

{c.layers.footnote}

+ {c.layers.docs}
diff --git a/src/content.config.ts b/src/content.config.ts index 6b038e0..c9b7d8a 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -1,5 +1,6 @@ import { defineCollection, z } from "astro:content"; import { glob } from "astro/loaders"; +import { coreDocsLoader } from "./lib/core-docs-loader"; const changelogSchema = z.object({ version: z.string(), @@ -39,4 +40,17 @@ const docs_core = defineCollection({ schema: z.object({}).passthrough(), }); -export const collections = { changelog, changelog_zh, docs, docs_lite, docs_core }; +// Core documents whose text lives in the ThinkWatch Core repository (the +// configuration reference, the server deployment guide): fetched from the latest +// Core release at build time, see src/lib/core-docs.mjs. Same ids as docs_core. +const docs_core_synced = defineCollection({ + loader: coreDocsLoader(), + schema: z.object({ + /** Path of the document in the Core repository */ + source: z.string(), + /** The tag it was taken from */ + ref: z.string(), + }), +}); + +export const collections = { changelog, changelog_zh, docs, docs_lite, docs_core, docs_core_synced }; diff --git a/src/content/docs-core/en/crate-layers.md b/src/content/docs-core/en/crate-layers.md index 994d685..3cca065 100644 --- a/src/content/docs-core/en/crate-layers.md +++ b/src/content/docs-core/en/crate-layers.md @@ -1,29 +1,52 @@ # Crate layers -Core's crates are organised into four layers. +ThinkWatch Core consists of sixteen crates and one binary, `twcore`. The workspace divides the crates in two: the three that ThinkWatch Enterprise depends on, and the crates of the gateway that `twcore` runs, which ThinkWatch Enterprise does not use. Within the second part, the crates are grouped by role. ``` -tw-types · tw-protocol · tw-provider · tw-resil · tw-crypto ← defined by external constraints -tw-engine · tw-pricing · tw-redact · tw-yaml · tw-secret ← domain logic -tw-config · tw-store · tw-scan · tw-adopt · tw-observe ← assembly -tw-gateway · tw-control ← data plane / control plane +tw-dialect · tw-guard · tw-breaker ← shared with ThinkWatch Enterprise +tw-types · tw-engine · tw-pricing · tw-yaml · tw-secret · tw-watch ← domain logic +tw-api · tw-link ← control-plane contract +tw-config · tw-store · tw-observe ← assembly +tw-gateway · tw-control ← data plane / control plane ``` -| Layer | Crates | Role | +No crate depends on a group below its own. + +| Group | Crate | Role | | --- | --- | --- | -| 1 | `tw-types`, `tw-protocol`, `tw-provider`, `tw-resil`, `tw-crypto` | Defined by external constraints | -| 2 | `tw-engine`, `tw-pricing`, `tw-redact`, `tw-yaml`, `tw-secret` | Domain logic | -| 3 | `tw-config`, `tw-store`, `tw-scan`, `tw-adopt`, `tw-observe` | Assembly | -| 4 | `tw-gateway`, `tw-control` | Data plane and control plane | +| Shared with ThinkWatch Enterprise | `tw-dialect` | Conversion of requests, responses and streams between Anthropic Messages, OpenAI Chat Completions, OpenAI Responses and Gemini; usage parsing | +| | `tw-guard` | Outbound redaction and restoration, inspection of the tool calls an upstream returns, hidden characters, content filtering and the output length limit | +| | `tw-breaker` | The circuit-breaker state machine | +| Domain logic | `tw-types` | Messages for people: a stable code, its arguments and the English sentence | +| | `tw-engine` | The routing rule engine and strategy groups | +| | `tw-pricing` | The public price table, price sheets, and cost in three states: measured, estimated and unpriced | +| | `tw-yaml` | Minimal edits to YAML text that keep comments and layout | +| | `tw-secret` | Environment variable interpolation, credentials from commands, and secret masking | +| | `tw-watch` | Directory watching with one signal per burst of changes | +| Control-plane contract | `tw-api` | Request and response types of the control plane, and a client | +| | `tw-link` | The handshake and encryption of the control channel (Noise `NNpsk0`) | +| Assembly | `tw-config` | Configuration schema, loading and validation | +| | `tw-store` | Request history and runtime state on SQLite and the file system | +| | `tw-observe` | The event bus | +| Data plane / control plane | `tw-gateway` | The gateway's HTTP server | +| | `tw-control` | The control-plane API over a unix socket, a loopback port on Windows, and the optional remote control port | + +## Shared with ThinkWatch Enterprise + +ThinkWatch Enterprise depends on the first group and nothing else: format conversion and usage parsing (`tw-dialect`), the guards (`tw-guard`) and the circuit breaker (`tw-breaker`). These three depend only on each other, which a test in `tw-dialect` enforces, and CI builds ThinkWatch Enterprise against every change to them. A component that only one product uses lives in that product's repository rather than in Core. + +## Used by ThinkWatch Lite + +ThinkWatch Lite bundles the `twcore` binary of a Core release and compiles `tw-api`, `tw-link`, `tw-types`, `tw-yaml`, `tw-guard` and `tw-watch` from the same tag: the app and the binary speak one control-plane protocol, so both have to come from one commit. -## Shared layers +Configuring AI clients to use the gateway, editing their MCP servers and scanning their configuration are part of the desktop app, not of Core. They change files on the machine the app runs on, which need not be the machine that runs core. Core only issues a client its own gateway key. -The top two layers remain stable with respect to external constraints. The server edition depends on them directly. +## The single-machine implementation -## Unshared layers +The assembly group and the data plane and control plane form the single-machine implementation: SQLite, the local control channel and the optional remote control port. They are intentionally **not** shared. Single-machine SQLite and multi-tenant Postgres differ too much for one abstraction to serve both. -The bottom two layers form the single-machine implementation, built on SQLite and a unix socket, and are intentionally **not** shared. Single-machine SQLite and multi-tenant Postgres differ too much for one abstraction to serve both. +## Prices in tw-pricing -## Price list in tw-pricing +`tw-pricing` embeds a pinned snapshot of a public price table, so that a fresh installation and a machine without network access can price requests. At runtime, the control plane refreshes the table once a day unless `pricing.auto_update` is `false`, and whichever copy is newer prices requests. Price sheets in `config.yaml` apply a multiplier, or prices per model, to the upstreams that select them. -`crates/tw-pricing` embeds a pinned price snapshot that is not updated automatically. Tracking upstream automatically would allow two builds to compute different prices, and a discrepancy between one day's figures and the next could not be explained to users. See [Development and tests](/docs/core/development#the-price-list) for the snapshot update procedure. +A refresh or an edited price sheet changes the price of later requests, never of those already recorded. Each request stores its cost and where the price came from, so a figure can be explained afterwards. See [The price list](/docs/core/development#the-price-list) for how the snapshot is updated. diff --git a/src/content/docs-core/en/development.md b/src/content/docs-core/en/development.md index c0d3ef5..c2abf5b 100644 --- a/src/content/docs-core/en/development.md +++ b/src/content/docs-core/en/development.md @@ -7,7 +7,7 @@ cargo test --workspace # unit and integration tests scripts/smoke.sh # from a clean state, exercise every path on the real binary ``` -`scripts/smoke.sh` runs the real binary against a real socket and a real data plane. It detects issues that unit tests cannot structurally detect: file permissions, socket path length limits, unregistered endpoints, and config fields that are silently ignored. The project's first four real bugs were all in these areas. +`scripts/smoke.sh` runs the real binary against a real socket and a real data plane, and reaches the control plane through `twcore call`: every control connection starts with a Noise handshake, so `curl` cannot talk to it. The script detects issues that unit tests structurally cannot: file permissions, socket path length limits, unregistered endpoints, and configuration fields that are silently ignored. The project's first four real bugs were all in these areas. The smoke script does not modify any user files. `HOME` and `THINKWATCH_HOME` both point to a temporary directory that is deleted on completion. @@ -22,26 +22,42 @@ cargo test --workspace ./scripts/smoke.sh ``` -Warnings are treated as errors; relaxing this rule in CI would be equivalent to removing it. The toolchain tracks `stable`, so a stable release newer than your local one may report lints that cannot be reproduced locally. Run `rustup update stable` before investigating a CI failure. +Warnings are treated as errors; relaxing this rule in CI would be equivalent to removing it. The toolchain tracks `stable`, so a stable release newer than the local one may report lints that cannot be reproduced locally. Run `rustup update stable` before investigating a CI failure. -Open pull requests against `dev`, not `main`: +Pull requests target `main`, the only long-lived branch; a release is a tagged commit on it: ```bash -gh pr create --base dev --head your-branch +gh pr create --base main --head your-branch ``` -`main` is the release branch; `dev` receives routine work. Commit messages follow Conventional Commits (`fix(scope): subject`) and are written in English. Explain *why* in the body, not only *what*. +Commit messages follow Conventional Commits (`fix(scope): subject`) and are written in English. The body explains *why*, not only *what*. ## Mandatory rules -Both editions depend on these crates, so working for a single use case is not sufficient. A pull request that violates any of these rules will be returned for revision, regardless of the quality of the diff: +ThinkWatch Lite and ThinkWatch Enterprise both depend on these crates, so working for a single use case is not sufficient. A pull request that violates any of these rules will be returned for revision, regardless of the quality of the diff: - **Never echo a real secret** in the UI, a diff, a log, an event, a diagnostic bundle, or a test fixture. Masking is applied before data leaves the process. - **Never present an estimate as exact.** Cost has three states: measured, estimated, and no price. Treating the third as 0 makes a total incorrect without any indication. - **Observation must never block forwarding.** Storage, pricing, and scanning run on bounded channels; when a channel is full, the observation is dropped rather than delaying the request. -- **Report, never auto-delete.** The scanner has no write path, and a test inspects the product code to verify this. - **Any path that bypasses the main pipeline must re-apply its protections.** Replay nearly became a legitimate way to bypass redaction. +- **One door into the control plane.** Every transport (the unix socket, the Windows loopback port and the remote control port) hands its connections to the same handshake before HTTP. The control key never leaves through the control plane and cannot be changed through it. + +## The configuration reference + +The [Configuration reference](/docs/core/configuration) is `docs/config.md` (and `docs/config.zh-CN.md`) in the repository; this site publishes it from the latest release. The text is written by hand, except the field tables and the lists of built-in rules, which are generated from `crates/tw-config/tests/manual/schema.rs`. That file declares every section of `config.yaml` against its Rust type, and a test checks the declaration against the code: field names come from serde itself, declared defaults are parsed and compared with leaving the field out, and the examples in the manual are parsed as configuration. + +After changing a configuration type, add or change its row in both languages and regenerate the tables: + +```bash +UPDATE_CONFIG_DOCS=1 cargo test -p tw-config --test manual +``` ## The price list -`crates/tw-pricing` embeds a pinned snapshot that is not updated automatically. The update procedure is documented in `crates/tw-pricing/data/PROVENANCE.md`, and a CI test compares the snapshot row by row against the manually verified `data/verified.yaml`. +Prices come in two layers. The default price table is LiteLLM's public dataset: a pinned snapshot is embedded in `crates/tw-pricing`, the update procedure is documented in `crates/tw-pricing/data/PROVENANCE.md`, and a CI test compares the snapshot row by row against the manually verified `data/verified.yaml`. At runtime the control plane refreshes the table once a day, unless `pricing.auto_update: false`, and saves it as `model_prices.json` beside `config.yaml`; whichever copy is newer prices requests. + +Price sheets live in `config.yaml` under `pricing.sheets`: a multiplier over the default table, plus per-model overrides with every field written out. An upstream selects a sheet with `pricing:`; without one, the default table applies. + +## Releases + +A release is a tag `vX.Y.Z` on `main`, after the version in the workspace `Cargo.toml` has been bumped. The release workflow builds `twcore` for macOS on Apple silicon, Windows on x64 and ARM64, and Linux on x86_64 and aarch64, checks each binary, and publishes them together with a `.sha256` file for each; if one target fails, nothing is published. The Linux archives carry the systemd unit and are what the server install script installs; the bare binaries are what ThinkWatch Lite bundles and what `twcore upgrade` downloads. diff --git a/src/content/docs-core/en/overview.md b/src/content/docs-core/en/overview.md index 73fc350..443d9f6 100644 --- a/src/content/docs-core/en/overview.md +++ b/src/content/docs-core/en/overview.md @@ -1,28 +1,31 @@ # ThinkWatch Core -ThinkWatch Core is the shared core of the ThinkWatch AI API gateways: routing, forwarding, observability, cost accounting, and a set of data-plane guards. It is used by the desktop app, [ThinkWatch Lite](/docs/lite), and by the server edition. +ThinkWatch Core is the gateway engine of the ThinkWatch products: a set of MIT-licensed Rust crates and `twcore`, a self-contained AI API gateway binary. `twcore` is the gateway inside the desktop app, [ThinkWatch Lite](/docs/lite), and also runs on its own as a systemd service on a Linux server. ThinkWatch Enterprise depends on three of the crates: `tw-dialect`, `tw-guard` and `tw-breaker`. ## Scope of Core -Core is a set of Rust crates, not an installable application. For a runnable gateway, `bin/twcore` is a complete, self-contained gateway binary; [Quick start with twcore](/docs/core/quick-start) describes how to use it. +Core is a set of crates and one binary, not a desktop application. On a desktop, `twcore` comes with ThinkWatch Lite and is not installed separately. On a server, a single command installs it as a service, and ThinkWatch Lite on macOS, Windows or Linux connects to it; see [Server deployment](/docs/core/server-deployment). [Quick start with twcore](/docs/core/quick-start) covers the prebuilt binaries, building from source and a first configuration. -Both editions depend on these crates, so a change in Core affects both. +A change in Core reaches every product that uses it, so each change follows the rules in [Development and tests](/docs/core/development). ## What it does -When a client such as Claude Code or Codex is pointed at a local port, Core provides the following: +Clients such as Claude Code and Codex send their requests to the gateway, and Core provides the following: -- **Rule-based routing** to different upstreams. Conditions include the model name, the client, the context length, and whether tools are present; actions include switching upstream, rewriting parameters, and rejecting the request. -- **Mid-stream failover.** Before the first byte is sent, the gateway can switch upstreams transparently. After streaming has started, it reports the failure. -- **Cost visibility.** Token usage and cache hits are priced against a snapshot table. Usage that cannot be priced is labelled *unknown* rather than assigned a fabricated figure. -- **Outbound redaction.** Secrets in a request are replaced with placeholders before they reach a relay, and restored when the model echoes them back. -- **Inbound inspection.** Tool calls returned by an upstream are checked against a rule set, and a dangerous call can be terminated mid-frame. +- **Rule-based routing.** Rules match on the model, the gateway key, the input size, the presence of tools or images and other properties of a request, and send it to an upstream or a group, rewrite its parameters or refuse it. When the client and the upstream use different API formats, the request is converted between Anthropic Messages, OpenAI Chat Completions, OpenAI Responses and Gemini. +- **Mid-stream failover.** Until the first byte reaches the client, a failing upstream is replaced by the next one without the client noticing. After that point, the failure is reported. A circuit breaker keeps requests away from an upstream that keeps failing. +- **Cost accounting.** Token usage and cache hits are priced from a public price table, which the control plane refreshes daily, or from a price sheet in the configuration. Each request records its cost and where the price came from. Estimated amounts are marked as such, and usage that cannot be priced is labelled *unknown* rather than given an invented figure. +- **Outbound redaction.** Credentials in a request are replaced with placeholders before the request leaves, and restored when the model echoes them back. +- **Tool-call inspection.** Tool calls returned by an upstream are checked against a rule set, and a dangerous call can be cut off mid-stream. With checks for hidden characters, content rules and an output limit, these form five guards, each set to `off`, `observe` or `enforce`. +- **An encrypted control plane.** The desktop app and `twcore` commands reach core over a local socket (a loopback port on Windows) and, when it is enabled, a remote control port. Every control connection starts with a Noise handshake keyed by `listen.control.key`; there are no certificates. ## Further reading -- [Quick start with twcore](/docs/core/quick-start): writing, checking, and serving a config. -- [Crate layers](/docs/core/crate-layers): how the crates are organised, and which layers are shared. -- [Development and tests](/docs/core/development): tests, the smoke script, and the rules every change must follow. +- [Quick start with twcore](/docs/core/quick-start): getting the binary, writing and checking a configuration, and pointing a client at the gateway. +- [Server deployment](/docs/core/server-deployment): running `twcore` as a systemd service on Linux and connecting ThinkWatch Lite to it. +- [Configuration reference](/docs/core/configuration): every field of `config.yaml`. +- [Crate layers](/docs/core/crate-layers): how the crates are grouped, and which of them each product uses. +- [Development and tests](/docs/core/development): tests, the smoke script, releases, and the rules every change follows. ## License diff --git a/src/content/docs-core/en/quick-start.md b/src/content/docs-core/en/quick-start.md index 7f326af..797a5d3 100644 --- a/src/content/docs-core/en/quick-start.md +++ b/src/content/docs-core/en/quick-start.md @@ -1,23 +1,65 @@ # Quick start with twcore -ThinkWatch Core is a set of crates, not an installable application. `bin/twcore` is a complete, self-contained gateway binary, and it is the recommended way to observe Core's behaviour. +`twcore` is the gateway binary built from ThinkWatch Core. ThinkWatch Lite includes it, so a desktop with the app installed already runs one; this page covers running `twcore` on its own. To run it as a service on a Linux server and manage it from ThinkWatch Lite, follow [Server deployment](/docs/core/server-deployment) instead. -## Write, check, and serve a config +## Get the binary -Run the following commands in a checkout of the [ThinkWatch Core repository](https://github.com/ThinkWatchProject/ThinkWatch-Core): +Every [release](https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/latest) carries prebuilt binaries, each with a `.sha256` file: -```bash -cargo run -p twcore -- init # write a commented config.yaml -cargo run -p twcore -- check # validate without starting -cargo run -p twcore -- serve # start the gateway and control plane +| Platform | File | +| --- | --- | +| macOS, Apple silicon | `twcore-aarch64-apple-darwin` | +| Windows, x64 | `twcore-x86_64-pc-windows-msvc.exe` | +| Windows, ARM64 | `twcore-aarch64-pc-windows-msvc.exe` | +| Linux, x86_64 | `twcore-x86_64-unknown-linux-gnu`, and a `.tar.gz` that adds the systemd unit | +| Linux, aarch64 | `twcore-aarch64-unknown-linux-gnu`, and a `.tar.gz` that adds the systemd unit | + +Download the file for the platform, check it against its `.sha256` file, and install it on the `PATH` as `twcore`. The Linux builds need glibc 2.35 or newer (Ubuntu 22.04, Debian 12 or later). + +On a Linux server, the install script does this in one step, and also creates a service user and the systemd unit: + +```sh +curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh +``` + +To build from source instead, with a stable Rust toolchain (1.85 or newer): + +```sh +git clone https://github.com/ThinkWatchProject/ThinkWatch-Core.git +cd ThinkWatch-Core +cargo build --release -p twcore # writes target/release/twcore +``` + +In a checkout, `cargo run -p twcore -- ` runs any of the commands below without installing anything. + +## Write, check, and serve a configuration + +```sh +twcore init # write an initial config.yaml with a gateway key and the control key +twcore check # validate the configuration without starting anything +twcore serve # start the gateway and the control plane +``` + +The configuration is `~/.thinkwatch/config.yaml`, or `%APPDATA%\ThinkWatch\config.yaml` on Windows; `THINKWATCH_HOME` moves the directory, and `--config ` names the file for a single command. `twcore init` prints the gateway key it generated; `twcore serve` writes a starting configuration of its own when there is none. + +The starting configuration has no upstream: the control plane runs, and requests are answered with an error saying that no upstream is configured. Adding one makes the gateway forward: + +```yaml +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} ``` -- **`init`** writes a commented `config.yaml`. -- **`check`** validates the config without starting anything. -- **`serve`** starts the gateway and the control plane. +`${NAME}` reads an environment variable of the `twcore` process. A running core reloads the file within a second of a save; a version that does not validate is refused, and the previous configuration keeps serving. Every field is described in the [Configuration reference](/docs/core/configuration). + +## Connect a client + +The gateway listens on `127.0.0.1:8788` by default (`listen.gateway` in the configuration). A client needs two settings: the gateway's address as its base URL, and a gateway key from `clients` as its API key. -## Connecting a client +- **Anthropic-format clients** use `http://127.0.0.1:8788`. For Claude Code, set `ANTHROPIC_BASE_URL=http://127.0.0.1:8788` and `ANTHROPIC_AUTH_TOKEN` to the gateway key. +- **OpenAI-format clients** use `http://127.0.0.1:8788/v1`, with the gateway key as the API key. -Point a client such as Claude Code or Codex at the local port. Core then routes each request according to the configured rules, fails over between upstreams, prices usage against its snapshot table, redacts secrets in outbound requests, and inspects tool calls in responses. Each capability is described in the [Overview](/docs/core#what-it-does). +ThinkWatch Lite points Claude Code, Codex and other supported clients at the gateway from its Clients page; see the [ThinkWatch Lite documentation](/docs/lite). The fields of a gateway key, such as the models it may use and the route it takes, are described under [`clients`](/docs/core/configuration#clients) in the configuration reference. -Step-by-step configuration for individual clients is not yet documented. +Core then routes each request by the configured rules, fails over between upstreams, prices usage, redacts credentials in outbound requests and inspects the tool calls in responses. Each capability is described in the [Overview](/docs/core#what-it-does). diff --git a/src/content/docs-core/zh-CN/crate-layers.md b/src/content/docs-core/zh-CN/crate-layers.md index 79cca83..a9596d2 100644 --- a/src/content/docs-core/zh-CN/crate-layers.md +++ b/src/content/docs-core/zh-CN/crate-layers.md @@ -1,29 +1,52 @@ # crate 分层 -Core 的 crate 分为四层。 +ThinkWatch Core 由十六个 crate 和一个二进制 `twcore` 组成。工作区把这些 crate 分为两部分:ThinkWatch 企业版依赖的三个 crate,以及 `twcore` 所运行网关的其余 crate(ThinkWatch 企业版不使用)。第二部分再按职责分组。 ``` -tw-types · tw-protocol · tw-provider · tw-resil · tw-crypto ← 由外部约束决定 -tw-engine · tw-pricing · tw-redact · tw-yaml · tw-secret ← 领域逻辑 -tw-config · tw-store · tw-scan · tw-adopt · tw-observe ← 装配 -tw-gateway · tw-control ← 数据面 / 控制面 +tw-dialect · tw-guard · tw-breaker ← 与 ThinkWatch 企业版共用 +tw-types · tw-engine · tw-pricing · tw-yaml · tw-secret · tw-watch ← 领域逻辑 +tw-api · tw-link ← 控制面契约 +tw-config · tw-store · tw-observe ← 装配 +tw-gateway · tw-control ← 数据面 / 控制面 ``` -| 层 | crate | 作用 | +任何 crate 都不依赖排在其所在组下方的组。 + +| 分组 | crate | 作用 | | --- | --- | --- | -| 1 | `tw-types`、`tw-protocol`、`tw-provider`、`tw-resil`、`tw-crypto` | 由外部约束决定 | -| 2 | `tw-engine`、`tw-pricing`、`tw-redact`、`tw-yaml`、`tw-secret` | 领域逻辑 | -| 3 | `tw-config`、`tw-store`、`tw-scan`、`tw-adopt`、`tw-observe` | 装配 | -| 4 | `tw-gateway`、`tw-control` | 数据面与控制面 | +| 与 ThinkWatch 企业版共用 | `tw-dialect` | 请求、响应与流在 Anthropic Messages、OpenAI Chat Completions、OpenAI Responses 与 Gemini 之间的转换;用量解析 | +| | `tw-guard` | 出站脱敏与还原、上游返回的工具调用审查、隐藏字符、内容过滤与输出长度限制 | +| | `tw-breaker` | 熔断器状态机 | +| 领域逻辑 | `tw-types` | 面向用户的消息:稳定的消息码、参数与英文句子 | +| | `tw-engine` | 路由规则引擎与策略组 | +| | `tw-pricing` | 公开价目表、自定义价目表,以及实测、估算、无法计价三种状态的费用 | +| | `tw-yaml` | 对 YAML 文本做最小改动,保留注释与排版 | +| | `tw-secret` | 环境变量插值、由命令提供的凭据,以及密钥遮蔽 | +| | `tw-watch` | 目录监视,一连串改动只发一次信号 | +| 控制面契约 | `tw-api` | 控制面的请求与响应类型,以及客户端 | +| | `tw-link` | 控制通道的握手与加密(Noise `NNpsk0`) | +| 装配 | `tw-config` | 配置的结构、加载与校验 | +| | `tw-store` | 基于 SQLite 与文件系统的请求历史和运行状态 | +| | `tw-observe` | 事件总线 | +| 数据面 / 控制面 | `tw-gateway` | 网关的 HTTP 服务 | +| | `tw-control` | 控制面 API,经 unix socket、Windows 回环端口与可选的远程控制端口提供 | + +## 与 ThinkWatch 企业版共用 + +ThinkWatch 企业版只依赖第一组:格式转换与用量解析(`tw-dialect`)、各项防护(`tw-guard`)与熔断器(`tw-breaker`)。这三个 crate 只依赖彼此,`tw-dialect` 中的一项测试保证这一点;每次改动它们,CI 都会用 ThinkWatch 企业版编译一遍。只有一个产品使用的组件放在该产品自己的仓库中,不留在 Core。 -## 共享层 +## ThinkWatch Lite 使用的部分 -上面两层相对外部约束保持稳定,服务端版本直接依赖这两层。 +ThinkWatch Lite 内置 Core 发布版本中的 `twcore` 二进制,并从同一 tag 编译 `tw-api`、`tw-link`、`tw-types`、`tw-yaml`、`tw-guard` 与 `tw-watch`:应用与二进制使用同一套控制面协议,两者必须出自同一个提交。 -## 非共享层 +让 AI 客户端改用网关、编辑其 MCP 服务器、扫描其配置,都属于桌面应用而非 Core:这些操作修改的是应用所在机器上的文件,而这台机器不一定运行 core。Core 只负责为客户端签发专属的网关密钥。 -下面两层为单机实现(SQLite、unix socket),**有意不共享**。单机 SQLite 与多租户 Postgres 差异过大,统一的抽象难以同时满足两者。 +## 单机实现 + +装配组以及数据面与控制面组构成单机实现:SQLite、本地控制通道与可选的远程控制端口。它们**有意不共享**:单机 SQLite 与多租户 Postgres 差异过大,统一的抽象难以同时满足两者。 ## tw-pricing 中的价目表 -`crates/tw-pricing` 内嵌一份固定的价目表快照,**不会**自动更新。若自动跟随上游,两次构建可能计算出不同的价格,而前后两天数字不一致的情况无法向用户解释。快照的更新方式见[开发与测试](/zh-CN/docs/core/development)。 +`tw-pricing` 内嵌一份固定的公开价目表快照,使新安装的实例与无法联网的机器也能计价。运行时,控制面每天刷新一次价目表(`pricing.auto_update` 为 `false` 时不刷新),两份价目表中较新的一份用于计价。`config.yaml` 中的自定义价目表可为选用它的上游设置倍率或逐个模型的价格。 + +刷新价目表或修改自定义价目表,只影响此后的请求,不改变已记录的请求。每个请求都保存其费用与价格来源,事后可据此解释数字。快照的更新方式见[价目表](/zh-CN/docs/core/development#价目表)。 diff --git a/src/content/docs-core/zh-CN/development.md b/src/content/docs-core/zh-CN/development.md index 1abb80e..bfa71fe 100644 --- a/src/content/docs-core/zh-CN/development.md +++ b/src/content/docs-core/zh-CN/development.md @@ -7,7 +7,7 @@ cargo test --workspace # 单元与集成测试 scripts/smoke.sh # 从初始状态出发,在真实二进制上覆盖每条路径 ``` -`scripts/smoke.sh` 使用真实的二进制、socket 与数据面运行,可发现单元测试在结构上无法覆盖的问题:文件权限、socket 路径长度限制、未注册的端点,以及被静默忽略的配置字段。本项目最早的四个真实缺陷均出自这些环节。 +`scripts/smoke.sh` 使用真实的二进制、socket 与数据面运行,并通过 `twcore call` 访问控制面:每条控制连接都先进行 Noise 握手,`curl` 无法直接访问。它可发现单元测试在结构上无法覆盖的问题:文件权限、socket 路径长度限制、未注册的端点,以及被静默忽略的配置字段。本项目最早的四个真实缺陷均出自这些环节。 冒烟脚本不会修改用户的任何文件:`HOME` 与 `THINKWATCH_HOME` 均指向临时目录,执行结束后自动删除。 @@ -24,24 +24,40 @@ cargo test --workspace 警告视为错误,在 CI 上放宽该规则即等同于取消该规则。工具链为 `stable`,若其版本新于本地工具链,可能报告本地无法复现的 lint;排查 CI 问题前,请先执行 `rustup update stable`。 -PR 应提交至 `dev` 分支,而非 `main`: +PR 提交至 `main`,这是唯一的长期分支;发布版本即其上打了 tag 的提交: ```bash -gh pr create --base dev --head your-branch +gh pr create --base main --head your-branch ``` -`main` 为发布分支,`dev` 用于合入日常工作。提交信息遵循 Conventional Commits(`fix(scope): subject`),以英文书写。正文应说明*原因*,而不仅是*改动内容*。 +提交信息遵循 Conventional Commits(`fix(scope): subject`),以英文书写。正文应说明*原因*,而不仅是*改动内容*。 ## 强制规则 -两个版本均依赖这些 crate,因此仅满足单一场景并不足够。违反以下任何一条规则的 PR 都将被要求修改,无论 diff 质量如何: +ThinkWatch Lite 与 ThinkWatch 企业版都依赖这些 crate,因此仅满足单一场景并不足够。违反以下任何一条规则的 PR 都将被要求修改,无论 diff 质量如何: - **禁止回显真实密钥**:界面、diff、日志、事件、诊断包与测试夹具中均不得出现。遮蔽在数据离开进程之前完成。 -- **禁止将估算值作为精确值呈现。** 成本有三种状态:实测、估算、无价格。将第三种视为 0 会导致合计在无任何提示的情况下出错。 +- **禁止将估算值作为精确值呈现。** 费用有三种状态:实测、估算、无价格。将第三种视为 0 会导致合计在无任何提示的情况下出错。 - **观测不得阻塞转发。** 存储、计价与扫描均通过有界通道执行;通道已满时丢弃该次观测,而不延迟请求。 -- **只报告,不自动删除。** 扫描器不存在写入路径,并有一项测试通过读取产品代码加以验证。 - **任何绕过主流水线的路径都必须重新施加其保护措施。** 重放功能曾险些成为绕过脱敏的合法途径。 +- **控制面只有一个入口。** 每种传输方式(unix socket、Windows 回环端口与远程控制端口)都先把连接交给同一套握手,之后才进入 HTTP。控制密钥不会经控制面流出,也无法经控制面修改。 + +## 配置手册 + +[配置手册](/zh-CN/docs/core/configuration)即仓库中的 `docs/config.zh-CN.md`(英文版为 `docs/config.md`),本站从最新的发布版本取用。正文由人工撰写,字段表与内置规则列表则由 `crates/tw-config/tests/manual/schema.rs` 生成。该文件按 Rust 类型声明 `config.yaml` 的每一节,并由测试对照代码检查这份声明:字段名取自 serde 本身,声明的默认值会被解析并与省略该字段的效果比较,手册中的示例也会作为配置解析。 + +修改配置类型后,须同时补充或修改中英文两份说明,再重新生成字段表: + +```bash +UPDATE_CONFIG_DOCS=1 cargo test -p tw-config --test manual +``` ## 价目表 -`crates/tw-pricing` 内嵌一份固定快照,不会自动更新。更新步骤记录于 `crates/tw-pricing/data/PROVENANCE.md`,CI 中的测试会将快照与人工核对的 `data/verified.yaml` 逐行比对。 +价格分两层。默认价目表是 LiteLLM 的公开数据集:`crates/tw-pricing` 内嵌一份固定快照,更新步骤记录于 `crates/tw-pricing/data/PROVENANCE.md`,CI 中的测试会将快照与人工核对的 `data/verified.yaml` 逐行比对。运行时,控制面每天刷新一次(`pricing.auto_update: false` 时不刷新),并保存为 `config.yaml` 旁的 `model_prices.json`;两份中较新的一份用于计价。 + +自定义价目表写在 `config.yaml` 的 `pricing.sheets` 下:在默认价目表之上乘一个倍率,外加逐个模型写全各项价格的覆盖。上游用 `pricing:` 选择一张价目表;未选择时使用默认价目表。 + +## 发布 + +发布版本是 `main` 上的一个 `vX.Y.Z` tag,打 tag 前须先提升工作区 `Cargo.toml` 中的版本号。发布流程为 Apple silicon 的 macOS、x64 与 ARM64 的 Windows、x86_64 与 aarch64 的 Linux 构建 `twcore`,逐一检查后连同各自的 `.sha256` 文件一并发布;任何一个目标构建失败,则什么都不发布。Linux 压缩包另含 systemd 服务单元,供服务器安装脚本使用;单独的二进制文件由 ThinkWatch Lite 打包,也由 `twcore upgrade` 下载。 diff --git a/src/content/docs-core/zh-CN/overview.md b/src/content/docs-core/zh-CN/overview.md index d0a78d4..0cf9a9f 100644 --- a/src/content/docs-core/zh-CN/overview.md +++ b/src/content/docs-core/zh-CN/overview.md @@ -1,28 +1,31 @@ # ThinkWatch Core -ThinkWatch Core 是 ThinkWatch AI API 网关的共享核心,提供路由、转发、可观测性、成本核算以及一组数据面防护。桌面应用 [ThinkWatch Lite](/zh-CN/docs/lite) 与服务端版本均使用它。 +ThinkWatch Core 是 ThinkWatch 各产品共用的网关引擎,由一组采用 MIT 许可证的 Rust crate 和独立运行的 AI API 网关二进制 `twcore` 组成。`twcore` 是桌面应用 [ThinkWatch Lite](/zh-CN/docs/lite) 内置的网关,也可以作为 systemd 服务独立运行在 Linux 服务器上。ThinkWatch 企业版依赖其中的三个 crate:`tw-dialect`、`tw-guard` 与 `tw-breaker`。 ## Core 的定位 -Core 是一组 Rust crate,而非可安装的应用。如需可运行的网关,`bin/twcore` 是一个完整且可独立运行的网关二进制,用法见 [twcore 快速入门](/zh-CN/docs/core/quick-start)。 +Core 是一组 crate 和一个二进制,而非桌面应用。在桌面上,`twcore` 随 ThinkWatch Lite 提供,无需单独安装;在服务器上,一条命令即可将其安装为服务,再由 macOS、Windows 或 Linux 上的 ThinkWatch Lite 连接,见[服务器部署](/zh-CN/docs/core/server-deployment)。预编译二进制、从源码构建与初始配置见 [twcore 快速入门](/zh-CN/docs/core/quick-start)。 -两个版本均依赖这些 crate,因此 Core 中的改动会同时影响两者。 +Core 中的改动会影响所有使用它的产品,因此每个改动都须遵守[开发与测试](/zh-CN/docs/core/development)中的规则。 ## 功能 -将客户端(如 Claude Code、Codex)指向本地端口后,Core 提供以下功能: +Claude Code、Codex 等客户端把请求发往网关后,Core 提供以下功能: -- **按规则路由**至不同上游。匹配条件包括模型名、客户端、上下文长度及是否携带工具;动作包括切换上游、改写参数或拒绝请求。 -- **流式故障转移。** 首字节发出之前,网关可透明切换上游;流式传输开始之后,网关报告故障情况。 -- **成本可见。** token 用量与缓存命中按内置价目表快照计价。无法计价的部分标记为「未知」,不会填入虚构的数值。 -- **出站脱敏。** 请求发往中转站之前,其中的密钥替换为占位符;模型回显时再恢复原值。 -- **入站审查。** 上游返回的工具调用按规则集审查,高危调用可在当前帧中止。 +- **按规则路由。** 规则按模型、网关密钥、输入规模、是否携带工具或图片等请求属性匹配,将请求发往某个上游或策略组、改写其参数,或拒绝请求。客户端与上游的接口格式不同时,请求在 Anthropic Messages、OpenAI Chat Completions、OpenAI Responses 与 Gemini 之间转换。 +- **流式故障转移。** 首字节到达客户端之前,出错的上游由下一个上游替换,客户端无从察觉;此后发生的故障如实报告。熔断器使持续出错的上游暂不接收请求。 +- **费用核算。** token 用量与缓存命中按公开价目表计价(控制面每天刷新一次),或按配置中的价目表计价;每个请求都记录费用及价格来源。估算的金额另行标注,无法计价的用量标记为「未知」,不会填入虚构的数值。 +- **出站脱敏。** 请求发出之前,其中的凭据替换为占位符;模型回显时再恢复原值。 +- **工具调用审查。** 上游返回的工具调用按规则集审查,高危调用可在流式传输中途截断。它与隐藏字符检查、内容规则和输出长度限制合为五项防护,每项可设为 `off`、`observe` 或 `enforce`。 +- **加密的控制面。** 桌面应用与 `twcore` 命令通过本地 socket(Windows 上为回环端口)连接 core,开启后也可经远程控制端口连接。每条控制连接都先以 `listen.control.key` 完成 Noise 握手,不使用证书。 ## 后续阅读 -- [twcore 快速入门](/zh-CN/docs/core/quick-start):生成、校验并启动配置。 -- [crate 分层](/zh-CN/docs/core/crate-layers):crate 的组织方式,以及哪些层共享。 -- [开发与测试](/zh-CN/docs/core/development):测试、冒烟脚本,以及改动必须遵守的规则。 +- [twcore 快速入门](/zh-CN/docs/core/quick-start):获取二进制,生成并校验配置,将客户端指向网关。 +- [服务器部署](/zh-CN/docs/core/server-deployment):在 Linux 上以 systemd 服务运行 `twcore`,并由 ThinkWatch Lite 连接。 +- [配置手册](/zh-CN/docs/core/configuration):`config.yaml` 的每个字段。 +- [crate 分层](/zh-CN/docs/core/crate-layers):crate 的分组方式,以及各产品分别使用哪些 crate。 +- [开发与测试](/zh-CN/docs/core/development):测试、冒烟脚本、发布流程,以及每个改动都须遵守的规则。 ## 许可证 diff --git a/src/content/docs-core/zh-CN/quick-start.md b/src/content/docs-core/zh-CN/quick-start.md index 7644f84..a566422 100644 --- a/src/content/docs-core/zh-CN/quick-start.md +++ b/src/content/docs-core/zh-CN/quick-start.md @@ -1,23 +1,65 @@ # twcore 快速入门 -ThinkWatch Core 是一组 crate,而非可安装的应用。`bin/twcore` 是一个完整且可独立运行的网关二进制,可用于观察 Core 的实际行为。 +`twcore` 是由 ThinkWatch Core 构建的网关二进制。ThinkWatch Lite 已内置它,安装了应用的桌面无需另装;本文介绍如何单独运行 `twcore`。如需在 Linux 服务器上将其作为服务运行、由 ThinkWatch Lite 远程管理,请参阅[服务器部署](/zh-CN/docs/core/server-deployment)。 + +## 获取二进制 + +每个[发布版本](https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/latest)都提供预编译二进制,各附一个 `.sha256` 文件: + +| 平台 | 文件 | +| --- | --- | +| macOS,Apple silicon | `twcore-aarch64-apple-darwin` | +| Windows,x64 | `twcore-x86_64-pc-windows-msvc.exe` | +| Windows,ARM64 | `twcore-aarch64-pc-windows-msvc.exe` | +| Linux,x86_64 | `twcore-x86_64-unknown-linux-gnu`,以及另含 systemd 服务单元的 `.tar.gz` | +| Linux,aarch64 | `twcore-aarch64-unknown-linux-gnu`,以及另含 systemd 服务单元的 `.tar.gz` | + +下载对应平台的文件,用其 `.sha256` 文件校验,再以 `twcore` 为名放入 `PATH`。Linux 版本需要 glibc 2.35 或更新(Ubuntu 22.04、Debian 12 及以后)。 + +在 Linux 服务器上,安装脚本一步完成上述操作,并创建服务用户与 systemd 服务单元: + +```sh +curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh +``` + +如需从源码构建,需要 Rust 稳定版工具链(1.85 或更新): + +```sh +git clone https://github.com/ThinkWatchProject/ThinkWatch-Core.git +cd ThinkWatch-Core +cargo build --release -p twcore # 生成 target/release/twcore +``` + +在仓库的检出目录中,`cargo run -p twcore -- <命令>` 无需安装即可运行下文的各条命令。 ## 生成、校验并启动配置 -在 [ThinkWatch Core 仓库](https://github.com/ThinkWatchProject/ThinkWatch-Core)的检出目录中执行以下命令: +```sh +twcore init # 生成初始的 config.yaml,其中含一把网关密钥与控制密钥 +twcore check # 仅校验配置,不启动任何服务 +twcore serve # 启动网关与控制面 +``` + +配置文件为 `~/.thinkwatch/config.yaml`,Windows 上为 `%APPDATA%\ThinkWatch\config.yaml`;`THINKWATCH_HOME` 可更换整个目录,`--config <路径>` 为单条命令指定文件。`twcore init` 会输出它生成的网关密钥;没有配置文件时,`twcore serve` 也会自行写入一份初始配置。 + +初始配置中没有上游:控制面照常运行,请求会得到「尚未配置上游」的错误。添加一个上游即可转发: -```bash -cargo run -p twcore -- init # 生成带注释的 config.yaml -cargo run -p twcore -- check # 仅校验,不启动 -cargo run -p twcore -- serve # 启动网关与控制面 +```yaml +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} ``` -- **`init`** 生成带注释的 `config.yaml`。 -- **`check`** 仅校验配置,不启动任何服务。 -- **`serve`** 启动网关与控制面。 +`${NAME}` 读取 `twcore` 进程的环境变量。运行中的 core 在文件保存后一秒内重新加载;未通过校验的版本会被拒绝,原有配置继续服务。每个字段的说明见[配置手册](/zh-CN/docs/core/configuration)。 ## 接入客户端 -将 Claude Code、Codex 等客户端指向本地端口。此后 Core 将按配置的规则路由每个请求、在上游之间执行故障转移、按价目表快照计价、对出站请求中的密钥进行脱敏,并审查返回的工具调用。各项功能说明见[概览](/zh-CN/docs/core)。 +网关默认监听 `127.0.0.1:8788`(配置中的 `listen.gateway`)。客户端需要两项设置:以网关地址作为 base URL,以 `clients` 中的一把网关密钥作为 API 密钥。 + +- **Anthropic 格式的客户端**使用 `http://127.0.0.1:8788`。以 Claude Code 为例:设置 `ANTHROPIC_BASE_URL=http://127.0.0.1:8788`,并将 `ANTHROPIC_AUTH_TOKEN` 设为网关密钥。 +- **OpenAI 格式的客户端**使用 `http://127.0.0.1:8788/v1`,API 密钥填网关密钥。 + +ThinkWatch Lite 可在客户端页将 Claude Code、Codex 等受支持的客户端指向网关,见 [ThinkWatch Lite 文档](/zh-CN/docs/lite)。网关密钥的各个字段(如允许使用的模型、所走的路由)见配置手册中的 [`clients`](/zh-CN/docs/core/configuration#clients) 一节。 -各客户端的分步配置说明尚未编写。 +此后 Core 将按配置的规则路由每个请求、在上游之间执行故障转移、计算费用、对出站请求中的凭据进行脱敏,并审查响应中的工具调用。各项功能说明见[概览](/zh-CN/docs/core#功能)。 diff --git a/src/content/docs/_meta.ts b/src/content/docs/_meta.ts index 8a5b02d..c2678c7 100644 --- a/src/content/docs/_meta.ts +++ b/src/content/docs/_meta.ts @@ -188,8 +188,8 @@ export const products: Product[] = [ base: "/docs/core", editUrl: "https://github.com/ThinkWatchProject/thinkwatch.github.io/tree/main/src/content/docs-core", tagline: { - en: "Shared core of the ThinkWatch gateways: MIT-licensed Rust crates and the twcore binary.", - "zh-CN": "ThinkWatch 网关的共享核心:采用 MIT 许可证的 Rust crate 与 twcore 二进制。", + en: "The gateway engine shared by ThinkWatch Lite and ThinkWatch Enterprise: MIT-licensed Rust crates and the twcore binary, which also runs on its own on a Linux server.", + "zh-CN": "ThinkWatch Lite 与 ThinkWatch 企业版共用的网关引擎:采用 MIT 许可证的 Rust crate 与 twcore 二进制,后者也可独立运行在 Linux 服务器上。", }, docs: [ overview(), @@ -199,8 +199,19 @@ export const products: Product[] = [ locales: both, group: "getStarted", summary: { - en: "Write, check, and serve a config with the twcore binary.", - "zh-CN": "使用 twcore 二进制生成、校验并启动配置。", + en: "Get twcore from a release or build it from source, write and check a configuration, and point a client at the gateway.", + "zh-CN": "从发布版本获取 twcore 或从源码构建,生成并校验配置,并将客户端指向网关。", + }, + }, + // Published from the Core repository (src/lib/core-docs.mjs). + { + slug: "server-deployment", + label: { en: "Server deployment", "zh-CN": "服务器部署" }, + locales: both, + group: "getStarted", + summary: { + en: "Run twcore as a systemd service on Linux, open the remote control port, connect ThinkWatch Lite, and upgrade with twcore upgrade.", + "zh-CN": "在 Linux 上以 systemd 服务运行 twcore,开启远程控制端口,连接 ThinkWatch Lite,并用 twcore upgrade 升级。", }, }, { @@ -209,8 +220,19 @@ export const products: Product[] = [ locales: both, group: "concepts", summary: { - en: "The four layers, and why the bottom two are not shared with the server edition.", - "zh-CN": "四层结构,以及下面两层不与服务端版本共享的原因。", + en: "The sixteen crates grouped by role, the three that ThinkWatch Enterprise depends on, and what ThinkWatch Lite compiles.", + "zh-CN": "十六个 crate 按职责的分组、ThinkWatch 企业版依赖的三个 crate,以及 ThinkWatch Lite 编译的部分。", + }, + }, + // Published from the Core repository (src/lib/core-docs.mjs). + { + slug: "configuration", + label: { en: "Configuration reference", "zh-CN": "配置手册" }, + locales: both, + group: "reference", + summary: { + en: "Every field of config.yaml: what it does, its default, the values it takes, and how a change reaches the running core.", + "zh-CN": "config.yaml 中每个字段的作用、默认值与可选值,以及改动如何进入正在运行的 core。", }, }, { @@ -219,8 +241,8 @@ export const products: Product[] = [ locales: both, group: "contributing", summary: { - en: "cargo test, scripts/smoke.sh, pull request checks, and mandatory rules.", - "zh-CN": "cargo test、scripts/smoke.sh、PR 前的检查,以及必须遵守的规则。", + en: "cargo test, scripts/smoke.sh, the checks before a pull request, releases, and the rules every change follows.", + "zh-CN": "cargo test、scripts/smoke.sh、提交 PR 前的检查、发布流程,以及每个改动都须遵守的规则。", }, }, ], diff --git a/src/data/core-docs/config.md b/src/data/core-docs/config.md new file mode 100644 index 0000000..5dc2074 --- /dev/null +++ b/src/data/core-docs/config.md @@ -0,0 +1,871 @@ +# Configuration reference + +[中文](config.zh-CN.md) + +ThinkWatch Core reads one file, `config.yaml`. This page describes every +field in it: what it does, its default, the values it takes, and how a +change reaches the running process. To run core on a server and control it +from the desktop app, see [Running core on a server](server.md). + +The field tables on this page are generated from the code, and a test fails +when they disagree, so a field listed here is a field the binary reads. + +## Where the file is + +| Platform | Default location | +|---|---| +| macOS, Linux | `~/.thinkwatch/config.yaml` | +| Windows | `%APPDATA%\ThinkWatch\config.yaml` | + +`THINKWATCH_HOME` replaces the directory, and `--config ` names the +file for a single command. The directory holds everything else core keeps +as well: the request database (`data.db`), the configuration history +(`history/`), the downloaded price table (`model_prices.json`) and the local +control socket (`twcore.sock`; on Windows a loopback port recorded in +`control.port`). The directory is private to its owner (`0700`), the file is +`0600`: it holds keys in plain text. + +`twcore serve` writes a starting configuration when there is none, and +`twcore init` writes one on request. Both produce this: + +```yaml +version: 1 +listen: + control: + key: 6629…753d # generated +clients: + - name: default + key: tw-… # generated +``` + +That is a complete, valid configuration. It has no upstream yet, so the +control plane runs and requests are answered with an error saying so. +Adding one upstream makes it forward: + +```yaml +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} +``` + +## How the file is read + +- **A field name that is not in this reference is an error.** A misspelled + `prot:` is not ignored while the gateway quietly starts on the default + port; the whole file is refused and the message names the field. +- **Defaults are not written into the file.** Anything left out has the + default in the tables below. The app and the command line add a field + only when its value differs from the default, so what is in the file is + what someone chose. +- **`${VAR}` reads an environment variable** in fields marked "`${VAR}` + allowed": upstream keys, header values, proxy passwords. It is read from + the environment of the core process when a request is sent; an unset + variable fails that upstream's requests and names the variable. Under + systemd, that environment is the unit's `EnvironmentFile`. +- **Names are references.** Rules, groups and keys refer to upstreams, + groups, routes and price sheets by name. A name that points nowhere is an + error at load time rather than a rule that never matches. Renaming in the + app changes every reference in the same write. Names starting with `__` + are reserved for built-ins. +- **`version`** is the format version, `1`. A file with a higher version was + written by a newer twcore and is refused. + +## How a change takes effect + +There are three ways to change the configuration, and they all go through +the same path: the desktop app, `twcore config …`, and editing the file +in an editor. Core watches the file and reloads it within a second of a +save; nothing needs to be restarted. + +A new version is applied only if it passes every stage: + +1. It parses as YAML. +2. It matches the schema: known fields, the right types. +3. It is consistent: names are unique, references resolve, patterns + compile, CIDR ranges are well formed. +4. The runtime objects can be built from it. + +If any stage fails, **the previous configuration stays in service**, and the +error says which stage failed and where. A typo never takes the gateway +down. The desktop app shows the rejection until a valid version is saved. + +Changes to `listen.gateway` apply live as well: core opens the new +listener, and if it cannot (the port is taken) it keeps the old one and +reports why. Retention changes are applied on the next hourly clean-up. + +When two writers edit at once (the app and a hand edit), the second write +is refused with a version mismatch instead of overwriting the first. + +### History and rollback + +Every version that was in effect is kept in `history/` beside the file, +with where it came from (the app, the command line, an outside edit, a +rollback, a credential rotation). The last 50 are kept. + +```sh +twcore check # validate the file without starting anything +twcore config show # print it, with its version +twcore config history # list the versions, newest first +twcore config rollback 3f9a2c # go back to a version (a prefix is enough) +twcore config set /listen/gateway/port 8790 --int +``` + +These commands work on the file directly, so they work when core is not +running, which is when a rollback is most needed. A running core picks +their changes up like any other save. + +`twcore config set ` changes one value that is already written +in the file. The path names list items by their `name` +(`/providers/anthropic/base_url`); a number is an index (`/routes/0/rules/1/to`). +The value is a string unless `--int`, `--bool` or `--null` says otherwise. +The result is validated before it is written. To add a field that is not in +the file yet, edit the file. + +### Credentials the gateway writes back + +One write is not made by a person. When an upstream's OAuth token endpoint +issues a new refresh token, the old one stops working, so the gateway +writes the new token (and the access token with its expiry) back into +`providers[].oauth`. Only those values change; comments and layout are left +as they are. + +## Reference + +Each table lists every field of one section. "—" in the Default column means +the field is simply absent unless written; the description says what that +means. + +### Top level + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `version` | integer | **required** | Format version of this file. The only version is `1`. A file with a higher number was written by a newer twcore and is refused rather than half-understood. | +| `listen` | object, [`listen`](#cfg-listen) | — | Where the gateway and the control channel listen. | +| `clients` | list of [`clients[]`](#cfg-clients) | `[]` | Gateway keys. At least one is required; `twcore init` and the first `twcore serve` write one named `default`. | +| `providers` | list of [`providers[]`](#cfg-providers) | `[]` | Upstreams. None is a valid configuration: the control plane runs and requests are answered with an error saying no upstream is configured. | +| `proxies` | list of [`proxies[]`](#cfg-proxies) | `[]` | Outbound proxies, declared once and referred to by name from `providers[].proxy`. | +| `pricing` | object, [`pricing`](#cfg-pricing) | — | Refreshing the default price table, and price sheets of your own. | +| `client_probes` | object, [`client_probes`](#cfg-client_probes) | — | What happens to the helper requests clients send on their own (health checks, warm-ups, titles). | +| `security` | object, [`security`](#cfg-security) | — | The five guards. All of them start in `observe` or `off`, so out of the box nothing is changed or blocked. | +| `retention` | object, [`retention`](#cfg-retention) | — | How long request logs are kept. | +| `groups` | list of [`groups[]`](#cfg-groups) | `[]` | Strategy groups: several upstreams behind one name, with a way to pick among them. | +| `routes` | list of [`routes[]`](#cfg-routes) | `[]` | Routes. Without any, requests fail over across all upstreams in the order they are declared. | +| `default_route` | string | — | The route for keys that do not name one. Unset: the route named `default`, or the built-in failover when there is none. | +| `default_key` | string | — | The gateway key for clients that were not given a key of their own. Unset: the key named `default`, or the first key. It cannot be disabled. | + + +### `listen` + +Where core accepts connections. There are two kinds: the AI gateway that +clients send requests to, and the control channel the desktop app and +`twcore` commands use. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `gateway` | object, [`listen.gateway`](#cfg-listen-gateway) | — | The AI gateway: the address clients send requests to. | +| `control` | object, [`listen.control`](#cfg-listen-control) | — | The control channel: how the desktop app and `twcore` commands reach core. It holds the control key, so every configuration has it. | + + +#### `listen.gateway` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `bind` | `loopback` \| `all` \| interface name \| IP address | `loopback` | `loopback` is this machine only; `all` is every interface; an interface name (`en0`, `eth0`) is looked up at start and follows address changes; a fixed IP address stops working when the address changes. Binding one interface also listens on 127.0.0.1. | +| `port` | integer | `8788` | TCP port of the gateway. | +| `allow_from` | list of strings | `[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]` | Sources other than this machine that may connect, as CIDR ranges or single addresses. This machine is always allowed. `[]` means this machine only; `0.0.0.0/0` allows everyone and has to be written out. | + + +When `bind` reaches beyond this machine, `allow_from` decides who gets in. +The gateway has no TLS: expose it on networks you trust, or put it behind a +tunnel or VPN. + +```yaml +listen: + gateway: + bind: all + port: 8788 + allow_from: [192.168.1.0/24] +``` + +#### `listen.control` + +The control channel is how the desktop app, and `twcore config` and +`twcore control-key` on the same machine, talk to core. Locally it is a +socket file in the data directory (a loopback port on Windows); no network +port is opened for it unless `remote` is enabled. + +Every control connection, local or remote, starts with a handshake that +proves both ends hold `key` +(`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`: the key is the pre-shared key, +each connection negotiates fresh session keys, and the traffic is +encrypted). There are no certificates. A peer without the key cannot +complete the first message. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `key` | string | generated | The control key: 64 hexadecimal characters (32 bytes). Every control connection, local or remote, proves it knows this key. `twcore serve` writes one before listening if it is missing; a configuration where it is malformed is refused. Show it with `twcore control-key`, replace it with `twcore control-key --rotate`. | +| `remote` | object, [`listen.control.remote`](#cfg-listen-control-remote) | — | A network port for the desktop app on another machine. Additional to the local channel, never instead of it. | + + +`key` is written by `twcore serve` before the control channel starts +listening, if it is missing, and by `twcore init`. It is masked wherever the +configuration is shown or kept in the history; saving a masked value back +keeps the real one. A missing or malformed key (not 64 hexadecimal +characters) makes the whole file invalid, so it cannot be replaced by a +short, guessable one. + +```sh +twcore control-key # print the key, to paste into the desktop app +twcore control-key --rotate # replace it; connected apps have to reconnect +``` + +Both run on the machine where core runs. + +#### `listen.control.remote` + +A network port for a desktop app on another machine. It is opened in +addition to the local channel, so a mistake here (a port that is taken, an +`allow_from` that shuts you out) never locks out the machine itself: +`twcore config` and the local app keep working. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `enabled` | bool | `false` | Listen on the remote port. Unset or `false`: no network port is opened for control. `twcore remote enable` and `twcore remote disable` switch it; a running core follows within a second. | +| `bind` | `loopback` \| `all` \| interface name \| IP address | `all` | Interface to listen on, written as for `listen.gateway.bind`. | +| `port` | integer | **required** | TCP port. There is no fixed default: `twcore init` and `twcore remote enable` write a random port between 20000 and 32000 (never the gateway's) when they write this section. It cannot be 0 or the gateway's port. | +| `allow_from` | list of strings | `[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]` | Sources that may connect, as for `listen.gateway.allow_from`, except that this machine is not let in automatically (it has the local channel). A connection from anywhere else is closed before the handshake, without a byte in reply; narrowing the list also closes open connections it no longer allows. A source that fails the handshake 5 times within a minute is ignored for a minute. | + + +```yaml +listen: + control: + key: 9f2c…e41a # 64 hexadecimal characters + remote: + enabled: true + bind: all + port: 23483 # random, written when the section is generated + allow_from: [192.168.1.0/24] +``` + +A connection over the remote port cannot do three things, whatever the app +asks: shut core down (it is managed by systemd), change `listen.control` +(the door it came in through), or produce a diagnostic bundle (it would be +written on the server). Handshakes time out after 5 seconds; five failed +handshakes from one source within a minute block that source for a minute. + +### `clients` + +Gateway keys: the keys clients such as Claude Code and Codex send to the +gateway. A key is an identity. Limits, model scope and route are per key. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name of the key; unique. Routing rules match it with `when.client`. | +| `key` | string | **required** | The key clients send (as `x-api-key` or `Authorization: Bearer`). Generated keys start with `tw-` so they are not mistaken for an upstream's key. Unique. | +| `max_concurrent` | integer | — | Requests with this key that may run at once; the rest wait. Unset: no limit. `0` is refused. | +| `allow` | list of strings | — | Models this key may use, as model ids or globs (`claude-*`). Unset: every model. `[]`: none at all. | +| `route` | string | — | Name of the route requests with this key take. Unset: `default_route`. | +| `client` | string | — | The client this key was made for (`claude-code`, `codex`, …), recorded when the desktop app points a client at the gateway. A client has at most one. | +| `disabled` | bool | `false` | Refuse every request made with this key, and keep the key. | + + +```yaml +clients: + - name: default + key: tw-a3f9c8d1e5b2h7k4m6n8p2q4 + - name: build-server + key: tw-q8r2s4t6u8v2w4x6y8z2a4b6 + max_concurrent: 4 + allow: [claude-sonnet-*] + route: cheap +``` + +### `providers` + +Upstreams: the APIs requests are forwarded to. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name of the upstream; unique, and not the name of a group. Names starting with `__` are reserved. | +| `base_url` | string | **required** | Endpoint, `http://` or `https://`, up to the version segment where the provider documents one (`https://api.anthropic.com`, `https://api.openai.com/v1`). | +| `key` | string, `${VAR}` allowed | — | API key. It goes in the header the protocol expects: `x-api-key` (Anthropic), `Authorization: Bearer` (OpenAI), `x-goog-api-key` (Gemini). Leave it out for upstreams without a key, or when the credential is written in `headers`. Cannot be combined with `oauth`. | +| `headers` | map of header name → value | `{}` | Additional request headers, in the order written; values may use `${VAR}`, and `{{access_token}}` where `oauth` is set. At most 32. Headers HTTP or the gateway manages (`host`, `content-length`, `connection`, …) cannot be set. | +| `oauth` | object, [`providers[].oauth`](#cfg-providers-oauth) | — | OAuth credential: an access token obtained from a refresh token. Instead of `key`. | +| `protocol` | `anthropic` \| `openai-chat` \| `openai-responses` \| `gemini` \| `chatgpt` | — | API format of the upstream. Unset: recognized from `base_url` for the official endpoints, otherwise treated as `anthropic`. | +| `proxy` | string | `direct` | `direct`; `system`, the proxy in the core process's `HTTPS_PROXY`, `HTTP_PROXY` or `ALL_PROXY` environment variables; or the name of an entry in `proxies`. | +| `on_proxy_fail` | `fail` \| `direct` | `fail` | When the proxy cannot be reached: `fail` the request, or go `direct`. | +| `models` | list of strings | `[]` | Models to assume when the upstream does not answer `/v1/models`. | +| `models_only` | list of strings | — | Use only these of the upstream's models, as ids or globs. Others are not listed and are not routed here. Unset: all of them. Empty is refused; use `disabled`. | +| `billing` | `per-token` \| `free` | `per-token` | `per-token`: cost is usage times the price in the upstream's price sheet, subscription accounts included. `free`: cost is recorded as 0. | +| `pricing` | string | — | Name of a price sheet under `pricing.sheets`. Unset: the default price table. | +| `disabled` | bool | `false` | Take the upstream out of routing and out of the model list, and keep its configuration. | + + +A credential is one of three things: `key`, which goes in the header the +protocol expects; `oauth`, a token obtained from a refresh token; or +`headers`, when the upstream wants something of its own. `headers` can be +combined with the other two, except for the header that already carries the +credential. + +```yaml +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} + + - name: relay + base_url: https://relay.example.com/v1 + protocol: openai-chat + headers: + X-Relay-Token: ${RELAY_TOKEN} + proxy: office + models_only: [gpt-4.1*, o3] + pricing: relay-discount + + - name: local + base_url: http://127.0.0.1:11434/v1 + protocol: openai-chat + billing: free +``` + +A ChatGPT account upstream (`protocol: chatgpt`) takes only the credential +the desktop app obtains by signing in; it cannot be written by hand. Claude +and Google subscription sign-ins are not supported; use an API key. + +#### `providers[].oauth` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `access` | string | — | Current access token. Written back by the gateway after every refresh; unset means one is obtained on first use. | +| `expires_at` | string | — | When `access` expires, RFC 3339 in UTC. Written back with it. Unset: used until the upstream answers 401. | +| `refresh` | string | **required** | Refresh token. When the token endpoint issues a new one, the old one stops working, so the gateway writes the new one back into this file. | +| `endpoint` | string | **required** | Token endpoint URL. | +| `client_id` | string | — | OAuth client id, if the endpoint wants one. | +| `client_secret` | string | — | OAuth client secret, if the endpoint wants one. | +| `refresh_before` | duration (`30s`, `5m`, `1h`) | — | How long before expiry to refresh. Unset or unreadable: `5m`. | + + +The access token goes in the protocol's authorization header. To put it +somewhere else, write the header in `headers` with `{{access_token}}` where +the token goes: + +```yaml + oauth: + refresh: ${VENDOR_REFRESH_TOKEN} + endpoint: https://auth.example.com/oauth/token + client_id: my-client + headers: + X-Access: Token {{access_token}} +``` + +### `proxies` + +Outbound proxies. Different upstreams often need different ones, so there +is no global switch: an upstream picks one with `proxy`. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name used in `providers[].proxy`. `direct` and `system` are built in. | +| `type` | `socks5` \| `socks5h` \| `http` \| `https` | `socks5h` | `socks5h` sends the host name to the proxy to resolve; `socks5` resolves it locally first. `http` and `https` are HTTP proxies. | +| `addr` | string | **required** | `host:port` of the proxy. | +| `auth` | object, [`proxies[].auth`](#cfg-proxies-auth) | — | User name and password, if the proxy wants them. | + + +#### `proxies[].auth` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `user` | string | **required** | User name. | +| `pass` | string, `${VAR}` allowed | **required** | Password. | + + +```yaml +proxies: + - name: office + type: http + addr: proxy.example.com:3128 + auth: + user: alice + pass: ${PROXY_PASSWORD} +``` + +`on_proxy_fail: fail` is the default because falling back silently sends a +request by a path you did not intend; you would believe you were on the +proxy while you were not. + +### `pricing` + +The cost of a request is its usage times the price of the model. Prices +come from the default price table (LiteLLM's public dataset, a copy of +which is built into the binary and refreshed daily) or from a price sheet +an upstream picks. A change of price applies to requests from then on, +never to ones already recorded. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `auto_update` | bool | `true` | Refresh the default price table from the network once a day. It is saved as `model_prices.json` beside `config.yaml`; the table built into the binary is used until then and when offline. | +| `sheets` | list of [`pricing.sheets[]`](#cfg-pricing-sheets) | `[]` | Price sheets of your own. An upstream uses one with `providers[].pricing`. | + + +#### `pricing.sheets` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name of the sheet; unique. | +| `multiplier` | number | `1` | Applied to every price of the default table, cache and long-context prices included. | +| `models` | map of model id → [`pricing.sheets[].models.*`](#cfg-pricing-sheets-models) | `{}` | Prices for single models. They replace the default table's price for that model and are not multiplied. | + + +#### `pricing.sheets[].models` + +Prices are in US dollars per million tokens, as printed on vendors' price +pages. Every field is written out; nothing is inferred when pricing. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `input` | number | **required** | US dollars per million input tokens. | +| `output` | number | **required** | US dollars per million output tokens. | +| `cache_read` | number | **required** | US dollars per million tokens read from the prompt cache. | +| `cache_write_5m` | number | **required** | US dollars per million tokens written to a 5-minute cache. | +| `cache_write_1h` | number | **required** | US dollars per million tokens written to a 1-hour cache. | +| `input_above_200k` | number | — | Input price once a request's input exceeds 200K tokens. Written together with `output_above_200k`, or neither. | +| `output_above_200k` | number | — | Output price once a request's input exceeds 200K tokens. | + + +```yaml +pricing: + sheets: + - name: relay-discount + multiplier: 0.8 + models: + claude-sonnet-4-5-thinking: + input: 3 + output: 15 + cache_read: 0.3 + cache_write_5m: 3.75 + cache_write_1h: 6 +``` + +### `client_probes` + +Some requests clients send are not the user's: connectivity checks, +warm-ups, session titles, topic detection, suggestions. Each class can be +answered locally (`intercept`, nothing is sent upstream), passed through +(`passthrough`), or handed to the routing rules (`route`, matched with +`when.intent`). The defaults intercept only what nobody would miss. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `health_check` | `intercept` \| `passthrough` \| `route` | `intercept` | Connectivity checks (`max_tokens: 1`). Answered locally by default: nothing is lost. | +| `warmup` | `intercept` \| `passthrough` \| `route` | `intercept` | Warm-up requests. Answered locally by default. | +| `titling` | `intercept` \| `passthrough` \| `route` | `passthrough` | Requests that name a session. Passed through by default: intercepting them gives every session the same title. | +| `topic_detect` | `intercept` \| `passthrough` \| `route` | `passthrough` | Topic detection. Passed through by default. | +| `suggestion` | `intercept` \| `passthrough` \| `route` | `passthrough` | Suggestions. Passed through by default. | + + +### `security` + +Five guards, applied to every upstream alike. Each has a `mode`: `off`, +`observe` (detect and record, change nothing) or `enforce` (act). They start +in `observe`, except the output limit, which starts `off`. What `enforce` +does differs per guard, and each says so below. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `redact` | object, [`security.redact`](#cfg-security-redact) | — | Outbound redaction: credentials found in a request are replaced before it leaves. | +| `inspect_tools` | object, [`security.inspect_tools`](#cfg-security-inspect_tools) | — | Tool-call inspection: dangerous commands in the tool calls a model returns cut the response off. | +| `hidden_text` | object, [`security.hidden_text`](#cfg-security-hidden_text) | — | Hidden characters that people cannot see and models can read refuse the request. | +| `content` | object, [`security.content`](#cfg-security-content) | — | Content filter: words or patterns in what the caller sends refuse the request. | +| `output_limit` | object, [`security.output_limit`](#cfg-security-output_limit) | — | Output length: a response longer than the limit is cut off. | + + +#### `security.redact` + +Before a request leaves, credentials in it are looked for. Under `enforce` +they are replaced. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` does nothing; `observe` detects and records only, and changes nothing; `enforce` detects and acts. | +| `enable` | list of strings | `[]` | Built-in rules to switch on that are off out of the box, by id. | +| `disable` | list of strings | `[]` | Built-in rules to switch off, by id. | +| `custom` | list of [`security.redact.custom[]`](#cfg-security-redact-custom) | `[]` | Rules of your own: whatever a pattern matches is treated as a credential. | + + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name shown in logs and in the app; it identifies the rule and has to be unique within this guard. | +| `pattern` | string | **required** | Regular expression. | +| `disabled` | bool | `false` | Switches the rule off and keeps it in the file. | + + +Built-in rules: + + +| id | Name | Out of the box | +|---|---|---| +| `anthropic-api-key` | Anthropic API key | on | +| `openai-project-key` | OpenAI project key | on | +| `openai-api-key` | OpenAI API key | on | +| `github-personal-token` | GitHub personal access token | on | +| `github-oauth-token` | GitHub OAuth token | on | +| `github-server-token` | GitHub server token | on | +| `github-user-token` | GitHub user token | on | +| `github-fine-grained-token` | GitHub fine-grained token | on | +| `slack-bot-token` | Slack bot token | on | +| `slack-user-token` | Slack user token | on | +| `slack-app-token` | Slack app token | on | +| `aws-access-key-id` | AWS access key ID | on | +| `aws-temporary-key-id` | AWS temporary access key ID | on | +| `google-api-key` | Google API key | on | +| `google-oauth-token` | Google OAuth token | on | +| `gitlab-token` | GitLab token | on | +| `stripe-live-key` | Stripe live key | on | +| `stripe-restricted-key` | Stripe restricted key | on | +| `npm-token` | npm token | on | +| `digitalocean-token` | DigitalOcean token | on | +| `sendgrid-key` | SendGrid key | on | +| `private-key` | Private key | on | +| `jwt` | JWT | on | +| `conn-string-password` | Connection string password | on | +| `internal-ip` | Internal IP address | off | +| `internal-domain` | Internal domain | off | + + +#### `security.inspect_tools` + +Tool calls a model returns are checked against the rules. Under `enforce`, +a match with rules set to `cut` stops the response, so the client never +receives a complete call to run. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` does nothing; `observe` detects and records only, and changes nothing; `enforce` detects and acts. | +| `enable` | list of strings | `[]` | Built-in rules to switch on that are off out of the box, by id. | +| `disable` | list of strings | `[]` | Built-in rules to switch off, by id. | +| `actions` | map of built-in rule id → `cut` \| `record` | `{}` | What a built-in rule does under `enforce`, written only where it differs from the factory setting (`rm-rf-root: record`). | +| `custom` | list of [`security.inspect_tools.custom[]`](#cfg-security-inspect_tools-custom) | `[]` | Rules of your own, matched against the arguments of a tool call. | + + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name shown in logs and in the app; it identifies the rule and has to be unique within this guard. | +| `pattern` | string | **required** | Regular expression. | +| `action` | `cut` \| `record` | `record` | Under `enforce`: `cut` the response off, or only `record` the match. | +| `disabled` | bool | `false` | Switches the rule off and keeps it in the file. | + + +Built-in rules: + + +| id | Name | Under `enforce`, out of the box | +|---|---|---| +| `curl-pipe-sh` | Download and run | `cut` | +| `base64-decode-exec` | Decode and run | `cut` | +| `exfil-env` | Send out environment variables | `cut` | +| `exfil-credentials` | Send out a credential file | `cut` | +| `exfil-credentials-reversed` | Send out a credential file (verb first) | `cut` | +| `ssh-key-read` | Read a private key or cloud credential | `cut` | +| `write-startup-item` | Write a startup item | `cut` | +| `crontab-install` | Install a scheduled job | `cut` | +| `rm-rf-root` | Delete home or root | `record` | +| `chmod-777` | World-writable permissions | `record` | + + +#### `security.hidden_text` + +Characters people cannot see and models can read, in what the caller sends +(tool results included). Under `enforce`, the request is refused. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` does nothing; `observe` detects and records only, and changes nothing; `enforce` detects and acts. | +| `disable` | list of strings | `[]` | Kinds not to look for: `tag`, `bidi`. | + + + +| Kind | What it is | +|---|---| +| `tag` | Unicode tag characters (U+E0000 to U+E007F): invisible everywhere, read by the model, able to carry a whole instruction. | +| `bidi` | Bidirectional control characters: make the order shown differ from the order the model reads. | + + +#### `security.content` + +Words or patterns in what the caller sends. Under `enforce`, a match with +rules set to `block` refuses the request. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` does nothing; `observe` detects and records only, and changes nothing; `enforce` detects and acts. | +| `enable` | list of strings | `[]` | Built-in rules to switch on that are off out of the box, by id. | +| `disable` | list of strings | `[]` | Built-in rules to switch off, by id. | +| `actions` | map of built-in rule id → `block` \| `record` | `{}` | What a built-in rule does under `enforce`, written only where it differs from the factory setting. | +| `custom` | list of [`security.content.custom[]`](#cfg-security-content-custom) | `[]` | Rules of your own. | + + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name shown in logs and in the app; it identifies the rule and has to be unique within this guard. | +| `pattern` | string | **required** | A keyword, or a regular expression with `match: regex`. Case-insensitive either way. | +| `match` | `contains` \| `regex` | `contains` | `contains`: the text contains `pattern`. `regex`: `pattern` is a regular expression. | +| `action` | `block` \| `record` | `record` | Under `enforce`: `block` the request, or only `record` the match. | +| `disabled` | bool | `false` | Switches the rule off and keeps it in the file. | + + +Built-in rules: + + +| id | Name | Group | Out of the box | Under `enforce`, out of the box | +|---|---|---|---|---| +| `ignore-previous-instructions` | Ignore previous instructions | injection | on | `block` | +| `ignore-all-previous` | Ignore all previous | injection | on | `block` | +| `disregard-your-instructions` | Disregard your instructions | injection | on | `block` | +| `jailbreak` | Jailbreak | injection | off | `block` | +| `dan` | DAN | injection | off | `block` | +| `developer-mode` | Developer mode | injection | off | `block` | +| `you-are-now` | Persona manipulation | persona | off | `block` | +| `new-persona` | New persona | persona | off | `record` | +| `act-as` | Act as | persona | off | `record` | +| `pretend-to-be` | Pretend to be | persona | off | `record` | +| `system-prompt` | System prompt extraction | persona | off | `record` | +| `reveal-your-instructions` | Reveal instructions | persona | off | `record` | +| `what-are-your-rules` | What are your rules | persona | off | `record` | +| `base64-wall` | Base64 smuggling | persona | off | `record` | +| `zh-ignore-previous` | Ignore previous instructions (Chinese) | chinese | off | `block` | +| `zh-forget-your` | Forget your instructions (Chinese) | chinese | off | `block` | +| `zh-do-not-follow` | Do not follow (Chinese) | chinese | off | `block` | +| `zh-you-are-now` | You are now (Chinese) | chinese | off | `block` | +| `zh-role-play` | Role-play (Chinese) | chinese | off | `record` | +| `zh-reveal-your` | Reveal your instructions (Chinese) | chinese | off | `record` | +| `zh-system-prompt` | System prompt (Chinese) | chinese | off | `record` | +| `zh-jailbreak` | Jailbreak (Chinese) | chinese | off | `block` | + + +#### `security.output_limit` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `off` | Off out of the box: no single limit suits every use. `observe` records long responses; `enforce` stops the stream at the limit. | +| `max_chars` | integer | `100000` | Limit in characters (Unicode scalar values), from 1 to 1000000. | + + +```yaml +security: + redact: + mode: enforce + enable: [internal-ip] + custom: + - name: employee-id + pattern: 'EMP-\d{6}' + inspect_tools: + mode: enforce + output_limit: + mode: enforce + max_chars: 200000 +``` + +### `retention` + +Two limits, because the two kinds of data differ in size by three orders +of magnitude: request bodies are tens of kilobytes each, a request's record +a few hundred bytes. The byte limit covers bursts. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `body_days` | integer | `7` | Days to keep request and response bodies. | +| `row_days` | integer | `90` | Days to keep the record of each request (time, model, usage, cost). | +| `body_max_bytes` | integer | `2147483648` | Upper bound on the bytes bodies may take; beyond it the oldest days go first. The default is 2 GiB. | + + +### `groups` + +A group puts several upstreams behind one name. Rules send requests to a +group with `to`. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name of the group; unique, and not the name of an upstream. | +| `type` | `fallback` \| `select` \| `load-balance` \| `url-test` \| `cheapest` | `fallback` | `fallback`: the first healthy member, in order. `select`: the member named in `selected`. `load-balance`: take turns. `url-test`: the fastest by measured time to first byte. `cheapest`: the lowest input price. | +| `providers` | list of strings | **required** | Member upstreams, by name. | +| `session_affinity` | bool | `true` | Keep a session on the same upstream so its prompt cache keeps hitting. Turning it off under `load-balance` spreads every turn and loses the cache. | +| `selected` | string | — | For `select`: the chosen member. | + + +`fallback` is the default because spreading a session across upstreams +loses the prompt cache, which is worth far more than any spread of load on +a single user's machine. + +### `routes` + +A route is a list of rules evaluated top to bottom. Each key takes the route +named in its `route`, otherwise `default_route`, otherwise the route named +`default`; without any, requests fail over across all upstreams in the +order they are declared. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name of the route; unique. `default` is the one keys use unless told otherwise. | +| `rules` | list of [`routes[].rules[]`](#cfg-routes-rules) | `[]` | Evaluated top to bottom; the first rule with `to` or `deny` that matches decides where the request goes. | + + +#### `routes[].rules` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name shown in logs and in the traffic view. | +| `when` | object, [`routes[].rules[].when`](#cfg-routes-rules-when) | — | Conditions, all of which have to hold. Unset: matches every request. | +| `to` | string | — | An upstream or a group, by name; `__all__` is every upstream in declared order. Not allowed together with `when.provider_would_be`. | +| `set` | object, [`routes[].rules[].set`](#cfg-routes-rules-set) | — | Parameters to rewrite. Collected from every matching rule, not only the first. | +| `deny` | string | — | Refuse the request with this reason. | + + +#### `routes[].rules[].when` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `model` | string | — | Requested model, glob (`claude-opus-*`). | +| `client` | string | — | Name of the gateway key the request used, exactly. | +| `dialect` | string | — | API format the client spoke: `anthropic`, `openai-chat`, `openai-responses`, `gemini`. | +| `input_tokens` | comparison (`>200k`, `<=4k`, `==3`) | — | Estimated input tokens. | +| `max_tokens` | comparison (`>200k`, `<=4k`, `==3`) | — | The request's `max_tokens`. A request without one never matches. | +| `tool_count` | comparison (`>200k`, `<=4k`, `==3`) | — | Number of tools offered. | +| `cache` | bool | — | Whether the request uses the prompt cache. | +| `tools` | bool | — | Whether the request offers tools. | +| `image` | bool | — | Whether the request contains an image. | +| `thinking` | bool | — | Whether extended thinking is on. | +| `stream` | bool | — | Whether the response is streamed. | +| `intent` | string or list of strings | — | A client helper request: `assistant_internal` for any of them, or one class (`titling`). Only classes set to `route` in `client_probes` reach routing. | +| `provider_would_be` | string or list of strings | — | The upstream routing chose. Such a rule is evaluated after routing, may only `set` or `deny`, and cannot have `to`. | + + +A comparison starts with `>`, `>=`, `<`, `<=` or `==`, and the number may +end in `k` or `m`: `">200k"`, `"<=4k"`. Without an operator it is an error, +not an equality: `"200k"` alone is refused. + +#### `routes[].rules[].set` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `model` | string | — | Send a different model. The prompt cache of the session is lost. | +| `max_tokens` | integer | — | Replace `max_tokens`. | +| `thinking` | bool | — | Turn extended thinking on or off. | +| `only_at_session_start` | bool | `false` | Apply only when a session starts. Recorded and shown; not in effect yet. | + + +```yaml +groups: + - name: fast + type: url-test + providers: [anthropic, relay] + +routes: + - name: default + rules: + - name: long context goes to the official API + when: { input_tokens: ">200k" } + to: anthropic + - name: titles go to the cheap model + when: { intent: titling } + set: { model: claude-haiku-4-5 } + - name: everything else + to: fast +default_route: default +``` + +## Environment variables + +| Variable | Effect | +|---|---| +| `THINKWATCH_HOME` | Data directory, instead of `~/.thinkwatch` (`%APPDATA%\ThinkWatch` on Windows). | +| `TWCORE_LOG` | Log filter, in `tracing` syntax (`info`, `debug`, `tw_gateway=debug`). | +| `HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY`, `NO_PROXY` | Used by upstreams with `proxy: system`. | +| Any other | Read where the configuration writes `${NAME}`. | diff --git a/src/data/core-docs/config.zh-CN.md b/src/data/core-docs/config.zh-CN.md new file mode 100644 index 0000000..3fd04b1 --- /dev/null +++ b/src/data/core-docs/config.zh-CN.md @@ -0,0 +1,757 @@ +# 配置手册 + +[English](config.md) + +ThinkWatch Core 只读一个文件:`config.yaml`。本文逐项说明其中每个字段的作用、默认值、可选值,以及改动如何进入正在运行的进程。在服务器上运行 core、由桌面应用远程管理,见[在服务器上运行 core](server.zh-CN.md)。 + +本文的字段表由代码生成,与代码不一致时测试失败。表中列出的字段,就是程序实际读取的字段。 + +## 文件位置 + +| 平台 | 默认位置 | +|---|---| +| macOS、Linux | `~/.thinkwatch/config.yaml` | +| Windows | `%APPDATA%\ThinkWatch\config.yaml` | + +`THINKWATCH_HOME` 替换整个目录;`--config <路径>` 为单条命令指定文件。core 的其余数据也在这个目录里:请求数据库(`data.db`)、配置历史(`history/`)、下载的价目表(`model_prices.json`),以及本地控制通道的 socket 文件(`twcore.sock`;Windows 上是回环端口,记录在 `control.port` 中)。目录只有所有者可访问(`0700`),配置文件权限为 `0600`:其中以明文保存密钥。 + +没有配置文件时,`twcore serve` 会写入一份初始配置;`twcore init` 也可以按需生成。两者生成的内容如下: + +```yaml +version: 1 +listen: + control: + key: 6629…753d # 自动生成 +clients: + - name: default + key: tw-… # 自动生成 +``` + +这已是一份完整、合法的配置。其中还没有上游,因此控制面照常运行,请求会得到「尚未配置上游」的错误。加上一个上游即可转发: + +```yaml +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} +``` + +## 读取规则 + +- **本文没有的字段名一律是错误。**把 `port` 写成 `prot` 时,不会被悄悄忽略、让网关在默认端口上起来;整份配置被拒绝,错误信息指出那个字段。 +- **默认值不写进文件。**没写的字段取下表中的默认值。应用和命令行只在取值不同于默认值时才写入字段,因此文件里出现的都是有人做出的选择。 +- **`${VAR}` 读取环境变量**,适用于标注「可写 `${VAR}`」的字段:上游密钥、请求头的值、代理密码。取值来自 core 进程的环境,在发送请求时读取;变量未设置时,该上游的请求失败,错误信息指出变量名。在 systemd 下,这个环境就是 unit 的 `EnvironmentFile`。 +- **名字即引用。**规则、策略组、密钥按名字引用上游、策略组、路由和价目表。指向不存在的名字,在加载时就报错,而不是成为一条永不命中的规则。在应用里改名时,所有引用在同一次写入中一起修改。以 `__` 开头的名字保留给内置项。 +- **`version`** 是格式版本,目前为 `1`。版本更高的文件出自更新的 twcore,整份拒绝。 + +## 改动如何生效 + +修改配置有三种途径:桌面应用、`twcore config …` 命令、用编辑器直接修改文件。三者走同一条路径。core 监视配置文件,保存后一秒内重新加载,无需重启。 + +新版本要通过以下每一关才会换入: + +1. 能按 YAML 解析。 +2. 符合结构:字段名已知,类型正确。 +3. 自洽:名字不重复、引用都能找到、正则能编译、CIDR 写法正确。 +4. 能据此建立运行时对象。 + +任何一关失败,**原有配置继续服务**,错误信息说明失败在哪一关、哪个位置。写错一个字不会让网关停下。在保存出合法版本之前,桌面应用会一直显示这次拒绝。 + +`listen.gateway` 的改动同样即时生效:core 打开新的监听;打不开时(例如端口被占用)保留原监听并报告原因。保留期限的改动在下一次每小时的清理时生效。 + +两方同时修改时(应用和手工编辑),后写入的一方因版本不一致被拒绝,不会覆盖先写入的内容。 + +### 历史与回滚 + +每个生效过的版本都保存在配置文件旁边的 `history/` 目录中,并记录来源(应用、命令行、外部编辑、回滚、凭据轮换)。保留最近 50 个版本。 + +```sh +twcore check # 只校验配置,不启动任何服务 +twcore config show # 打印当前配置及其版本 +twcore config history # 列出历史版本,最新的在前 +twcore config rollback 3f9a2c # 回滚到某个版本(写前几位即可) +twcore config set /listen/gateway/port 8790 --int +``` + +这些命令直接操作文件,因此 core 没有运行时也能使用,而那往往正是最需要回滚的时候。正在运行的 core 会像对待其他保存一样接收这些改动。 + +`twcore config set <路径> <值>` 修改文件中已经写出的一个值。路径中的列表项按其 `name` 定位(`/providers/anthropic/base_url`);数字表示下标(`/routes/0/rules/1/to`)。值默认按字符串写入,`--int`、`--bool`、`--null` 另作指定。写入前先校验结果。要添加文件中还没有的字段,请直接编辑文件。 + +### 网关写回的凭据 + +有一种写入不是由人发起的。上游的 OAuth token 端点换发新的 refresh token 后,旧的随即作废,因此网关会把新 token(以及 access token 和过期时间)写回 `providers[].oauth`。只改这几个值,注释和排版保持原样。 + +## 字段参考 + +每张表列出一节的全部字段。「默认值」一栏为「—」表示不写就没有这个字段,其含义见说明。 + +### 顶层 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `version` | 整数 | **必填** | 文件格式的版本,目前只有 `1`。更大的数字说明文件出自更新的 twcore,整份拒绝,不按一知半解的方式读。 | +| `listen` | 对象,见 [`listen`](#cfg-listen) | — | 网关和控制通道在哪里监听。 | +| `clients` | 对象列表,见 [`clients[]`](#cfg-clients) | `[]` | 网关密钥。至少要有一把;`twcore init` 和首次 `twcore serve` 会写入一把名为 `default` 的。 | +| `providers` | 对象列表,见 [`providers[]`](#cfg-providers) | `[]` | 上游。一个都没有也是合法配置:控制面照常运行,请求得到「尚未配置上游」的错误。 | +| `proxies` | 对象列表,见 [`proxies[]`](#cfg-proxies) | `[]` | 出站代理。在这里声明一次,由 `providers[].proxy` 按名字引用。 | +| `pricing` | 对象,见 [`pricing`](#cfg-pricing) | — | 默认价目表是否定期刷新,以及自定义价目表。 | +| `client_probes` | 对象,见 [`client_probes`](#cfg-client_probes) | — | 客户端自行发出的辅助请求(连通性检查、预热、起标题)如何处理。 | +| `security` | 对象,见 [`security`](#cfg-security) | — | 五项防护。出厂时都处在 `observe` 或 `off`,不改变、不拦截任何请求。 | +| `retention` | 对象,见 [`retention`](#cfg-retention) | — | 请求日志保留多久。 | +| `groups` | 对象列表,见 [`groups[]`](#cfg-groups) | `[]` | 策略组:多个上游合用一个名字,并规定如何在其中选择。 | +| `routes` | 对象列表,见 [`routes[]`](#cfg-routes) | `[]` | 路由。一条都不写时,请求按上游的声明顺序故障转移。 | +| `default_route` | 字符串 | — | 未指定路由的密钥走哪条路由。不写:名为 `default` 的路由;没有这条路由时走内置的故障转移。 | +| `default_key` | 字符串 | — | 没有专用密钥的客户端使用哪一把。不写:名为 `default` 的那把,没有则取第一把。这把密钥不能停用。 | + + +### `listen` + +core 在哪里接受连接。连接分两种:客户端发送请求的 AI 网关,以及桌面应用和 `twcore` 命令使用的控制通道。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `gateway` | 对象,见 [`listen.gateway`](#cfg-listen-gateway) | — | AI 网关,即客户端发送请求的地址。 | +| `control` | 对象,见 [`listen.control`](#cfg-listen-control) | — | 控制通道,即桌面应用和 `twcore` 命令连接 core 的途径。其中有控制密钥,因此每份配置都有这一节。 | + + +#### `listen.gateway` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `bind` | `loopback` \| `all` \| 网卡名 \| IP 地址 | `loopback` | `loopback` 只有本机;`all` 所有网卡;网卡名(`en0`、`eth0`)在启动时解析,地址变了也能跟上;写死的 IP 地址在地址变化后失效。绑定单张网卡时同时监听 127.0.0.1。 | +| `port` | 整数 | `8788` | 网关的 TCP 端口。 | +| `allow_from` | 字符串列表 | `[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]` | 本机以外允许连接的来源,写 CIDR 网段或单个地址。本机始终放行。`[]` 表示只有本机;放行所有来源要明确写 `0.0.0.0/0`。 | + + +`bind` 超出本机范围时,由 `allow_from` 决定允许谁连接。网关不提供 TLS:只在可信的网络中开放,或放在隧道、VPN 之后。 + +```yaml +listen: + gateway: + bind: all + port: 8788 + allow_from: [192.168.1.0/24] +``` + +#### `listen.control` + +控制通道供桌面应用,以及同一台机器上的 `twcore config`、`twcore control-key` 与 core 通信。本地通道是数据目录中的 socket 文件(Windows 上是回环端口);除非启用 `remote`,控制通道不开任何网络端口。 + +每条控制连接,无论本地还是远程,都以一次握手开始,证明双方都持有 `key`(`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`:密钥作为预共享密钥,每条连接协商新的会话密钥,通信内容加密)。不使用证书。不持有密钥的一方无法完成第一条握手消息。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `key` | 字符串 | 自动生成 | 控制密钥:64 个十六进制字符(32 字节)。所有控制连接,无论本地还是远程,都要证明持有这把密钥。缺失时 `twcore serve` 在开始监听前写入一把;格式不对的配置整份拒绝。用 `twcore control-key` 查看,`twcore control-key --rotate` 更换。 | +| `remote` | 对象,见 [`listen.control.remote`](#cfg-listen-control-remote) | — | 供另一台机器上的桌面应用连接的网络端口。它是本地通道之外额外开的,不取代本地通道。 | + + +`key` 缺失时,由 `twcore serve` 在控制通道开始监听之前写入;`twcore init` 生成的配置也带有它。凡是显示配置或写入配置历史的地方,这个字段一律打码;把打码值原样存回时保留原值。密钥缺失或格式不对(不是 64 个十六进制字符)时整份配置无效,因此无法把它换成一把容易猜到的短密钥。 + +```sh +twcore control-key # 显示密钥,用于粘贴到桌面应用 +twcore control-key --rotate # 更换密钥;已连接的应用需要重新连接 +``` + +两条命令都在运行 core 的机器上执行。 + +#### `listen.control.remote` + +供另一台机器上的桌面应用连接的网络端口。它在本地通道之外额外开启,因此这里写错(端口被占用、`allow_from` 把自己挡在外面)也不会把本机锁在门外:`twcore config` 和本机应用照常可用。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `enabled` | 布尔 | `false` | 是否监听远程端口。不写或 `false`:不为控制面开任何网络端口。`twcore remote enable` / `twcore remote disable` 切换它;运行中的 core 在一秒内跟上。 | +| `bind` | `loopback` \| `all` \| 网卡名 \| IP 地址 | `all` | 监听哪张网卡,写法同 `listen.gateway.bind`。 | +| `port` | 整数 | **必填** | TCP 端口。没有固定默认值:`twcore init` 和 `twcore remote enable` 写出这一节时随机写入 20000 到 32000 之间的一个端口(不会和网关相同)。不能是 0,也不能和网关端口相同。 | +| `allow_from` | 字符串列表 | `[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]` | 允许连接的来源,写法同 `listen.gateway.allow_from`,但本机不会自动放行(本机有本地通道)。其他来源的连接在握手之前关闭,不回任何字节;收窄名单时,已经连着、不再放行的连接也随即断开。同一来源一分钟内握手失败 5 次,之后一分钟不理它。 | + + +```yaml +listen: + control: + key: 9f2c…e41a # 64 个十六进制字符 + remote: + enabled: true + bind: all + port: 23483 # 随机生成,在生成这一节时写入 + allow_from: [192.168.1.0/24] +``` + +经远程端口的连接无论应用如何请求,都不能做三件事:关闭 core(它由 systemd 管理)、修改 `listen.control`(它进来的那扇门)、生成诊断包(诊断包会写在服务器上)。握手限时 5 秒;同一来源一分钟内握手失败 5 次,封禁一分钟。 + +### `clients` + +网关密钥,即 Claude Code、Codex 等客户端向网关发送的密钥。密钥即身份:并发上限、模型范围、路由都按密钥设置。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 密钥的名字,不能重复。路由规则用 `when.client` 匹配它。 | +| `key` | 字符串 | **必填** | 客户端发送的密钥(放在 `x-api-key` 或 `Authorization: Bearer` 中)。生成的密钥以 `tw-` 开头,以免被误认作上游的密钥。不能重复。 | +| `max_concurrent` | 整数 | — | 用这把密钥同时进行的请求数上限,超出的排队等待。不写:不限。`0` 会被拒绝。 | +| `allow` | 字符串列表 | — | 这把密钥可用的模型,写模型 ID 或通配(`claude-*`)。不写:全部模型。`[]`:一个都不给。 | +| `route` | 字符串 | — | 这把密钥的请求走哪条路由。不写:`default_route`。 | +| `client` | 字符串 | — | 这把密钥是为哪个客户端生成的(`claude-code`、`codex` 等),由桌面应用接管客户端时写入。一个客户端最多一把。 | +| `disabled` | 布尔 | `false` | 拒绝使用这把密钥的所有请求,密钥本身保留。 | + + +```yaml +clients: + - name: default + key: tw-a3f9c8d1e5b2h7k4m6n8p2q4 + - name: build-server + key: tw-q8r2s4t6u8v2w4x6y8z2a4b6 + max_concurrent: 4 + allow: [claude-sonnet-*] + route: cheap +``` + +### `providers` + +上游,即请求被转发到的接口。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 上游的名字,不能重复,也不能和策略组同名。以 `__` 开头的名字保留给内置项。 | +| `base_url` | 字符串 | **必填** | 接口地址,`http://` 或 `https://`,按服务商文档写到版本段为止(`https://api.anthropic.com`、`https://api.openai.com/v1`)。 | +| `key` | 字符串,可写 `${VAR}` | — | API 密钥,放进协议规定的请求头:`x-api-key`(Anthropic)、`Authorization: Bearer`(OpenAI)、`x-goog-api-key`(Gemini)。上游不需要密钥、或凭据写在 `headers` 里时不写。不能和 `oauth` 同时写。 | +| `headers` | 请求头名 → 值的映射 | `{}` | 额外的请求头,按书写顺序发送;值可以用 `${VAR}`,配置了 `oauth` 时可以用 `{{access_token}}`。最多 32 个。HTTP 或网关管理的请求头(`host`、`content-length`、`connection` 等)不能设置。 | +| `oauth` | 对象,见 [`providers[].oauth`](#cfg-providers-oauth) | — | OAuth 凭据:用 refresh token 换取 access token。与 `key` 二选一。 | +| `protocol` | `anthropic` \| `openai-chat` \| `openai-responses` \| `gemini` \| `chatgpt` | — | 上游的接口格式。不写:官方地址按 `base_url` 识别,其余按 `anthropic` 处理。 | +| `proxy` | 字符串 | `direct` | `direct`;`system`,即 core 进程环境变量 `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY` 中的代理;或 `proxies` 中某一项的名字。 | +| `on_proxy_fail` | `fail` \| `direct` | `fail` | 代理不可用时:请求失败(`fail`),或改为直连(`direct`)。 | +| `models` | 字符串列表 | `[]` | 上游不支持 `/v1/models` 时,按这份清单认定它提供的模型。 | +| `models_only` | 字符串列表 | — | 只使用这家的这些模型,写 ID 或通配。范围外的模型不出现在模型列表里,也不会路由到这家。不写:全部。写空列表会被拒绝,暂停使用请用 `disabled`。 | +| `billing` | `per-token` \| `free` | `per-token` | `per-token`:费用为用量乘以所选价目表中的单价,订阅账号同样如此。`free`:费用记为 0。 | +| `pricing` | 字符串 | — | `pricing.sheets` 中某张价目表的名字。不写:默认价目表。 | +| `disabled` | 布尔 | `false` | 不参与路由,模型也不出现在模型列表里;配置原样保留。 | + + +凭据有三种写法:`key`,放进协议规定的请求头;`oauth`,用 refresh token 换取 token;`headers`,用于上游自有的鉴权方式。`headers` 可以和前两者同时使用,但不能再设置已经承载凭据的那个请求头。 + +```yaml +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} + + - name: relay + base_url: https://relay.example.com/v1 + protocol: openai-chat + headers: + X-Relay-Token: ${RELAY_TOKEN} + proxy: office + models_only: [gpt-4.1*, o3] + pricing: relay-discount + + - name: local + base_url: http://127.0.0.1:11434/v1 + protocol: openai-chat + billing: free +``` + +ChatGPT 账号上游(`protocol: chatgpt`)只接受桌面应用登录得到的凭据,不能手写。不支持 Claude 和 Google 的订阅登录,请使用 API 密钥。 + +#### `providers[].oauth` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `access` | 字符串 | — | 当前的 access token。每次刷新后由网关写回;不写则在第一次使用时换取。 | +| `expires_at` | 字符串 | — | `access` 的过期时间,RFC 3339(UTC),随 token 一起写回。不写:一直用到上游返回 401。 | +| `refresh` | 字符串 | **必填** | Refresh token。token 端点换发新的之后旧的即作废,因此网关会把新的写回本文件。 | +| `endpoint` | 字符串 | **必填** | token 端点的地址。 | +| `client_id` | 字符串 | — | OAuth 客户端 ID,端点需要时填写。 | +| `client_secret` | 字符串 | — | OAuth 客户端密钥,端点需要时填写。 | +| `refresh_before` | 时长(`30s`、`5m`、`1h`) | — | 提前多久刷新。不写或写法无法识别:`5m`。 | + + +access token 默认放进协议的鉴权请求头。要放在别处,在 `headers` 中写出那个请求头,用 `{{access_token}}` 标出 token 的位置: + +```yaml + oauth: + refresh: ${VENDOR_REFRESH_TOKEN} + endpoint: https://auth.example.com/oauth/token + client_id: my-client + headers: + X-Access: Token {{access_token}} +``` + +### `proxies` + +出站代理。不同上游需要的代理往往不同,因此没有全局开关:由每个上游用 `proxy` 选择。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | `providers[].proxy` 引用的名字。`direct` 和 `system` 是内置的。 | +| `type` | `socks5` \| `socks5h` \| `http` \| `https` | `socks5h` | `socks5h` 把域名交给代理解析;`socks5` 先在本地解析。`http` 和 `https` 是 HTTP 代理。 | +| `addr` | 字符串 | **必填** | 代理的 `host:port`。 | +| `auth` | 对象,见 [`proxies[].auth`](#cfg-proxies-auth) | — | 代理需要时填写用户名和密码。 | + + +#### `proxies[].auth` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `user` | 字符串 | **必填** | 用户名。 | +| `pass` | 字符串,可写 `${VAR}` | **必填** | 密码。 | + + +```yaml +proxies: + - name: office + type: http + addr: proxy.example.com:3128 + auth: + user: alice + pass: ${PROXY_PASSWORD} +``` + +`on_proxy_fail` 默认为 `fail`:静默改为直连会让请求走一条意料之外的路径,而使用者仍以为请求经过了代理。 + +### `pricing` + +一次请求的费用为用量乘以模型单价。单价来自默认价目表(LiteLLM 的公开数据集,程序内置一份,每天联网刷新),或来自上游选用的自定义价目表。单价变动只影响此后的请求,不改变已记录请求的费用。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `auto_update` | 布尔 | `true` | 每天联网刷新一次默认价目表,保存为 `config.yaml` 旁边的 `model_prices.json`;此前以及离线时使用内置于程序中的价目表。 | +| `sheets` | 对象列表,见 [`pricing.sheets[]`](#cfg-pricing-sheets) | `[]` | 自定义价目表。上游用 `providers[].pricing` 选用。 | + + +#### `pricing.sheets` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 价目表的名字,不能重复。 | +| `multiplier` | 数字 | `1` | 作用于默认价目表的全部单价,包括缓存和长上下文单价。 | +| `models` | 映射: 模型 ID → [`pricing.sheets[].models.*`](#cfg-pricing-sheets-models) | `{}` | 单独定价的模型。它们取代默认价目表中该模型的单价,不乘倍率。 | + + +#### `pricing.sheets[].models` + +单价以每百万 token 的美元计,与厂商价格页上的写法一致。每个字段都要写明,计价时不做任何推算。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `input` | 数字 | **必填** | 每百万输入 token 的美元价格。 | +| `output` | 数字 | **必填** | 每百万输出 token 的美元价格。 | +| `cache_read` | 数字 | **必填** | 每百万缓存读取 token 的美元价格。 | +| `cache_write_5m` | 数字 | **必填** | 每百万写入 5 分钟缓存 token 的美元价格。 | +| `cache_write_1h` | 数字 | **必填** | 每百万写入 1 小时缓存 token 的美元价格。 | +| `input_above_200k` | 数字 | — | 单次请求输入超过 200K token 后的输入单价。与 `output_above_200k` 同时写或都不写。 | +| `output_above_200k` | 数字 | — | 单次请求输入超过 200K token 后的输出单价。 | + + +```yaml +pricing: + sheets: + - name: relay-discount + multiplier: 0.8 + models: + claude-sonnet-4-5-thinking: + input: 3 + output: 15 + cache_read: 0.3 + cache_write_5m: 3.75 + cache_write_1h: 6 +``` + +### `client_probes` + +客户端发出的请求中,有一部分并非出自使用者:连通性检查、预热、会话标题、话题检测、建议。每一类都可以在本地应答(`intercept`,不向上游发送任何内容)、原样放行(`passthrough`),或交给路由规则(`route`,由 `when.intent` 匹配)。默认只拦下拦了也不会少任何东西的那几类。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `health_check` | `intercept` \| `passthrough` \| `route` | `intercept` | 连通性检查(`max_tokens: 1`)。默认在本地应答,不影响任何功能。 | +| `warmup` | `intercept` \| `passthrough` \| `route` | `intercept` | 预热请求。默认在本地应答。 | +| `titling` | `intercept` \| `passthrough` \| `route` | `passthrough` | 为会话起标题的请求。默认放行:拦下后所有会话都会是同一个标题。 | +| `topic_detect` | `intercept` \| `passthrough` \| `route` | `passthrough` | 话题检测。默认放行。 | +| `suggestion` | `intercept` \| `passthrough` \| `route` | `passthrough` | 建议。默认放行。 | + + +### `security` + +五项防护,对所有上游一视同仁。每一项都有 `mode`:`off`、`observe`(检测并记录,不改变任何行为)、`enforce`(处置)。出厂时除输出长度为 `off` 外,其余都是 `observe`。各项在 `enforce` 下的处置不同,分别见下文。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `redact` | 对象,见 [`security.redact`](#cfg-security-redact) | — | 出站脱敏:请求发出前,把其中的凭据替换掉。 | +| `inspect_tools` | 对象,见 [`security.inspect_tools`](#cfg-security-inspect_tools) | — | 工具调用审查:模型返回的工具调用中出现危险命令时切断响应。 | +| `hidden_text` | 对象,见 [`security.hidden_text`](#cfg-security-hidden_text) | — | 人看不见、模型读得到的隐藏字符,出现时拒绝请求。 | +| `content` | 对象,见 [`security.content`](#cfg-security-content) | — | 内容过滤:调用方发送的内容中出现指定的词或写法时拒绝请求。 | +| `output_limit` | 对象,见 [`security.output_limit`](#cfg-security-output_limit) | — | 输出长度:回答超过上限时切断。 | + + +#### `security.redact` + +请求发出前查找其中的凭据。`enforce` 下将其替换。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` 不检测;`observe` 检测并记录,不改变任何行为;`enforce` 检测并处置。 | +| `enable` | 字符串列表 | `[]` | 打开出厂时关着的内置规则,按 id。 | +| `disable` | 字符串列表 | `[]` | 关掉内置规则,按 id。 | +| `custom` | 对象列表,见 [`security.redact.custom[]`](#cfg-security-redact-custom) | `[]` | 自定义规则:正则匹配到的内容按凭据处理。 | + + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 日志和应用里显示的名字,也是规则的标识;同一项防护里不能重名。 | +| `pattern` | 字符串 | **必填** | 正则表达式。 | +| `disabled` | 布尔 | `false` | 停用这条规则,规则本身留在文件里。 | + + +内置规则: + + +| id | 名称 | 出厂 | +|---|---|---| +| `anthropic-api-key` | Anthropic API key | 开 | +| `openai-project-key` | OpenAI project key | 开 | +| `openai-api-key` | OpenAI API key | 开 | +| `github-personal-token` | GitHub personal access token | 开 | +| `github-oauth-token` | GitHub OAuth token | 开 | +| `github-server-token` | GitHub server token | 开 | +| `github-user-token` | GitHub user token | 开 | +| `github-fine-grained-token` | GitHub fine-grained token | 开 | +| `slack-bot-token` | Slack bot token | 开 | +| `slack-user-token` | Slack user token | 开 | +| `slack-app-token` | Slack app token | 开 | +| `aws-access-key-id` | AWS access key ID | 开 | +| `aws-temporary-key-id` | AWS temporary access key ID | 开 | +| `google-api-key` | Google API key | 开 | +| `google-oauth-token` | Google OAuth token | 开 | +| `gitlab-token` | GitLab token | 开 | +| `stripe-live-key` | Stripe live key | 开 | +| `stripe-restricted-key` | Stripe restricted key | 开 | +| `npm-token` | npm token | 开 | +| `digitalocean-token` | DigitalOcean token | 开 | +| `sendgrid-key` | SendGrid key | 开 | +| `private-key` | Private key | 开 | +| `jwt` | JWT | 开 | +| `conn-string-password` | Connection string password | 开 | +| `internal-ip` | Internal IP address | 关 | +| `internal-domain` | Internal domain | 关 | + + +#### `security.inspect_tools` + +按规则检查模型返回的工具调用。`enforce` 下命中处置为 `cut` 的规则时切断响应,客户端拿不到可执行的完整调用。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` 不检测;`observe` 检测并记录,不改变任何行为;`enforce` 检测并处置。 | +| `enable` | 字符串列表 | `[]` | 打开出厂时关着的内置规则,按 id。 | +| `disable` | 字符串列表 | `[]` | 关掉内置规则,按 id。 | +| `actions` | 映射: 内置规则 id → `cut` \| `record` | `{}` | 内置规则在 `enforce` 下的处置,只写与出厂不同的(`rm-rf-root: record`)。 | +| `custom` | 对象列表,见 [`security.inspect_tools.custom[]`](#cfg-security-inspect_tools-custom) | `[]` | 自定义规则,按工具调用的参数匹配。 | + + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 日志和应用里显示的名字,也是规则的标识;同一项防护里不能重名。 | +| `pattern` | 字符串 | **必填** | 正则表达式。 | +| `action` | `cut` \| `record` | `record` | `enforce` 下切断响应(`cut`),或只记录(`record`)。 | +| `disabled` | 布尔 | `false` | 停用这条规则,规则本身留在文件里。 | + + +内置规则: + + +| id | 名称 | `enforce` 下出厂处置 | +|---|---|---| +| `curl-pipe-sh` | Download and run | `cut` | +| `base64-decode-exec` | Decode and run | `cut` | +| `exfil-env` | Send out environment variables | `cut` | +| `exfil-credentials` | Send out a credential file | `cut` | +| `exfil-credentials-reversed` | Send out a credential file (verb first) | `cut` | +| `ssh-key-read` | Read a private key or cloud credential | `cut` | +| `write-startup-item` | Write a startup item | `cut` | +| `crontab-install` | Install a scheduled job | `cut` | +| `rm-rf-root` | Delete home or root | `record` | +| `chmod-777` | World-writable permissions | `record` | + + +#### `security.hidden_text` + +调用方发送的内容中(包括工具结果)人看不见、模型读得到的字符。`enforce` 下拒绝请求。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` 不检测;`observe` 检测并记录,不改变任何行为;`enforce` 检测并处置。 | +| `disable` | 字符串列表 | `[]` | 不检查的种类:`tag`、`bidi`。 | + + + +| 种类 | 说明 | +|---|---| +| `tag` | Unicode 标签字符(U+E0000 至 U+E007F):在任何地方都不可见,模型却能读到,足以藏下一整段指令。 | +| `bidi` | 双向控制符:使显示顺序与模型读到的顺序不一致。 | + + +#### `security.content` + +调用方发送的内容中出现的词或写法。`enforce` 下命中处置为 `block` 的规则时拒绝请求。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` 不检测;`observe` 检测并记录,不改变任何行为;`enforce` 检测并处置。 | +| `enable` | 字符串列表 | `[]` | 打开出厂时关着的内置规则,按 id。 | +| `disable` | 字符串列表 | `[]` | 关掉内置规则,按 id。 | +| `actions` | 映射: 内置规则 id → `block` \| `record` | `{}` | 内置规则在 `enforce` 下的处置,只写与出厂不同的。 | +| `custom` | 对象列表,见 [`security.content.custom[]`](#cfg-security-content-custom) | `[]` | 自定义规则。 | + + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 日志和应用里显示的名字,也是规则的标识;同一项防护里不能重名。 | +| `pattern` | 字符串 | **必填** | 关键词;`match: regex` 时为正则表达式。均不区分大小写。 | +| `match` | `contains` \| `regex` | `contains` | `contains`:正文包含 `pattern`。`regex`:`pattern` 是正则表达式。 | +| `action` | `block` \| `record` | `record` | `enforce` 下拒绝请求(`block`),或只记录(`record`)。 | +| `disabled` | 布尔 | `false` | 停用这条规则,规则本身留在文件里。 | + + +内置规则: + + +| id | 名称 | 分组 | 出厂 | `enforce` 下出厂处置 | +|---|---|---|---|---| +| `ignore-previous-instructions` | Ignore previous instructions | injection | 开 | `block` | +| `ignore-all-previous` | Ignore all previous | injection | 开 | `block` | +| `disregard-your-instructions` | Disregard your instructions | injection | 开 | `block` | +| `jailbreak` | Jailbreak | injection | 关 | `block` | +| `dan` | DAN | injection | 关 | `block` | +| `developer-mode` | Developer mode | injection | 关 | `block` | +| `you-are-now` | Persona manipulation | persona | 关 | `block` | +| `new-persona` | New persona | persona | 关 | `record` | +| `act-as` | Act as | persona | 关 | `record` | +| `pretend-to-be` | Pretend to be | persona | 关 | `record` | +| `system-prompt` | System prompt extraction | persona | 关 | `record` | +| `reveal-your-instructions` | Reveal instructions | persona | 关 | `record` | +| `what-are-your-rules` | What are your rules | persona | 关 | `record` | +| `base64-wall` | Base64 smuggling | persona | 关 | `record` | +| `zh-ignore-previous` | Ignore previous instructions (Chinese) | chinese | 关 | `block` | +| `zh-forget-your` | Forget your instructions (Chinese) | chinese | 关 | `block` | +| `zh-do-not-follow` | Do not follow (Chinese) | chinese | 关 | `block` | +| `zh-you-are-now` | You are now (Chinese) | chinese | 关 | `block` | +| `zh-role-play` | Role-play (Chinese) | chinese | 关 | `record` | +| `zh-reveal-your` | Reveal your instructions (Chinese) | chinese | 关 | `record` | +| `zh-system-prompt` | System prompt (Chinese) | chinese | 关 | `record` | +| `zh-jailbreak` | Jailbreak (Chinese) | chinese | 关 | `block` | + + +#### `security.output_limit` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `off` | 出厂关闭:没有一个上限适合所有用途。`observe` 记录超长的回答;`enforce` 在超过上限处停止输出。 | +| `max_chars` | 整数 | `100000` | 上限,按字符(Unicode 标量)计,取值 1 到 1000000。 | + + +```yaml +security: + redact: + mode: enforce + enable: [internal-ip] + custom: + - name: employee-id + pattern: 'EMP-\d{6}' + inspect_tools: + mode: enforce + output_limit: + mode: enforce + max_chars: 200000 +``` + +### `retention` + +设两个期限,是因为两类数据的体积相差三个数量级:一条请求的正文有几十 KB,一条请求记录只有几百字节。字节上限用于应对用量突增。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `body_days` | 整数 | `7` | 请求和响应正文保留的天数。 | +| `row_days` | 整数 | `90` | 每条请求记录(时间、模型、用量、费用)保留的天数。 | +| `body_max_bytes` | 整数 | `2147483648` | 正文最多占用的字节数,超出时从最早的日期开始删除。默认 2 GiB。 | + + +### `groups` + +策略组让多个上游合用一个名字。规则用 `to` 把请求交给策略组。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 策略组的名字,不能重复,也不能和上游同名。 | +| `type` | `fallback` \| `select` \| `load-balance` \| `url-test` \| `cheapest` | `fallback` | `fallback`:按顺序取第一个健康的。`select`:取 `selected` 指定的那个。`load-balance`:轮流。`url-test`:按实测首字节时间取最快的。`cheapest`:取输入单价最低的。 | +| `providers` | 字符串列表 | **必填** | 成员上游的名字。 | +| `session_affinity` | 布尔 | `true` | 同一会话固定走同一家,使 prompt cache 持续命中。在 `load-balance` 下关闭会让每一轮都换一家,缓存随之失效。 | +| `selected` | 字符串 | — | `select` 类型选中的成员。 | + + +默认类型为 `fallback`:把一个会话分散到多家上游会丢掉 prompt cache,而在单个使用者的机器上,分散负载换来的远不及缓存省下的。 + +### `routes` + +一条路由是一组自上而下求值的规则。每把密钥使用其 `route` 指定的路由;没有指定时用 `default_route`;再没有时用名为 `default` 的路由;一条路由都没有时,请求按上游的声明顺序故障转移。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 路由的名字,不能重复。`default` 是密钥默认使用的那条。 | +| `rules` | 对象列表,见 [`routes[].rules[]`](#cfg-routes-rules) | `[]` | 自上而下求值;第一条匹配且带有 `to` 或 `deny` 的规则决定请求去向。 | + + +#### `routes[].rules` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 日志和流量详情中显示的名字。 | +| `when` | 对象,见 [`routes[].rules[].when`](#cfg-routes-rules-when) | — | 条件,须全部满足。不写:匹配所有请求。 | +| `to` | 字符串 | — | 上游或策略组的名字;`__all__` 表示按声明顺序的全部上游。不能与 `when.provider_would_be` 同时写。 | +| `set` | 对象,见 [`routes[].rules[].set`](#cfg-routes-rules-set) | — | 改写请求参数。从所有匹配的规则累积,不只第一条。 | +| `deny` | 字符串 | — | 以这句原因拒绝请求。 | + + +#### `routes[].rules[].when` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `model` | 字符串 | — | 请求的模型,可用通配(`claude-opus-*`)。 | +| `client` | 字符串 | — | 请求所用网关密钥的名字,精确匹配。 | +| `dialect` | 字符串 | — | 客户端使用的接口格式:`anthropic`、`openai-chat`、`openai-responses`、`gemini`。 | +| `input_tokens` | 比较式(`>200k`、`<=4k`、`==3`) | — | 估算的输入 token 数。 | +| `max_tokens` | 比较式(`>200k`、`<=4k`、`==3`) | — | 请求中的 `max_tokens`。未写该参数的请求不匹配。 | +| `tool_count` | 比较式(`>200k`、`<=4k`、`==3`) | — | 请求中提供的工具数量。 | +| `cache` | 布尔 | — | 请求是否使用 prompt cache。 | +| `tools` | 布尔 | — | 请求是否带工具。 | +| `image` | 布尔 | — | 请求是否包含图片。 | +| `thinking` | 布尔 | — | 是否开启扩展思考。 | +| `stream` | 布尔 | — | 是否流式返回。 | +| `intent` | 字符串或字符串列表 | — | 客户端的辅助请求:`assistant_internal` 表示任意一类,也可以写具体的一类(`titling`)。只有在 `client_probes` 中设为 `route` 的类别才会进入路由。 | +| `provider_would_be` | 字符串或字符串列表 | — | 路由选中的上游。这类规则在路由完成后求值,只能 `set` 或 `deny`,不能写 `to`。 | + + +比较式以 `>`、`>=`、`<`、`<=` 或 `==` 开头,数字可以带 `k` 或 `m` 后缀:`">200k"`、`"<=4k"`。不带运算符是错误,不当作相等:单写 `"200k"` 会被拒绝。 + +#### `routes[].rules[].set` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `model` | 字符串 | — | 换成另一个模型发送。该会话的 prompt cache 随之失效。 | +| `max_tokens` | 整数 | — | 替换 `max_tokens`。 | +| `thinking` | 布尔 | — | 开启或关闭扩展思考。 | +| `only_at_session_start` | 布尔 | `false` | 只在会话开始时应用。目前只记录和显示,尚未生效。 | + + +```yaml +groups: + - name: fast + type: url-test + providers: [anthropic, relay] + +routes: + - name: default + rules: + - name: 长上下文走官方 + when: { input_tokens: ">200k" } + to: anthropic + - name: 标题用便宜模型 + when: { intent: titling } + set: { model: claude-haiku-4-5 } + - name: 其余 + to: fast +default_route: default +``` + +## 环境变量 + +| 变量 | 作用 | +|---|---| +| `THINKWATCH_HOME` | 数据目录,替代 `~/.thinkwatch`(Windows 上为 `%APPDATA%\ThinkWatch`)。 | +| `TWCORE_LOG` | 日志过滤,`tracing` 语法(`info`、`debug`、`tw_gateway=debug`)。 | +| `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY`、`NO_PROXY` | 供 `proxy: system` 的上游使用。 | +| 其他 | 配置中写 `${NAME}` 的位置读取。 | diff --git a/src/data/core-docs/manifest.json b/src/data/core-docs/manifest.json new file mode 100644 index 0000000..7d9f774 --- /dev/null +++ b/src/data/core-docs/manifest.json @@ -0,0 +1,10 @@ +{ + "repository": "ThinkWatchProject/ThinkWatch-Core", + "ref": "v0.47.0", + "files": { + "docs/config.md": "0cc9efd9cbb11ea69b3cf17e2d980376580afe6e3b38d628fe2f4be883ad18fb", + "docs/config.zh-CN.md": "54a095a491f8f2ce711f6f58166e2fc95d130a2c6bd53b9236af93147a598368", + "docs/server.md": "450735c4f18e2c284c3e2d5b111ba8501193047cc7b3277f9fa480af6d0482d4", + "docs/server.zh-CN.md": "423a8f992467fb45e38e0e368aab5ee0bca246bdc047b6a63904da2aff9b1a99" + } +} diff --git a/src/data/core-docs/server.md b/src/data/core-docs/server.md new file mode 100644 index 0000000..e0b2c29 --- /dev/null +++ b/src/data/core-docs/server.md @@ -0,0 +1,239 @@ +# Running core on a server + +[中文](server.zh-CN.md) + +ThinkWatch Core runs without a desktop: on a Linux machine it is started by +systemd from its configuration file, and the ThinkWatch Lite app on a Mac +connects to it over the network to show traffic and change settings. Clients +anywhere on the network send their requests to the server's gateway. + +This page covers installing, configuring, starting, connecting and +upgrading. Every field mentioned is described in the +[configuration reference](config.md). + +## Requirements + +- Linux on x86_64 or aarch64, with glibc 2.35 or newer (Ubuntu 22.04, + Debian 12, or later). +- systemd. +- The server's core version has to match the desktop app's. The app checks + this when it connects and shows both versions if they differ. + +## 1. Install + +```sh +curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh +``` + +To install a particular version, the one your desktop app expects: + +```sh +curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh -s -- --version 0.47.0 +``` + +The script: + +1. downloads `twcore--unknown-linux-gnu.tar.gz` from the GitHub + release and checks it against the release's SHA-256 sum; +2. installs the binary as `/usr/local/bin/twcore`; +3. creates the system user `thinkwatch` and the data directory + `/var/lib/thinkwatch` (mode `0700`); +4. installs `/etc/systemd/system/twcore.service` and an empty + `/etc/thinkwatch/env`; +5. runs `twcore init` as `thinkwatch` if there is no configuration yet; +6. prints the next steps. It does not start the service. + +Running it again is safe: it replaces the binary and the unit, and leaves +the configuration, the environment file and the data alone. For later +upgrades, `twcore upgrade` is the shorter way (see below). + +To install by hand instead, download the tarball and its `.sha256` from the +[releases page](https://github.com/ThinkWatchProject/ThinkWatch-Core/releases), +check it with `sha256sum -c`, and follow the steps above; the unit file is +in the tarball and in [`packaging/systemd/twcore.service`](../packaging/systemd/twcore.service). + +Every `twcore` command that reads the configuration has to run as the +service user with the service's data directory. The examples below spell +that out; a shell alias saves typing: + +```sh +alias twc='sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore' +``` + +## 2. Configure + +Open `/var/lib/thinkwatch/config.yaml` as root (`sudoedit` works) and change +three things: + +1. **Let clients on the network reach the gateway**: `listen.gateway.bind: + all`, and list their networks in `listen.gateway.allow_from`. +2. **Open the remote control port**: `listen.control.remote.enabled: true`, + and list the networks the desktop app connects from in its + `allow_from`. `twcore init` writes this section with `enabled: false` and + a random port between 20000 and 32000. The same can be done with a + command, which also writes the section if the file has none: + + ```sh + sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore remote enable --allow 192.168.1.0/24 + ``` + + `--allow` can be repeated and replaces the list; `--bind` and `--port` + change the interface and the port. `twcore remote disable` closes the + port again and keeps the rest, and `twcore remote` shows the current + state. A running core follows within a second. +3. **Add at least one upstream** under `providers`, or add it later from the + desktop app. + +```yaml +version: 1 +listen: + gateway: + bind: all + port: 8788 + allow_from: [192.168.1.0/24] + control: + key: 9f2c…e41a # written by twcore init; leave it as it is + remote: + enabled: true + bind: all + port: 23483 # written by twcore init, at random + allow_from: [192.168.1.0/24] +clients: + - name: default + key: tw-… # written by twcore init +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} +``` + +Check the result without starting anything: + +```sh +sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore check +``` + +### Secrets in the environment + +`${NAME}` in the configuration reads the environment of the core process. +Under systemd that is `/etc/thinkwatch/env`, one `NAME=value` per line: + +```sh +sudoedit /etc/thinkwatch/env # created by the installer: root:thinkwatch, 0640 +``` + +```ini +ANTHROPIC_API_KEY=sk-ant-… +HTTPS_PROXY=http://proxy.example.com:3128 +``` + +The file is read when the service starts: after changing it, restart the +service. Proxy variables here are what `proxy: system` uses. + +### Network + +Neither port has TLS. The control port is encrypted and authenticated by +its handshake; the gateway port carries requests in plain HTTP, like any +local model server. Keep both reachable only from networks you trust: set +`allow_from`, and open the two ports in the server's firewall to those +networks only. For access from outside, use a VPN or an SSH tunnel rather +than exposing the ports. + +## 3. Start + +```sh +sudo systemctl enable --now twcore +systemctl status twcore +journalctl -u twcore -f +``` + +The unit runs core as `thinkwatch`, restarts it if it fails, and keeps it +out of the rest of the file system: it can write only its data directory. + +Configuration changes need no restart. Core reloads the file within a +second of a save, from an editor, `twcore config`, or the desktop app; a +change that does not validate is refused and the previous configuration +keeps serving. + +## 4. Connect the desktop app + +Show the control key on the server: + +```sh +sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore control-key +``` + +``` +9f2c…e41a +remote control: port 23483; connect to 192.168.1.20:23483 +allowed sources: 192.168.1.0/24 +``` + +The first line, the key, is the only thing on standard output, so +`$(twcore control-key)` in a script gets just the key. The two lines after +it go to standard error: the port, the addresses of this server's +interfaces that listen on it, and the allowed sources. When the port is +closed the second line reads `remote control: off (twcore remote enable +opens it)`. + +In the desktop app, open **Settings → Connections → Add remote connection** +and enter: + +- **Address**: the server's host name or IP address; +- **Control port**: `listen.control.remote.port`; +- **Key**: the 64 characters `twcore control-key` printed. + +The app tests the connection before saving and says what is wrong if it +fails: no answer (address, port, firewall, `enabled`), connection closed +(this Mac's address is probably not in `allow_from`), wrong key, or +different versions. `allow_from` for this port does not let the server +itself in automatically; commands on the server use the local channel. + +A source that fails the handshake five times within a minute is ignored +for a minute. Removing a network from `allow_from` also closes the +connections already open from it. + +The key is kept in the Mac's keychain. To replace it, run +`twcore control-key --rotate` on the server; connections made with the old +key are closed at once, and connected apps then have to be given the new +key. + +A remote connection can do everything the app does on its own Mac except +three things, which the server refuses: stopping core (systemd runs it), +taking the diagnostic bundle, and changing `listen.control`, the section +it came in through. Do those on the server. + +### Point clients at the server + +Clients use the server's gateway, `http://:8788`, with a gateway key +from `clients`. The desktop app can point the clients on the Mac at the +server (Clients page); on other machines, configure them by hand. + +## Upgrading + +```sh +sudo twcore upgrade --check # compare with the latest release, change nothing +sudo twcore upgrade --restart # install the latest release and restart the service +sudo twcore upgrade --version 0.48.0 --restart +``` + +`twcore upgrade` downloads the release for this machine, checks its +SHA-256 sum, and replaces `/usr/local/bin/twcore` in one step, so a failed +download never leaves a broken binary. The configuration and the data are +not touched. Without `--restart` it prints the command to restart the +service; the running process keeps the old version until then. + +Upgrade the server and the desktop app together: the app refuses to connect +to a core of another version and shows the command above with the version +it needs. + +## Uninstalling + +```sh +sudo systemctl disable --now twcore +sudo rm /etc/systemd/system/twcore.service /usr/local/bin/twcore +sudo systemctl daemon-reload +# The configuration, keys and request history: +sudo rm -r /var/lib/thinkwatch /etc/thinkwatch +sudo userdel thinkwatch +``` diff --git a/src/data/core-docs/server.zh-CN.md b/src/data/core-docs/server.zh-CN.md new file mode 100644 index 0000000..4f4b3aa --- /dev/null +++ b/src/data/core-docs/server.zh-CN.md @@ -0,0 +1,175 @@ +# 在服务器上运行 core + +[English](server.md) + +ThinkWatch Core 可以脱离桌面运行:在 Linux 机器上由 systemd 按配置文件启动,Mac 上的 ThinkWatch Lite 通过网络连接它,查看流量、修改设置。网络中各处的客户端把请求发往服务器的网关。 + +本文依次说明安装、配置、启动、连接和升级。文中提到的每个字段,详见[配置手册](config.zh-CN.md)。 + +## 要求 + +- x86_64 或 aarch64 的 Linux,glibc 2.35 或更新(Ubuntu 22.04、Debian 12 及以后)。 +- systemd。 +- 服务器上的 core 版本须与桌面应用一致。应用在连接时核对版本,不一致时显示双方的版本号。 + +## 1. 安装 + +```sh +curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh +``` + +安装指定版本(即桌面应用要求的版本): + +```sh +curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh -s -- --version 0.47.0 +``` + +安装脚本依次: + +1. 从 GitHub Release 下载 `twcore-<架构>-unknown-linux-gnu.tar.gz`,并用 Release 中的 SHA-256 校验; +2. 把程序安装为 `/usr/local/bin/twcore`; +3. 创建系统用户 `thinkwatch` 和数据目录 `/var/lib/thinkwatch`(权限 `0700`); +4. 安装 `/etc/systemd/system/twcore.service`,以及空的 `/etc/thinkwatch/env`; +5. 还没有配置时,以 `thinkwatch` 身份执行 `twcore init`; +6. 打印后续步骤。脚本不启动服务。 + +脚本可以重复执行:它替换程序和 unit 文件,不动配置、环境变量文件和数据。之后升级用 `twcore upgrade` 更简便(见下文)。 + +也可以手动安装:从 [Releases 页面](https://github.com/ThinkWatchProject/ThinkWatch-Core/releases)下载压缩包及其 `.sha256`,用 `sha256sum -c` 校验,再按上述步骤操作;unit 文件在压缩包中,也在 [`packaging/systemd/twcore.service`](../packaging/systemd/twcore.service)。 + +读取配置的 `twcore` 命令,都要以服务用户的身份、带上服务的数据目录执行。下文的示例都写全了;可以用一个 shell 别名简化: + +```sh +alias twc='sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore' +``` + +## 2. 配置 + +以 root 身份打开 `/var/lib/thinkwatch/config.yaml`(可用 `sudoedit`),修改三处: + +1. **让网络中的客户端能访问网关**:`listen.gateway.bind: all`,并在 `listen.gateway.allow_from` 中列出客户端所在的网段。 +2. **打开远程控制端口**:`listen.control.remote.enabled: true`,并在其 `allow_from` 中列出桌面应用所在的网段。`twcore init` 生成的配置带有这一节,`enabled: false`,端口是 20000 到 32000 之间随机的一个。也可以用命令完成,文件中没有这一节时命令会一并写出: + + ```sh + sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore remote enable --allow 192.168.1.0/24 + ``` + + `--allow` 可以写多次,替换整个名单;`--bind`、`--port` 改网卡和端口。`twcore remote disable` 关闭端口,其余设置保留;`twcore remote` 查看当前状态。运行中的 core 在一秒内跟上。 +3. **在 `providers` 下添加至少一个上游**,也可以之后在桌面应用中添加。 + +```yaml +version: 1 +listen: + gateway: + bind: all + port: 8788 + allow_from: [192.168.1.0/24] + control: + key: 9f2c…e41a # twcore init 生成,保持原样 + remote: + enabled: true + bind: all + port: 23483 # twcore init 随机生成 + allow_from: [192.168.1.0/24] +clients: + - name: default + key: tw-… # twcore init 生成 +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} +``` + +只校验、不启动: + +```sh +sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore check +``` + +### 用环境变量存放密钥 + +配置中的 `${NAME}` 读取 core 进程的环境变量。在 systemd 下,环境变量来自 `/etc/thinkwatch/env`,每行一个 `NAME=value`: + +```sh +sudoedit /etc/thinkwatch/env # 由安装脚本创建:root:thinkwatch,0640 +``` + +```ini +ANTHROPIC_API_KEY=sk-ant-… +HTTPS_PROXY=http://proxy.example.com:3128 +``` + +这个文件在服务启动时读取,修改后需要重启服务。其中的代理变量就是 `proxy: system` 使用的代理。 + +### 网络 + +两个端口都没有 TLS。控制端口由握手完成加密和鉴权;网关端口以明文 HTTP 传输请求,和本地模型服务一样。两个端口都只应对可信的网络开放:设置 `allow_from`,并在服务器防火墙中只对这些网段开放这两个端口。需要从外部访问时,使用 VPN 或 SSH 隧道,不要直接暴露端口。 + +## 3. 启动 + +```sh +sudo systemctl enable --now twcore +systemctl status twcore +journalctl -u twcore -f +``` + +unit 以 `thinkwatch` 身份运行 core,失败后自动重启,并把它与文件系统的其余部分隔开:它只能写自己的数据目录。 + +修改配置不需要重启。无论通过编辑器、`twcore config` 还是桌面应用保存,core 都会在一秒内重新加载;未通过校验的改动被拒绝,原有配置继续服务。 + +## 4. 连接桌面应用 + +在服务器上查看控制密钥: + +```sh +sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore control-key +``` + +``` +9f2c…e41a +remote control: port 23483; connect to 192.168.1.20:23483 +allowed sources: 192.168.1.0/24 +``` + +第一行是密钥,也是标准输出上唯一的内容,脚本里 `$(twcore control-key)` 取到的就是密钥。后两行在标准错误上:端口、这台服务器上在该端口监听的网卡地址,以及放行的来源。端口关闭时第二行是 `remote control: off (twcore remote enable opens it)`。 + +在桌面应用中打开 **设置 → 连接 → 添加远程连接**,填写: + +- **地址**:服务器的主机名或 IP 地址; +- **控制端口**:`listen.control.remote.port` 的值; +- **密钥**:`twcore control-key` 输出的 64 个字符。 + +应用在保存前先试连,失败时说明原因:无响应(检查地址、端口、防火墙和 `enabled`)、连接被关闭(本机地址可能不在 `allow_from` 中)、密钥不正确、版本不一致。这个端口的 `allow_from` 不会自动放行服务器本机;服务器上的命令走本地通道。 + +同一来源一分钟内握手失败五次,之后一分钟不理它。从 `allow_from` 中删掉一个网段,已经从那里连着的连接也随即断开。 + +密钥保存在 Mac 的钥匙串中。要更换密钥,在服务器上执行 `twcore control-key --rotate`:用旧密钥建立的连接立即断开,之后已连接的应用需要填入新密钥。 + +远程连接能做应用在本机能做的一切,只有三件事服务器会拒绝:停止 core(它由 systemd 管理)、生成诊断包、修改 `listen.control`(这条连接进来的那一节)。这三件事在服务器上操作。 + +### 让客户端指向服务器 + +客户端使用服务器的网关 `http://<服务器>:8788`,以及 `clients` 中的一把网关密钥。桌面应用可以把这台 Mac 上的客户端改为指向服务器(客户端页);其他机器上的客户端需手动配置。 + +## 升级 + +```sh +sudo twcore upgrade --check # 与最新版本比较,不做任何改动 +sudo twcore upgrade --restart # 安装最新版本并重启服务 +sudo twcore upgrade --version 0.48.0 --restart +``` + +`twcore upgrade` 下载适合本机的版本,校验 SHA-256,一步替换 `/usr/local/bin/twcore`,下载失败也不会留下损坏的程序。配置和数据不受影响。不带 `--restart` 时只打印重启服务的命令;在重启之前,运行中的进程仍是旧版本。 + +服务器和桌面应用要一起升级:版本不一致时应用拒绝连接,并显示上面的命令及所需的版本。 + +## 卸载 + +```sh +sudo systemctl disable --now twcore +sudo rm /etc/systemd/system/twcore.service /usr/local/bin/twcore +sudo systemctl daemon-reload +# 配置、密钥和请求历史: +sudo rm -r /var/lib/thinkwatch /etc/thinkwatch +sudo userdel thinkwatch +``` diff --git a/src/i18n/pages/core.ts b/src/i18n/pages/core.ts index c6c94e3..0e40d19 100644 --- a/src/i18n/pages/core.ts +++ b/src/i18n/pages/core.ts @@ -1,27 +1,34 @@ -// Copy for the /core page. Facts come from the ThinkWatch Core README, plus the -// ThinkWatch README for which crates the server edition depends on. +// Copy for the /core page. Facts come from the ThinkWatch Core repository: the +// README and CONTRIBUTING (what the crates do, how releases are built), the +// workspace Cargo.toml and each crate's dependencies (the groups under "Crate +// layers"), docs/server.md and scripts/install.sh (server deployment), and from +// ThinkWatch Enterprise's Cargo.toml for the three crates it depends on. +// +// meta.twcoreDescription describes the twcore binary in the structured data +// (src/lib/structured-data.ts); the build fails without it. export const coreCopy = { en: { meta: { - title: "ThinkWatch Core — Shared core of the ThinkWatch gateways", + title: "ThinkWatch Core — AI API gateway engine in Rust", description: - "Routing, forwarding, observability, cost accounting, and data-plane guards, provided as MIT-licensed Rust crates. Used by ThinkWatch Lite and by the server edition.", - /** The twcore binary, in the structured data (JSON-LD) */ - twcoreDescription: "A complete, self-contained gateway binary", + "Rust crates and the twcore binary for an AI API gateway: rule-based routing, mid-stream failover, cost accounting, outbound secret redaction and tool-call inspection. Runs inside ThinkWatch Lite or as a standalone gateway on a Linux server. MIT License.", + twcoreDescription: + "Self-contained AI API gateway binary: the local engine of ThinkWatch Lite, or a standalone gateway run by systemd on a Linux server.", }, hero: { - eyebrow: "ThinkWatch Core · Shared engine", - titleA: "Shared core of ", - titleHighlight: "the ThinkWatch gateways", - sub: "Routing, forwarding, observability, cost accounting, and data-plane guards, provided as MIT-licensed Rust crates. Used by ThinkWatch Lite and by the server edition.", - ctaPrimary: "Core documentation", - ctaSecondary: "View on GitHub", - cardTitle: "twcore · a complete, self-contained gateway binary", + eyebrow: "ThinkWatch Core · Gateway engine", + titleA: "An AI API gateway engine ", + titleHighlight: "for desktops and servers", + sub: "MIT-licensed Rust crates and the twcore binary. twcore is the gateway inside ThinkWatch Lite, and runs on its own as a systemd service on a Linux server, managed from ThinkWatch Lite over an encrypted control channel. ThinkWatch Enterprise uses its format-conversion, guard and circuit-breaker crates.", + ctaInstall: "Install twcore", + ctaDocs: "Core documentation", + ctaGithub: "View on GitHub", + cardTitle: "twcore · the gateway binary", commands: [ - { cmd: "cargo run -p twcore -- init", note: "# write a commented config.yaml" }, - { cmd: "cargo run -p twcore -- check", note: "# validate without starting" }, - { cmd: "cargo run -p twcore -- serve", note: "# start the gateway and control plane" }, + { cmd: "twcore init", note: "# write an initial config.yaml and its keys" }, + { cmd: "twcore check", note: "# validate the configuration without starting" }, + { cmd: "twcore serve", note: "# start the gateway and the control plane" }, ], }, does: { @@ -29,43 +36,84 @@ export const coreCopy = { items: [ { title: "Rule-based routing", - body: "Rules match on model, client, context length, or the presence of tools, and can switch upstream, rewrite parameters, or reject the request.", + body: "Rules match on the model, the gateway key, the input size, tools, images and other properties of a request, and send it to an upstream or a group, rewrite its parameters or refuse it. When the client and the upstream use different API formats, the request is converted between Anthropic Messages, OpenAI Chat Completions, OpenAI Responses and Gemini.", }, { title: "Mid-stream failover", - body: "Before the first byte is sent, upstreams can be switched transparently. After streaming has started, the failure is reported.", + body: "Until the first byte reaches the client, a failing upstream is replaced by the next one without the client noticing; after that point, the failure is reported. A circuit breaker keeps requests away from an upstream that keeps failing.", }, { - title: "Cost visibility", - body: "Token usage and cache hits are priced against a snapshot table. Usage that cannot be priced is labelled unknown.", + title: "Cost accounting", + body: "Each request is priced from a public price table, which twcore refreshes daily, or from a price sheet in the configuration, and records where its price came from. Estimated amounts are marked as such, and usage that cannot be priced is labelled unknown.", }, { title: "Outbound redaction", - body: "Secrets are replaced with placeholders before they reach a relay, and restored when the model echoes them.", + body: "Credentials in a request are replaced with placeholders before the request leaves, and restored when the model echoes them back.", }, { - title: "Inbound inspection", - body: "Tool calls from an upstream are checked against rules; a dangerous call can be terminated mid-frame.", + title: "Tool-call inspection", + body: "Tool calls returned by a model are checked against rules, and a dangerous call can be cut off mid-stream. Together with checks for hidden characters, content rules and an output limit, these form five guards, each set to off, observe or enforce.", }, + { + title: "Encrypted control plane", + body: "The desktop app and twcore commands reach core over a local socket, a loopback port on Windows, and an optional remote port. Every control connection starts with a Noise handshake keyed by the control key; no certificates are involved.", + }, + ], + }, + install: { + eyebrow: "Install and deploy", + title: "Server deployment", + body: [ + "On Linux on x86_64 or aarch64, with glibc 2.35 or newer and systemd, one command installs twcore, a service user and the systemd unit. ThinkWatch Lite on macOS, Windows or Linux then connects to it through the remote control port, and clients anywhere on the network send their requests to its gateway.", + "On a desktop, twcore comes with ThinkWatch Lite and is not installed separately.", + ], + guide: "Server deployment guide", + reference: "Configuration reference", + scriptLabel: "Install on a Linux server", + pinNote: "A particular version, such as the one ThinkWatch Lite expects:", + nextLabel: "Then", + next: [ + { cmd: "twcore remote enable --allow 192.168.1.0/24", note: "# open the remote control port to a network" }, + { cmd: "sudo systemctl enable --now twcore", note: "# start the service" }, + { cmd: "twcore control-key", note: "# print the key ThinkWatch Lite connects with" }, + { cmd: "sudo twcore upgrade --restart", note: "# later: install the latest release" }, ], + serviceUser: "Commands that read the configuration run as the service user; the guide describes each step.", + binariesTitle: "Prebuilt binaries", + binariesBody: + "Every release carries twcore for five targets, each with a SHA-256 file; the Linux archives add the systemd unit. twcore upgrade replaces a standalone installation, while the copy inside ThinkWatch Lite is updated with the app.", + platforms: { + mac: "macOS · Apple silicon", + winX64: "Windows · x64", + winArm: "Windows · ARM64", + linuxX64: "Linux · x86_64", + linuxArm: "Linux · aarch64", + }, + releases: "All releases", + version: "Version", + sourceTitle: "Build from source", + sourceBody: "With a stable Rust toolchain, 1.85 or newer. The binary is written to target/release/twcore.", }, layers: { eyebrow: "Crate layers", - sharedTag: "Also used by the server edition", - localNote: "Single-machine: SQLite, unix socket", + /** mark "shared": the group ThinkWatch Enterprise depends on; "local": the single-machine implementation */ + sharedTag: "Also used by ThinkWatch Enterprise", + localNote: "Single-machine implementation", rows: [ - { name: "Defined by external constraints", crates: "tw-types · tw-protocol · tw-provider · tw-resil · tw-crypto" }, - { name: "Domain logic", crates: "tw-engine · tw-pricing · tw-redact · tw-yaml · tw-secret" }, - { name: "Assembly", crates: "tw-config · tw-store · tw-scan · tw-adopt · tw-observe" }, - { name: "Data plane and control plane", crates: "tw-gateway · tw-control" }, + { name: "Conversion, guards and circuit breaker", crates: "tw-dialect · tw-guard · tw-breaker", mark: "shared" }, + { name: "Domain logic", crates: "tw-types · tw-engine · tw-pricing · tw-yaml · tw-secret · tw-watch", mark: null }, + { name: "Control-plane contract", crates: "tw-api · tw-link", mark: null }, + { name: "Assembly", crates: "tw-config · tw-store · tw-observe", mark: "local" }, + { name: "Data plane and control plane", crates: "tw-gateway · tw-control", mark: "local" }, ], footnote: - "The bottom two layers form the single-machine implementation and are intentionally not shared. Single-machine SQLite and multi-tenant Postgres differ too much for one abstraction to serve both.", + "ThinkWatch Enterprise depends on the first group and nothing else. Those three crates depend only on each other, which a test enforces, and CI builds ThinkWatch Enterprise against every change to them. No crate depends on a group below its own. The last two groups are the single-machine implementation (SQLite, the local control channel and the optional remote port) and are intentionally not shared: single-machine SQLite and multi-tenant Postgres differ too much for one abstraction to serve both.", + docs: "Crate layers in detail", }, dev: { eyebrow: "Development", title: "Testing", - body: "The smoke script exercises every path on the real binary from a clean state. HOME and THINKWATCH_HOME point to a temporary directory that is deleted on completion.", + body: "The smoke script exercises every path on the real binary from a clean state and reaches the control plane through twcore call. HOME and THINKWATCH_HOME point to a temporary directory that is deleted on completion.", commands: [ { cmd: "cargo test --workspace", note: "# unit and integration tests" }, { cmd: "scripts/smoke.sh", note: "# every path on the real binary" }, @@ -79,24 +127,24 @@ export const coreCopy = { }, "zh-CN": { meta: { - title: "ThinkWatch Core — ThinkWatch 网关的共享核心", + title: "ThinkWatch Core — 以 Rust 编写的 AI API 网关引擎", description: - "路由、转发、可观测性、成本核算与数据面防护,以 MIT 许可证的 Rust crate 形式提供,供 ThinkWatch Lite 与服务端版本使用。", - /** The twcore binary, in the structured data (JSON-LD) */ - twcoreDescription: "完整且可独立运行的网关二进制", + "一组 Rust crate 与 twcore 二进制,提供 AI API 网关的规则路由、流式故障转移、费用核算、出站密钥脱敏与工具调用审查;随 ThinkWatch Lite 运行,也可作为独立网关部署在 Linux 服务器上。采用 MIT 许可证。", + twcoreDescription: "独立运行的 AI API 网关二进制:ThinkWatch Lite 的本地引擎,也可由 systemd 在 Linux 服务器上作为独立网关运行。", }, hero: { - eyebrow: "ThinkWatch Core · 共享引擎", - titleA: "ThinkWatch 网关的", - titleHighlight: "共享核心", - sub: "路由、转发、可观测性、成本核算与数据面防护,以 MIT 许可证的 Rust crate 形式提供,供 ThinkWatch Lite 与服务端版本使用。", - ctaPrimary: "Core 文档", - ctaSecondary: "在 GitHub 上查看", - cardTitle: "twcore · 完整且可独立运行的网关二进制", + eyebrow: "ThinkWatch Core · 网关引擎", + titleA: "适用于桌面与服务器的 ", + titleHighlight: "AI API 网关引擎", + sub: "采用 MIT 许可证的 Rust crate 与 twcore 二进制。twcore 是 ThinkWatch Lite 内置的网关,也可以作为 systemd 服务独立运行在 Linux 服务器上,由 ThinkWatch Lite 通过加密的控制通道远程管理。ThinkWatch 企业版使用其中的格式转换、防护与熔断 crate。", + ctaInstall: "安装 twcore", + ctaDocs: "Core 文档", + ctaGithub: "在 GitHub 上查看", + cardTitle: "twcore · 网关二进制", commands: [ - { cmd: "cargo run -p twcore -- init", note: "# 生成带注释的 config.yaml" }, - { cmd: "cargo run -p twcore -- check", note: "# 仅校验,不启动" }, - { cmd: "cargo run -p twcore -- serve", note: "# 启动网关与控制面" }, + { cmd: "twcore init", note: "# 生成初始的 config.yaml 及其密钥" }, + { cmd: "twcore check", note: "# 仅校验配置,不启动" }, + { cmd: "twcore serve", note: "# 启动网关与控制面" }, ], }, does: { @@ -104,43 +152,83 @@ export const coreCopy = { items: [ { title: "按规则路由", - body: "匹配条件包括模型名、客户端、上下文长度及是否携带工具;动作包括切换上游、改写参数或拒绝请求。", + body: "规则按模型、网关密钥、输入规模、是否携带工具或图片等请求属性匹配,将请求发往某个上游或策略组、改写其参数,或拒绝请求。客户端与上游的接口格式不同时,请求在 Anthropic Messages、OpenAI Chat Completions、OpenAI Responses 与 Gemini 之间转换。", }, { title: "流式故障转移", - body: "首字节发出之前,可透明切换上游;流式传输开始之后,报告故障情况。", + body: "首字节到达客户端之前,出错的上游由下一个上游替换,客户端无从察觉;此后发生的故障如实报告。熔断器使持续出错的上游暂不接收请求。", }, { - title: "成本可见", - body: "token 用量与缓存命中按价目表快照计价,无法计价的部分标记为未知。", + title: "费用核算", + body: "每个请求按公开价目表(twcore 每天刷新一次)或配置中的价目表计价,并记录价格来源。估算的金额另行标注,无法计价的用量标记为未知。", }, { title: "出站脱敏", - body: "请求发往中转站之前,其中的密钥替换为占位符;模型回显时再恢复原值。", + body: "请求发出之前,其中的凭据替换为占位符;模型回显时再恢复原值。", }, { - title: "入站审查", - body: "上游返回的工具调用按规则审查,高危调用可在当前帧中止。", + title: "工具调用审查", + body: "模型返回的工具调用按规则审查,高危调用可在流式传输中途截断。它与隐藏字符检查、内容规则和输出长度限制合为五项防护,每项可设为关闭、观察或拦截。", }, + { + title: "加密的控制面", + body: "桌面应用与 twcore 命令通过本地 socket(Windows 上为回环端口)连接 core,也可以开启远程端口。每条控制连接都先以控制密钥完成 Noise 握手,不使用证书。", + }, + ], + }, + install: { + eyebrow: "安装与部署", + title: "服务器部署", + body: [ + "在 x86_64 或 aarch64 的 Linux 上(glibc 2.35 或更新,使用 systemd),一条命令即可安装 twcore、专用的服务用户与 systemd 服务单元。此后 macOS、Windows 或 Linux 上的 ThinkWatch Lite 通过远程控制端口连接它,网络中各处的客户端把请求发往它的网关。", + "在桌面上,twcore 随 ThinkWatch Lite 提供,无需单独安装。", + ], + guide: "服务器部署指南", + reference: "配置手册", + scriptLabel: "在 Linux 服务器上安装", + pinNote: "安装指定版本,例如 ThinkWatch Lite 要求的版本:", + nextLabel: "随后", + next: [ + { cmd: "twcore remote enable --allow 192.168.1.0/24", note: "# 向指定网段开放远程控制端口" }, + { cmd: "sudo systemctl enable --now twcore", note: "# 启动服务" }, + { cmd: "twcore control-key", note: "# 输出 ThinkWatch Lite 连接所用的密钥" }, + { cmd: "sudo twcore upgrade --restart", note: "# 日后升级到最新版本" }, ], + serviceUser: "读取配置的命令须以服务用户身份运行,各步骤详见部署指南。", + binariesTitle: "预编译二进制", + binariesBody: + "每个发布版本都提供以下五个平台的 twcore,各附 SHA-256 校验文件;Linux 压缩包另含 systemd 服务单元。twcore upgrade 用于升级单独安装的 twcore,ThinkWatch Lite 内置的那一份随应用更新。", + platforms: { + mac: "macOS · Apple silicon", + winX64: "Windows · x64", + winArm: "Windows · ARM64", + linuxX64: "Linux · x86_64", + linuxArm: "Linux · aarch64", + }, + releases: "全部发布版本", + version: "版本", + sourceTitle: "从源码构建", + sourceBody: "需要 Rust 稳定版工具链(1.85 或更新)。生成的二进制位于 target/release/twcore。", }, layers: { eyebrow: "crate 分层", - sharedTag: "服务端版本同样使用", - localNote: "单机实现:SQLite、unix socket", + sharedTag: "ThinkWatch 企业版同样使用", + localNote: "单机实现", rows: [ - { name: "由外部约束决定", crates: "tw-types · tw-protocol · tw-provider · tw-resil · tw-crypto" }, - { name: "领域逻辑", crates: "tw-engine · tw-pricing · tw-redact · tw-yaml · tw-secret" }, - { name: "装配", crates: "tw-config · tw-store · tw-scan · tw-adopt · tw-observe" }, - { name: "数据面与控制面", crates: "tw-gateway · tw-control" }, + { name: "格式转换、防护与熔断", crates: "tw-dialect · tw-guard · tw-breaker", mark: "shared" }, + { name: "领域逻辑", crates: "tw-types · tw-engine · tw-pricing · tw-yaml · tw-secret · tw-watch", mark: null }, + { name: "控制面契约", crates: "tw-api · tw-link", mark: null }, + { name: "装配", crates: "tw-config · tw-store · tw-observe", mark: "local" }, + { name: "数据面与控制面", crates: "tw-gateway · tw-control", mark: "local" }, ], footnote: - "下面两层为单机实现,有意不共享:单机 SQLite 与多租户 Postgres 差异过大,统一的抽象难以同时满足两者。", + "ThinkWatch 企业版只依赖第一组。这三个 crate 只依赖彼此,由一项测试保证;每次改动它们,CI 都会用 ThinkWatch 企业版编译一遍。任何 crate 都不依赖排在其所在组下方的组。最后两组是单机实现(SQLite、本地控制通道与可选的远程端口),有意不共享:单机 SQLite 与多租户 Postgres 差异过大,统一的抽象难以同时满足两者。", + docs: "crate 分层详解", }, dev: { eyebrow: "开发", title: "测试", - body: "smoke 脚本从初始状态出发,在真实二进制上覆盖每条路径。HOME 与 THINKWATCH_HOME 均指向临时目录,执行结束后自动删除。", + body: "smoke 脚本从初始状态出发,在真实二进制上覆盖每条路径,并通过 twcore call 访问控制面。HOME 与 THINKWATCH_HOME 均指向临时目录,执行结束后自动删除。", commands: [ { cmd: "cargo test --workspace", note: "# 单元与集成测试" }, { cmd: "scripts/smoke.sh", note: "# 在真实二进制上覆盖每条路径" }, diff --git a/src/i18n/pages/home.ts b/src/i18n/pages/home.ts index ae5a7e3..824dd85 100644 --- a/src/i18n/pages/home.ts +++ b/src/i18n/pages/home.ts @@ -6,7 +6,7 @@ export const homeCopy = { meta: { title: "ThinkWatch — AI Gateways for Organizations and Individual Developers", description: - "ThinkWatch provides AI gateways that route, inspect, and meter model requests and MCP tool calls: ThinkWatch, a self-hosted server for organizations, and ThinkWatch Lite, a desktop application for individual developers. Both are built on the MIT-licensed ThinkWatch Core.", + "ThinkWatch provides AI gateways that route, inspect, and meter model requests and MCP tool calls: ThinkWatch Enterprise, a self-hosted server for organizations, and ThinkWatch Lite, a desktop app for individual developers on macOS, Windows and Linux. Both share ThinkWatch Core, the MIT-licensed gateway engine, which also runs on its own on a Linux server.", }, eyebrow: "AI API and MCP gateways", h1a: "AI gateways for", @@ -14,7 +14,7 @@ export const homeCopy = { sub: "ThinkWatch routes, inspects, and meters model requests and MCP tool calls. It is available as a self-hosted server for organizations and as a desktop application for individual developers.", doors: { teams: { label: "For organizations", name: "ThinkWatch Enterprise", text: "Self-hosted AI API and MCP gateway.", cta: "Deploy ThinkWatch" }, - machine: { label: "For individual developers", name: "ThinkWatch Lite", text: "Desktop application backed by a local gateway, for macOS on Apple Silicon, Windows on x64 or ARM64, and Linux on x86_64 or aarch64.", cta: "Install Lite" }, + machine: { label: "For individual developers", name: "ThinkWatch Lite", text: "Desktop application backed by a local gateway, for macOS on Apple silicon, Windows on x64 or ARM64, and Linux on x86_64 or aarch64.", cta: "Install Lite" }, }, trace: { tag: "Sample trace", @@ -80,19 +80,22 @@ export const homeCopy = { eyebrow: "ThinkWatch Lite · For individual developers", title: "A local AI gateway for macOS, Windows and Linux", points: [ - { t: "Cost reporting", b: "Measured, estimated, and unpriced usage are reported separately and never combined; subscription usage is counted apart." }, + { t: "Cost reporting", b: "Every request is priced from the price table; estimated amounts are marked as such, and requests that cannot be priced are counted as unpriced rather than as zero." }, { t: "Request routing", b: "The matched rule, the group, and every upstream attempt, with a dry run for rules before any traffic." }, - { t: "Outbound inspection", b: "Secret redaction, tool-call inspection, and a scan of the client configuration files." }, + { t: "Security checks", b: "Five guards on requests and responses, from secret redaction and tool-call inspection to an output limit, and a scan of client configurations, skills and hooks for hidden characters, prompt injection and dangerous commands." }, + { t: "Remote core", b: "The app can also connect to ThinkWatch Core running on a server, over an encrypted control channel." }, ], - pills: ["Available", "macOS · Apple Silicon", "Windows · x64 · ARM64", "Linux · x86_64 · aarch64", "MIT"], + pills: ["Available", "macOS · Apple silicon", "Windows · x64 · ARM64", "Linux · x86_64 · aarch64", "MIT"], shotAlt: "The usage overview: tokens, cost and requests, a 24-hour trend stacked by model, the leaderboard by model and the cache hit rate", cta: "Explore ThinkWatch Lite", }, core: { - a: "Both are built on ", + a: "ThinkWatch Enterprise and ThinkWatch Lite share ", name: "ThinkWatch Core", - b: ", a set of MIT-licensed Rust crates. Lite uses the complete engine; the server edition shares its protocol, provider, and resilience crates.", + b: ", MIT-licensed Rust crates and the twcore gateway binary. Lite runs the complete engine; Enterprise uses its format-conversion, guard and circuit-breaker crates. twcore also runs on its own on a Linux server, managed from ThinkWatch Lite.", + commandLabel: "Install twcore on a Linux server", + docs: "Server deployment guide", }, compare: { eyebrow: "Compare editions", @@ -111,13 +114,13 @@ export const homeCopy = { lite: { runsAs: "Desktop app in the macOS menu bar, the Windows notification area or the Linux system tray", builtFor: "Individual developers", - status: "Available · macOS on Apple Silicon, Homebrew or disk image · Windows on x64 or ARM64, installer · Linux on x86_64 or aarch64, AppImage", + status: "Available · macOS on Apple silicon, Homebrew or disk image · Windows on x64 or ARM64, installer · Linux on x86_64 or aarch64, AppImage", license: "MIT", }, core: { - runsAs: "Rust crates and the twcore binary", - builtFor: "Developers building on the engine", - status: "Source available on GitHub", + runsAs: "Rust crates and the twcore binary, inside ThinkWatch Lite or as a systemd service on a Linux server", + builtFor: "Developers who build on the engine or run the gateway on a server", + status: "Released · prebuilt binaries for macOS, Windows and Linux · one-command install on Linux servers", license: "MIT", }, link: "Full licensing details", @@ -127,7 +130,7 @@ export const homeCopy = { meta: { title: "ThinkWatch — 面向组织与个人开发者的 AI 网关", description: - "ThinkWatch 提供对模型请求与 MCP 工具调用进行路由、检查和计量的 AI 网关,包括面向组织的自托管服务端 ThinkWatch 和面向个人开发者的桌面应用 ThinkWatch Lite,二者均基于 MIT 许可证的 ThinkWatch Core。", + "ThinkWatch 提供对模型请求与 MCP 工具调用进行路由、检查和计量的 AI 网关,包括面向组织的自托管服务端 ThinkWatch 企业版,以及面向个人开发者、支持 macOS、Windows 与 Linux 的桌面应用 ThinkWatch Lite。二者共用采用 MIT 许可证的网关引擎 ThinkWatch Core,它也可以独立运行在 Linux 服务器上。", }, eyebrow: "AI API 与 MCP 网关", h1a: "面向组织与个人开发者的", @@ -135,7 +138,7 @@ export const homeCopy = { sub: "ThinkWatch 对模型请求与 MCP 工具调用进行路由、检查与计量,提供面向组织的自托管服务端,以及面向个人开发者的桌面应用。", doors: { teams: { label: "面向组织", name: "ThinkWatch 企业版", text: "自托管的 AI API 与 MCP 网关。", cta: "部署 ThinkWatch" }, - machine: { label: "面向个人开发者", name: "ThinkWatch Lite", text: "基于本地网关的桌面应用,支持 Apple Silicon 机型的 macOS、x64 与 ARM64 机型的 Windows,以及 x86_64 与 aarch64 机型的 Linux。", cta: "安装 Lite" }, + machine: { label: "面向个人开发者", name: "ThinkWatch Lite", text: "基于本地网关的桌面应用,支持 Apple silicon 机型的 macOS、x64 与 ARM64 机型的 Windows,以及 x86_64 与 aarch64 机型的 Linux。", cta: "安装 Lite" }, }, trace: { tag: "示例追踪", @@ -201,19 +204,22 @@ export const homeCopy = { eyebrow: "ThinkWatch Lite · 面向个人开发者", title: "适用于 macOS、Windows 与 Linux 的本地 AI 网关", points: [ - { t: "费用报告", b: "实测、估算与无法计价的用量分别显示,不合并计算;订阅制用量单独统计。" }, + { t: "费用报告", b: "每个请求按价目表计算费用;估算的金额另行标注,无法计价的请求单独计数,不按零计入。" }, { t: "请求路由", b: "展示每个请求命中的规则、策略组与每一次尝试,改规则前可以先试算。" }, - { t: "出站检查", b: "出站脱敏、工具调用审查,以及客户端配置文件的扫描。" }, + { t: "安全检查", b: "五项防护作用于请求与响应,包括出站脱敏、工具调用审查与输出长度限制等;另可扫描客户端配置、技能与钩子,检查隐藏字符、提示注入与危险命令。" }, + { t: "连接远程 core", b: "应用也可以通过加密的控制通道,连接运行在服务器上的 ThinkWatch Core。" }, ], - pills: ["已发布", "macOS · Apple Silicon", "Windows · x64 · ARM64", "Linux · x86_64 · aarch64", "MIT"], + pills: ["已发布", "macOS · Apple silicon", "Windows · x64 · ARM64", "Linux · x86_64 · aarch64", "MIT"], shotAlt: "ThinkWatch Lite 的用量概览:token、费用与请求数,按模型分层的 24 小时趋势,模型排行与缓存命中率", cta: "了解 ThinkWatch Lite", }, core: { - a: "两者均构建于 ", + a: "ThinkWatch 企业版与 ThinkWatch Lite 共用 ", name: "ThinkWatch Core", - b: " 之上,该项目是一组采用 MIT 许可证的 Rust crate。Lite 使用完整引擎,服务端版本共用其中的协议、Provider 适配与容错 crate。", + b: ":一组采用 MIT 许可证的 Rust crate 与 twcore 网关二进制。Lite 运行完整的引擎,企业版使用其中的格式转换、防护与熔断 crate。twcore 也可以独立运行在 Linux 服务器上,由 ThinkWatch Lite 远程管理。", + commandLabel: "在 Linux 服务器上安装 twcore", + docs: "服务器部署指南", }, compare: { eyebrow: "产品对比", @@ -232,13 +238,13 @@ export const homeCopy = { lite: { runsAs: "桌面应用,常驻 macOS 菜单栏、Windows 通知区域或 Linux 系统托盘", builtFor: "个人开发者", - status: "已发布 · macOS(Apple Silicon):Homebrew 或磁盘映像 · Windows(x64、ARM64):安装程序 · Linux(x86_64、aarch64):AppImage", + status: "已发布 · macOS(Apple silicon):Homebrew 或磁盘映像 · Windows(x64、ARM64):安装程序 · Linux(x86_64、aarch64):AppImage", license: "MIT", }, core: { - runsAs: "Rust crate 与 twcore 二进制", - builtFor: "基于该引擎进行开发的开发者", - status: "源码托管于 GitHub", + runsAs: "Rust crate 与 twcore 二进制,随 ThinkWatch Lite 运行,或作为 systemd 服务运行在 Linux 服务器上", + builtFor: "基于该引擎开发,或在服务器上运行网关的开发者", + status: "已发布 · 提供 macOS、Windows 与 Linux 的预编译二进制 · Linux 服务器可一条命令安装", license: "MIT", }, link: "查看完整许可证说明", diff --git a/src/layouts/DocsLayout.astro b/src/layouts/DocsLayout.astro index 4a86ad3..0deb5dc 100644 --- a/src/layouts/DocsLayout.astro +++ b/src/layouts/DocsLayout.astro @@ -39,6 +39,8 @@ interface Props { /** For articles: first published and last changed, as ISO timestamps */ published?: string; modified?: string; + /** Where the page's text is edited, when not with the product's other docs */ + editUrl?: string; } const { @@ -54,10 +56,12 @@ const { ogCard = docsOgCard[product], published, modified, + editUrl, } = Astro.props; const lang = getLang(Astro); const zh = lang === "zh-CN"; const p = getProduct(product); +const edit = editUrl ?? p.editUrl; const sidebar = getSidebar(product, lang); const currentDoc = p.docs.find((d) => d.slug === current); @@ -211,7 +215,7 @@ const itemIdle = "text-[var(--color-text)]/80 hover:text-[var(--color-text)]"; )} (err instanceof Error ? err.message : String(err)); + +export function coreDocsLoader(): Loader { + return { + name: "core-docs", + async load({ store, logger, parseData, renderMarkdown, generateDigest }) { + const snapshot = await readSnapshot(); + let docs: { ref: string; files: Record } = snapshot; + try { + docs = await fetchCoreDocs(await coreDocsRef()); + } catch (err) { + warn(logger, `The Core documents could not be fetched (${message(err)}); the copy in ${SNAPSHOT_DIR} from ${snapshot.ref} is published instead.`); + } + if (docs !== snapshot) { + const stale = staleFiles(snapshot, docs); + if (stale.length) { + warn( + logger, + `The copy of the Core documents in ${SNAPSHOT_DIR} (${snapshot.ref}) differs from ${CORE_REPO} at ${docs.ref} in ${stale.join(", ")}. Run \`pnpm core-docs\` and commit the result.`, + ); + } + logger.info(`Core documents from ${docs.ref}`); + } + + store.clear(); + for (const doc of coreDocs) { + for (const [lang, source] of Object.entries(doc.files)) { + const body = dropLanguageLink(docs.files[source]); + const rendered = await renderMarkdown(body); + rendered.html = rewriteLinks(rendered.html, { ref: docs.ref, source }); + // The glob loader lowercases ids; src/pages/docs/_lib.ts expects "zh-cn/…". + const id = `${lang === "zh-CN" ? "zh-cn" : "en"}/${doc.slug}`; + const data = await parseData({ id, data: { source, ref: docs.ref } }); + store.set({ + id, + data, + body, + rendered, + filePath: snapshotPath(source), + digest: generateDigest(`${docs.ref}\n${body}`), + }); + } + } + }, + }; +} diff --git a/src/lib/core-docs.mjs b/src/lib/core-docs.mjs new file mode 100644 index 0000000..913f1a6 --- /dev/null +++ b/src/lib/core-docs.mjs @@ -0,0 +1,190 @@ +// ThinkWatch Core's configuration reference and server deployment guide are +// written in the Core repository, next to the code they describe (the field +// tables of the configuration reference are generated from it). This site +// publishes them under /docs/core rather than keeping a second copy of the +// text. +// +// At build time the documents are fetched from the latest Core release, so the +// pages describe the twcore that can be downloaded, not unreleased work on +// main. src/data/core-docs holds a committed copy of them, used when GitHub +// cannot be reached; the build warns when that copy no longer matches the +// latest release, and `pnpm core-docs` refreshes it. +// +// Plain JavaScript, so that scripts/update-core-docs.mjs can use it outside the +// site build. The content collection that renders the pages is in +// ./core-docs-loader.ts. +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { posix } from "node:path"; +import { githubHeaders, productRepos } from "./releases.mjs"; + +/** @typedef {"en" | "zh-CN"} Lang */ + +export const CORE_REPO = productRepos.core; + +/** + * The Core documents published on this site, by the slug of their page under + * /docs/core. Paths are relative to the root of the Core repository. The + * sidebar entries for these pages are in src/content/docs/_meta.ts. + * + * @type {{ slug: string, files: Record }[]} + */ +export const coreDocs = [ + { slug: "configuration", files: { en: "docs/config.md", "zh-CN": "docs/config.zh-CN.md" } }, + { slug: "server-deployment", files: { en: "docs/server.md", "zh-CN": "docs/server.zh-CN.md" } }, +]; + +/** Every source file, in a fixed order */ +export const coreDocSources = coreDocs.flatMap((doc) => [doc.files.en, doc.files["zh-CN"]]); + +/** The committed copy, relative to the project root */ +export const SNAPSHOT_DIR = "src/data/core-docs"; +const MANIFEST = `${SNAPSHOT_DIR}/manifest.json`; + +/** Where the committed copy of a source file is kept */ +export const snapshotPath = (/** @type {string} */ source) => `${SNAPSHOT_DIR}/${posix.basename(source)}`; + +const TIMEOUT = 15_000; + +/** @param {string} text */ +export const sha256 = (text) => createHash("sha256").update(text).digest("hex"); + +/** + * @typedef {object} CoreDocsSource + * @property {string} ref The tag (or other git ref) the files were taken from + * @property {Record} files Source path → text + */ + +/** + * The ref to publish: CORE_DOCS_REF when set (to preview the documents of + * another tag or of a branch), otherwise the tag of the latest Core release. + * Throws when GitHub cannot be reached. + * + * @returns {Promise} + */ +export async function coreDocsRef() { + const override = typeof process !== "undefined" ? process.env.CORE_DOCS_REF : undefined; + if (override) return override; + const res = await fetch(`https://api.github.com/repos/${CORE_REPO}/releases/latest`, { + headers: githubHeaders(), + signal: AbortSignal.timeout(TIMEOUT), + }); + if (!res.ok) throw new Error(`GitHub answered ${res.status} for the latest release of ${CORE_REPO}`); + /** @type {{ tag_name?: string }} */ + const data = await res.json(); + if (!data.tag_name) throw new Error(`the latest release of ${CORE_REPO} has no tag`); + return data.tag_name; +} + +/** + * The documents as they are at `ref`. Throws unless every file could be + * fetched, so that the pages never mix two versions. + * + * @param {string} ref + * @returns {Promise} + */ +export async function fetchCoreDocs(ref) { + const texts = await Promise.all( + coreDocSources.map(async (path) => { + const res = await fetch(`https://raw.githubusercontent.com/${CORE_REPO}/${ref}/${path}`, { + headers: { "User-Agent": githubHeaders()["User-Agent"] }, + signal: AbortSignal.timeout(TIMEOUT), + }); + if (!res.ok) throw new Error(`${path} at ${ref}: GitHub answered ${res.status}`); + return res.text(); + }), + ); + return { ref, files: Object.fromEntries(coreDocSources.map((path, i) => [path, texts[i]])) }; +} + +/** + * The committed copy, with the hashes its manifest recorded for each file. + * + * @returns {Promise }>} + */ +export async function readSnapshot() { + /** @type {{ ref: string, files: Record }} */ + const manifest = JSON.parse(await readFile(MANIFEST, "utf8")); + /** @type {Record} */ + const files = {}; + for (const path of coreDocSources) files[path] = await readFile(snapshotPath(path), "utf8"); + return { ref: manifest.ref, files, hashes: manifest.files }; +} + +/** + * Replace the committed copy. + * + * @param {CoreDocsSource} docs + */ +export async function writeSnapshot(docs) { + await mkdir(SNAPSHOT_DIR, { recursive: true }); + for (const path of coreDocSources) await writeFile(snapshotPath(path), docs.files[path]); + const manifest = { + repository: CORE_REPO, + ref: docs.ref, + files: Object.fromEntries(coreDocSources.map((path) => [path, sha256(docs.files[path])])), + }; + await writeFile(MANIFEST, `${JSON.stringify(manifest, null, 2)}\n`); +} + +/** + * Files of the committed copy that differ from `docs`, or that were changed by + * hand since they were copied. + * + * @param {CoreDocsSource & { hashes: Record }} snapshot + * @param {CoreDocsSource} docs + * @returns {string[]} + */ +export function staleFiles(snapshot, docs) { + return coreDocSources.filter( + (path) => sha256(docs.files[path]) !== snapshot.hashes[path] || sha256(snapshot.files[path]) !== snapshot.hashes[path], + ); +} + +// ---------- From the repository to this site ---------- + +/** The page on this site that publishes a source file, by its path in the repository */ +const pages = new Map( + coreDocs.flatMap((doc) => + /** @type {[Lang, string][]} */ (Object.entries(doc.files)).map(([lang, path]) => [ + path, + `${lang === "zh-CN" ? "/zh-CN" : ""}/docs/core/${doc.slug}`, + ]), + ), +); + +/** + * A document's first lines link to its translation ("[中文](config.zh-CN.md)"). + * This site switches languages in its header, and a link to the other + * language would be undone by its language redirect, so the line is dropped. + * + * @param {string} markdown + */ +export function dropLanguageLink(markdown) { + return markdown.replace(/^(# [^\n]*\n)[ \t]*\n\[(?:中文|English)\]\([^)\s]+\)[ \t]*\n/, "$1"); +} + +/** + * Point the links of a rendered document at this site: a link to another + * published document goes to its page here, any other file of the repository + * to GitHub at the same ref. Links elsewhere and links within the page are + * left alone. Works on the HTML, where only real links have an href, so that + * code blocks are never touched. + * + * @param {string} html + * @param {{ ref: string, source: string }} from The ref and the document's path in the repository + */ +export function rewriteLinks(html, { ref, source }) { + const base = new URL(`https://repository.invalid/${posix.dirname(source)}/`); + return html.replace(/(\s(href|src)=")([^"]*)(")/g, (all, before, attr, value, after) => { + if (!value || value.startsWith("#") || value.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(value)) return all; + const url = new URL(value.replaceAll("&", "&"), base); + const path = url.pathname.slice(1); + const page = pages.get(decodeURIComponent(path)); + let to; + if (page) to = `${page}${url.hash}`; + else if (attr === "src") to = `https://raw.githubusercontent.com/${CORE_REPO}/${ref}/${path}`; + else to = `https://github.com/${CORE_REPO}/${path.endsWith("/") ? "tree" : "blob"}/${ref}/${path}${url.hash}`; + return `${before}${to}${after}`; + }); +} diff --git a/src/lib/lastmod.ts b/src/lib/lastmod.ts index 27413ca..0c82a83 100644 --- a/src/lib/lastmod.ts +++ b/src/lib/lastmod.ts @@ -9,6 +9,7 @@ // workflow checks out the full history for this. import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; +import { coreDocs, snapshotPath } from "./core-docs.mjs"; import type { Product } from "./releases.mjs"; interface FileDates { @@ -96,11 +97,12 @@ export function pageSources(pathname: string): string[] { "src/components/ui/Terminal.tsx", "src/components/mocks", "src/components/LiteDownload.astro", + "src/components/CopyCommand.astro", ]; case "/lite": return ["src/components/pages/LitePage.astro", "src/i18n/pages/lite.ts", "src/components/LiteDownload.astro", "public/lite"]; case "/core": - return ["src/components/pages/CorePage.astro", "src/i18n/pages/core.ts"]; + return ["src/components/pages/CorePage.astro", "src/i18n/pages/core.ts", "src/components/CopyCommand.astro"]; case "/thinkwatch": return [ "src/components/pages/ThinkWatchPage.astro", @@ -118,6 +120,9 @@ export function pageSources(pathname: string): string[] { return ["src/pages/docs/_DocsHome.astro", "src/content/docs/_meta.ts"]; } const product = path.match(/^\/docs\/(lite|core)(?:\/([^/]+))?$/); + // A Core document published from the Core repository: its committed copy. + const synced = product?.[1] === "core" && coreDocs.find((d) => d.slug === product[2]); + if (synced) return [snapshotPath(synced.files[lang])]; if (product) return doc(`docs-${product[1]}`, product[2] ?? "overview"); const guide = path.match(/^\/docs\/([^/]+)$/); if (guide) return doc("docs", guide[1]); diff --git a/src/lib/structured-data.ts b/src/lib/structured-data.ts index 99279f3..0921a0d 100644 --- a/src/lib/structured-data.ts +++ b/src/lib/structured-data.ts @@ -41,10 +41,16 @@ const free = { "@type": "Offer", price: 0, priceCurrency: "USD" }; /** Platforms of the Lite downloads and the prebuilt twcore binaries, which match. */ const processors: Record = { - en: "Apple Silicon (arm64) on macOS; x64 or ARM64 on Windows; x86_64 or aarch64 on Linux", - "zh-CN": "macOS:Apple Silicon(arm64);Windows:x64 或 ARM64;Linux:x86_64 或 aarch64", + en: "Apple silicon (arm64) on macOS; x64 or ARM64 on Windows; x86_64 or aarch64 on Linux", + "zh-CN": "macOS:Apple silicon(arm64);Windows:x64 或 ARM64;Linux:x86_64 或 aarch64", }; +/** A description the page copy must provide: a missing one fails the build rather than leaving the field out. */ +function required(value: string | undefined, what: string): string { + if (!value?.trim()) throw new Error(`[structured-data] ${what} is missing`); + return value; +} + /** On every page. */ export const organization: JsonLd = { "@context": CONTEXT, @@ -198,12 +204,13 @@ export async function coreLd(lang: Lang): Promise { "@type": "SoftwareApplication", "@id": ids.twcore, name: "twcore", - description: c.meta.twcoreDescription, + description: required(c.meta.twcoreDescription, `coreCopy["${lang}"].meta.twcoreDescription`), applicationCategory: "DeveloperApplication", operatingSystem: "Linux, macOS, Windows", processorRequirements: processors[lang], ...(release ? { softwareVersion: release.tag.replace(/^v/, "") } : {}), url, + installUrl: `${url}#install`, downloadUrl: binaries.length ? binaries : `${repo}/releases/latest`, image: abs(ogImagePath("core")), license: MIT, diff --git a/src/pages/docs/_DocArticle.astro b/src/pages/docs/_DocArticle.astro index 3ab6231..9784736 100644 --- a/src/pages/docs/_DocArticle.astro +++ b/src/pages/docs/_DocArticle.astro @@ -6,6 +6,7 @@ import { render } from "astro:content"; import DocsLayout from "~/layouts/DocsLayout.astro"; import { docHref, getNeighbours, getProduct, productHomeHref, type ProductId } from "~/content/docs/_meta"; import { localePath, type Lang } from "~/i18n"; +import { CORE_REPO } from "~/lib/core-docs.mjs"; import { firstPublished, lastModified } from "~/lib/lastmod"; import { docsOgCard, ogImagePath } from "~/lib/og"; import { organizationRef } from "~/lib/structured-data"; @@ -30,7 +31,14 @@ const docLabel = meta?.label[lang] ?? slug; const title = `${docLabel} · ${productName(p, lang)} ${zh ? "文档" : "Docs"}`; const description = meta?.summary?.[lang] ?? p.tagline[lang]; -// From git history: when the markdown file was added and last changed. +// A document published from the Core repository: edited there, and credited +// to the file and release it was taken from. +const synced = entry.collection === "docs_core_synced" ? entry.data : undefined; +const editUrl = synced && `https://github.com/${CORE_REPO}/blob/main/${synced.source}`; +const sourceUrl = synced && `https://github.com/${CORE_REPO}/blob/${synced.ref}/${synced.source}`; + +// From git history: when the markdown file (for a synced document, its +// committed copy) was added and last changed. const sources = entry.filePath ? [entry.filePath] : []; const published = firstPublished(sources); const modified = lastModified(sources); @@ -87,10 +95,18 @@ const homeCards = slug ? [] : p.docs.filter((d) => d.slug); jsonLd={jsonLd} published={published} modified={modified} + editUrl={editUrl} >
+ {synced && ( +

+ {zh ? "本页取自 ThinkWatch Core 仓库 " : "This page is published from "} + {synced.source} + {zh ? `(${synced.ref})。` : ` in the ThinkWatch Core repository, at ${synced.ref}.`} +

+ )} {homeCards.length > 0 && (
{homeCards.map((d) => ( diff --git a/src/pages/docs/_lib.ts b/src/pages/docs/_lib.ts index 9359f1d..d308d4e 100644 --- a/src/pages/docs/_lib.ts +++ b/src/pages/docs/_lib.ts @@ -4,7 +4,11 @@ import { getCollection, type CollectionEntry } from "astro:content"; import type { Lang } from "~/i18n"; import type { ProductId } from "~/content/docs/_meta"; -export type DocsEntry = CollectionEntry<"docs"> | CollectionEntry<"docs_lite"> | CollectionEntry<"docs_core">; +export type DocsEntry = + | CollectionEntry<"docs"> + | CollectionEntry<"docs_lite"> + | CollectionEntry<"docs_core"> + | CollectionEntry<"docs_core_synced">; const collectionFor = { thinkwatch: "docs", lite: "docs_lite", core: "docs_core" } as const; @@ -18,12 +22,22 @@ export const HOME_ENTRY = "overview"; export const RESERVED_SLUGS = ["lite", "core"]; export async function getProductEntries(product: ProductId, lang: Lang) { - const all = (await getCollection(collectionFor[product])) as DocsEntry[]; + const all: DocsEntry[] = [ + ...((await getCollection(collectionFor[product])) as DocsEntry[]), + // Core also publishes documents whose text lives in the Core repository. + ...(product === "core" ? await getCollection("docs_core_synced") : []), + ]; // The glob loader lowercases ids, e.g. "zh-cn/architecture". const prefix = lang === "zh-CN" ? "zh-cn/" : "en/"; - return all + const entries = all .filter((entry) => entry.id.toLowerCase().startsWith(prefix)) .map((entry) => ({ entry, slug: entry.id.slice(prefix.length).replace(/\.md$/, "") })); + const taken = new Set(); + for (const { slug } of entries) { + if (taken.has(slug)) throw new Error(`[docs] two ${product} ${lang} documents have the slug "${slug}"`); + taken.add(slug); + } + return entries; } /** getStaticPaths() result for a product's articles (the docs home excluded). */ diff --git a/src/styles/global.css b/src/styles/global.css index 5a782d9..c443e61 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -288,6 +288,22 @@ font-size: 0.85rem; margin: 1.5em 0; } +/* A table wider than the column scrolls inside its wrapper (rehypeScrollingTables + in astro.config.mjs). */ +.prose-tw .table-scroll { + overflow-x: auto; + margin: 1.5em 0; +} +.prose-tw .table-scroll > table { margin: 0; } +@media (max-width: 40rem) { + /* On a phone a wide table keeps readable columns and scrolls, rather than + being squeezed into the width of the screen. */ + .prose-tw .table-scroll > table { + width: max-content; + min-width: 100%; + max-width: 36rem; + } +} .prose-tw thead { background: rgba(255,255,255,0.03); } .prose-tw th { text-align: left; From 3a8828519079057a08ef1bf27a071f835e3b7771 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:35:47 +0800 Subject: [PATCH 2/2] fix(core): failover before the first byte, the core version the app requires, Core docs from v0.48.0 - Name the capability "failover before the first byte" on the Core page, in its meta description (and so its structured data) and in the Core docs overview: the gateway switches upstream only until the first byte reaches the client, never in the middle of a stream. - The pinned install example no longer claims to be the version ThinkWatch Lite expects. The server has to run the core version the app requires, which the app shows when the versions differ; the page installs it with --version and moves to it later, newer or older, with sudo twcore upgrade --version --restart. - Refresh the committed copy of the Core documents to v0.48.0: the desktop app keeps the connection key in a private file, not in the keychain, and the guide speaks of macOS, Windows and Linux rather than only the Mac. - Home: the alt text of the Lite band describes the 7-day Overview screenshot it shows once the Lite page's screenshots are updated. - Docs home: ThinkWatch Enterprise for organizations; ThinkWatch Lite runs the gateway locally or connects to ThinkWatch Core on a server; ThinkWatch Enterprise uses three of Core's crates, not all of them. Co-Authored-By: Claude Opus 5.5 --- src/components/pages/CorePage.astro | 2 +- src/content/docs-core/en/overview.md | 2 +- src/content/docs-core/zh-CN/overview.md | 2 +- src/data/core-docs/manifest.json | 6 ++--- src/data/core-docs/server.md | 29 +++++++++++++------------ src/data/core-docs/server.zh-CN.md | 6 ++--- src/i18n/pages/core.ts | 19 +++++++++------- src/i18n/pages/home.ts | 4 ++-- src/pages/docs/_DocsHome.astro | 8 +++---- 9 files changed, 41 insertions(+), 37 deletions(-) diff --git a/src/components/pages/CorePage.astro b/src/components/pages/CorePage.astro index 7e6d761..d5acb90 100644 --- a/src/components/pages/CorePage.astro +++ b/src/components/pages/CorePage.astro @@ -171,7 +171,7 @@ const fileLink = "break-all text-[var(--color-brand-1)] transition-colors hover:

{c.install.scriptLabel}

{c.install.pinNote}

- +

{c.install.nextLabel}

    diff --git a/src/content/docs-core/en/overview.md b/src/content/docs-core/en/overview.md index 443d9f6..d8037e0 100644 --- a/src/content/docs-core/en/overview.md +++ b/src/content/docs-core/en/overview.md @@ -13,7 +13,7 @@ A change in Core reaches every product that uses it, so each change follows the Clients such as Claude Code and Codex send their requests to the gateway, and Core provides the following: - **Rule-based routing.** Rules match on the model, the gateway key, the input size, the presence of tools or images and other properties of a request, and send it to an upstream or a group, rewrite its parameters or refuse it. When the client and the upstream use different API formats, the request is converted between Anthropic Messages, OpenAI Chat Completions, OpenAI Responses and Gemini. -- **Mid-stream failover.** Until the first byte reaches the client, a failing upstream is replaced by the next one without the client noticing. After that point, the failure is reported. A circuit breaker keeps requests away from an upstream that keeps failing. +- **Failover before the first byte.** Until the first byte reaches the client, a failing upstream is replaced by the next one without the client noticing. After that point, the failure is reported. A circuit breaker keeps requests away from an upstream that keeps failing. - **Cost accounting.** Token usage and cache hits are priced from a public price table, which the control plane refreshes daily, or from a price sheet in the configuration. Each request records its cost and where the price came from. Estimated amounts are marked as such, and usage that cannot be priced is labelled *unknown* rather than given an invented figure. - **Outbound redaction.** Credentials in a request are replaced with placeholders before the request leaves, and restored when the model echoes them back. - **Tool-call inspection.** Tool calls returned by an upstream are checked against a rule set, and a dangerous call can be cut off mid-stream. With checks for hidden characters, content rules and an output limit, these form five guards, each set to `off`, `observe` or `enforce`. diff --git a/src/content/docs-core/zh-CN/overview.md b/src/content/docs-core/zh-CN/overview.md index 0cf9a9f..fc2ce9e 100644 --- a/src/content/docs-core/zh-CN/overview.md +++ b/src/content/docs-core/zh-CN/overview.md @@ -13,7 +13,7 @@ Core 中的改动会影响所有使用它的产品,因此每个改动都须遵 Claude Code、Codex 等客户端把请求发往网关后,Core 提供以下功能: - **按规则路由。** 规则按模型、网关密钥、输入规模、是否携带工具或图片等请求属性匹配,将请求发往某个上游或策略组、改写其参数,或拒绝请求。客户端与上游的接口格式不同时,请求在 Anthropic Messages、OpenAI Chat Completions、OpenAI Responses 与 Gemini 之间转换。 -- **流式故障转移。** 首字节到达客户端之前,出错的上游由下一个上游替换,客户端无从察觉;此后发生的故障如实报告。熔断器使持续出错的上游暂不接收请求。 +- **首字节前的故障转移。** 首字节到达客户端之前,出错的上游由下一个上游替换,客户端无从察觉;此后发生的故障如实报告。熔断器使持续出错的上游暂不接收请求。 - **费用核算。** token 用量与缓存命中按公开价目表计价(控制面每天刷新一次),或按配置中的价目表计价;每个请求都记录费用及价格来源。估算的金额另行标注,无法计价的用量标记为「未知」,不会填入虚构的数值。 - **出站脱敏。** 请求发出之前,其中的凭据替换为占位符;模型回显时再恢复原值。 - **工具调用审查。** 上游返回的工具调用按规则集审查,高危调用可在流式传输中途截断。它与隐藏字符检查、内容规则和输出长度限制合为五项防护,每项可设为 `off`、`observe` 或 `enforce`。 diff --git a/src/data/core-docs/manifest.json b/src/data/core-docs/manifest.json index 7d9f774..384d465 100644 --- a/src/data/core-docs/manifest.json +++ b/src/data/core-docs/manifest.json @@ -1,10 +1,10 @@ { "repository": "ThinkWatchProject/ThinkWatch-Core", - "ref": "v0.47.0", + "ref": "v0.48.0", "files": { "docs/config.md": "0cc9efd9cbb11ea69b3cf17e2d980376580afe6e3b38d628fe2f4be883ad18fb", "docs/config.zh-CN.md": "54a095a491f8f2ce711f6f58166e2fc95d130a2c6bd53b9236af93147a598368", - "docs/server.md": "450735c4f18e2c284c3e2d5b111ba8501193047cc7b3277f9fa480af6d0482d4", - "docs/server.zh-CN.md": "423a8f992467fb45e38e0e368aab5ee0bca246bdc047b6a63904da2aff9b1a99" + "docs/server.md": "60d35704d27e2abf0943ef9113633ab5ab8ca0bc398a79626502b18779641acc", + "docs/server.zh-CN.md": "fb2d02e20cf30132ab8a487e88000224ec9952a4bef999187f7049b0728d6c7c" } } diff --git a/src/data/core-docs/server.md b/src/data/core-docs/server.md index e0b2c29..319ba24 100644 --- a/src/data/core-docs/server.md +++ b/src/data/core-docs/server.md @@ -3,9 +3,9 @@ [中文](server.zh-CN.md) ThinkWatch Core runs without a desktop: on a Linux machine it is started by -systemd from its configuration file, and the ThinkWatch Lite app on a Mac -connects to it over the network to show traffic and change settings. Clients -anywhere on the network send their requests to the server's gateway. +systemd from its configuration file, and ThinkWatch Lite on macOS, Windows or +Linux connects to it over the network to show traffic and change settings. +Clients anywhere on the network send their requests to the server's gateway. This page covers installing, configuring, starting, connecting and upgrading. Every field mentioned is described in the @@ -185,7 +185,7 @@ and enter: The app tests the connection before saving and says what is wrong if it fails: no answer (address, port, firewall, `enabled`), connection closed -(this Mac's address is probably not in `allow_from`), wrong key, or +(this computer's address is probably not in `allow_from`), wrong key, or different versions. `allow_from` for this port does not let the server itself in automatically; commands on the server use the local channel. @@ -193,21 +193,22 @@ A source that fails the handshake five times within a minute is ignored for a minute. Removing a network from `allow_from` also closes the connections already open from it. -The key is kept in the Mac's keychain. To replace it, run -`twcore control-key --rotate` on the server; connections made with the old -key are closed at once, and connected apps then have to be given the new -key. +The desktop app stores the key in its data directory, in a file readable +only by the user who runs the app, rather than in the system keychain. To +replace it, run `twcore control-key --rotate` on the server; connections +made with the old key are closed at once, and connected apps then have to +be given the new key. -A remote connection can do everything the app does on its own Mac except -three things, which the server refuses: stopping core (systemd runs it), -taking the diagnostic bundle, and changing `listen.control`, the section -it came in through. Do those on the server. +A remote connection can do everything the app does on its own computer +except three things, which the server refuses: stopping core (systemd +runs it), taking the diagnostic bundle, and changing `listen.control`, +the section it came in through. Do those on the server. ### Point clients at the server Clients use the server's gateway, `http://:8788`, with a gateway key -from `clients`. The desktop app can point the clients on the Mac at the -server (Clients page); on other machines, configure them by hand. +from `clients`. The desktop app can point the clients on its own computer at +the server (Clients page); on other machines, configure them by hand. ## Upgrading diff --git a/src/data/core-docs/server.zh-CN.md b/src/data/core-docs/server.zh-CN.md index 4f4b3aa..3b253d3 100644 --- a/src/data/core-docs/server.zh-CN.md +++ b/src/data/core-docs/server.zh-CN.md @@ -2,7 +2,7 @@ [English](server.md) -ThinkWatch Core 可以脱离桌面运行:在 Linux 机器上由 systemd 按配置文件启动,Mac 上的 ThinkWatch Lite 通过网络连接它,查看流量、修改设置。网络中各处的客户端把请求发往服务器的网关。 +ThinkWatch Core 可以脱离桌面运行:在 Linux 机器上由 systemd 按配置文件启动,macOS、Windows 或 Linux 上的 ThinkWatch Lite 通过网络连接它,查看流量、修改设置。网络中各处的客户端把请求发往服务器的网关。 本文依次说明安装、配置、启动、连接和升级。文中提到的每个字段,详见[配置手册](config.zh-CN.md)。 @@ -143,13 +143,13 @@ allowed sources: 192.168.1.0/24 同一来源一分钟内握手失败五次,之后一分钟不理它。从 `allow_from` 中删掉一个网段,已经从那里连着的连接也随即断开。 -密钥保存在 Mac 的钥匙串中。要更换密钥,在服务器上执行 `twcore control-key --rotate`:用旧密钥建立的连接立即断开,之后已连接的应用需要填入新密钥。 +桌面应用把密钥存放在其数据目录下的一个文件中,该文件只有运行应用的用户可以读取;不使用系统钥匙串。要更换密钥,在服务器上执行 `twcore control-key --rotate`:用旧密钥建立的连接立即断开,之后已连接的应用需要填入新密钥。 远程连接能做应用在本机能做的一切,只有三件事服务器会拒绝:停止 core(它由 systemd 管理)、生成诊断包、修改 `listen.control`(这条连接进来的那一节)。这三件事在服务器上操作。 ### 让客户端指向服务器 -客户端使用服务器的网关 `http://<服务器>:8788`,以及 `clients` 中的一把网关密钥。桌面应用可以把这台 Mac 上的客户端改为指向服务器(客户端页);其他机器上的客户端需手动配置。 +客户端使用服务器的网关 `http://<服务器>:8788`,以及 `clients` 中的一把网关密钥。桌面应用可以把本机的客户端改为指向服务器(客户端页);其他机器上的客户端需手动配置。 ## 升级 diff --git a/src/i18n/pages/core.ts b/src/i18n/pages/core.ts index 0e40d19..6124ddd 100644 --- a/src/i18n/pages/core.ts +++ b/src/i18n/pages/core.ts @@ -12,7 +12,7 @@ export const coreCopy = { meta: { title: "ThinkWatch Core — AI API gateway engine in Rust", description: - "Rust crates and the twcore binary for an AI API gateway: rule-based routing, mid-stream failover, cost accounting, outbound secret redaction and tool-call inspection. Runs inside ThinkWatch Lite or as a standalone gateway on a Linux server. MIT License.", + "Rust crates and the twcore binary for an AI API gateway: rule-based routing, failover before the first byte, cost accounting, outbound secret redaction and tool-call inspection. Runs inside ThinkWatch Lite or as a standalone gateway on a Linux server. MIT License.", twcoreDescription: "Self-contained AI API gateway binary: the local engine of ThinkWatch Lite, or a standalone gateway run by systemd on a Linux server.", }, @@ -39,7 +39,7 @@ export const coreCopy = { body: "Rules match on the model, the gateway key, the input size, tools, images and other properties of a request, and send it to an upstream or a group, rewrite its parameters or refuse it. When the client and the upstream use different API formats, the request is converted between Anthropic Messages, OpenAI Chat Completions, OpenAI Responses and Gemini.", }, { - title: "Mid-stream failover", + title: "Failover before the first byte", body: "Until the first byte reaches the client, a failing upstream is replaced by the next one without the client noticing; after that point, the failure is reported. A circuit breaker keeps requests away from an upstream that keeps failing.", }, { @@ -70,13 +70,15 @@ export const coreCopy = { guide: "Server deployment guide", reference: "Configuration reference", scriptLabel: "Install on a Linux server", - pinNote: "A particular version, such as the one ThinkWatch Lite expects:", + pinNote: "The server has to run the core version that ThinkWatch Lite requires; the app shows that version when the two differ. To install it:", + /** Placeholder for the version number in the command that installs a particular version */ + versionPlaceholder: "", nextLabel: "Then", next: [ { cmd: "twcore remote enable --allow 192.168.1.0/24", note: "# open the remote control port to a network" }, { cmd: "sudo systemctl enable --now twcore", note: "# start the service" }, { cmd: "twcore control-key", note: "# print the key ThinkWatch Lite connects with" }, - { cmd: "sudo twcore upgrade --restart", note: "# later: install the latest release" }, + { cmd: "sudo twcore upgrade --version --restart", note: "# later: move to the version ThinkWatch Lite requires, newer or older" }, ], serviceUser: "Commands that read the configuration run as the service user; the guide describes each step.", binariesTitle: "Prebuilt binaries", @@ -129,7 +131,7 @@ export const coreCopy = { meta: { title: "ThinkWatch Core — 以 Rust 编写的 AI API 网关引擎", description: - "一组 Rust crate 与 twcore 二进制,提供 AI API 网关的规则路由、流式故障转移、费用核算、出站密钥脱敏与工具调用审查;随 ThinkWatch Lite 运行,也可作为独立网关部署在 Linux 服务器上。采用 MIT 许可证。", + "一组 Rust crate 与 twcore 二进制,提供 AI API 网关的规则路由、首字节前的故障转移、费用核算、出站密钥脱敏与工具调用审查;随 ThinkWatch Lite 运行,也可作为独立网关部署在 Linux 服务器上。采用 MIT 许可证。", twcoreDescription: "独立运行的 AI API 网关二进制:ThinkWatch Lite 的本地引擎,也可由 systemd 在 Linux 服务器上作为独立网关运行。", }, hero: { @@ -155,7 +157,7 @@ export const coreCopy = { body: "规则按模型、网关密钥、输入规模、是否携带工具或图片等请求属性匹配,将请求发往某个上游或策略组、改写其参数,或拒绝请求。客户端与上游的接口格式不同时,请求在 Anthropic Messages、OpenAI Chat Completions、OpenAI Responses 与 Gemini 之间转换。", }, { - title: "流式故障转移", + title: "首字节前的故障转移", body: "首字节到达客户端之前,出错的上游由下一个上游替换,客户端无从察觉;此后发生的故障如实报告。熔断器使持续出错的上游暂不接收请求。", }, { @@ -186,13 +188,14 @@ export const coreCopy = { guide: "服务器部署指南", reference: "配置手册", scriptLabel: "在 Linux 服务器上安装", - pinNote: "安装指定版本,例如 ThinkWatch Lite 要求的版本:", + pinNote: "服务器上运行的 core 须为 ThinkWatch Lite 要求的版本;两者不一致时,应用会显示所需的版本。安装该版本:", + versionPlaceholder: "<版本号>", nextLabel: "随后", next: [ { cmd: "twcore remote enable --allow 192.168.1.0/24", note: "# 向指定网段开放远程控制端口" }, { cmd: "sudo systemctl enable --now twcore", note: "# 启动服务" }, { cmd: "twcore control-key", note: "# 输出 ThinkWatch Lite 连接所用的密钥" }, - { cmd: "sudo twcore upgrade --restart", note: "# 日后升级到最新版本" }, + { cmd: "sudo twcore upgrade --version <版本号> --restart", note: "# 日后切换到 ThinkWatch Lite 要求的版本,升级或降级均可" }, ], serviceUser: "读取配置的命令须以服务用户身份运行,各步骤详见部署指南。", binariesTitle: "预编译二进制", diff --git a/src/i18n/pages/home.ts b/src/i18n/pages/home.ts index 824dd85..509b81a 100644 --- a/src/i18n/pages/home.ts +++ b/src/i18n/pages/home.ts @@ -87,7 +87,7 @@ export const homeCopy = { ], pills: ["Available", "macOS · Apple silicon", "Windows · x64 · ARM64", "Linux · x86_64 · aarch64", "MIT"], shotAlt: - "The usage overview: tokens, cost and requests, a 24-hour trend stacked by model, the leaderboard by model and the cache hit rate", + "The Overview page for the last 7 days: 83.1M tokens, $68.11 in cost including $0.441 estimated and 13 unpriced requests, and 1,339 requests of which 11 failed, each compared with the prior 7 days; a token trend stacked by model with the periods that had failures marked; and the models ranked by tokens", cta: "Explore ThinkWatch Lite", }, core: { @@ -211,7 +211,7 @@ export const homeCopy = { ], pills: ["已发布", "macOS · Apple silicon", "Windows · x64 · ARM64", "Linux · x86_64 · aarch64", "MIT"], shotAlt: - "ThinkWatch Lite 的用量概览:token、费用与请求数,按模型分层的 24 小时趋势,模型排行与缓存命中率", + "概览页(最近 7 天):token 83.1M、费用 $68.11(含估算 $0.441,13 条无法计价)、请求 1,339 次(失败 11 次),均与上一个 7 天对比;按模型分层的 token 趋势,并标出存在失败的时段;以及按 token 排序的模型列表", cta: "了解 ThinkWatch Lite", }, core: { diff --git a/src/pages/docs/_DocsHome.astro b/src/pages/docs/_DocsHome.astro index 5b92004..c0118c6 100644 --- a/src/pages/docs/_DocsHome.astro +++ b/src/pages/docs/_DocsHome.astro @@ -17,20 +17,20 @@ const copy = zh ? { title: "文档 · ThinkWatch", description: - "ThinkWatch、ThinkWatch Lite 与 ThinkWatch Core 的文档,涵盖架构、部署、配置、API 参考、安全模型、桌面应用与共享核心。", + "ThinkWatch 企业版、ThinkWatch Lite 与 ThinkWatch Core 的文档,涵盖架构、部署、配置、API 参考、安全模型、桌面应用与共享核心。", h1: "文档", intro: - "三个产品分别提供文档:面向团队与企业的 ThinkWatch、在本地运行的桌面应用 ThinkWatch Lite,以及二者共用的 ThinkWatch Core。可通过上方的切换器选择产品,或从下方卡片进入。", + "三个产品分别提供文档:面向组织的 ThinkWatch 企业版;桌面应用 ThinkWatch Lite,在本机运行网关,也可以连接服务器上的 ThinkWatch Core;以及 ThinkWatch Core,即 ThinkWatch Lite 所基于的网关引擎与 Rust crate,ThinkWatch 企业版也使用其中的三个 crate。可通过上方的切换器选择产品,或从下方卡片进入。", open: (name: string) => `进入 ${name} 文档`, more: "其他资源", } : { title: "Documentation · ThinkWatch", description: - "Documentation for ThinkWatch, ThinkWatch Lite, and ThinkWatch Core, covering architecture, deployment, configuration, API reference, the security model, the desktop application, and the shared core.", + "Documentation for ThinkWatch Enterprise, ThinkWatch Lite, and ThinkWatch Core, covering architecture, deployment, configuration, API reference, the security model, the desktop application, and the shared core.", h1: "Documentation", intro: - "Documentation is provided for three products: ThinkWatch, for teams and enterprises; ThinkWatch Lite, a desktop application that runs locally; and ThinkWatch Core, the crates both are built on. Select a product with the switcher above or from the cards below.", + "Documentation is provided for three products: ThinkWatch Enterprise, for organizations; ThinkWatch Lite, a desktop application that runs the gateway locally or connects to ThinkWatch Core on a server; and ThinkWatch Core, the gateway engine and Rust crates that ThinkWatch Lite is built on, three of which ThinkWatch Enterprise also uses. Select a product with the switcher above or from the cards below.", open: (name: string) => `Open ${name} docs`, more: "Additional resources", };