diff --git a/src/components/ImportLinkBuilder.astro b/src/components/ImportLinkBuilder.astro index b87cd4a..2d8e35d 100644 --- a/src/components/ImportLinkBuilder.astro +++ b/src/components/ImportLinkBuilder.astro @@ -41,6 +41,8 @@ const t = zh awsId: "大写字母与数字,16 到 128 个字符。", headers: "每行一个「名称: 值」,最多 16 行,名称不重复,值为可打印 ASCII;鉴权、Cookie、Host、内容与连接相关的请求头、User-Agent 及 proxy-*、sec-* 开头的请求头不接受。", concurrency: "1 到 1000 之间的整数。", + limits: + "每行一个 per|measure|max|every|cache_reads,最多 16 行:per 为 minute、hour、day、week 或 month;measure 为 requests、tokens 或 cost;max 为不以 0 开头的整数,cost 为至少 0.01 的美元金额,最多 6 位小数;every 只用于 minute(1 到 60)与 hour(1 到 24);cache_reads 写 y,只用于 tokens;末尾留空的几项可以省略;各行的 per、every、measure 与 cache_reads 不能完全相同。", models: "模型 ID 以逗号分隔,只含字母、数字与 - _ . : / @ +,最多 64 项。", modelsOnly: "模型 ID 或含 * ? 的通配规则,以逗号分隔,最多 64 项。", specs: "每行一个 model|context|max_output|reasoning|image:数值为 1 到 10000000,reasoning 与 image 为 y 或 n,至少填写一项。", @@ -75,6 +77,8 @@ const t = zh awsId: "Uppercase letters and digits, 16 to 128 characters.", headers: "One Name: Value per line, at most 16, no repeated names, printable ASCII values; authentication, cookie, host, content and connection headers, User-Agent, and names starting with proxy- or sec- are not accepted.", concurrency: "A whole number from 1 to 1000.", + limits: + "One per|measure|max|every|cache_reads per line, at most 16: per is minute, hour, day, week or month; measure is requests, tokens or cost; max is a whole number without leading zeros, or for cost an amount in USD of at least 0.01 with up to six decimals; every is for minute (1 to 60) and hour (1 to 24) only; cache_reads is y, for tokens only; empty fields at the end may be left out; no two lines with the same per, every, measure and cache_reads.", models: "Comma-separated model IDs of letters, digits and - _ . : / @ +, at most 64.", modelsOnly: "Comma-separated model IDs or patterns with * and ?, at most 64.", specs: "One model|context|max_output|reasoning|image per line: numbers from 1 to 10000000, y or n, at least one field filled.", @@ -98,6 +102,7 @@ const ruleOf: Record = { aws_access_key_id: "awsId", headers: "headers", concurrency: "concurrency", + limits: "limits", models: "models", models_only: "modelsOnly", specs: "specs", @@ -227,6 +232,11 @@ const fieldsetCls = "grid gap-4 border-t border-[var(--color-border)] pt-5"; {L.clientIdentity} +
@@ -412,7 +422,7 @@ const fieldsetCls = "grid gap-4 border-t border-[var(--color-border)] pt-5"; let v: string; if (p === "client_identity") v = field.checked ? "on" : ""; else if (p === "headers") v = lines(field.value).join(LF); - else if (p === "specs" || p === "prices") v = lines(field.value).join(";"); + else if (p === "specs" || p === "prices" || p === "limits") v = lines(field.value).join(";"); else if (p === "models" || p === "models_only") v = field.value .split(",") @@ -440,7 +450,7 @@ const fieldsetCls = "grid gap-4 border-t border-[var(--color-border)] pt-5"; if (r.ok) { code("app").textContent = appLink(r.link); code("web").textContent = webLink(r.link); - version.textContent = r.link.v2 ? s.needsVersion : s.worksBefore; + version.textContent = r.link.since ? s.needsVersion.replace("{v}", r.link.since) : s.worksBefore; output.hidden = false; message.hidden = true; return; diff --git a/src/components/pages/ImportPage.astro b/src/components/pages/ImportPage.astro index feddd17..d6f36d1 100644 --- a/src/components/pages/ImportPage.astro +++ b/src/components/pages/ImportPage.astro @@ -170,7 +170,7 @@ const value = "min-w-0 break-all font-mono text-[15px] text-[var(--color-text)]" > {t.open} - +
@@ -232,7 +232,7 @@ const value = "min-w-0 break-all font-mono text-[15px] text-[var(--color-text)]" showableParam, type ImportLink, } from "~/lib/import-link"; - import type { ImportLinkText } from "~/lib/import-link-text"; + import { limitPhrase, type ImportLinkText } from "~/lib/import-link-text"; type Strings = ImportLinkText & { tokenEndpoint: string; @@ -364,6 +364,7 @@ const value = "min-w-0 break-all font-mono text-[15px] text-[var(--color-text)]" if (l.headers.length) advanced.push([L.headers, l.headers.map((h) => `${h.name}: ${h.value}`)]); if (l.clientIdentity) advanced.push([L.clientIdentity, s.on]); if (l.concurrency !== undefined) advanced.push([L.concurrency, String(l.concurrency)]); + if (l.limits.length) advanced.push([L.limits, l.limits.map((x) => limitPhrase(s.limit, x))]); if (l.balance) advanced.push([L.balance, s.balances[l.balance]]); addGroup(s.groups.advanced, advanced); @@ -437,7 +438,10 @@ const value = "min-w-0 break-all font-mono text-[15px] text-[var(--color-text)]" $("[data-update]").hidden = false; } show(l); - $("[data-version]").hidden = !l.v2; + if (l.since) { + $("[data-version]").textContent = s.needsVersion.replace("{v}", l.since); + $("[data-version]").hidden = false; + } // The only place this page navigates to, built from the checked values // (and the parameters of a newer version, re-encoded). const target = appLink(l, result.unknown); diff --git a/src/content/docs-lite/en/failover-and-load-balancing.md b/src/content/docs-lite/en/failover-and-load-balancing.md index 7e622ba..17e9c1f 100644 --- a/src/content/docs-lite/en/failover-and-load-balancing.md +++ b/src/content/docs-lite/en/failover-and-load-balancing.md @@ -4,7 +4,7 @@ ThinkWatch Lite turns each relay key into an upstream and puts several upstreams ## Before you start -- ThinkWatch Lite, [installed](/lite/#install), with a client connected on the Clients page. This guide follows version 2026.10.11. +- ThinkWatch Lite, [installed](/lite/#install), with a client connected on the Clients page. This guide follows version 2026.10.26. - The base URL and API keys of each relay. ## Steps @@ -26,12 +26,15 @@ ThinkWatch Lite turns each relay key into an upstream and puts several upstreams A strategy only sets the order; every member remains available for failover. -- **When the next upstream is tried.** Before anything has reached the client, the gateway moves on when the upstream cannot be reached or its credential cannot be read; answers 5xx, 429, 401, 403, 402 or 404; answers 400 or 422 with an error about an insufficient balance, a used-up quota or an unavailable model; in a streamed answer, reports an error before the first content, such as an overload; or sends no content within the no-response timeout. Other 4xx responses, and the last member's 4xx other than 429, go back to the client unchanged. +- **When the next upstream is tried.** Before anything has reached the client, the gateway moves on when the upstream cannot be reached or its credential cannot be read; answers 5xx, 401, 403, 402 or 404; answers 400 or 422 with an error about an insufficient balance, a used-up quota or an unavailable model; in a streamed answer, reports an error before the first content, such as an overload; or sends no content within the no-response timeout. A 429 that reports a used-up quota counts as **Quota used up**; any other 429 is a rate limit, which pauses the upstream instead of counting as a failure, and the request moves on or waits (see **Rate limits**). Other 4xx responses, and the last member's 4xx, go back to the client unchanged. - **Weights and distribution.** In a **Round robin** group, a member's weight, from 1 to 100, is its long-run share of requests: weights 7 and 3 send seven requests in ten to the first, interleaved with the other three. **By ratio** uses the weights alone; **By speed** multiplies each by how much faster than the members' median the upstream starts answering (time to first token, squared, kept between 0.1 and 10), **By reliability** by its success rate over its last 50 outcomes within 30 minutes (squared, at least 0.05), and **By speed and reliability** by both. An upstream with too few samples counts as average, requests that stay with an upstream for their conversation count toward its share, and paused or full members sit out. -- **No response.** An upstream that sends no content for the **No-response timeout** in Settings › Failover, 300 seconds by default (30 to 3,600), is given up on. Only content counts: text, reasoning or a tool call, not keep-alive pings. The clock starts when the request is sent to that upstream, so time spent waiting for a free slot does not count, and starts again with every piece of content. Before anything has reached the client, the request moves to the next upstream and the silent one counts as a failure toward its pause; when none is left, the client receives a timeout error in its own format. Once the answer has started, it ends with an error event the client understands. A whole, non-streamed answer has the same time to arrive. A streamed answer is held back for at most 15 seconds; after that the client receives the response headers and keep-alive comments (Gemini clients get the headers only), so it does not give up while the gateway can still move to the next upstream. The silent upstream's time to first token is recorded as the full wait, which ranks it lower for **Lowest latency** and **By speed**. The request's **Routing** tab marks the attempt **No response · 300 s** with the input the upstream may have billed for it. A model that thinks for a long time without sending anything may need a longer timeout. -- **Concurrency limits.** An upstream with a **Concurrency limit** takes at most that many requests at once. When it is full, a conversation that stays on it waits for a free slot and then moves on, and other requests go straight to the next member; when every member is full, a request waits for the first free slot for up to **Wait for a free slot at most** in Settings › Failover, 30 seconds by default, and then receives a 429 with `Retry-After`, or the error of an earlier attempt when one was sent. The same wait covers a key's per-minute and per-hour usage limits, and the Traffic page shows each skip and the time queued. -- **Pauses.** A failing upstream is paused for as long as Settings › Failover sets: by default 60 seconds after 3 **Consecutive failures**, doubling up to 600; 30 minutes for **Insufficient balance**; until the reset time, or 60 minutes, for **Quota used up**; the wait a rate-limited upstream asks for, up to 60 minutes. A rule with a single upstream is never held back, and when every member is paused they are tried anyway. A request aborted on the Traffic page does not count against its upstream. -- **Sessions and prompt cache.** Within a turn, while the client sends tool results back, requests keep the rule chosen at the start of the turn and the upstream that answered. Across turns, a conversation stays with the upstream that answered last if that answer read or wrote at least 1,024 cached tokens within the last five minutes; moving would rebuild the cache at full price. Otherwise, or when that upstream is paused, the strategy orders the members again, which is when **Round robin** moves on. The **Conversation** line on a request's **Routing** tab shows when a request stayed. +- **No response.** An upstream that sends no content for the **No-response timeout** in Settings › Failover, 300 seconds by default (30 to 3,600), is given up on. Only content counts: text, reasoning or a tool call, not keep-alive pings. The clock starts when the request is sent to that upstream, so time spent waiting before it is sent does not count, and starts again with every piece of content. Before anything has reached the client, the request moves to the next upstream and the silent one counts as a failure toward its pause; when none is left, the client receives a timeout error in its own format. Once the answer has started, it ends with an error event the client understands. A whole, non-streamed answer has the same time to arrive. A streamed answer is held back for at most 15 seconds; after that the client receives the response headers and keep-alive comments (Gemini clients get the headers only), so it does not give up while the gateway can still move to the next upstream. The silent upstream's time to first token is recorded as the full wait, which ranks it lower for **Lowest latency** and **By speed**. The request's **Routing** tab marks the attempt **No response · 300 s** with the input the upstream may have billed for it. A model that thinks for a long time without sending anything may need a longer timeout. +- **Concurrency limits.** An upstream with a **Concurrency limit** takes at most that many requests at once. When it is full, a conversation that stays on it waits for it, and other requests go straight to the next member; how long a request waits is described under **Waiting**. +- **Rate limits.** An upstream that answers 429 is paused for the time it asks for, given in a `retry-after-ms` or `Retry-After` header or in the error body. When it gives none, the pause lasts 5, 10, 20 and 40 seconds, then 60 seconds for each further 429 in a row; any other answer starts the count over. **Rate limit** in Settings › Failover caps the pause, 60 minutes by default. A rate limit is not a failure: it does not count toward **Consecutive failures** or toward the success rate used by **By reliability**. The request that received the 429 goes to the next member when one can take it at once, and otherwise waits for the pause to end; the client sees nothing of the 429. When the pause ends, one request goes first, and the others follow once it gets a normal answer. A rule with a single upstream waits for it as well. The Upstreams page tags a paused upstream **Rate limited · back in 42 s**, and the request's **Routing** tab marks the attempt **429 · Paused 30 s**. +- **Usage limits.** **Usage limits** under **Advanced settings** in the upstream dialog (for a ChatGPT account, below its **Concurrency limit**) keep an upstream within the quota of the relay or plan behind it. Each caps the requests, tokens or cost in USD per minute, hour, day, week or month; a minute or hour limit can also span several, from 1 to 60 minutes or 1 to 24 hours, such as 10,000,000 tokens every 5 minutes. When a minute or hour limit is full, the upstream is passed over like a full one: a conversation that stays on it waits for room, and other requests go to the next member. When a day, week or month limit is used up, requests go to the next member until it resets, at local midnight, on Monday or on the 1st; none waits for it. Every request sent to the upstream counts, except one it answered with 429. Tokens and cost come from the answer: tokens are uncached input, cache writes and output, and **Count cache reads** adds cache reads to a token limit; a free upstream, and a model without a price, count as $0 toward a cost limit. Being at a limit is not a failure. The Upstreams page tags such an upstream **Usage limit reached · back in 1 min 10 s**, or **Daily usage limit reached** (weekly, monthly) for a used-up period, and hovering over the tag or the **Quota / billing** cell lists each limit with its use. After a restart, day, week and month limits are added up again from the request records, which must therefore be kept for at least 1, 7 or 31 days; minute and hour limits start empty. +- **Waiting.** A request waits for upstreams that cannot take it yet, because they are full, rate limited or at a minute or hour usage limit, for at most **Wait at most** in Settings › Failover: 120 seconds by default, from 0 to 600, with 0 for no wait. The same time also covers a key's per-minute and per-hour usage limits. A conversation that stays on an upstream for its prompt cache waits for it when it can recover within that time, and otherwise moves on, where its cache starts over; any other request goes to the next member at once. When no member is left, the request waits for the first one that becomes available. A streamed request receives the response headers and keep-alive comments meanwhile (Gemini clients get the headers only), so the client sees only a slower first token. If none becomes available in time, the client receives a 429 with `Retry-After` set to when the first one will, or the error of an earlier attempt when one was sent. When every member has used up a day, week or month limit, `Retry-After` gives the earliest reset and the response tells the client not to retry before then. On the Traffic page, a waiting request is **Queued**, with the upstreams it waits for in place of the upstream and, on hover, how long it waits at most; its **Routing** tab gives each wait, such as **Queued 12 s**. +- **Pauses.** A failing upstream is paused for as long as Settings › Failover sets: by default 60 seconds after 3 **Consecutive failures**, doubling up to 600; 30 minutes for **Insufficient balance**; until the reset time, or 60 minutes, for **Quota used up**. A rule with a single upstream is never held back by these pauses, and when every member is paused they are tried anyway. A request aborted on the Traffic page does not count against its upstream. +- **Sessions and prompt cache.** Within a turn, while the client sends tool results back, requests keep the rule chosen at the start of the turn and the upstream that answered. Across turns, a conversation stays with the upstream that answered last if that answer read or wrote at least 1,024 cached tokens within the last five minutes; moving would rebuild the cache at full price. Otherwise, or when that upstream is paused after failures, is rate limited past the wait limit, or has used up a day, week or month limit, the strategy orders the members again, which is when **Round robin** moves on. The **Conversation** line on a request's **Routing** tab shows when a request stayed. - **Same model name.** Every member is asked for the model the client sent, or the name a rule rewrote it to. A member whose model list lacks it is skipped; one without a list is tried, and its 404 moves the request on. A model that a member serves but leaves out of its list can be added with **Add models…** in the member's model list on the Upstreams page, or in the **Models** section of its edit dialog. - **Falling back to another model.** Add a rule with the condition **Selected upstream** set to the backup upstream, **On match** set to **Continue matching**, and its model in **Change model to** under **Parameter rewrites**. Such a rule is evaluated for each upstream as it is tried, failover included. The changed model no longer hits the cached prompt, and the request is priced by the name sent. diff --git a/src/content/docs-lite/en/features.md b/src/content/docs-lite/en/features.md index dac62dd..2e104f3 100644 --- a/src/content/docs-lite/en/features.md +++ b/src/content/docs-lite/en/features.md @@ -22,7 +22,7 @@ A request opens into its timeline, its routing (the rule it matched, the group i The **Content** tab has two views, **Parsed** and **Raw**, and both are live while the request runs. **Parsed** shows the conversation, with the answer appearing as it streams, thinking and tool-call arguments included. **Raw** shows the request and response lines, headers and bodies, with each streamed event appended as it arrives; **Follow latest** keeps the newest in view. In Raw, **Client side** and **Upstream side** show what the client and the upstream each exchanged with the gateway, which differ when the API format is converted or a plugin rewrites the request. Keys are replaced in both views, as in stored requests. The places a security rule matched are marked in both views, in the colors of the security log, and a folded part that holds one carries a dot. -The attempts also show an upstream passed over at its concurrency limit (**At its concurrency limit**), the time spent waiting for a free slot, an upstream given up because it sent no content within the no-response timeout (**No response · 300 s** at the default), with the input tokens that upstream may have billed for it, and the attempt under way when a request was aborted (**Aborted**). A request refused by its key's usage limit, or because every upstream stayed at its concurrency limit, shows **Usage limit** or **At capacity** in place of an upstream, and a connection in the Responses API's WebSocket mode is listed one request per turn, each with its own usage and cost. +The attempts also show an upstream passed over at its concurrency limit (**At its concurrency limit**), while rate limited (**Rate limited**) or at a usage limit (**Usage limit reached**, or **Usage limit reached for this period** for a day, week or month), an attempt answered with 429 and the pause it caused (**429 · Paused 30 s**), the time spent queued for any of these, an upstream given up because it sent no content within the no-response timeout (**No response · 300 s** at the default), with the input tokens that upstream may have billed for it, and the attempt under way when a request was aborted (**Aborted**). A request that is waiting shows **Queued**, with the upstreams it waits for in place of the upstream. A request refused by its key's usage limit, or not taken because every upstream stayed at its concurrency limit, stayed rate limited, was at a usage limit, or none became available in time, shows **Usage limit**, **At capacity**, **Rate limited**, **Upstream limit** or **Wait timed out** in place of an upstream, and a connection in the Responses API's WebSocket mode is listed one request per turn, each with its own usage and cost. A request in progress can be stopped with **Abort request** in its menu. **Abort session**, in a session's menu or at the top of its panel, stops every request in progress in that session after a confirmation. The upstream call is dropped at once, the client receives an error, and the request is marked **Aborted**, in grey; its upstream is not paused for it. Turns on a Responses API WebSocket connection cannot be aborted. @@ -41,17 +41,17 @@ WSL 2 uses NAT networking by default, and the gateway on Windows cannot be reach Clients reach the gateway with a key, on the local machine as well. The Keys page lists the default key, used by clients that were not given one of their own, and a key for each connected client, labelled with the client it belongs to so that its requests can be told apart in Traffic. Each key has a route, the models it may use (all, none, or chosen models and patterns such as `gpt-5*`), an optional limit on concurrent requests, optional usage limits, and its requests and cost over the last 24 hours. A key can be disabled, which rejects every request made with it, or rotated; rotating writes the new key into the configuration of the client that uses it. -Usage limits keep one client from using up a budget: each caps the key at a number of requests, tokens or a cost in USD per minute, hour, day, week or month, and once one is used up the key's requests are refused until it resets, with the key marked **Limit reached**. Token limits can count cache reads, models without a price count as zero toward a cost limit and are listed in the key dialog, and a daily, weekly or monthly limit raises a notification at 80% and when it is reached. +Usage limits keep one client from using up a budget: each caps the key at a number of requests, tokens or a cost in USD per minute, hour, day, week or month, or every few minutes or hours, such as every 5 hours. When a minute or hour limit is full, a request waits for room within **Wait at most** in Settings › Failover and is refused if none frees up; once a day, week or month limit is used up, the key's requests are refused until it resets, with the key marked **Limit reached**. Token limits can count cache reads, models without a price count as zero toward a cost limit and are listed in the key dialog, and a daily, weekly or monthly limit raises a notification at 80% and when it is reached. ## Upstreams -Upstreams are the services requests are forwarded to: API keys for Anthropic, OpenAI, Google Gemini, DeepSeek or any compatible endpoint, Amazon Bedrock (with an API key, access keys or an AWS profile), a ChatGPT account or a Z.ai / BigModel account signed in from the app, relays such as OpenRouter, Sub2API and New API / One API, a ThinkWatch Enterprise gateway, and local models such as Ollama. When a client and an upstream use different API formats, requests are converted between Anthropic Messages, OpenAI Chat Completions, OpenAI Responses and Gemini, and the fields that cannot be carried over are listed on the request. A model's reasoning survives the conversion: the reasoning a Chat Completions upstream such as DeepSeek, Kimi or GLM returns reaches the client as it is and goes back to that upstream on the next turn, so a conversation with tool calls can continue, and the thought signatures on a Gemini model's tool calls are kept as well. When a Chat Completions model writes a tool call into its answer text, the gateway recognises it as an ordinary tool call, provided the request defined that tool, and Dangerous commands checks it like any other. A request body a client sends compressed with gzip, deflate or zstd is read as usual, and an answer an upstream sends compressed reaches the client decoded. Clients such as Codex mark no prompt-cache breakpoints; when their requests are converted for an upstream in Anthropic Messages format, or for a Claude model on Bedrock that AWS lists for prompt caching (Claude 3.5 Sonnet v2, Claude 3.7 Sonnet, and Claude 4.5 and later), the gateway marks them, so each turn reads what the previous one cached. An upstream that refuses the marks receives the request again without them. Upstreams can be reached through an outbound proxy and priced with a price sheet of their own; aliases, proxies and price sheets have tabs on the same page. The **Traffic · 24 h** column of the **Proxies** tab gives the upload and download through each proxy over the last 24 hours, with 7 and 30 days on hover. An optional **Concurrency limit** suits relays and accounts that allow only so many requests at once: when the upstream is full, a conversation that stays on it waits for a free slot, other requests go to the next upstream, and when every upstream is full a request waits and then receives a busy error. A streamed request waiting for a slot or for its key's usage window receives keep-alive pings while it waits. In **Edit upstream** › **Models**, the context window and price cells and the menu at the end of each model row (also on right-click, and in the model popover of the upstream list) offer **Specs…**, **Set price…** and **Add alias…**; an unknown context window reads **Unknown · Set** and an unpriced model **Unpriced · Set price**. **Specs…** sets by hand a model's context window, maximum output, and whether it reasons and takes images, for a model the price table lacks or gets wrong; the values set there are used in place of the price table's. **Set price…** opens the price sheet the upstream uses with an override for that model; an upstream on the default price sheet gets a new sheet of its own, which the next **Set price…** edits. A connection test times the DNS lookup and the TCP, TLS and proxy handshakes without incurring any cost; an inference test measures the time to first token and estimates its cost before it runs. +Upstreams are the services requests are forwarded to: API keys for Anthropic, OpenAI, Google Gemini, DeepSeek or any compatible endpoint, Amazon Bedrock (with an API key, access keys or an AWS profile), a ChatGPT account or a Z.ai / BigModel account signed in from the app, relays such as OpenRouter, Sub2API and New API / One API, a ThinkWatch Enterprise gateway, and local models such as Ollama. When a client and an upstream use different API formats, requests are converted between Anthropic Messages, OpenAI Chat Completions, OpenAI Responses and Gemini, and the fields that cannot be carried over are listed on the request. A model's reasoning survives the conversion: the reasoning a Chat Completions upstream such as DeepSeek, Kimi or GLM returns reaches the client as it is and goes back to that upstream on the next turn, so a conversation with tool calls can continue, and the thought signatures on a Gemini model's tool calls are kept as well. When a Chat Completions model writes a tool call into its answer text, the gateway recognises it as an ordinary tool call, provided the request defined that tool, and Dangerous commands checks it like any other. A request body a client sends compressed with gzip, deflate or zstd is read as usual, and an answer an upstream sends compressed reaches the client decoded. Clients such as Codex mark no prompt-cache breakpoints; when their requests are converted for an upstream in Anthropic Messages format, or for a Claude model on Bedrock that AWS lists for prompt caching (Claude 3.5 Sonnet v2, Claude 3.7 Sonnet, and Claude 4.5 and later), the gateway marks them, so each turn reads what the previous one cached. An upstream that refuses the marks receives the request again without them. Upstreams can be reached through an outbound proxy and priced with a price sheet of their own; aliases, proxies and price sheets have tabs on the same page. The **Traffic · 24 h** column of the **Proxies** tab gives the upload and download through each proxy over the last 24 hours, with 7 and 30 days on hover. An optional **Concurrency limit** suits relays and accounts that allow only so many requests at once, and optional **Usage limits** keep an upstream within the quota behind it: requests, tokens or cost per minute, hour, day, week or month, or every few minutes or hours, such as 10,000,000 tokens every 5 minutes. A rate limit does not interrupt the client: an upstream that answers 429 is paused for the time it asks for, and its requests go to the next upstream or wait for it, as they do for an upstream that is full or at a minute or hour limit; an upstream that has used up a day, week or month limit is skipped until it resets. None of these counts as a failure. A streamed request that waits receives the response headers and keep-alive pings meanwhile, so the client sees only a slower first token, and a 429 reaches the client only after **Wait at most** in Settings › Failover. The Upstreams page tags an upstream that cannot take requests for now with a countdown, such as **Rate limited · back in 42 s** or **Usage limit reached · back in 1 min 10 s**, and **Daily usage limit reached** (weekly, monthly) for a used-up period. The details are in [Fail over and balance load](/docs/lite/failover-and-load-balancing/). In **Edit upstream** › **Models**, the context window and price cells and the menu at the end of each model row (also on right-click, and in the model popover of the upstream list) offer **Specs…**, **Set price…** and **Add alias…**; an unknown context window reads **Unknown · Set** and an unpriced model **Unpriced · Set price**. **Specs…** sets by hand a model's context window, maximum output, and whether it reasons and takes images, for a model the price table lacks or gets wrong; the values set there are used in place of the price table's. **Set price…** opens the price sheet the upstream uses with an override for that model; an upstream on the default price sheet gets a new sheet of its own, which the next **Set price…** edits. A connection test times the DNS lookup and the TCP, TLS and proxy handshakes without incurring any cost; an inference test measures the time to first token and estimates its cost before it runs. -**New upstream** starts with the service. The services are listed in three groups, each with its logo and a one-line description, and can be searched: **Model vendors** (Anthropic, OpenAI, Google Gemini, DeepSeek, Z.ai / BigModel), **Platforms and relays** (Amazon Bedrock, OpenRouter, ThinkWatch, Sub2API, New API / One API) and **Local and custom** (Ollama, Custom). Picking one goes straight to **Connection**, which asks only for what that service needs. **Authentication** offers the methods the service has: an API key or a ChatGPT account for OpenAI, an API key or an account sign-in for Z.ai / BigModel, an API key, access keys or an AWS profile for Bedrock, and an API key or OAuth for custom endpoints, while ThinkWatch, Sub2API and New API / One API take an API key; a service with a single method shows no choice. What the service already decides, such as its address, its protocol or that Ollama takes no key, is filled in or left out. Request headers, client identity forwarding, the behaviour when the proxy is unavailable and the concurrency limit are under **Advanced settings**, which opens by itself when one of them is set. **Next** checks the connection at no cost: when it passes, **Models** opens with the latency, the number of models found and any balance on top; when it fails, the reason is shown with **Retry** and **Continue anyway**, for an upstream that refuses the check but serves requests. A ChatGPT or Z.ai / BigModel account is signed in inside the dialog, once its notes are acknowledged: in the default browser, or in another browser through **Copy link**, and a ChatGPT account also with a device code. Once signed in, the upstream is saved and the dialog goes to **Models**, with the account on top. When the app is connected to a remote core, a ChatGPT account signs in with a device code only, and the verification address and the code can each be copied. +**New upstream** starts with the service. The services are listed in three groups, each with its logo and a one-line description, and can be searched: **Model vendors** (Anthropic, OpenAI, Google Gemini, DeepSeek, Z.ai / BigModel), **Platforms and relays** (Amazon Bedrock, OpenRouter, ThinkWatch, Sub2API, New API / One API) and **Local and custom** (Ollama, Custom). Picking one goes straight to **Connection**, which asks only for what that service needs. **Authentication** offers the methods the service has: an API key or a ChatGPT account for OpenAI, an API key or an account sign-in for Z.ai / BigModel, an API key, access keys or an AWS profile for Bedrock, and an API key or OAuth for custom endpoints, while ThinkWatch, Sub2API and New API / One API take an API key; a service with a single method shows no choice. What the service already decides, such as its address, its protocol or that Ollama takes no key, is filled in or left out. Request headers, client identity forwarding, the behaviour when the proxy is unavailable, the concurrency limit and usage limits are under **Advanced settings**, which opens by itself when one of them is set. **Next** checks the connection at no cost: when it passes, **Models** opens with the latency, the number of models found and any balance on top; when it fails, the reason is shown with **Retry** and **Continue anyway**, for an upstream that refuses the check but serves requests. A ChatGPT or Z.ai / BigModel account is signed in inside the dialog, once its notes are acknowledged: in the default browser, or in another browser through **Copy link**, and a ChatGPT account also with a device code. Once signed in, the upstream is saved and the dialog goes to **Models**, with the account on top. When the app is connected to a remote core, a ChatGPT account signs in with a device code only, and the verification address and the code can each be copied. **Edit upstream** recognizes the service from the upstream's address, or for Sub2API, New API / One API and ThinkWatch from where its balance is read, shows its logo and name, and asks what creating it would ask. A value already set is always shown, even where the service would not ask for it. A Z.ai or BigModel upstream written by signing in shows **Account sign-in** as its method, with **Sign in again**; one added with a key can switch to **Account sign-in**, and signing in replaces its key while the model scope, billing and other settings stay as they are. -The **Quota / billing** column shows the quotas and balances that services report. A ChatGPT account shows its usage limits and reset times. So does an upstream on a GLM Coding Plan, that is, one whose address is on `api.z.ai` or `open.bigmodel.cn`, whether it was signed in from the app or added with a key: its 5-hour and weekly limits and, on a plan billed in credits, the credits left (“1,976 / 2,000 credits left”). Other upstreams show their balance: the key's quota, or the tightest of its time windows, as a bar with what is left; the money left in the account (“Balance $18.40”); or, for a key without a limit, what it has spent (“$12.40 spent this month”). Hovering lists every window with its use and reset time, the exact amounts and when the balance was read. Balances are read from OpenRouter, DeepSeek, Moonshot, Sub2API, New API / One API and ThinkWatch Enterprise; the source is recognized from the address, or set by the service picked when the upstream was created. On a ThinkWatch Enterprise gateway, a key with limits of its own shows its own limits and use; a key without them shows the account's limits and its total over all of its keys, marked as the account's. The connection check shows the balance with its result, and an upstream's menu has **Refresh balance**. A balance that cannot be read shows why on hover; reading it never affects requests. +The **Quota / billing** column shows the quotas and balances that services report. A ChatGPT account shows its usage limits and reset times. So does an upstream on a GLM Coding Plan, that is, one whose address is on `api.z.ai` or `open.bigmodel.cn`, whether it was signed in from the app or added with a key: its 5-hour and weekly limits and, on a plan billed in credits, the credits left (“1,976 / 2,000 credits left”). Other upstreams show their balance: the key's quota, or the tightest of its time windows, as a bar with what is left; the money left in the account (“Balance $18.40”); or, for a key without a limit, what it has spent (“$12.40 spent this month”). Hovering lists every window with its use and reset time, the exact amounts and when the balance was read, followed by each of the upstream's own usage limits with its use. Balances are read from OpenRouter, DeepSeek, Moonshot, Sub2API, New API / One API and ThinkWatch Enterprise; the source is recognized from the address, or set by the service picked when the upstream was created. On a ThinkWatch Enterprise gateway, a key with limits of its own shows its own limits and use; a key without them shows the account's limits and its total over all of its keys, marked as the account's. The connection check shows the balance with its result, and an upstream's menu has **Refresh balance**. A balance that cannot be read shows why on hover; reading it never affects requests. An upstream's models are the ones it lists plus any added by hand. Upstreams often leave out models they serve: an account backend hides newer models, a relay lists only some. **Add models…** in an upstream's model list adds such models by their exact IDs, several at a time. An ID cannot contain `*` or `?` or have spaces around it, is at most 256 characters, and is added once; the dialog points out an ID that breaks these rules, or one the upstream already lists, before saving. A model added by hand is marked **Manual**, and routing and failover, aliases, routing rules that name a model, a key's allowed models, the model list clients see, the dry run and the speed test all treat it like a listed model. The summary at the top of the list counts the models the upstream lists apart from those added by hand. Hovering over a manual model shows **Remove**, which takes effect at once, with **Undo** in the message that follows. The **Models** section of the **New upstream** and **Edit upstream** dialogs ends with the same input, for an upstream with or without a list, so models can also be added while it is created or edited. They are saved with the rest of the upstream, and with **Selected models** a model added there is enabled at once. For an upstream that lists no models, the models added by hand are its whole list. @@ -61,11 +61,11 @@ Each upstream is also compared over the last 7 days with the others serving the API keys and header values can be written as `${NAME}` to read a system environment variable. On macOS these come from the login shell, so variables exported in `~/.zshrc` and similar files apply, and the same holds on Linux (`~/.bashrc`, `~/.profile` and so on); on Windows they are the environment variables configured in system settings. After a variable changes, reopening the app picks it up. Proxy variables such as `HTTPS_PROXY`, and `PATH`, are not read. -A relay, a vendor or a ThinkWatch Enterprise console can hand out an import link, `thinkwatch://import?…` or its web form `https://thinkwat.ch/import#…`, that opens the New upstream dialog with the service, address, key, headers, concurrency limit, models, model specs and a price sheet already filled in. The dialog names the host that will receive requests and keys, and nothing is written or sent before Next is chosen or a sign-in is started. A link only ever adds one upstream and cannot set proxies, aliases, routes or environment variable references; a link that needs a newer version of the app says so. The parameters for each service and a link builder are in [Import links](/docs/lite/import-links). **Share…** in an upstream row's menu writes an import link for an existing upstream, with or without its key, and **Export N selected upstreams…** on the toolbar exports the selected upstreams as a config pack (see [Sync and backup](#sync-and-backup)). +A relay, a vendor or a ThinkWatch Enterprise console can hand out an import link, `thinkwatch://import?…` or its web form `https://thinkwat.ch/import#…`, that opens the New upstream dialog with the service, address, key, headers, concurrency limit, usage limits, models, model specs and a price sheet already filled in. The dialog names the host that will receive requests and keys, and nothing is written or sent before Next is chosen or a sign-in is started. A link only ever adds one upstream and cannot set proxies, aliases, routes or environment variable references; a link that needs a newer version of the app says so. The parameters for each service and a link builder are in [Import links](/docs/lite/import-links). **Share…** in an upstream row's menu writes an import link for an existing upstream, with or without its key, and **Export N selected upstreams…** on the toolbar exports the selected upstreams as a config pack (see [Sync and backup](#sync-and-backup)). ## Routing and failover -Each key follows a route, and keys without one follow the default route. A route is a list of rules evaluated in order. A rule matches on the model, the key, the client's API format, input tokens, `max_tokens`, the number of tools, images, extended thinking, streaming, prompt caching, the kind of auxiliary request or the time of day; it then forwards the request to an upstream, a group or specified models, or refuses it, and can rewrite the model, `max_tokens` or extended thinking. Specified models name an upstream and one of its models, with backups tried in order, and send the model as written; to have one client use one model under another model's name, a rule on that client's key does it without changing what the name means for other clients. A model condition written for an upstream model also matches the aliases that list it. The time of day is judged by the local clock of the machine the gateway runs on: pick the weekdays and a start and end time, several windows per rule if needed, and an end earlier than the start is an overnight window. Setting `max_tokens` caps the length of answers: the upstream stops at that point by itself, without an error. A group puts several upstreams behind one name and decides the order in which they are tried: as listed, manually selected, in turn, lowest latency first or lowest cost first. In a round-robin group each member has a weight from 1 to 100, its long-run share of requests, and **Distribute by** can multiply the weights by each upstream's speed (time to first token), its reliability (recent success rate) or both; conversations in progress stay on their upstream. When an attempt fails, the request moves on to the next upstream, and by default a session stays on one upstream so that its prompt cache keeps hitting. An upstream that sends no content for the **No-response timeout** in Settings › Failover, 300 seconds by default, is given up on; keep-alive pings do not count as content. Before anything has reached the client, the request moves to the next upstream, and when none is left the client receives a timeout error; once the answer has started, it ends with an error. A map at the top of the page traces every key through its route and groups to the upstreams, and requests in flight move along their path from the key to the upstream, each in its own colour. +Each key follows a route, and keys without one follow the default route. A route is a list of rules evaluated in order. A rule matches on the model, the key, the client's API format, input tokens, `max_tokens`, the number of tools, images, extended thinking, streaming, prompt caching, the kind of auxiliary request or the time of day; it then forwards the request to an upstream, a group or specified models, or refuses it, and can rewrite the model, `max_tokens` or extended thinking. Specified models name an upstream and one of its models, with backups tried in order, and send the model as written; to have one client use one model under another model's name, a rule on that client's key does it without changing what the name means for other clients. A model condition written for an upstream model also matches the aliases that list it. The time of day is judged by the local clock of the machine the gateway runs on: pick the weekdays and a start and end time, several windows per rule if needed, and an end earlier than the start is an overnight window. Setting `max_tokens` caps the length of answers: the upstream stops at that point by itself, without an error. A group puts several upstreams behind one name and decides the order in which they are tried: as listed, manually selected, in turn, lowest latency first or lowest cost first. In a round-robin group each member has a weight from 1 to 100, its long-run share of requests, and **Distribute by** can multiply the weights by each upstream's speed (time to first token), its reliability (recent success rate) or both; conversations in progress stay on their upstream. When an attempt fails, the request moves on to the next upstream, and by default a session stays on one upstream so that its prompt cache keeps hitting. An upstream that answers 429 is paused instead of failing, and its requests move on or wait for it. An upstream that sends no content for the **No-response timeout** in Settings › Failover, 300 seconds by default, is given up on; keep-alive pings do not count as content. Before anything has reached the client, the request moves to the next upstream, and when none is left the client receives a timeout error; once the answer has started, it ends with an error. A map at the top of the page traces every key through its route and groups to the upstreams, and requests in flight move along their path from the key to the upstream, each in its own colour. Auxiliary requests that clients send on their own (health checks, warm-ups, titles, topic detection and input suggestions) can be answered locally at no cost, or forwarded. Forwarded ones go through the routing rules like any other request, and a rule can send a given kind to a lower-cost upstream. @@ -105,7 +105,7 @@ Plugins are short JavaScript files that change requests before they go to an ups ## Settings -Settings has eight sections. Connection lists the local core and the saved remote cores, described in [Connecting to a remote core](/docs/lite/remote-core). General sets the language, the appearance, what the menu bar item shows on macOS (or that it is hidden), launch at login, whether notices arrive as system notifications, in the app only or not at all, and shows hidden guidance hints and ignored warnings again. Listening sets who can reach the gateway (this machine only, the local network of a chosen interface, or every interface), its port and the allowed address ranges. Failover sets how long a failing upstream is paused before requests go to the next one: after how many consecutive failures, for how long, and separate pauses for an insufficient balance, a used-up quota and rate limits. It also sets the no-response timeout, after which an upstream that has sent no content is given up on (300 seconds by default, from 30 to 3,600), and how long in total a request may wait for a free slot on a full upstream or for a key's per-minute or per-hour limit, 30 seconds by default. Log retention sets how long request payloads and request records are kept, and a size cap for payloads; the security log expires with the request records. Sync and backup exports and imports the configuration, turns on sync and lists the versions of the configuration that can be restored (see [Sync and backup](#sync-and-backup)). About shows the version, checks for updates and produces a diagnostics bundle with keys and addresses masked. Uninstall restores every connected client and removes the autostart entry, and is meant to be run before the app is deleted. +Settings has eight sections. Connection lists the local core and the saved remote cores, described in [Connecting to a remote core](/docs/lite/remote-core). General sets the language, the appearance, what the menu bar item shows on macOS (or that it is hidden), launch at login, whether notices arrive as system notifications, in the app only or not at all, and shows hidden guidance hints and ignored warnings again. Listening sets who can reach the gateway (this machine only, the local network of a chosen interface, or every interface), its port and the allowed address ranges. Failover sets how long a failing upstream is paused before requests go to the next one: after how many consecutive failures, for how long, and separate pauses for an insufficient balance, a used-up quota and rate limits. It also sets the no-response timeout, after which an upstream that has sent no content is given up on (300 seconds by default, from 30 to 3,600), and **Wait at most**, how long in total a request may wait while upstreams are full, rate limited or at a usage limit, or while a key's per-minute or per-hour limit is used up: 120 seconds by default, after which the request ends with an error. Log retention sets how long request payloads and request records are kept, and a size cap for payloads; the security log expires with the request records. Sync and backup exports and imports the configuration, turns on sync and lists the versions of the configuration that can be restored (see [Sync and backup](#sync-and-backup)). About shows the version, checks for updates and produces a diagnostics bundle with keys and addresses masked. Uninstall restores every connected client and removes the autostart entry, and is meant to be run before the app is deleted. ## Sync and backup diff --git a/src/content/docs-lite/en/import-links.md b/src/content/docs-lite/en/import-links.md index ebbb391..b03f78f 100644 --- a/src/content/docs-lite/en/import-links.md +++ b/src/content/docs-lite/en/import-links.md @@ -4,7 +4,7 @@ ThinkWatch Lite accepts links that open the New upstream dialog with the setting This page is written for relay and vendor operators. It describes the two forms of the link, the parameters grouped the way the dialog groups them, which service takes which parameter, the encodings, and what a link intentionally cannot set. A link builder is at the end of the page. ThinkWatch Lite can also write such a link for an existing upstream; see [Generated by Lite](#generated-by-lite). -The parameters other than `name`, `url`, `protocol`, `key` and `models` need ThinkWatch Lite 2026.10.16 or later. A link that uses only those five also opens in earlier versions, and every link written for earlier versions stays valid. +The parameters other than `name`, `url`, `protocol`, `key` and `models` need ThinkWatch Lite 2026.10.16 or later, and `limits` needs 2026.10.26 or later. A link that uses only those five also opens in earlier versions, and every link written for earlier versions stays valid. ## Link forms @@ -55,6 +55,7 @@ Plain `http://` is accepted in `url` and `oauth_endpoint` for addresses on this | `headers` | Extra request headers, one `Name: Value` per line. The encoding is described under [Encodings](#encodings). Not accepted with `auth=account`. | | `client_identity` | `on` turns on Forward client identity, for relays that accept only specific clients. Sub2API, New API and custom upstreams only. | | `concurrency` | The concurrency limit: a whole number from 1 to 1000, without a sign or leading zeros. Not accepted with `auth=account`. | +| `limits` | Usage limits, entries separated by `;`, each `per\|measure\|max\|every\|cache_reads`, such as 10,000,000 tokens every 5 minutes or $20 a day. The encoding is described under [Encodings](#encodings). Not accepted with `auth=account`. | | `balance` | Where the balance is read: `off`, `openrouter`, `deepseek`, `moonshot`, `sub2api`, `newapi` or `thinkwatch`. When omitted, the source is detected automatically. Custom upstreams only; other services decide their own source. | ### Models @@ -92,7 +93,7 @@ When `price_multiplier` or `prices` is present, the Billing step preselects a ne | Ollama | `ollama` | `key`, without a key field | `http://127.0.0.1:11434`, or another address in `url` | `openai-chat` | | Custom | `custom` | `key`, `oauth` | `url`, required | Any, or detected | -`headers` and `concurrency` apply to every service except an account sign-in. `client_identity` applies to Sub2API, New API and custom upstreams, and `balance` to custom upstreams. The parameters of the Models and Billing groups apply to every service, account sign-ins included. +`headers`, `concurrency` and `limits` apply to every service except an account sign-in. `client_identity` applies to Sub2API, New API and custom upstreams, and `balance` to custom upstreams. The parameters of the Models and Billing groups apply to every service, account sign-ins included. A link with `auth=account` carries no credential: the sign-in happens in the app. Such a link takes `service`, `auth`, `site`, `name` and the Models and Billing parameters only. @@ -112,6 +113,7 @@ Lists and entries: | `headers` | Lines separated by a line feed (`%0A`), each `Name: Value` | `X-Relay-Group: pro`, line feed, `X-Relay-Region: hk` | | `specs` | Entries separated by `;`, each `model\|context\|max_output\|reasoning\|image` | `glm-5-air\|128000\|96000\|y\|n` | | `prices` | Entries separated by `;`, each `model\|input\|output\|cache_read\|cache_write_5m\|cache_write_1h` | `glm-5\|1\|3.2\|0.2` | +| `limits` | Entries separated by `;`, each `per\|measure\|max\|every\|cache_reads` | `minute\|tokens\|10000000\|5;day\|cost\|20` | In `headers`, a name uses the token characters of HTTP (letters, digits and ``! # % & ' * + - . ^ _ ` | ~``), and a value is printable ASCII with surrounding spaces removed. No name may appear twice, ignoring case. Headers that carry credentials or that the gateway writes itself are rejected: `Authorization`, `Proxy-Authorization`, `X-Api-Key`, `X-Goog-Api-Key`, `Api-Key`, `Cookie`, `Host`, `Content-Length`, `Content-Type`, `Content-Encoding`, `Transfer-Encoding`, `Connection`, `Keep-Alive`, `Upgrade`, `TE`, `Trailer`, `User-Agent`, and any name starting with `Proxy-` or `Sec-`. @@ -119,6 +121,8 @@ In `specs`, `context` and `max_output` are token counts from 1 to 10,000,000, an In `prices`, each price is in USD per million tokens, with up to six digits before and six after the decimal point. `input` and `output` are required. A cache price that is empty or left out takes the input price. +In `limits`, `per` is `minute`, `hour`, `day`, `week` or `month`, and `measure` is `requests`, `tokens` or `cost`. For requests and tokens, `max` is a whole number from 1 to 999,999,999,999,999 without leading zeros; for cost, it is an amount in US dollars of at least `0.01`, with up to nine digits before the decimal point and six after and no leading zeros, such as `5` or `0.5`. `every` is how many minutes (1 to 60) or hours (1 to 24) the window spans, for `minute` and `hour` only, and is 1 when empty. `cache_reads` is `y` to count cache reads, for `tokens` only, and is off when empty. Empty fields at the end may be left out, so `day|cost|5` is a complete entry. No two entries may have the same `per`, `every`, `measure` and `cache_reads`. + Limits: | Item | Limit | @@ -132,6 +136,7 @@ Limits: | `region` | 32 characters; lowercase letters, digits and `-` | | `headers` | 16 lines; names up to 64 characters, values up to 1024 | | `models`, `models_only`, `specs`, `prices` | 64 entries each; model IDs up to 128 characters; no model twice in `specs` or `prices` | +| `limits` | 16 entries | ## What the app does with a link @@ -165,16 +170,16 @@ These settings can be changed in the app after the upstream is created. ## Examples -A relay that needs two headers, limits concurrency to 4, lists only some of its models, and charges 0.8 times the public prices with one override (no `service`: the address is recognized as a custom upstream): +A relay that needs two headers, limits concurrency to 4, allows 10,000,000 tokens every 5 minutes and $20 a day, lists only some of its models, and charges 0.8 times the public prices with one override (no `service`: the address is recognized as a custom upstream): ```text -thinkwatch://import?name=example-relay&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-relay-EXAMPLE&headers=X-Relay-Group%3A%20pro%0AX-Relay-Region%3A%20hk&concurrency=4&models=glm-5%2Cglm-5-air&specs=glm-5-air%7C128000%7C96000%7Cy%7Cn&price_multiplier=0.8&prices=glm-5%7C1%7C3.2%7C0.2 +thinkwatch://import?name=example-relay&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-relay-EXAMPLE&headers=X-Relay-Group%3A%20pro%0AX-Relay-Region%3A%20hk&concurrency=4&limits=minute%7Ctokens%7C10000000%7C5%3Bday%7Ccost%7C20&models=glm-5%2Cglm-5-air&specs=glm-5-air%7C128000%7C96000%7Cy%7Cn&price_multiplier=0.8&prices=glm-5%7C1%7C3.2%7C0.2 ``` The same link in its web form: ```text -https://thinkwat.ch/import#name=example-relay&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-relay-EXAMPLE&headers=X-Relay-Group%3A%20pro%0AX-Relay-Region%3A%20hk&concurrency=4&models=glm-5%2Cglm-5-air&specs=glm-5-air%7C128000%7C96000%7Cy%7Cn&price_multiplier=0.8&prices=glm-5%7C1%7C3.2%7C0.2 +https://thinkwat.ch/import#name=example-relay&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-relay-EXAMPLE&headers=X-Relay-Group%3A%20pro%0AX-Relay-Region%3A%20hk&concurrency=4&limits=minute%7Ctokens%7C10000000%7C5%3Bday%7Ccost%7C20&models=glm-5%2Cglm-5-air&specs=glm-5-air%7C128000%7C96000%7Cy%7Cn&price_multiplier=0.8&prices=glm-5%7C1%7C3.2%7C0.2 ``` Amazon Bedrock in `eu-west-1` with access keys: @@ -226,7 +231,7 @@ What the link carries: | Content | Written | Notes | |---|---|---| | Service, authentication method, site, name, address, protocol (`service`, `auth`, `site`, `name`, `url`, `protocol`) | Yes | — | -| Concurrency limit, client identity forwarding, balance source, model list, the models it is limited to, specs set by hand, billing mode, price multiplier, prices (`concurrency`, `client_identity`, `balance`, `models`, `models_only`, `specs`, `billing`, `price_multiplier`, `prices`) | Yes | Values equal to the defaults are left out | +| Concurrency limit, usage limits, client identity forwarding, balance source, model list, the models it is limited to, specs set by hand, billing mode, price multiplier, prices (`concurrency`, `limits`, `client_identity`, `balance`, `models`, `models_only`, `specs`, `billing`, `price_multiplier`, `prices`) | Yes | Values equal to the defaults are left out | | Key, headers, AWS access keys, OAuth credentials | As set by **Include keys**, on by default | A value written as `${NAME}` cannot be written, since links do not accept that form; it is left out, with the reason | | Outbound proxy, aliases, routes, weights, the disabled state, AWS profile | No | Import links do not accept them; see [What a link can never set](#what-a-link-can-never-set) | | ChatGPT and Z.ai / BigModel account sign-ins | No | The link carries the sign-in method only; the recipient stops at the sign-in step and signs in | diff --git a/src/content/docs-lite/en/sync-and-backup.md b/src/content/docs-lite/en/sync-and-backup.md index 4ccd053..f11726f 100644 --- a/src/content/docs-lite/en/sync-and-backup.md +++ b/src/content/docs-lite/en/sync-and-backup.md @@ -243,7 +243,7 @@ Each version records the version it was based on. A merge takes the last version | Item | Merge units | |---|---| -| Upstream | Connection (service, address, protocol, authentication method, key, headers, outbound proxy, AWS settings); models (model list, the models it is limited to, models added by hand, specs); billing (billing mode, prices, price multiplier); other (concurrency limit, client identity forwarding, balance source, disabled state) | +| Upstream | Connection (service, address, protocol, authentication method, key, headers, outbound proxy, AWS settings); models (model list, the models it is limited to, models added by hand, specs); billing (billing mode, prices, price multiplier); other (concurrency limit, usage limits, client identity forwarding, balance source, disabled state) | | Proxy, gateway key, alias, group, route, price sheet, custom security rule | The whole item | | Plugin | Script; settings; on or off | | Upstream order, route order, plugin order | One unit each | diff --git a/src/content/docs-lite/zh-CN/failover-and-load-balancing.md b/src/content/docs-lite/zh-CN/failover-and-load-balancing.md index 3ba4565..7395126 100644 --- a/src/content/docs-lite/zh-CN/failover-and-load-balancing.md +++ b/src/content/docs-lite/zh-CN/failover-and-load-balancing.md @@ -4,7 +4,7 @@ ThinkWatch Lite 把每个中转站的每把密钥建成一个上游,再把多 ## 准备 -- 已[安装](/zh-CN/lite/#install) ThinkWatch Lite,并已在客户端页接管客户端。本文按 2026.10.11 版编写。 +- 已[安装](/zh-CN/lite/#install) ThinkWatch Lite,并已在客户端页接管客户端。本文按 2026.10.26 版编写。 - 各中转站的接口地址和 API 密钥。 ## 步骤 @@ -26,12 +26,15 @@ ThinkWatch Lite 把每个中转站的每把密钥建成一个上游,再把多 策略只决定顺序,所有成员都留作故障转移的后备。 -- **何时换到下一个上游。**在尚未向客户端发出任何内容时,上游出现以下情况,网关即改用下一个成员:无法连接或读不到它的凭据;返回 5xx、429、401、403、402 或 404;返回 400 或 422,且错误信息说明余额不足、额度用完或模型不可用;流式回答在第一段内容之前报错,例如过载;在无响应超时内没有发出内容。其他 4xx,以及最后一个成员返回的 4xx(429 除外),原样返回客户端。 +- **何时换到下一个上游。**在尚未向客户端发出任何内容时,上游出现以下情况,网关即改用下一个成员:无法连接或读不到它的凭据;返回 5xx、401、403、402 或 404;返回 400 或 422,且错误信息说明余额不足、额度用完或模型不可用;流式回答在第一段内容之前报错,例如过载;在无响应超时内没有发出内容。说明额度用完的 429 按「额度用完」处理;其他 429 属于限流,上游随之暂停,不计为失败,请求换到下一个成员或等待(见「限流」)。其他 4xx,以及最后一个成员返回的 4xx,原样返回客户端。 - **权重与分配依据。**「轮询」策略组中,成员的权重(1 到 100)就是它长期分到的请求比例:权重 7 与 3 时,每十个请求中七个交给前者,与另外三个交错分配。「按比例」只看权重;「按速度」再乘以该上游开始回答比成员中位数快多少(首 token 时间之比的平方,限定在 0.1 到 10 之间);「按稳定性」再乘以它 30 分钟内最近 50 次结果的成功率(取平方,最低 0.05);「按速度和稳定性」两者都乘。样本不足的上游按平均水平计算;因对话延续而留在某个上游的请求计入它的份额;暂停中或并发已满的成员本轮不参与。 -- **无响应。**上游连续「设置 › 故障转移」中的「无响应超时」(默认 300 秒,可设 30 到 3,600 秒)没有发出内容时,网关放弃这个上游。只有内容才计入:文字、推理或工具调用,保活心跳不算。计时从请求发给这个上游时开始,等待空位的时间不计入,每收到一段内容重新计时。尚未向客户端发出内容时,请求转到下一个上游,没有响应的上游计一次失败,按暂停规则处理;没有可用的上游时,客户端收到其自身格式的超时错误。回答已经开始时,以客户端能识别的错误事件结束。非流式回答同样最多等待这么久。流式回答最多压住 15 秒,此后客户端先收到响应头和保活注释(Gemini 客户端只收到响应头),因此不会在网关仍可转到下一个上游时自行放弃。没有响应的上游,首 token 时间按等待的全部时长记录,「延迟最低」与「按速度」因此把它排到后面。请求的「路由」标签把这次尝试标为「无响应超时 · 300 秒」,并标出上游可能已计费的输入。长时间思考而不发出任何内容的模型,可能需要更长的超时。 -- **并发上限。**设置了「并发上限」的上游同时最多接收这么多请求。上游已满时,留在它上面的对话等待空位,等不到再换下一个,其他请求直接交给下一个成员;所有成员都满时,请求等待最先空出的位置,最长为「设置 › 故障转移」中的「最多等待空位」(默认 30 秒),仍无空位则返回 429 并带 `Retry-After`;此前已有尝试发出时,返回那次尝试的错误。密钥的分钟、小时用量上限共用这段等待;流量页会标出每一次跳过和排队时间。 -- **暂停。**失败的上游按「设置 › 故障转移」暂停使用,默认值为:「连续失败」3 次后暂停 60 秒,此后每次加倍,最长 600 秒;「余额不足」暂停 30 分钟;「额度用完」暂停到重置时刻,未给出时暂停 60 分钟;「限流」按上游要求的等待时间暂停,最长 60 分钟。只有一个上游的规则不受暂停影响;所有成员都在暂停时,网关仍会逐个尝试。在流量页手动中止的请求不计入上游的失败。 -- **会话与提示缓存。**同一轮之内(客户端回传工具结果期间),请求沿用这一轮开头确定的规则和回答它的上游。跨轮时,如果上次回答在五分钟以内、且读写了至少 1,024 个缓存 token,对话继续使用该上游,因为换到别处要按全价重建缓存;否则,或者该上游正在暂停时,由策略重新排序,「轮询」正是在这时轮到下一个成员。请求「路由」标签中的「对话延续」一行说明请求是否因此留在原上游。 +- **无响应。**上游连续「设置 › 故障转移」中的「无响应超时」(默认 300 秒,可设 30 到 3,600 秒)没有发出内容时,网关放弃这个上游。只有内容才计入:文字、推理或工具调用,保活心跳不算。计时从请求发给这个上游时开始,发出之前的等待不计入,每收到一段内容重新计时。尚未向客户端发出内容时,请求转到下一个上游,没有响应的上游计一次失败,按暂停规则处理;没有可用的上游时,客户端收到其自身格式的超时错误。回答已经开始时,以客户端能识别的错误事件结束。非流式回答同样最多等待这么久。流式回答最多压住 15 秒,此后客户端先收到响应头和保活注释(Gemini 客户端只收到响应头),因此不会在网关仍可转到下一个上游时自行放弃。没有响应的上游,首 token 时间按等待的全部时长记录,「延迟最低」与「按速度」因此把它排到后面。请求的「路由」标签把这次尝试标为「无响应超时 · 300 秒」,并标出上游可能已计费的输入。长时间思考而不发出任何内容的模型,可能需要更长的超时。 +- **并发上限。**设置了「并发上限」的上游同时最多接收这么多请求。上游已满时,留在它上面的对话等待它,其他请求直接交给下一个成员;最多等待多久见「等待」。 +- **限流。**返回 429 的上游按它要求的时长暂停,时长取自 `retry-after-ms` 或 `Retry-After` 响应头,或错误信息中的等待时间。上游没有给出时,暂停 5、10、20、40 秒,此后每次连续返回 429 暂停 60 秒;收到其他答复即重新计数。暂停最长为「设置 › 故障转移」中的「限流」,默认 60 分钟。限流不算失败:不计入「连续失败」,也不计入「按稳定性」所用的成功率。收到 429 的请求,有成员可以立即接收时交给它,否则等待暂停结束;客户端看不到这次 429。暂停结束时先放行一个请求,它收到正常答复之后,其余请求随后发出。只有一个上游的规则同样等待它恢复。上游页把暂停中的上游标为「限流中 · 42 秒后恢复」,请求的「路由」标签把这次尝试标为「429 · 暂停 30 秒」。 +- **用量上限。**上游对话框「高级设置」中的「用量上限」(ChatGPT 账号在「并发上限」下方)让上游不超出中转站或套餐本身的额度。每条上限限定每分钟、小时、天、周或月最多使用的请求次数、token 数或费用(美元);分钟与小时上限还可以跨越多个单位,可设 1 到 60 分钟或 1 到 24 小时,例如每 5 分钟 10,000,000 token。分钟或小时上限已满时,上游按并发已满处理:留在它上面的对话等待空出用量,其他请求交给下一个成员。天、周或月上限用满后,请求交给下一个成员,直到本地时间零点、周一或每月 1 日重置,不等待它。发给上游的每个请求都计入,上游返回 429 的除外。token 与费用按回答计算:token 为未命中缓存的输入、缓存写入与输出,勾选「计入缓存读取」后 token 上限也计入缓存读取;不计费的上游和没有价格的模型按 0 计入费用上限。达到上限不算失败。上游页把这样的上游标为「用量已满 · 1 分 10 秒后恢复」,用满一整期的标为「今日用量已满」(或本周、本月),悬停在标签或「额度 / 计费」一格上可以查看每条上限的用量。重启之后,天、周、月上限的用量按请求记录重新累计,因此请求记录至少要保留 1、7 或 31 天;分钟与小时上限从零开始。 +- **等待。**上游并发已满、正在限流或达到分钟、小时用量上限而暂时无法接收时,请求最多等待「设置 › 故障转移」中的「最多等待」,默认 120 秒,可设 0 到 600 秒,0 表示不等待。密钥的分钟、小时用量上限共用这段时间。为保留提示缓存而留在某个上游的对话,在它能于这段时间内恢复时等待它,否则换到下一个成员,在那里重新建立缓存;其他请求直接交给下一个成员。没有可用的成员时,请求等待最先恢复的那一个。等待期间,流式请求先收到响应头和保活注释(Gemini 客户端只收到响应头),客户端只会感到首 token 慢了一些。到时仍没有成员可用,客户端收到 429,`Retry-After` 为最早恢复的时刻;此前已有尝试发出时,返回那次尝试的错误。所有成员都用满了天、周或月上限时,`Retry-After` 为最早的重置时刻,并告知客户端在此之前不要重试。流量页把等待中的请求标为「排队中」,在上游的位置列出它等待的上游,悬停可以查看最晚等到何时;请求的「路由」标签写明每次等待的时长,例如「排队 12 秒」。 +- **暂停。**失败的上游按「设置 › 故障转移」暂停使用,默认值为:「连续失败」3 次后暂停 60 秒,此后每次加倍,最长 600 秒;「余额不足」暂停 30 分钟;「额度用完」暂停到重置时刻,未给出时暂停 60 分钟。只有一个上游的规则不受这些暂停影响;所有成员都在暂停时,网关仍会逐个尝试。在流量页手动中止的请求不计入上游的失败。 +- **会话与提示缓存。**同一轮之内(客户端回传工具结果期间),请求沿用这一轮开头确定的规则和回答它的上游。跨轮时,如果上次回答在五分钟以内、且读写了至少 1,024 个缓存 token,对话继续使用该上游,因为换到别处要按全价重建缓存;否则,或者该上游因失败而暂停、限流要到等待时限之后才结束、已用满天、周或月上限时,由策略重新排序,「轮询」正是在这时轮到下一个成员。请求「路由」标签中的「对话延续」一行说明请求是否因此留在原上游。 - **模型名相同。**每个成员收到的都是客户端请求的模型名,或者规则改写之后的模型名。模型列表中没有该模型的成员会被跳过;没有模型列表的成员照常尝试,它返回 404 时请求换到下一个。成员能服务、却没有列进模型列表的模型,可以在上游页该成员的模型列表中选择「添加模型…」加上,也可以在它的编辑对话框的「模型」一节中添加。 - **回退到另一个模型。**添加一条规则:条件「选定上游」选择后备上游,「命中后」选择「继续匹配」,在「改写参数」的「模型改为」中填写它的模型。这样的规则在每次选定上游之后判断,故障转移之后同样适用。更换模型后已缓存的 prompt 不再命中,费用按发出的模型名计算。 diff --git a/src/content/docs-lite/zh-CN/features.md b/src/content/docs-lite/zh-CN/features.md index 267cc14..bf5dd91 100644 --- a/src/content/docs-lite/zh-CN/features.md +++ b/src/content/docs-lite/zh-CN/features.md @@ -22,7 +22,7 @@ 「内容」标签页有「解析」与「原始报文」两种视图,请求进行中时两者都实时更新。「解析」以对话的形式显示,回答随流式输出逐步出现,思考与工具调用的参数同样实时显示。「原始报文」显示请求行与状态行、报文头和正文,流式返回的每个事件到达后依次追加;「跟随最新」使最新的内容保持在视野内。「原始报文」中的「客户端一侧」与「上游一侧」分别显示客户端和上游与网关之间收发的内容,转换 API 格式或插件改写请求时两者不同。两种视图中的密钥与存下的请求一样已被替换。安全规则命中之处在两种视图中都以安全日志的颜色标出,折叠的部分中含有命中时,折叠处带有一个圆点。 -尝试链还会标出因并发已满而跳过的上游、等待空位的排队时间、超过无响应超时仍未发出内容而被放弃的上游(默认值下显示为「无响应超时 · 300 秒」)及该上游可能已计费的输入 token,以及请求被手动中止时正在进行的尝试(「手动中止」)。因密钥的用量上限或上游并发均已满而未被接下的请求,在上游的位置显示「用量上限」或「并发已满」;以 Responses API 的 WebSocket 模式建立的连接按轮记为请求,每一轮单独统计用量与费用。 +尝试链还会标出因并发已满、限流或达到用量上限而跳过的上游(「并发已满」「限流中」「用量已满」,天、周、月上限用满时为「本期用量已满」)、返回 429 的尝试及其引起的暂停(「429 · 暂停 30 秒」)、为这些情况排队等待的时间、超过无响应超时仍未发出内容而被放弃的上游(默认值下显示为「无响应超时 · 300 秒」)及该上游可能已计费的输入 token,以及请求被手动中止时正在进行的尝试(「手动中止」)。正在等待的请求显示「排队中」,在上游的位置列出它等待的上游。因密钥的用量上限而被拒绝,或因上游均并发已满、均在限流、均达到用量上限、等待期间没有上游恢复而未被接下的请求,在上游的位置显示「用量上限」「并发已满」「上游限流中」「上游用量已满」或「等待超时」;以 Responses API 的 WebSocket 模式建立的连接按轮记为请求,每一轮单独统计用量与费用。 进行中的请求可以在它的操作菜单中选择「中止请求」停止。会话的操作菜单和会话详情顶部的「中止会话」经确认后停止该会话中所有进行中的请求。与上游的连接立即断开,客户端收到错误,请求以灰色标为「手动中止」,上游不会因此被暂停。Responses API 的 WebSocket 连接中的轮次不能中止。 @@ -41,17 +41,17 @@ WSL 2 默认使用 NAT 网络,此时 Windows 上的网关无法从 WSL 内访 客户端连接网关必须携带密钥,本机也不例外。密钥页列出默认密钥(供未单独分配密钥的客户端使用)和每个已接管客户端的专用密钥,并注明所属客户端,便于在流量页中区分各客户端的请求。每把密钥有各自的路由、可用模型(全部、无,或指定的模型与 `gpt-5*` 这类通配模式)、可选的并发上限与用量上限,以及最近 24 小时的请求数与费用。密钥可以停用,停用后使用它的请求一律被拒绝;也可以更换,新密钥会写入使用它的客户端的配置。 -用量上限防止单个客户端用光预算:每条上限限定这把密钥每分钟、小时、天、周或月最多使用的请求次数、token 数或费用(美元),任一上限用满后,使用这把密钥的请求一律被拒绝,直到重置,密钥列表中标出「已达上限」。token 上限可以计入缓存读取;没有价格的模型按 0 计入费用上限,密钥对话框会列出这些模型;每天、每周或每月的上限用到八成和用满时各发送一次通知。 +用量上限防止单个客户端用光预算:每条上限限定这把密钥每分钟、小时、天、周或月,或者每若干分钟、小时(例如每 5 小时)最多使用的请求次数、token 数或费用(美元)。分钟或小时上限已满时,请求在「设置 › 故障转移」的「最多等待」之内等待空出用量,等不到则被拒绝;天、周或月上限用满后,使用这把密钥的请求一律被拒绝,直到重置,密钥列表中标出「已达上限」。token 上限可以计入缓存读取;没有价格的模型按 0 计入费用上限,密钥对话框会列出这些模型;每天、每周或每月的上限用到八成和用满时各发送一次通知。 ## 上游 -上游是网关转发请求的目标:Anthropic、OpenAI、Google Gemini、DeepSeek 或任何兼容接口的 API 密钥,Amazon Bedrock(API 密钥、访问密钥或 AWS profile),在应用内登录的 ChatGPT 账号或 Z.ai / BigModel 账号,OpenRouter、Sub2API、New API / One API 等中转服务,ThinkWatch 企业版网关,以及 Ollama 等本机模型。客户端与上游的 API 格式不同时,请求在 Anthropic Messages、OpenAI Chat Completions、OpenAI Responses 与 Gemini 之间自动转换,无法转换的字段会在请求上逐一列出。模型的思考在转换中保留:DeepSeek、Kimi、GLM 等 Chat Completions 格式上游返回的思考内容原样交给客户端,下一轮送回同一上游,带工具调用的对话因此能继续;Gemini 模型工具调用上的思考签名同样保留。Chat Completions 格式的模型把工具调用写进回答正文时,只要请求定义了这个工具,网关就把它识别为普通的工具调用,并与其他工具调用一样经过「危险命令」检查。客户端以 gzip、deflate 或 zstd 压缩发送的请求正文照常读取,上游压缩返回的回答解码后交给客户端。Codex 等不标注提示缓存断点的客户端,请求转换后发往 Anthropic Messages 格式的上游,或 Bedrock 上 AWS 列为支持提示缓存的 Claude 模型(Claude 3.5 Sonnet v2、Claude 3.7 Sonnet 以及 Claude 4.5 起的模型)时,由网关标注断点,每一轮都能读取上一轮写入的缓存。上游拒绝这些标注时,网关去掉标注重新发送一次。上游可以经出站代理访问,也可以使用单独的价目表计价,别名、代理与价目表在同一页的标签中管理。「代理」标签页的「流量 · 24 小时」一列给出经过每个代理的上传与下载,悬停可查看 7 天与 30 天的数据。可选的「并发上限」适用于限制并发的中转站或账号:上游已满时,留在它上面的对话等待空位,其他请求交给下一个上游;所有上游都满时,请求先等待,仍无空位则返回繁忙错误。流式请求等待空位或密钥的用量窗口期间,客户端会收到保活心跳。编辑上游的「模型」一节中,上下文窗口与定价两格、每个模型行尾的菜单(右键相同,上游列表的模型弹层中也有)提供「规格…」「设置价格…」「起别名…」:上下文窗口未知时显示「未知 · 填写」,未定价时显示「未定价 · 设置价格」。「规格…」可以手动设置模型的上下文窗口、输出上限,以及是否支持推理与图片输入,适用于价目表中没有或数值有误的模型,手动设置的值优先于价目表;「设置价格…」打开上游所用的价目表并为这个模型添加覆盖,上游使用默认价目表时为它新建一张,之后再设置价格时编辑这一张。链路测速测量 DNS 解析以及 TCP、TLS、代理握手的耗时,不产生费用;推理测速测量首个 token 的时间,运行前先给出费用预估。 +上游是网关转发请求的目标:Anthropic、OpenAI、Google Gemini、DeepSeek 或任何兼容接口的 API 密钥,Amazon Bedrock(API 密钥、访问密钥或 AWS profile),在应用内登录的 ChatGPT 账号或 Z.ai / BigModel 账号,OpenRouter、Sub2API、New API / One API 等中转服务,ThinkWatch 企业版网关,以及 Ollama 等本机模型。客户端与上游的 API 格式不同时,请求在 Anthropic Messages、OpenAI Chat Completions、OpenAI Responses 与 Gemini 之间自动转换,无法转换的字段会在请求上逐一列出。模型的思考在转换中保留:DeepSeek、Kimi、GLM 等 Chat Completions 格式上游返回的思考内容原样交给客户端,下一轮送回同一上游,带工具调用的对话因此能继续;Gemini 模型工具调用上的思考签名同样保留。Chat Completions 格式的模型把工具调用写进回答正文时,只要请求定义了这个工具,网关就把它识别为普通的工具调用,并与其他工具调用一样经过「危险命令」检查。客户端以 gzip、deflate 或 zstd 压缩发送的请求正文照常读取,上游压缩返回的回答解码后交给客户端。Codex 等不标注提示缓存断点的客户端,请求转换后发往 Anthropic Messages 格式的上游,或 Bedrock 上 AWS 列为支持提示缓存的 Claude 模型(Claude 3.5 Sonnet v2、Claude 3.7 Sonnet 以及 Claude 4.5 起的模型)时,由网关标注断点,每一轮都能读取上一轮写入的缓存。上游拒绝这些标注时,网关去掉标注重新发送一次。上游可以经出站代理访问,也可以使用单独的价目表计价,别名、代理与价目表在同一页的标签中管理。「代理」标签页的「流量 · 24 小时」一列给出经过每个代理的上传与下载,悬停可查看 7 天与 30 天的数据。可选的「并发上限」适用于限制并发的中转站或账号,「用量上限」让上游不超出它背后的额度:每分钟、小时、天、周或月,或者每若干分钟、小时的请求次数、token 数或费用,例如每 5 分钟 10,000,000 token。限流不会打断客户端:返回 429 的上游按它要求的时长暂停,发给它的请求交给下一个上游或等待它恢复,并发已满或达到分钟、小时上限的上游也是如此;用满天、周或月上限的上游在重置之前被跳过。这些都不算失败。等待期间,流式请求先收到响应头和保活心跳,客户端只会感到首 token 慢了一些;超过「设置 › 故障转移」中的「最多等待」之后,客户端才收到 429。上游页为暂时无法接收请求的上游标出倒计时,例如「限流中 · 42 秒后恢复」「用量已满 · 1 分 10 秒后恢复」,用满一整期的标为「今日用量已满」(或本周、本月)。详见[在多个中转站和多把密钥之间自动故障转移与负载均衡](/zh-CN/docs/lite/failover-and-load-balancing/)。编辑上游的「模型」一节中,上下文窗口与定价两格、每个模型行尾的菜单(右键相同,上游列表的模型弹层中也有)提供「规格…」「设置价格…」「起别名…」:上下文窗口未知时显示「未知 · 填写」,未定价时显示「未定价 · 设置价格」。「规格…」可以手动设置模型的上下文窗口、输出上限,以及是否支持推理与图片输入,适用于价目表中没有或数值有误的模型,手动设置的值优先于价目表;「设置价格…」打开上游所用的价目表并为这个模型添加覆盖,上游使用默认价目表时为它新建一张,之后再设置价格时编辑这一张。链路测速测量 DNS 解析以及 TCP、TLS、代理握手的耗时,不产生费用;推理测速测量首个 token 的时间,运行前先给出费用预估。 -「新建上游」先选择服务。服务分为「模型厂商」(Anthropic、OpenAI、Google Gemini、DeepSeek、Z.ai / BigModel)、「平台与中转」(Amazon Bedrock、OpenRouter、ThinkWatch、Sub2API、New API / One API)与「本机与自定义」(Ollama、自定义)三组,各带标志和一句介绍,可以搜索。选定后直接进入「连接」,只询问该服务需要的内容。「认证方式」按服务给出:OpenAI 为 API 密钥或 ChatGPT 账号,Z.ai / BigModel 为 API 密钥或账号登录,Bedrock 为 API 密钥、访问密钥或 AWS profile,自定义为 API 密钥或 OAuth,ThinkWatch、Sub2API 与 New API / One API 为 API 密钥;只有一种方式的服务不显示这一项。服务已经决定的内容,例如地址、接口协议,或 Ollama 不需要密钥,直接填好或不再显示。请求头、转发客户端身份、代理不可用时的处理与并发上限收在「高级设置」中,其中任何一项有设置时自动展开。点击「下一步」即检测连接,不产生费用:通过后进入「模型」,顶部显示响应时间、发现的模型数和余额(如有);没有通过时写明原因,可以「重试」,也可以「仍然继续」,适用于拒绝检测但能正常转发的上游。ChatGPT 与 Z.ai / BigModel 账号在对话框内登录,阅读说明后,可以在默认浏览器中登录,也可以「复制链接」到常用的浏览器中打开;ChatGPT 账号还可以用设备码登录。登录后上游即写入配置,对话框直接进入「模型」,顶部显示登录的账号。连接远程 core 时,ChatGPT 账号只能用设备码登录,验证网址和设备码都可以复制。 +「新建上游」先选择服务。服务分为「模型厂商」(Anthropic、OpenAI、Google Gemini、DeepSeek、Z.ai / BigModel)、「平台与中转」(Amazon Bedrock、OpenRouter、ThinkWatch、Sub2API、New API / One API)与「本机与自定义」(Ollama、自定义)三组,各带标志和一句介绍,可以搜索。选定后直接进入「连接」,只询问该服务需要的内容。「认证方式」按服务给出:OpenAI 为 API 密钥或 ChatGPT 账号,Z.ai / BigModel 为 API 密钥或账号登录,Bedrock 为 API 密钥、访问密钥或 AWS profile,自定义为 API 密钥或 OAuth,ThinkWatch、Sub2API 与 New API / One API 为 API 密钥;只有一种方式的服务不显示这一项。服务已经决定的内容,例如地址、接口协议,或 Ollama 不需要密钥,直接填好或不再显示。请求头、转发客户端身份、代理不可用时的处理、并发上限与用量上限收在「高级设置」中,其中任何一项有设置时自动展开。点击「下一步」即检测连接,不产生费用:通过后进入「模型」,顶部显示响应时间、发现的模型数和余额(如有);没有通过时写明原因,可以「重试」,也可以「仍然继续」,适用于拒绝检测但能正常转发的上游。ChatGPT 与 Z.ai / BigModel 账号在对话框内登录,阅读说明后,可以在默认浏览器中登录,也可以「复制链接」到常用的浏览器中打开;ChatGPT 账号还可以用设备码登录。登录后上游即写入配置,对话框直接进入「模型」,顶部显示登录的账号。连接远程 core 时,ChatGPT 账号只能用设备码登录,验证网址和设备码都可以复制。 「编辑上游」按地址认出服务(Sub2API、New API / One API 与 ThinkWatch 按余额来源认出),显示它的标志和名称,询问的内容与新建时一致;已经设置的值始终显示,即使该服务新建时不询问这一项。经账号登录写入的 Z.ai 或 BigModel 上游,认证方式显示为「账号登录」,可以「重新登录」;手动填写密钥的上游可以改为「账号登录」,登录后账号中取得的密钥替换当前密钥,模型范围、计费方式等设置保持不变。 -「额度 / 计费」一列显示服务报告的额度与余额。ChatGPT 账号显示订阅额度与重置时间;GLM Coding Plan 的上游(地址在 `api.z.ai` 或 `open.bigmodel.cn` 上,在应用内登录或手动填写密钥均可)同样显示:5 小时与每周额度,积分制套餐另外显示剩余积分(「剩余 1,976 / 2,000 积分」)。其他上游显示余额:密钥的额度或最紧的时间窗口,以进度条和剩余量表示;账户中的余额(「余额 $18.40」);没有限额的密钥则显示已用金额(「本月已用 $12.40」)。悬停可以查看全部窗口的用量与重置时间、确切数字和读取时间。余额来源包括 OpenRouter、DeepSeek、Moonshot、Sub2API、New API / One API 与 ThinkWatch 企业版,按地址认出,或由新建上游时所选的服务决定。在 ThinkWatch 企业版网关上,密钥设了自己的限额时显示密钥的限额与用量;没设时显示账号的限额与所有密钥合计的用量,并注明是账号的。检测连接的结果中也显示余额,上游的菜单中有「刷新余额」。余额读不到时,悬停可以查看原因;读取余额不影响请求转发。 +「额度 / 计费」一列显示服务报告的额度与余额。ChatGPT 账号显示订阅额度与重置时间;GLM Coding Plan 的上游(地址在 `api.z.ai` 或 `open.bigmodel.cn` 上,在应用内登录或手动填写密钥均可)同样显示:5 小时与每周额度,积分制套餐另外显示剩余积分(「剩余 1,976 / 2,000 积分」)。其他上游显示余额:密钥的额度或最紧的时间窗口,以进度条和剩余量表示;账户中的余额(「余额 $18.40」);没有限额的密钥则显示已用金额(「本月已用 $12.40」)。悬停可以查看全部窗口的用量与重置时间、确切数字和读取时间,其后列出上游自身每条用量上限的用量。余额来源包括 OpenRouter、DeepSeek、Moonshot、Sub2API、New API / One API 与 ThinkWatch 企业版,按地址认出,或由新建上游时所选的服务决定。在 ThinkWatch 企业版网关上,密钥设了自己的限额时显示密钥的限额与用量;没设时显示账号的限额与所有密钥合计的用量,并注明是账号的。检测连接的结果中也显示余额,上游的菜单中有「刷新余额」。余额读不到时,悬停可以查看原因;读取余额不影响请求转发。 一家上游的模型,是它自己列出的模型加上手动添加的模型。上游常常没有列全它能服务的模型:账号类的后端会隐藏较新的模型,中转站只列出一部分。在上游的模型列表中选择「添加模型…」,可以按确切的模型 ID 添加这类模型,一次可以添加多个。模型 ID 不能含 `*` 或 `?`,前后不能有空格,最多 256 个字符,且不能重复;不符合要求的,以及上游已经列出的,对话框在保存之前当场指出。手动添加的模型标有「手动」,路由与故障转移、别名、指定模型的路由规则、密钥的可用模型、客户端看到的模型列表、试算与推理测速都把它当作列出的模型。列表顶部的摘要把上游列出的模型与手动添加的模型分开计数。悬停在手动添加的模型上会出现「移除」,立即生效,随后的提示中可以「撤销」。「新建上游」与「编辑上游」对话框的「模型」一节末尾有同样的输入框,无论上游是否提供模型列表,创建或编辑时都可以在这里添加模型,随上游的其他设置一起保存;启用范围为「指定模型」时,在这里添加的模型立即勾选启用。上游不提供模型列表时,手动添加的模型就是它的全部模型。 @@ -61,11 +61,11 @@ WSL 2 默认使用 NAT 网络,此时 Windows 上的网关无法从 WSL 内访 API 密钥和请求头的值可以写成 `${变量名}`,读取系统环境变量。macOS 上读的是登录 shell 里的环境变量,`~/.zshrc` 等文件中 `export` 的变量都会生效,Linux 同理(`~/.bashrc`、`~/.profile` 等);Windows 上读的是系统设置里配置的环境变量。修改变量后,重新打开应用即可生效。代理相关的变量(`HTTPS_PROXY` 等)和 `PATH` 不会被读取。 -中转站、服务商或 ThinkWatch 企业版控制台可以提供导入链接(`thinkwatch://import?…`,或网页形式 `https://thinkwat.ch/import#…`),打开预填好的「新建上游」对话框,服务类型、地址、密钥、请求头、并发上限、模型、模型规格与价目表都可以预先填写。对话框写明接收请求与密钥的主机;选择「下一步」或开始登录之前,不写入配置,也不发出任何请求。一条链接只能新增一个上游,不能设置代理、模型别名、路由或环境变量引用;需要更新版本应用的链接会提示更新。各服务类型的参数与链接生成器见[导入链接](/zh-CN/docs/lite/import-links)。上游行菜单中的「分享…」为已有的上游生成导入链接,可以选择是否包含密钥;多选上游之后,工具条上的「导出所选 N 个上游…」把它们导出为配置包(见[同步与备份](#同步与备份))。 +中转站、服务商或 ThinkWatch 企业版控制台可以提供导入链接(`thinkwatch://import?…`,或网页形式 `https://thinkwat.ch/import#…`),打开预填好的「新建上游」对话框,服务类型、地址、密钥、请求头、并发上限、用量上限、模型、模型规格与价目表都可以预先填写。对话框写明接收请求与密钥的主机;选择「下一步」或开始登录之前,不写入配置,也不发出任何请求。一条链接只能新增一个上游,不能设置代理、模型别名、路由或环境变量引用;需要更新版本应用的链接会提示更新。各服务类型的参数与链接生成器见[导入链接](/zh-CN/docs/lite/import-links)。上游行菜单中的「分享…」为已有的上游生成导入链接,可以选择是否包含密钥;多选上游之后,工具条上的「导出所选 N 个上游…」把它们导出为配置包(见[同步与备份](#同步与备份))。 ## 路由与故障转移 -每把密钥使用一条路由,未指定的使用默认路由。路由由按顺序匹配的规则组成。规则的条件包括模型、密钥、客户端的 API 格式、输入 token 数、`max_tokens`、工具数量、图片、扩展思考、流式、提示缓存、辅助请求的类型以及时段;命中后把请求交给某个上游、策略组或指定模型,或拒绝请求,也可以改写模型、`max_tokens` 或扩展思考。指定模型写明上游和它的某个模型,可按顺序列出备用,模型名原样发出;要让某个客户端把一个模型当另一个模型的名称使用,在这个客户端的密钥上写规则即可,不会改变这个名称对其他客户端的含义。条件写上游模型名时,也匹配列有它的别名。时段按网关所在机器的本地时间判断:选择星期与起止时刻即可,一条规则可以写多个时段,结束早于开始的为跨夜时段。设置 `max_tokens` 可以限制回答的长度:上游到达上限时自行停止,不会报错。策略组把多个上游放在同一个名字下,并决定尝试的先后:按顺序、手动选择、轮询、延迟最低优先或费用最低优先。轮询策略组的每个成员有 1 到 100 的权重,即长期分到的请求比例;「分配依据」可以在权重之上再乘以各上游的速度(首 token 时间)、稳定性(最近的成功率)或两者,进行中的对话仍留在原上游。一次尝试失败时,请求转到下一个上游;同一会话默认保持在同一个上游上,以便提示缓存持续命中。上游连续「设置 › 故障转移」中的「无响应超时」(默认 300 秒)没有发出内容时,网关放弃这个上游,保活心跳不算内容:尚未向客户端发出内容时,请求转到下一个上游,没有可用的上游时客户端收到超时错误;回答已经开始时,以错误结束。页面顶部的路由图显示每把密钥经过的路由、策略组和上游,进行中的请求各有一种颜色,沿各自的路径从密钥移向上游。 +每把密钥使用一条路由,未指定的使用默认路由。路由由按顺序匹配的规则组成。规则的条件包括模型、密钥、客户端的 API 格式、输入 token 数、`max_tokens`、工具数量、图片、扩展思考、流式、提示缓存、辅助请求的类型以及时段;命中后把请求交给某个上游、策略组或指定模型,或拒绝请求,也可以改写模型、`max_tokens` 或扩展思考。指定模型写明上游和它的某个模型,可按顺序列出备用,模型名原样发出;要让某个客户端把一个模型当另一个模型的名称使用,在这个客户端的密钥上写规则即可,不会改变这个名称对其他客户端的含义。条件写上游模型名时,也匹配列有它的别名。时段按网关所在机器的本地时间判断:选择星期与起止时刻即可,一条规则可以写多个时段,结束早于开始的为跨夜时段。设置 `max_tokens` 可以限制回答的长度:上游到达上限时自行停止,不会报错。策略组把多个上游放在同一个名字下,并决定尝试的先后:按顺序、手动选择、轮询、延迟最低优先或费用最低优先。轮询策略组的每个成员有 1 到 100 的权重,即长期分到的请求比例;「分配依据」可以在权重之上再乘以各上游的速度(首 token 时间)、稳定性(最近的成功率)或两者,进行中的对话仍留在原上游。一次尝试失败时,请求转到下一个上游;同一会话默认保持在同一个上游上,以便提示缓存持续命中。返回 429 的上游暂停而不算失败,发给它的请求转到下一个上游或等待它恢复。上游连续「设置 › 故障转移」中的「无响应超时」(默认 300 秒)没有发出内容时,网关放弃这个上游,保活心跳不算内容:尚未向客户端发出内容时,请求转到下一个上游,没有可用的上游时客户端收到超时错误;回答已经开始时,以错误结束。页面顶部的路由图显示每把密钥经过的路由、策略组和上游,进行中的请求各有一种颜色,沿各自的路径从密钥移向上游。 客户端自行发出的辅助请求(连通性检查、预热、生成标题、话题识别、输入建议)可以由网关在本地应答而不产生费用,也可以转发。转发的辅助请求和普通请求一样经过路由规则,规则可以按辅助请求的类型把它们分流到费用更低的上游。 @@ -105,7 +105,7 @@ MCP 页管理客户端从自己的配置文件中加载的内容,这些内容 ## 设置 -设置页分为八节。「连接」列出本机 core 和已保存的远程 core,详见[连接远程 core](/zh-CN/docs/lite/remote-core)。「通用」设置语言、外观、菜单栏显示的内容(仅 macOS,也可设为不显示)、开机启动、提醒以系统通知发送、仅在应用内显示还是关闭,并可让设为不再显示的引导提示和忽略过的提醒重新显示。「网关监听」设置网关的访问范围(仅本机、所选网卡所在的局域网或所有网卡)、端口和放行网段。「故障转移」设置上游失败后暂停多久、请求交给下一个上游:连续失败几次后暂停、暂停多长,以及余额不足、额度用完和限流时各自的暂停时长;还设置无响应超时,即上游多久没有发出内容即被放弃(默认 300 秒,可设 30 到 3,600 秒),以及上游并发已满或密钥的分钟、小时上限用满时请求合计最多等待的时长(默认 30 秒)。「日志保留」分别设置请求报文与请求记录的保留天数,以及报文的空间上限;安全日志随请求记录一同过期。「同步与备份」导出与导入配置、开启同步,并列出可以恢复的配置版本(见[同步与备份](#同步与备份))。「关于」显示版本、检查更新,并可生成诊断包,其中的密钥与地址均已脱敏。「卸载」还原所有已接管的客户端并取消开机启动,应在删除应用之前执行。 +设置页分为八节。「连接」列出本机 core 和已保存的远程 core,详见[连接远程 core](/zh-CN/docs/lite/remote-core)。「通用」设置语言、外观、菜单栏显示的内容(仅 macOS,也可设为不显示)、开机启动、提醒以系统通知发送、仅在应用内显示还是关闭,并可让设为不再显示的引导提示和忽略过的提醒重新显示。「网关监听」设置网关的访问范围(仅本机、所选网卡所在的局域网或所有网卡)、端口和放行网段。「故障转移」设置上游失败后暂停多久、请求交给下一个上游:连续失败几次后暂停、暂停多长,以及余额不足、额度用完和限流时各自的暂停时长;还设置无响应超时,即上游多久没有发出内容即被放弃(默认 300 秒,可设 30 到 3,600 秒),以及「最多等待」,即上游并发已满、正在限流或达到用量上限,以及密钥的分钟、小时上限用满时,请求合计最多等待的时长,默认 120 秒,超过后请求以错误结束。「日志保留」分别设置请求报文与请求记录的保留天数,以及报文的空间上限;安全日志随请求记录一同过期。「同步与备份」导出与导入配置、开启同步,并列出可以恢复的配置版本(见[同步与备份](#同步与备份))。「关于」显示版本、检查更新,并可生成诊断包,其中的密钥与地址均已脱敏。「卸载」还原所有已接管的客户端并取消开机启动,应在删除应用之前执行。 ## 同步与备份 diff --git a/src/content/docs-lite/zh-CN/import-links.md b/src/content/docs-lite/zh-CN/import-links.md index d04a427..eb6f74a 100644 --- a/src/content/docs-lite/zh-CN/import-links.md +++ b/src/content/docs-lite/zh-CN/import-links.md @@ -4,7 +4,7 @@ ThinkWatch Lite 支持通过链接打开预填好设置的「新建上游」对 本页面向中转站与服务商的运营人员,说明链接的两种形式、按对话框分组的参数、各服务类型接受哪些参数、编码方式,以及链接刻意不能设置的内容。页面末尾附有链接生成器。ThinkWatch Lite 自身也能为已有的上游生成这样的链接,见[由 Lite 生成](#由-lite-生成)。 -`name`、`url`、`protocol`、`key`、`models` 以外的参数需要 ThinkWatch Lite 2026.10.16 或更高版本。只用这五个参数的链接在更早的版本中同样可以打开,为更早版本编写的链接全部继续有效。 +`name`、`url`、`protocol`、`key`、`models` 以外的参数需要 ThinkWatch Lite 2026.10.16 或更高版本,`limits` 需要 2026.10.26 或更高版本。只用这五个参数的链接在更早的版本中同样可以打开,为更早版本编写的链接全部继续有效。 ## 链接形式 @@ -55,6 +55,7 @@ ThinkWatch Lite 支持通过链接打开预填好设置的「新建上游」对 | `headers` | 额外的请求头,每行一个 `名称: 值`,编码方式见[编码](#编码)。`auth=account` 时不接受。 | | `client_identity` | `on` 表示开启「转发客户端身份」,用于只接受特定客户端的中转站。仅用于 Sub2API、New API 与自定义上游。 | | `concurrency` | 并发上限:1 到 1000 之间的整数,不带正负号,不以 0 开头。`auth=account` 时不接受。 | +| `limits` | 用量上限,各项以 `;` 分隔,每项为 `per\|measure\|max\|every\|cache_reads`,例如每 5 分钟 10,000,000 token、每天 20 美元,编码方式见[编码](#编码)。`auth=account` 时不接受。 | | `balance` | 余额的读取来源:`off`、`openrouter`、`deepseek`、`moonshot`、`sub2api`、`newapi` 或 `thinkwatch`。省略时自动识别。仅用于自定义上游,其他服务类型自行决定来源。 | ### 模型 @@ -92,7 +93,7 @@ ThinkWatch Lite 支持通过链接打开预填好设置的「新建上游」对 | Ollama | `ollama` | `key`,没有密钥栏位 | `http://127.0.0.1:11434`,或 `url` 中的其他地址 | `openai-chat` | | 自定义 | `custom` | `key`、`oauth` | `url`,必须提供 | 任一协议,或自动识别 | -`headers` 与 `concurrency` 适用于账号登录以外的所有服务类型;`client_identity` 适用于 Sub2API、New API 与自定义上游;`balance` 只适用于自定义上游。「模型」与「计费」两组参数适用于所有服务类型,包括账号登录。 +`headers`、`concurrency` 与 `limits` 适用于账号登录以外的所有服务类型;`client_identity` 适用于 Sub2API、New API 与自定义上游;`balance` 只适用于自定义上游。「模型」与「计费」两组参数适用于所有服务类型,包括账号登录。 `auth=account` 的链接不含任何凭据,登录在应用中进行。这样的链接只接受 `service`、`auth`、`site`、`name` 以及「模型」与「计费」两组参数。 @@ -112,6 +113,7 @@ ThinkWatch Lite 支持通过链接打开预填好设置的「新建上游」对 | `headers` | 以换行符(`%0A`)分隔的多行,每行为 `名称: 值` | `X-Relay-Group: pro`、换行、`X-Relay-Region: hk` | | `specs` | 以 `;` 分隔的多项,每项为 `model\|context\|max_output\|reasoning\|image` | `glm-5-air\|128000\|96000\|y\|n` | | `prices` | 以 `;` 分隔的多项,每项为 `model\|input\|output\|cache_read\|cache_write_5m\|cache_write_1h` | `glm-5\|1\|3.2\|0.2` | +| `limits` | 以 `;` 分隔的多项,每项为 `per\|measure\|max\|every\|cache_reads` | `minute\|tokens\|10000000\|5;day\|cost\|20` | `headers` 中,名称只能使用 HTTP 的 token 字符(字母、数字与 `` ! # % & ' * + - . ^ _ ` | ~ ``),值为可打印 ASCII 字符,首尾空白会被去除。名称不得重复(不区分大小写)。携带凭据或由网关自行写入的请求头一律拒绝:`Authorization`、`Proxy-Authorization`、`X-Api-Key`、`X-Goog-Api-Key`、`Api-Key`、`Cookie`、`Host`、`Content-Length`、`Content-Type`、`Content-Encoding`、`Transfer-Encoding`、`Connection`、`Keep-Alive`、`Upgrade`、`TE`、`Trailer`、`User-Agent`,以及以 `Proxy-` 或 `Sec-` 开头的名称。 @@ -119,6 +121,8 @@ ThinkWatch Lite 支持通过链接打开预填好设置的「新建上游」对 `prices` 中,各单价以美元 / 百万 tokens 计,整数部分与小数部分各最多 6 位。`input` 与 `output` 必须填写;缓存单价留空或省略时,等于输入单价。 +`limits` 中,`per` 为 `minute`、`hour`、`day`、`week` 或 `month`,`measure` 为 `requests`、`tokens` 或 `cost`。请求次数与 token 数的 `max` 为 1 到 999,999,999,999,999 之间的整数,不以 0 开头;费用的 `max` 为美元金额,至少 `0.01`,整数部分最多 9 位、小数部分最多 6 位,不以多余的 0 开头,例如 `5` 或 `0.5`。`every` 为窗口跨越的分钟数(1 到 60)或小时数(1 到 24),只用于 `minute` 与 `hour`,留空时为 1。`cache_reads` 写 `y` 表示计入缓存读取,只用于 `tokens`,留空时不计入。末尾留空的几项可以省略,因此 `day|cost|5` 是完整的一项。`per`、`every`、`measure` 与 `cache_reads` 都相同的两项不能同时出现。 + 长度与字符限制: | 项目 | 限制 | @@ -132,6 +136,7 @@ ThinkWatch Lite 支持通过链接打开预填好设置的「新建上游」对 | `region` | 32 个字符;只含小写字母、数字与 `-` | | `headers` | 最多 16 行;名称最长 64 个字符,值最长 1024 个字符 | | `models`、`models_only`、`specs`、`prices` | 各最多 64 项;模型 ID 最长 128 个字符;`specs` 与 `prices` 中同一模型只能出现一次 | +| `limits` | 最多 16 项 | ## 应用如何处理链接 @@ -165,16 +170,16 @@ ThinkWatch Lite 支持通过链接打开预填好设置的「新建上游」对 ## 示例 -需要两个请求头、并发上限为 4、只列出部分模型、按公开价格的 0.8 倍计费并覆盖一个模型价格的中转站(省略 `service`:该地址识别为自定义上游): +需要两个请求头、并发上限为 4、每 5 分钟最多 10,000,000 token 且每天最多 20 美元、只列出部分模型、按公开价格的 0.8 倍计费并覆盖一个模型价格的中转站(省略 `service`:该地址识别为自定义上游): ```text -thinkwatch://import?name=example-relay&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-relay-EXAMPLE&headers=X-Relay-Group%3A%20pro%0AX-Relay-Region%3A%20hk&concurrency=4&models=glm-5%2Cglm-5-air&specs=glm-5-air%7C128000%7C96000%7Cy%7Cn&price_multiplier=0.8&prices=glm-5%7C1%7C3.2%7C0.2 +thinkwatch://import?name=example-relay&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-relay-EXAMPLE&headers=X-Relay-Group%3A%20pro%0AX-Relay-Region%3A%20hk&concurrency=4&limits=minute%7Ctokens%7C10000000%7C5%3Bday%7Ccost%7C20&models=glm-5%2Cglm-5-air&specs=glm-5-air%7C128000%7C96000%7Cy%7Cn&price_multiplier=0.8&prices=glm-5%7C1%7C3.2%7C0.2 ``` 同一条链接的网页形式: ```text -https://thinkwat.ch/import#name=example-relay&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-relay-EXAMPLE&headers=X-Relay-Group%3A%20pro%0AX-Relay-Region%3A%20hk&concurrency=4&models=glm-5%2Cglm-5-air&specs=glm-5-air%7C128000%7C96000%7Cy%7Cn&price_multiplier=0.8&prices=glm-5%7C1%7C3.2%7C0.2 +https://thinkwat.ch/import#name=example-relay&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-relay-EXAMPLE&headers=X-Relay-Group%3A%20pro%0AX-Relay-Region%3A%20hk&concurrency=4&limits=minute%7Ctokens%7C10000000%7C5%3Bday%7Ccost%7C20&models=glm-5%2Cglm-5-air&specs=glm-5-air%7C128000%7C96000%7Cy%7Cn&price_multiplier=0.8&prices=glm-5%7C1%7C3.2%7C0.2 ``` 使用访问密钥的 Amazon Bedrock,区域为 `eu-west-1`: @@ -226,7 +231,7 @@ ThinkWatch Lite 2026.10.25 起也能为已有的上游生成导入链接,把 | 内容 | 是否写入 | 说明 | |---|---|---| | 服务类型、认证方式、站点、名称、地址、协议(`service`、`auth`、`site`、`name`、`url`、`protocol`) | 写入 | — | -| 并发上限、转发客户端身份、余额来源、模型列表、只用这些模型、手动规格、计费方式、价格倍率、价目(`concurrency`、`client_identity`、`balance`、`models`、`models_only`、`specs`、`billing`、`price_multiplier`、`prices`) | 写入 | 与缺省值相同的不写 | +| 并发上限、用量上限、转发客户端身份、余额来源、模型列表、只用这些模型、手动规格、计费方式、价格倍率、价目(`concurrency`、`limits`、`client_identity`、`balance`、`models`、`models_only`、`specs`、`billing`、`price_multiplier`、`prices`) | 写入 | 与缺省值相同的不写 | | 密钥、请求头、AWS 访问密钥、OAuth 凭据 | 由「包含密钥」决定,缺省写入 | 值写成 `${变量名}` 时不能写入,因为链接不接受这种写法;该项省略,并注明原因 | | 出站代理、别名、路由、权重、停用状态、AWS profile | 不写入 | 导入链接不接受这些参数,见[链接不能设置的内容](#链接不能设置的内容) | | ChatGPT、Z.ai / BigModel 的账号登录凭据 | 不写入 | 链接只写登录方式,接收方停在登录一步,自行登录 | diff --git a/src/content/docs-lite/zh-CN/sync-and-backup.md b/src/content/docs-lite/zh-CN/sync-and-backup.md index 4c35fdc..9328c57 100644 --- a/src/content/docs-lite/zh-CN/sync-and-backup.md +++ b/src/content/docs-lite/zh-CN/sync-and-backup.md @@ -243,7 +243,7 @@ ThinkWatch 强制校验 TLS 证书,DSM 自带的自签名证书无法通过校 | 对象 | 合并单位 | |---|---| -| 上游 | 连接(服务类型、地址、协议、认证方式、密钥、请求头、出站代理、AWS 设置);模型(模型列表、只用这些模型、手动添加的模型、规格);计费(计费方式、价目、价格倍率);其他(并发上限、转发客户端身份、余额来源、停用状态) | +| 上游 | 连接(服务类型、地址、协议、认证方式、密钥、请求头、出站代理、AWS 设置);模型(模型列表、只用这些模型、手动添加的模型、规格);计费(计费方式、价目、价格倍率);其他(并发上限、用量上限、转发客户端身份、余额来源、停用状态) | | 出站代理、网关密钥、别名、策略组、路由、价目表、自定义安全规则 | 整个对象 | | 插件 | 脚本;设置;启用状态 | | 上游顺序、路由顺序、插件顺序 | 各一个单位 | diff --git a/src/data/core-docs/config.md b/src/data/core-docs/config.md index 7ef1c66..31f55de 100644 --- a/src/data/core-docs/config.md +++ b/src/data/core-docs/config.md @@ -19,7 +19,7 @@ when they disagree, so a field listed here is a field the binary reads. `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 +as well: the request database (`data.db`), the backups of the configuration (`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 @@ -116,23 +116,43 @@ 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 +### Backups, export and import -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. +Every change to the configuration leaves a version in `history/` beside the +file: a configuration pack (`.twconfig`) with the plugins and the app's +preferences, recording where the change came from (the app, the command +line, an outside edit, an import, a restore, sync). The last 10 are kept; a +credential rotation does not make one. The control key is masked in them. ```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 backup list # list the versions, newest first +twcore backup restore 1792315800000 --dry-run # what going back would change +twcore backup restore 1792315800000 # go back to a version +twcore export -o all.twconfig # the whole configuration as a pack +twcore export --providers relay --no-secrets --encrypt -o relay.twconfig +twcore import all.twconfig --dry-run # what importing would do 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. +A restore keeps the settings that belong to this machine (listening, the +control key, retention, sync), the ChatGPT sign-ins and the gateway keys that +clients set up on this machine use. An import adds what is not here and asks, +item by item, what to do with what differs (`--on-conflict` answers for all). +A pack carries no settings of this machine and no ChatGPT sign-in; without +`--no-secrets` it holds keys in plain text unless `--encrypt` is given (the +passphrase is asked for, or read from `TWCORE_PASSPHRASE`). Overwriting an +item here with one from a pack without keys keeps this machine's keys only +while the item still points at the same place (scheme, host and port; AWS +region; OAuth token endpoint; the outbound proxy it goes through; a proxy's +address); otherwise, and for an item imported under another name, the +upstream comes in disabled until its key is entered. + +These commands work on the files directly, so they work when core is not +running, which is when a restore is most needed; when the file does not +load at all, a restore brings the whole version back with the current +control key. 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` @@ -178,6 +198,7 @@ means. | `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. | | `plugins` | list of [`plugins[]`](#cfg-plugins) | `[]` | Script plugins, in the order they run. The app installs them; each one's code and settings are a file next to this one. | +| `sync` | object, [`sync`](#cfg-sync) | — | Multi-device sync. A local setting: configuration packs and other devices never carry it, and restoring a backup keeps the current one. Unset: sync is off. | ### `listen` @@ -244,7 +265,7 @@ complete the first message. `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 +configuration is shown or kept in a backup; 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. @@ -329,9 +350,11 @@ minute, hour, day, week or month. Each entry counts one of the three; a key can have several, and a request has to pass every one. `minute` and `hour` are rolling: the last 60 seconds, the last 60 minutes. +With `every`, the window spans several of them: `{ per: hour, every: 5 }` is +the last 5 hours. When one is used up, a request waits for the next free slot if it frees -within `failover.slot_wait_secs` (30 seconds by default), and is refused -otherwise. Any later wait for a busy upstream comes out of the same time. +within `failover.wait_secs` (120 seconds by default), and is refused +otherwise. Any later wait for an upstream comes out of the same time. `day`, `week` and `month` follow the calendar in the time zone of the machine twcore runs on and start again at midnight, on Monday and on the 1st. When one is used up, requests are refused until it starts again. If the machine's time zone changes, the current day, week and month are added up @@ -362,7 +385,8 @@ all its answers count when it closes. | Field | Type | Default | Description | |---|---|---|---| -| `per` | `minute` \| `hour` \| `day` \| `week` \| `month` | **required** | The period. `minute` and `hour` are rolling (the last 60 seconds, the last 60 minutes); `day`, `week` and `month` start again at local midnight, on Monday and on the 1st. | +| `per` | `minute` \| `hour` \| `day` \| `week` \| `month` | **required** | The period. `minute` and `hour` are rolling (the last 60 seconds, the last 60 minutes, or `every` of them); `day`, `week` and `month` start again at local midnight, on Monday and on the 1st. | +| `every` | integer | — | How many minutes or hours the rolling window spans: `{per: hour, every: 5}` is the last 5 hours. From 1 to 60 for `minute`, from 1 to 24 for `hour`; `day`, `week` and `month` do not take it. Unset: 1. | | `requests` | integer | — | At most this many requests. Token counts, answers the gateway gives itself and requests that never reach an upstream do not count. | | `tokens` | integer | — | At most this many tokens: uncached input, cache writes and output. | | `cost` | number | — | At most this much, in US dollars, as recorded for each request; at least 0.01. Models without a price and upstreams with `billing: free` count as 0. | @@ -389,7 +413,7 @@ 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`). For Bedrock, the region's runtime endpoint: `https://bedrock-runtime..amazonaws.com`. | +| `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`). For Bedrock, the region's runtime endpoint: `https://bedrock-runtime..amazonaws.com`. No backslashes, and no user name or password before the host (credentials go in `key` or `headers`). | | `key` | string, `${VAR}` allowed | — | API key. It goes in the header the protocol expects: `x-api-key` (Anthropic), `Authorization: Bearer` (OpenAI, and a Bedrock API key), `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` or `aws`. | | `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`. | @@ -403,7 +427,8 @@ Upstreams: the APIs requests are forwarded to. | `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. | | `model_specs` | map of model id → [`providers[].model_specs.*`](#cfg-providers-model_specs) | `{}` | Context window, output limit, reasoning and image input of single models of this upstream, written by hand, by exact model id. They take precedence over the price table: for models it does not know, or gets wrong. | -| `max_concurrent` | integer | — | Most requests sent to this upstream at the same time, from 1 to 1000. When it is full, a conversation that stays on it waits for a free slot and other requests go to the next upstream; see `failover.slot_wait_secs`. Unset: no limit. | +| `max_concurrent` | integer | — | Most requests sent to this upstream at the same time, from 1 to 1000. When it is full, a conversation that stays on it waits for a free slot and other requests go to the next upstream; see `failover.wait_secs`. Unset: no limit. | +| `limits` | list of [`providers[].limits[]`](#cfg-providers-limits) | — | Usage limits of this upstream, written like its own quota: requests, tokens or cost every few minutes or hours, per day, week or month. Every one has to have room before a request is sent to it. When a rolling window is full, requests go to the next upstream or wait for room; when a day, week or month is used up, requests go to the next upstream until the next one starts. Unset: no limit. | | `disabled` | bool | `false` | Take the upstream out of routing and out of the model list, and keep its configuration. | | `balance` | `auto` \| `off` \| `openrouter` \| `deepseek` \| `moonshot` \| `sub2api` \| `newapi` \| `thinkwatch` | `auto` | Where the upstream's balance is read from, with its own key and proxy: `openrouter`, `deepseek`, `moonshot`, `sub2api`, `newapi` (New API and One API) or `thinkwatch` (a ThinkWatch Enterprise gateway). `auto`: recognized from `base_url` for OpenRouter, DeepSeek and Moonshot; any other address, except Anthropic's, OpenAI's and Gemini's own, is asked once whether it is a Sub2API, ThinkWatch Enterprise or New API relay, and the answer is kept in memory only. `off`: no balance. ChatGPT and Z.ai accounts and Bedrock have no balance whatever is written here. | | `signed_in` | `zai` \| `bigmodel` | — | Which account sign-in the `key` came from: `zai` (Z.ai) or `bigmodel` (BigModel). Signing in to the account in the desktop app writes it; replacing the key there removes it. It does not change how requests are sent. Unset: the key was written by hand. | @@ -486,12 +511,59 @@ slot on the upstream when it is sent, and gives it back when the answer has been passed on in full or the client has gone. When the upstream is full, a conversation that stays on it to reuse its prompt cache waits for a slot; any other request goes straight to the next upstream. How long a request -waits is `failover.slot_wait_secs`. Waiting is not a failure: the upstream is +waits is `failover.wait_secs`. Waiting is not a failure: the upstream is not set aside. Requests that only count tokens do not take a slot. On a Responses WebSocket connection, each `response.create` takes a slot from when it is sent until its answer ends, and so does a key's `max_concurrent`; an idle connection takes none. +#### `providers[].limits` + +Usage limits for an upstream, written like the quota the upstream itself +enforces: at most so many requests, tokens or dollars every few minutes or +hours, per day, week or month. The entries are written like a key's `limits`; +an upstream can have several, and every one has to have room before a request +is sent to it. + +Each attempt sent to the upstream counts as a request, whether it succeeds or +fails, unless the upstream answers it with 429. Tokens and cost count for the +attempt that is answered. Requests that only count tokens do not count. An +attempt still running counts with an estimate of its input until it is +recorded. + +When a `minute` or `hour` window is full, the upstream is passed over like a +busy one: a conversation that stays on it waits for room, other requests go to +the next upstream, and when no candidate has room a request waits for the +first one that does, within the time a request may wait (see `failover`). When +a `day`, `week` or `month` is used up, requests go to the next upstream until +the period starts again; none waits for it. Being full is not a failure: the +upstream is not set aside. After a restart the day, week and month are added up +again from the request records, so `retention.row_days` has to cover the +period, as for a key; minute and hour windows start empty. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `per` | `minute` \| `hour` \| `day` \| `week` \| `month` | **required** | The period. `minute` and `hour` are rolling (the last 60 seconds, the last 60 minutes, or `every` of them); `day`, `week` and `month` start again at local midnight, on Monday and on the 1st. | +| `every` | integer | — | How many minutes or hours the rolling window spans: `{per: hour, every: 5}` is the last 5 hours. From 1 to 60 for `minute`, from 1 to 24 for `hour`; `day`, `week` and `month` do not take it. Unset: 1. | +| `requests` | integer | — | At most this many requests sent to this upstream. Each attempt that reaches it counts, unless it answers 429; token counts do not count. | +| `tokens` | integer | — | At most this many tokens in the answers of this upstream: uncached input, cache writes and output. | +| `cost` | number | — | At most this much, in US dollars, as recorded for the requests this upstream answers; at least 0.01. Models without a price count as 0, and so does everything with `billing: free`. | +| `cache_reads` | bool | `false` | Count cache reads too. Only for a `tokens` limit. | + + +```yaml +providers: + - name: relay + base_url: https://relay.example.com + key: sk-relay + limits: + - { per: hour, every: 5, requests: 200 } + - { per: week, cost: 50 } +``` + #### `providers[].oauth` @@ -1050,9 +1122,9 @@ mode, `off` included. An upstream that fails is set aside for a while, so that the next requests go straight to the next candidate. How long depends on the reason the -upstream gives: an insufficient balance waits for a top-up, a used-up quota -waits until the moment the upstream says it resets, and a rate limit usually -passes within seconds. A request with a single candidate is never affected. +upstream gives: an insufficient balance waits for a top-up, and a used-up +quota waits until the moment the upstream says it resets. A request with a +single candidate is never affected. On a Responses WebSocket connection, each `response.create` counts here like a request: one that fails because of the upstream before any content arrives counts as a failure, and so does a connection the upstream refuses or that @@ -1101,16 +1173,39 @@ failover: idle_timeout_secs: 600 ``` -When upstreams are at their `max_concurrent`, a request waits for a free slot -for at most `slot_wait_secs` in all. The same time also covers waiting for a -key's `minute` or `hour` limit, so a request never waits longer than -`slot_wait_secs` for the two together. An ongoing conversation waits for the -upstream it stays on and, if no slot frees in time, moves on to the next one, -where its cache starts over. A new conversation skips a full upstream at once. -When every candidate is full, the request waits for whichever frees first; if -none does, the client gets a 429 with `Retry-After` saying the upstreams are -busy. If an upstream did receive the request and failed, the client gets that -failure instead. +An upstream that answers 429 (rate limited) is not set aside but paused: +for as long as it says (`retry-after-ms`, `Retry-After`, or a delay in the +error body; under one second, or under one millisecond for `retry-after-ms`, +counts as not said), and otherwise for 5, 10, 20 and 40 seconds, then 60 +seconds each time, by its consecutive 429s; any other answer starts the count +again. The +pause is at most `rate_limit_max_pause_secs`, and the circuit breaker and +`load-balance` do not count it as a failure. The request that got the 429 +moves to the next candidate, or waits for the pause to end; the client sees +nothing of it. A streamed answer that reports a rate limit before any content +counts as a 429 too; only on a request's last candidate that has not been rate +limited in the last five minutes does the client get that error (the upstream +is paused all the same). When the pause ends, one request goes first, and the +others follow once the upstream has accepted it (for a streamed answer, once +its content starts), or at once if that request gets no answer, times out or +finds the upstream's quota used up. A pause lasts until twcore restarts at +most. + +A request waits for upstreams that cannot take it yet for at most `wait_secs` +in all: an upstream at its `max_concurrent`, one paused after a 429, and one +at its `minute` or `hour` usage limit. The same time also covers waiting for +a key's `minute` or `hour` limit, so a request never waits longer than +`wait_secs` for all of them together. An ongoing conversation waits for the +upstream it stays on and, if it does not become available in time, moves on +to the next one, where its cache starts over. A new conversation skips such an +upstream at once. An upstream that has used up a day, week or month usage +limit is skipped. When no candidate is left, the request waits for whichever +becomes available first; streamed answers keep the client's connection open +with the `200` and keep-alives described above. If none becomes available, the +client gets a 429 with `Retry-After` and `retry-after-ms` set to when the +first one will; when every candidate has used up its period, `Retry-After` +is the earliest reset and `x-should-retry` is `false`. If an upstream did +receive the request and failed, the client gets that failure instead. @@ -1122,9 +1217,9 @@ failure instead. | `max_pause_secs` | integer | `600` | Upper bound on the doubled pause, in seconds; not less than `pause_secs`. | | `no_balance_pause_secs` | integer | `1800` | Seconds to set aside an upstream that reports an insufficient balance. | | `quota_pause_secs` | integer | `3600` | Seconds to set aside an upstream whose quota is used up when it does not say when the quota resets. When it does, the upstream is set aside until then. | -| `rate_limit_max_pause_secs` | integer | `3600` | A rate-limited upstream is set aside for the time its `Retry-After` gives, at most this many seconds. Without `Retry-After` it counts as a failure without a stated reason. | +| `rate_limit_max_pause_secs` | integer | `3600` | Longest pause, in seconds, after an upstream answers 429 (rate limited). The pause lasts as long as the upstream says (`retry-after-ms`, `Retry-After`, or a delay in the error body); when it says nothing, it backs off by the upstream's consecutive 429s: 5, 10, 20 and 40 seconds, then 60 seconds each time. A pause is not a failure: requests wait for the upstream within `wait_secs`, and the circuit breaker does not count it. | | `idle_timeout_secs` | integer | `300` | Seconds an upstream may go without sending content before the gateway stops waiting for it. Counted from the moment the request is sent and started again by every piece of content: text, reasoning and tool calls count, keep-alives do not. A whole (non-streamed) answer counts from sending to the complete answer. Before any content has reached the client, the upstream counts as failed and the request moves to the next one; with none left, the client gets a timeout error. After content has reached the client, the answer ends with an error. From 30 to 3600. | -| `slot_wait_secs` | integer | `30` | Seconds a request waits in all, counted once the key's own `max_concurrent` lets it in: for a key's `minute` or `hour` limit to free up, and for a free slot on upstreams at their `max_concurrent`. A key limit that does not free up in time refuses the request; without an upstream slot in time it goes to the next upstream, or, when every candidate is full, is answered with 429. `0`: never wait. From 0 to 300. | +| `wait_secs` | integer | `120` | Seconds a request waits in all, counted once the key's own `max_concurrent` lets it in: for a key's `minute` or `hour` limit to free up, and for an upstream that cannot take it yet — at its `max_concurrent`, paused after answering 429, or at one of its `minute` or `hour` usage limits. A key limit that does not free up in time refuses the request. An upstream that cannot take it is skipped for the next one, and when none is left the request waits for the first to become available; a conversation that stays on an upstream waits for it. When nothing became available in time, the client gets 429 with `Retry-After`. `0`: never wait. From 0 to 600. | ### `aliases` @@ -1236,12 +1331,13 @@ group shares out requests by the result in the same way as above. group has its weight multiplied by four, by at most ten and at least a tenth. - `health`: upstreams that fail less get a larger share. It looks at the - last 50 requests within the past 30 minutes. Server errors, rate limits, - used-up quota or balance, rejected credentials, timeouts (including + last 50 requests within the past 30 minutes. Server errors, used-up quota + or balance, rejected credentials, timeouts (including `failover.idle_timeout_secs` before any content) and connection errors count as failures; errors caused by the request itself do not, and neither - does a client that cancels, a request aborted by hand, or an upstream - skipped because it is at its `max_concurrent`. An upstream that keeps failing keeps a twentieth of its + does a rate limit (429), a client that cancels, a request aborted by hand, + or an upstream skipped because it is at its `max_concurrent` or at a usage + limit. An upstream that keeps failing keeps a twentieth of its weight, so it still gets the occasional request and its recovery is noticed; one that fails outright is set aside by [`failover`](#cfg-failover) as before. @@ -1487,6 +1583,108 @@ export const manifest = { }; ``` +### `sync` + +Several devices (this Mac, a laptop, a server running a remote core) can keep +one configuration in step through a sync location you choose: a folder that a +cloud drive carries between devices, a WebDAV server, or S3-compatible +storage. Each change is merged object by object with what the other devices +did since the last sync; only when two devices change the same part of the +same object to different values is there a conflict, which the app shows on +every device until one of them resolves it. Turn sync on, join a location and +resolve conflicts in the app (Settings, Sync and backup) or with +`twcore sync enable` on a server; this section records the result. + +What syncs is the configuration a configuration pack carries: upstreams, +proxies, gateway keys, aliases, groups, routes, price sheets, security rules, +plugins and the general settings, keys included. `listen`, `retention` and +`sync` itself stay on this device, and so do a ChatGPT account's sign-in and +OAuth tokens. An upstream signed in on another device stays turned off on this +one until it is signed in here; that does not turn it off anywhere else. + +A device that joins with a configuration of its own merges it item by item; +items that differ are chosen one by one, except the default gateway key: every +installation makes one, so the joining device takes the sync data's value and +the clients set up on it are updated. + +With encryption on, versions that are not encrypted are never read. A keyring +that went missing, or an older one put back, does not turn encryption off: the +app asks whether to put back the copy this device keeps. A location that reads +as empty is checked a few more times before the app says so, and turning sync +off is still up to you. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `device_id` | string | **required** | Generated when sync is turned on. File names at the sync location show its first 8 characters. | +| `device_name` | string | **required** | Shown in the device list of every device that syncs here. | +| `encrypt` | bool | `true` | Encrypt what goes to the sync location with the sync passphrase. Every device at one location uses the same setting; change it in the app, which re-uploads the latest version. | +| `passphrase` | string | — | The sync passphrase; required when `encrypt` is on, and kept after encryption is turned off: the keyring that says it is off is checked with it. Kept here in the clear, like the other keys in this file. | +| `exclude` | list of strings | `[]` | Upstreams, by name, that are not synced: they are not uploaded and another device's upstream of the same name does not replace them. Routes, aliases and groups that refer to them are not synced either. | +| `target` | object, [`sync.target`](#cfg-sync-target) | **required** | The sync location. The keyring, `versions/` and `devices/` live directly in it. | + + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `type` | `folder` \| `webdav` \| `s3` | **required** | Which kind of location; the other fields depend on it, see the three tables below. | + + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `type` | string | **required** | `folder` | +| `path` | string | **required** | An absolute path on the machine core runs on: a folder that iCloud Drive, Dropbox, OneDrive, Syncthing or a NAS share carries between devices. | +| `proxy` | string | `direct` | Not used for a folder. | + + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `type` | string | **required** | `webdav` | +| `url` | string | **required** | The WebDAV address of the folder. `https://`, with the certificate checked; `http://` only for this machine and the local network. | +| `user` | string | `''` | User name. | +| `pass` | string | `''` | Password, or the app password the service issues for WebDAV. | +| `proxy` | string | `direct` | Where requests to the sync location go out: `direct`, `system` (the system proxy), or the name of a proxy under `proxies`. An `http://` address on this machine or the local network has to be `direct`. | + + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `type` | string | **required** | `s3` | +| `endpoint` | string | **required** | The S3 endpoint. Hosts ending in `amazonaws.com`, `aliyuncs.com` or `myqcloud.com` put the bucket in the host name; every other endpoint (Cloudflare R2, MinIO, an address) puts it in the path. `http://` only for this machine and the local network. | +| `region` | string | **required** | The signing region; `auto` for Cloudflare R2. | +| `bucket` | string | **required** | The bucket. Keep it private. | +| `prefix` | string | `''` | A folder inside the bucket. No `..` parts, and not starting with `/`. | +| `access_key_id` | string | **required** | Access key ID. | +| `secret_access_key` | string | **required** | Secret access key. | +| `proxy` | string | `direct` | Where requests to the sync location go out: `direct`, `system` (the system proxy), or the name of a proxy under `proxies`. An `http://` address on this machine or the local network has to be `direct`. | + + +```yaml +sync: + device_id: 3f9a2c1e-0b1d-4e2f-9a3b-5c6d7e8f9a0b + device_name: Mac mini + encrypt: true + passphrase: "correct horse battery staple" + exclude: [local ollama] + target: + type: webdav + url: https://dav.example.com/dav/ThinkWatch + user: name@example.com + pass: "app password" +``` + ## Environment variables | Variable | Effect | diff --git a/src/data/core-docs/config.zh-CN.md b/src/data/core-docs/config.zh-CN.md index 1940b24..529f4b5 100644 --- a/src/data/core-docs/config.zh-CN.md +++ b/src/data/core-docs/config.zh-CN.md @@ -13,7 +13,7 @@ ThinkWatch Core 只读一个文件:`config.yaml`。本文逐项说明其中每 | 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`:其中以明文保存密钥。 +`THINKWATCH_HOME` 替换整个目录;`--config <路径>` 为单条命令指定文件。core 的其余数据也在这个目录里:请求数据库(`data.db`)、配置的备份(`history/`)、下载的价目表(`model_prices.json`),以及本地控制通道的 socket 文件(`twcore.sock`;Windows 上是回环端口,记录在 `control.port` 中)。目录只有所有者可访问(`0700`),配置文件权限为 `0600`:其中以明文保存密钥。 这两项权限就是这份文件在本机上的全部保护:挡得住其他用户,挡不住以同一用户身份运行的程序。这样的程序能读到文件里的每一把密钥,也能用其中的控制通道密钥经控制通道修改配置。出站脱敏不改变这一点:它保护的是请求带出本机的内容,不是磁盘上的文件。显示配置时给控制通道密钥打码,是为了让经控制通道的写入改不了它(见 [`listen.control`](#cfg-listen-control)),而不是对本机程序隐藏它;上游密钥和网关密钥按原样显示。 @@ -65,19 +65,25 @@ providers: 两方同时修改时(应用和手工编辑),后写入的一方因版本不一致被拒绝,不会覆盖先写入的内容。 -### 历史与回滚 +### 备份、导出与导入 -每个生效过的版本都保存在配置文件旁边的 `history/` 目录中,并记录来源(应用、命令行、外部编辑、回滚、凭据轮换)。保留最近 50 个版本。 +配置每改动一次,配置文件旁边的 `history/` 目录中就留下一个版本:一个配置包(`.twconfig`),连同插件和应用的通用偏好,并记录来源(应用、命令行、外部编辑、导入、恢复、同步)。保留最近 10 个版本;凭据轮换不生成版本。版本中的控制面密钥一律打码。 ```sh twcore check # 只校验配置,不启动任何服务 twcore config show # 打印当前配置及其版本 -twcore config history # 列出历史版本,最新的在前 -twcore config rollback 3f9a2c # 回滚到某个版本(写前几位即可) +twcore backup list # 列出备份版本,最新的在前 +twcore backup restore 1792315800000 --dry-run # 恢复会改动什么 +twcore backup restore 1792315800000 # 恢复到某个版本 +twcore export -o all.twconfig # 把全部配置导出为配置包 +twcore export --providers relay --no-secrets --encrypt -o relay.twconfig +twcore import all.twconfig --dry-run # 导入会做什么 twcore config set /listen/gateway/port 8790 --int ``` -这些命令直接操作文件,因此 core 没有运行时也能使用,而那往往正是最需要回滚的时候。正在运行的 core 会像对待其他保存一样接收这些改动。 +恢复时保留本机设置(监听、控制面密钥、保留期限、同步设置)、ChatGPT 的登录,以及本机已接管的客户端正在使用的网关密钥。导入时加入本机没有的项目,与本机不同的项目逐项选择处理方式(`--on-conflict` 一次指定全部)。配置包不含本机设置与 ChatGPT 登录;未指定 `--no-secrets` 时其中以明文保存密钥,除非指定 `--encrypt`(口令在终端输入,或取自环境变量 `TWCORE_PASSPHRASE`)。以不含密钥的配置包覆盖本机项目时,只有目的地不变(协议、主机与端口,AWS 区域,OAuth 令牌端点,所走的出站代理,代理的地址)才沿用本机的密钥;目的地变了的、改名导入的上游以停用状态导入,填入密钥后再启用。 + +这些命令直接操作文件,因此 core 没有运行时也能使用,而那往往正是最需要恢复的时候;配置完全无法读取时,恢复把整个版本写回,控制面密钥保持现在的值。正在运行的 core 会像对待其他保存一样接收这些改动。 `twcore config set <路径> <值>` 修改文件中已经写出的一个值。路径中的列表项按其 `name` 定位(`/providers/anthropic/base_url`);数字表示下标(`/routes/0/rules/1/to`)。值默认按字符串写入,`--int`、`--bool`、`--null` 另作指定。写入前先校验结果。要添加文件中还没有的字段,请直接编辑文件。 @@ -112,6 +118,7 @@ twcore config set /listen/gateway/port 8790 --int | `default_route` | 字符串 | — | 未指定路由的密钥走哪条路由。不写:名为 `default` 的路由;没有这条路由时走内置的故障转移。 | | `default_key` | 字符串 | — | 没有专用密钥的客户端使用哪一把。不写:名为 `default` 的那把,没有则取第一把。这把密钥不能停用。 | | `plugins` | 对象列表,见 [`plugins[]`](#cfg-plugins) | `[]` | 脚本插件,按运行的顺序。由应用安装,每个插件的代码和设置是本文件旁边的一个文件。 | +| `sync` | 对象,见 [`sync`](#cfg-sync) | — | 多设备同步。属于本机设置:配置包和其他设备不会带走它,恢复备份时保留当前值。不写:不同步。 | ### `listen` @@ -164,7 +171,7 @@ listen: | `remote` | 对象,见 [`listen.control.remote`](#cfg-listen-control-remote) | — | 供另一台机器上的桌面应用连接的网络端口。它是本地通道之外额外开的,不取代本地通道。 | -`key` 缺失时,由 `twcore serve` 在控制通道开始监听之前写入;`twcore init` 生成的配置也带有它。凡是显示配置或写入配置历史的地方,这个字段一律打码;把打码值原样存回时保留原值。密钥缺失或格式不对(不是 64 个十六进制字符)时整份配置无效,因此无法把它换成一把容易猜到的短密钥。 +`key` 缺失时,由 `twcore serve` 在控制通道开始监听之前写入;`twcore init` 生成的配置也带有它。凡是显示配置或写入备份的地方,这个字段一律打码;把打码值原样存回时保留原值。密钥缺失或格式不对(不是 64 个十六进制字符)时整份配置无效,因此无法把它换成一把容易猜到的短密钥。 ```sh twcore control-key # 显示密钥,用于粘贴到桌面应用 @@ -236,9 +243,10 @@ clients: 一把密钥的用量上限:每分钟、每小时、每天、每周或每月最多多少个请求、多少 token、多少美元。 每一条只数其中一种;一把密钥可以有好几条,请求要通过每一条。 -`minute`、`hour` 是滚动的:最近 60 秒、最近 60 分钟。用满之后,下一个空位在 -`failover.slot_wait_secs`(默认 30 秒)之内空出来,请求就等它;等不到就拒绝。之后若还要等 -上游空出位置,用的是同一段时间里剩下的部分。`day`、`week`、`month` 按 twcore 所在机器的 +`minute`、`hour` 是滚动的:最近 60 秒、最近 60 分钟;写了 `every` 就是几个这样的单位, +`{ per: hour, every: 5 }` 是最近 5 小时。用满之后,下一个空位在 +`failover.wait_secs`(默认 120 秒)之内空出来,请求就等它;等不到就拒绝。之后若还要等 +上游,用的是同一段时间里剩下的部分。`day`、`week`、`month` 按 twcore 所在机器的 本地时区算,在零点、周一零点、每月一号零点重新开始;用满之后,到重新开始之前的请求一律拒绝。 机器换了时区时,当前这一天、这一周、这个月的用量按新的时区从请求记录里重新加起来。 @@ -260,7 +268,8 @@ Responses 的 WebSocket 连接上,每个 `response.create` 各算一个请求 | 字段 | 类型 | 默认值 | 说明 | |---|---|---|---| -| `per` | `minute` \| `hour` \| `day` \| `week` \| `month` | **必填** | 按多长一段时间算。`minute`、`hour` 是滚动的(最近 60 秒、最近 60 分钟);`day`、`week`、`month` 在本地时间的零点、周一零点、每月一号零点重新算。 | +| `per` | `minute` \| `hour` \| `day` \| `week` \| `month` | **必填** | 按多长一段时间算。`minute`、`hour` 是滚动的(最近 60 秒、最近 60 分钟,或者 `every` 个这样的单位);`day`、`week`、`month` 在本地时间的零点、周一零点、每月一号零点重新算。 | +| `every` | 整数 | — | 滚动窗口有几分钟、几小时:`{per: hour, every: 5}` 是最近 5 小时。`minute` 取 1 到 60,`hour` 取 1 到 24;`day`、`week`、`month` 不能写。不写:1。 | | `requests` | 整数 | — | 最多这么多个请求。数 token 的请求、网关自己答的、没有发到上游的不算。 | | `tokens` | 整数 | — | 最多这么多 token:未命中缓存的输入、写入缓存的和输出。 | | `cost` | 数字 | — | 最多花这么多美元,按每个请求记下的费用算;至少 0.01。没有价格的模型、`billing: free` 的上游算 0。 | @@ -287,7 +296,7 @@ clients: | 字段 | 类型 | 默认值 | 说明 | |---|---|---|---| | `name` | 字符串 | **必填** | 上游的名字,不能重复,也不能和策略组同名。以 `__` 开头的名字保留给内置项。 | -| `base_url` | 字符串 | **必填** | 接口地址,`http://` 或 `https://`,按服务商文档写到版本段为止(`https://api.anthropic.com`、`https://api.openai.com/v1`)。Bedrock 写所在区域的推理地址:`https://bedrock-runtime.<区域>.amazonaws.com`。 | +| `base_url` | 字符串 | **必填** | 接口地址,`http://` 或 `https://`,按服务商文档写到版本段为止(`https://api.anthropic.com`、`https://api.openai.com/v1`)。Bedrock 写所在区域的推理地址:`https://bedrock-runtime.<区域>.amazonaws.com`。地址中不能有反斜杠,主机前不能写用户名或密码(凭据写在 `key` 或 `headers` 中)。 | | `key` | 字符串,可写 `${VAR}` | — | API 密钥,放进协议规定的请求头:`x-api-key`(Anthropic)、`Authorization: Bearer`(OpenAI,以及 Bedrock API Key)、`x-goog-api-key`(Gemini)。上游不需要密钥、或凭据写在 `headers` 里时不写。不能和 `oauth`、`aws` 同时写。 | | `headers` | 请求头名 → 值的映射 | `{}` | 额外的请求头,按书写顺序发送;值可以用 `${VAR}`,配置了 `oauth` 时可以用 `{{access_token}}`。最多 32 个。HTTP 或网关管理的请求头(`host`、`content-length`、`connection` 等)不能设置。 | | `oauth` | 对象,见 [`providers[].oauth`](#cfg-providers-oauth) | — | OAuth 凭据:用 refresh token 换取 access token。与 `key` 二选一。 | @@ -301,7 +310,8 @@ clients: | `billing` | `per-token` \| `free` | `per-token` | `per-token`:费用为用量乘以所选价目表中的单价,订阅账号同样如此。`free`:费用记为 0。 | | `pricing` | 字符串 | — | `pricing.sheets` 中某张价目表的名字。不写:默认价目表。 | | `model_specs` | 映射: 模型 ID → [`providers[].model_specs.*`](#cfg-providers-model_specs) | `{}` | 手写这家上游某些模型的上下文窗口、输出上限、会不会推理、收不收图,按模型 ID 完全匹配。写了就优先于价目表,用于价目表里没有或写错的模型。 | -| `max_concurrent` | 整数 | — | 同时发给这家的请求最多几个,取值 1 到 1000。满了的时候,留在这家的对话等空位,别的请求换下一家;等多久见 `failover.slot_wait_secs`。不写:不限。 | +| `max_concurrent` | 整数 | — | 同时发给这家的请求最多几个,取值 1 到 1000。满了的时候,留在这家的对话等空位,别的请求换下一家;等多久见 `failover.wait_secs`。不写:不限。 | +| `limits` | 对象列表,见 [`providers[].limits[]`](#cfg-providers-limits) | — | 这家上游的用量上限,照着它自己的限额写:每几分钟、每几小时、每天、每周或每月的请求数、token 数或费用。每一条都有余量才往这家发。滚动窗口满了,请求换下一家或者等它空出来;天、周、月用满了,到下一期之前请求换下一家。不写:不限。 | | `disabled` | 布尔 | `false` | 不参与路由,模型也不出现在模型列表里;配置原样保留。 | | `balance` | `auto` \| `off` \| `openrouter` \| `deepseek` \| `moonshot` \| `sub2api` \| `newapi` \| `thinkwatch` | `auto` | 从哪里读这家上游的余额,用它自己的密钥、走它自己的代理:`openrouter`、`deepseek`、`moonshot`、`sub2api`、`newapi`(New API 和 One API)或 `thinkwatch`(ThinkWatch 企业网关)。`auto`:OpenRouter、DeepSeek、Moonshot 按 `base_url` 识别;其他地址(Anthropic、OpenAI、Gemini 的官方地址除外)询问一次它是不是 Sub2API、ThinkWatch 企业网关或 New API 中转站,结果只保存在内存中。`off`:不读余额。ChatGPT、Z.ai 账号和 Bedrock 不读余额,与这里写什么无关。 | | `signed_in` | `zai` \| `bigmodel` | — | `key` 来自哪一家的账号登录:`zai`(Z.ai)或 `bigmodel`(BigModel)。在桌面端登录账号时写入,在桌面端更换密钥时删除。不影响请求的发送方式。不写:密钥是手动填写的。 | @@ -341,7 +351,46 @@ Bedrock 上只为 AWS 列出支持提示缓存的 Claude 模型标注:Claude 3 ChatGPT 账号上游(`protocol: chatgpt`)只接受桌面应用登录得到的凭据,不能手写。不支持 Claude 和 Google 的订阅登录,请使用 API 密钥。 -有的中转站和账号同时只接受几个请求,多出来的直接拒绝。`max_concurrent` 让网关守住这个数:请求发出时占用这家的一个位置,回答完整交给客户端、或者客户端断开时归还。这家满了的时候,为复用提示缓存而留在这家的对话等空位,别的请求直接换下一家。最多等多久由 `failover.slot_wait_secs` 决定。等待不算失败,这家不会因此停用。只计算 token 数的请求不占位置。Responses 的 WebSocket 连接上,每个 `response.create` 从发出起占一个位置,直到它的回答结束,密钥的 `max_concurrent` 也一样;空闲的连接不占位置。 +有的中转站和账号同时只接受几个请求,多出来的直接拒绝。`max_concurrent` 让网关守住这个数:请求发出时占用这家的一个位置,回答完整交给客户端、或者客户端断开时归还。这家满了的时候,为复用提示缓存而留在这家的对话等空位,别的请求直接换下一家。最多等多久由 `failover.wait_secs` 决定。等待不算失败,这家不会因此停用。只计算 token 数的请求不占位置。Responses 的 WebSocket 连接上,每个 `response.create` 从发出起占一个位置,直到它的回答结束,密钥的 `max_concurrent` 也一样;空闲的连接不占位置。 + +#### `providers[].limits` + +一家上游的用量上限,照着这家自己的限额写:每几分钟、每几小时、每天、每周或每月最多多少个 +请求、多少 token、多少美元。写法和密钥的 `limits` 一样;一家上游可以有好几条,每一条都有 +余量才往这家发。 + +每一次发到这家的尝试算一个请求,成功失败都算,这家回 429 的不算。token 和费用算在得到回答 +的那一次尝试上。只计算 token 数的请求不算。还在进行的尝试按输入的估算计入,记下之后换成 +实数。 + +`minute`、`hour` 的窗口满了,这家和并发数满了一样被跳过:留在这家的对话等它空出余量,别的 +请求换下一家;候选都没有余量时,请求等最先空出来的那一家,最多等一个请求能等的时间(见 +`failover`)。`day`、`week`、`month` 用满之后,到下一期开始之前请求换下一家,不等它。满了 +不算失败,这家不会因此停用。重启之后,这一天、这一周、这个月的用量从请求记录里重新加起来, +所以 `retention.row_days` 要留够那一期,和密钥的一样;分钟、小时的窗口从空的开始。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `per` | `minute` \| `hour` \| `day` \| `week` \| `month` | **必填** | 按多长一段时间算。`minute`、`hour` 是滚动的(最近 60 秒、最近 60 分钟,或者 `every` 个这样的单位);`day`、`week`、`month` 在本地时间的零点、周一零点、每月一号零点重新算。 | +| `every` | 整数 | — | 滚动窗口有几分钟、几小时:`{per: hour, every: 5}` 是最近 5 小时。`minute` 取 1 到 60,`hour` 取 1 到 24;`day`、`week`、`month` 不能写。不写:1。 | +| `requests` | 整数 | — | 最多往这家发这么多个请求。每一次发到了这家的尝试都算,它回 429 的不算;数 token 的请求不算。 | +| `tokens` | 整数 | — | 这家回答的请求最多用这么多 token:未命中缓存的输入、写入缓存的和输出。 | +| `cost` | 数字 | — | 这家回答的请求最多花这么多美元,按每个请求记下的费用算;至少 0.01。没有价格的模型算 0,`billing: free` 时全都算 0。 | +| `cache_reads` | 布尔 | `false` | 把读取缓存的 token 也算进去。只有 `tokens` 上限能写。 | + + +```yaml +providers: + - name: relay + base_url: https://relay.example.com + key: sk-relay + limits: + - { per: hour, every: 5, requests: 200 } + - { per: week, cost: 50 } +``` #### `providers[].oauth` @@ -795,8 +844,8 @@ security: ### `failover` 上游失败后会停用一段时间,接下来的请求直接交给下一个候选。停用多久取决于上游 -给出的原因:余额不足要等充值,额度用完要等到上游说的重置时刻,限流通常几秒钟就 -过去。只有一个候选的请求不受影响。Responses 的 WebSocket 连接上,每个 `response.create` +给出的原因:余额不足要等充值,额度用完要等到上游说的重置时刻。只有一个候选的请求 +不受影响。Responses 的 WebSocket 连接上,每个 `response.create` 在这里和一个请求一样算:第一段内容到达之前因为上游出错而失败的,算作一次失败;上游 拒绝或连不上的连接也一样。 @@ -827,12 +876,25 @@ failover: idle_timeout_secs: 600 ``` -上游的并发数满了(`max_concurrent`)时,一个请求等空位合计最多 `slot_wait_secs` -秒。等密钥的 `minute`、`hour` 上限也算在这段时间里,两样加起来不超过 `slot_wait_secs`。 -进行中的对话等它留在的那一家,到时还没有空位就换下一家,缓存在那边从头建; -新的对话遇到满着的上游直接跳过。候选全满时,请求等先空出来的那一家;都没有空出来, -客户端收到 429 和 `Retry-After`,说明上游都忙。如果有上游收到过这个请求并且失败了, -客户端收到的是那次失败。 +上游回 429(限流)时不停用,而是暂停:上游给出了等待时间的(`retry-after-ms`、 +`Retry-After` 或错误正文里的等待时间;不到一秒的、`retry-after-ms` 不到一毫秒的当作没有 +给出)暂停那么久,没有给出的按这家连续被限流的次数 +暂停 5、10、20、40 秒,之后每次 60 秒;上游回了别的答复就重新计数。暂停最长 +`rate_limit_max_pause_secs` 秒,熔断和 `load-balance` 都不把它算作失败。收到 429 的 +请求换下一个候选,或者等暂停结束,客户端察觉不到。流式回答在内容之前报的限流也按 429 +处理;只有请求的最后一个候选、而且这家五分钟内没被限流过时,客户端才会收到这个错误(这家照样 +暂停)。暂停结束时先放一个请求过去,这家接受了它(流式回答要等到内容开始),其余的请求再 +跟上;这个请求没有得到答复、超时,或者遇到这家额度用完,其余的请求也照常发出。暂停只保留 +到 twcore 重启为止。 + +一个请求等暂时不能发的上游合计最多 `wait_secs` 秒:并发数满了(`max_concurrent`)的、 +回了 429 正在暂停的、到了 `minute` 或 `hour` 用量上限的都算。等密钥的 `minute`、`hour` +上限也算在这段时间里,几样加起来不超过 `wait_secs`。进行中的对话等它留在的那一家,到时 +还没有恢复就换下一家,缓存在那边从头建;新的对话遇到这样的上游直接跳过。用完了天、周、 +月用量上限的上游也跳过。没有候选了,请求等最先恢复的那一家;流式回答照上文先收到 `200` +和保活,连接不会因此断开。都没有恢复,客户端收到 429,`Retry-After` 和 `retry-after-ms` +是最先恢复的那一家还要多久;候选全都用完了这一期的,`Retry-After` 是最早的重置时刻, +`x-should-retry` 是 `false`。如果有上游收到过这个请求并且失败了,客户端收到的是那次失败。 @@ -844,9 +906,9 @@ failover: | `max_pause_secs` | 整数 | `600` | 翻倍后的停用上限,单位秒,不小于 `pause_secs`。 | | `no_balance_pause_secs` | 整数 | `1800` | 上游报告余额不足时停用的秒数。 | | `quota_pause_secs` | 整数 | `3600` | 上游报告额度用完、但没有给出重置时间时停用的秒数。给出了重置时间的,停用到那一刻。 | -| `rate_limit_max_pause_secs` | 整数 | `3600` | 被限流的上游按它给的 `Retry-After` 停用,最多这么多秒。没有 `Retry-After` 的按没有说明原因的失败计。 | +| `rate_limit_max_pause_secs` | 整数 | `3600` | 上游回 429(限流)后暂停这家的最长秒数。上游给出了等待时间的(`retry-after-ms`、`Retry-After` 或错误正文里的等待时间)按它给的暂停;没有给出的,按这家连续被限流的次数退避:5、10、20、40 秒,之后每次 60 秒。暂停不算失败:请求在 `wait_secs` 之内等这家恢复,熔断也不计入。 | | `idle_timeout_secs` | 整数 | `300` | 上游多少秒没有发出内容,网关就不再等它。从请求发出的那一刻算起,每来一段内容重新计时:正文、推理、工具调用都算,心跳不算。整包(非流式)的回答从发出算到整份回来。还没有内容交给客户端时,这家上游记一次失败,请求换到下一家;没有下一家了,客户端收到超时错误。已经有内容交给客户端的,回答以一条错误收尾。取值 30 到 3600。 | -| `slot_wait_secs` | 整数 | `30` | 一个请求合计最多等的秒数,从过了密钥自己的 `max_concurrent` 时算起:等密钥的 `minute`、`hour` 上限空出名额,和等并发数满了(`max_concurrent`)的上游空出位置,都算在里面。密钥的上限到时空不出来就拒绝;等不到上游的空位就换下一家,候选全满时回 429。`0`:不等。取值 0 到 300。 | +| `wait_secs` | 整数 | `120` | 一个请求合计最多等的秒数,从过了密钥自己的 `max_concurrent` 时算起:等密钥的 `minute`、`hour` 上限空出名额,和等暂时不能发的上游 —— 并发数满了(`max_concurrent`)、回了 429 正在暂停、到了这家 `minute` 或 `hour` 的用量上限 —— 都算在里面。密钥的上限到时空不出来就拒绝。上游暂时不能发的先换下一家,没有下一家了就等最先恢复的那一家;留在某一家的对话等这一家。到时都没有恢复,客户端收到带 `Retry-After` 的 429。`0`:不等。取值 0 到 600。 | ### `aliases` @@ -914,7 +976,7 @@ groups: - `weights`(默认):只按权重。 - `latency`:越快的上游分得越多。快慢看典型的从发出请求到回答第一段内容的时间,与 `url-test` 使用同一份测量。比组内居中者快一倍的上游,权重乘以四;最多乘以十,最少乘以十分之一。 -- `health`:越少失败的上游分得越多。依据是最近 30 分钟内的最近 50 次请求:服务器错误、限流、额度或余额用尽、凭据被拒、超时和连接失败算作失败;请求本身导致的错误不算,客户端取消、手动中止、因并发数满了(`max_concurrent`)而跳过也不算;超过 `failover.idle_timeout_secs` 仍没有内容算作超时。经常失败的上游至少保留权重的二十分之一,仍会偶尔分到请求,以便发现它已经恢复;完全失败的上游照旧由 [`failover`](#cfg-failover) 暂停。 +- `health`:越少失败的上游分得越多。依据是最近 30 分钟内的最近 50 次请求:服务器错误、额度或余额用尽、凭据被拒、超时和连接失败算作失败;请求本身导致的错误不算,限流(429)、客户端取消、手动中止、因并发数满了(`max_concurrent`)或到了用量上限而跳过也不算;超过 `failover.idle_timeout_secs` 仍没有内容算作超时。经常失败的上游至少保留权重的二十分之一,仍会偶尔分到请求,以便发现它已经恢复;完全失败的上游照旧由 [`failover`](#cfg-failover) 暂停。 - `latency-health`:两个系数相乘。 快慢只在流式回答上测,从请求发给这家上游的那一刻算起:之前的等待、之前失败的上游都不算在内。因超过 `failover.idle_timeout_secs` 没有内容而被放弃的上游,按等满的那段时间计。Responses 的 WebSocket 连接上,每个 `response.create` 在快慢和成败上都算一个请求,快慢从上游开始回答它的那一刻算起。 @@ -1094,6 +1156,88 @@ export const manifest = { }; ``` +### `sync` + +多台设备(这台 Mac、一台笔记本、一台运行远程 core 的服务器)可以通过自选的同步位置保持同一份配置:由云盘在设备之间传送的文件夹、WebDAV 服务器或 S3 兼容存储。每次改动都按对象与其他设备自上次同步以来的改动合并;只有两台设备把同一对象的同一部分改成不同内容时才构成冲突,冲突在每台设备上显示,任一设备处理之后随之清除。开启同步、加入已有的同步和处理冲突在应用里进行(设置 → 同步与备份),服务器上用 `twcore sync enable`;本节记录的是结果。 + +同步的内容即配置包带的内容:上游、出站代理、网关密钥、别名、分组、路由、价目表、安全规则、插件和全局设置,包括密钥。`listen`、`retention` 和 `sync` 本身留在本机,ChatGPT 账号的登录、OAuth 的令牌也一样。别的设备上登录着的上游在这台设备上停用着,在这台登录之后才启用;这不会让它在别的设备上停用。 + +已有配置的设备加入时逐项合并,不一样的项目逐项选择;默认网关密钥例外:每个安装都会生成一把,加入的设备采用同步数据的值,本机已接管的客户端随之更新。 + +开着加密时,不加密的版本一概不读。keyring 不见了、被换成更早的那一份,都不会关掉加密:应用会问要不要放回这台设备记着的那一份。同步位置读起来是空的时,要再看几次才提示,停不停止同步由你决定。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `device_id` | 字符串 | **必填** | 开启同步时生成。同步位置上的文件名里出现的是它的前 8 个字符。 | +| `device_name` | 字符串 | **必填** | 在每台参与同步的设备的设备列表里显示的名字。 | +| `encrypt` | 布尔 | `true` | 以同步口令加密存到同步位置的内容。同一同步位置上的所有设备采用同一设置;在应用里更改,应用会重新上传最新版本。 | +| `passphrase` | 字符串 | — | 同步口令,`encrypt` 开着时必填;关闭加密之后也留着:同步位置上写着「已关闭」的 keyring 要用它核对。和本文件里的其他密钥一样以明文保存。 | +| `exclude` | 字符串列表 | `[]` | 不同步的上游,按名字:不上传,也不被其他设备的同名上游覆盖。引用它们的路由、别名、分组随之不同步。 | +| `target` | 对象,见 [`sync.target`](#cfg-sync-target) | **必填** | 同步位置。`keyring`、`versions/`、`devices/` 直接放在它里面。 | + + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `type` | `folder` \| `webdav` \| `s3` | **必填** | 同步位置的种类;其余字段随之不同,见下面三张表。 | + + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `type` | 字符串 | **必填** | `folder` | +| `path` | 字符串 | **必填** | core 所在机器上的绝对路径:由 iCloud Drive、Dropbox、OneDrive、Syncthing 或 NAS 共享在设备之间传送的文件夹。 | +| `proxy` | 字符串 | `direct` | 文件夹用不到。 | + + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `type` | 字符串 | **必填** | `webdav` | +| `url` | 字符串 | **必填** | 文件夹的 WebDAV 地址。`https://`,校验证书;`http://` 只用于本机和局域网。 | +| `user` | 字符串 | `''` | 用户名。 | +| `pass` | 字符串 | `''` | 密码,或者服务为 WebDAV 签发的应用密码。 | +| `proxy` | 字符串 | `direct` | 访问同步位置的请求从哪里出去:`direct`(直连)、`system`(跟随系统代理),或者 `proxies` 里一个代理的名字。本机、内网的 `http://` 地址只能是 `direct`。 | + + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `type` | 字符串 | **必填** | `s3` | +| `endpoint` | 字符串 | **必填** | S3 端点。主机以 `amazonaws.com`、`aliyuncs.com`、`myqcloud.com` 结尾的,存储桶名放进域名;其余端点(Cloudflare R2、MinIO、IP 地址)放进路径。`http://` 只用于本机和局域网。 | +| `region` | 字符串 | **必填** | 签名用的区域;Cloudflare R2 填 `auto`。 | +| `bucket` | 字符串 | **必填** | 存储桶,建议设为私有。 | +| `prefix` | 字符串 | `''` | 存储桶里的一个前缀(文件夹)。不能有 `..`,不以 `/` 开头。 | +| `access_key_id` | 字符串 | **必填** | Access Key ID。 | +| `secret_access_key` | 字符串 | **必填** | Secret Access Key。 | +| `proxy` | 字符串 | `direct` | 访问同步位置的请求从哪里出去:`direct`(直连)、`system`(跟随系统代理),或者 `proxies` 里一个代理的名字。本机、内网的 `http://` 地址只能是 `direct`。 | + + +```yaml +sync: + device_id: 3f9a2c1e-0b1d-4e2f-9a3b-5c6d7e8f9a0b + device_name: Mac mini + encrypt: true + passphrase: "correct horse battery staple" + exclude: [本机 Ollama] + target: + type: webdav + url: https://dav.example.com/dav/ThinkWatch + user: name@example.com + pass: "应用密码" +``` + ## 环境变量 | 变量 | 作用 | diff --git a/src/data/core-docs/manifest.json b/src/data/core-docs/manifest.json index 6354f6c..720d9cb 100644 --- a/src/data/core-docs/manifest.json +++ b/src/data/core-docs/manifest.json @@ -1,9 +1,9 @@ { "repository": "ThinkWatchProject/ThinkWatch-Core", - "ref": "v0.74.0", + "ref": "v0.79.0", "files": { - "docs/config.md": "d8027cba044015bd3ff43a8471280a3e755a0227d190838a53cd4baabf2aa1eb", - "docs/config.zh-CN.md": "705c4e1b2be4c6e05da083521a84cf904175a4523a8120b9dcac282047ea3279", + "docs/config.md": "6418013cd4dd862fb9735a05d19a5e0efa4e8f070ab89fc238b2c0e71622f8bb", + "docs/config.zh-CN.md": "4ac624456040885086a94b5795c8a9ef9f1fb8db83c51e0e3a9ef8c5dbfa0fcf", "docs/server.md": "5e1e9b901bef2b46d417aea057a1db24c78457d3c1938b8540ecb4766515b68f", "docs/server.zh-CN.md": "1f508e7c39b8ded28653773ca4d8701e6bdc2247bcd359c3a8fd00fd1401a6ff" } diff --git a/src/i18n/pages/core.ts b/src/i18n/pages/core.ts index 921cdee..b82845d 100644 --- a/src/i18n/pages/core.ts +++ b/src/i18n/pages/core.ts @@ -46,7 +46,7 @@ export const coreCopy = { }, { 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 failing upstream is paused for as long as its reason calls for: an exhausted balance, a used-up quota until its reset, a rate limit for the wait it asks.", + 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 failing upstream is paused for as long as its reason calls for: an exhausted balance, or a used-up quota until its reset. An upstream that answers 429 is paused for the wait it asks, without counting as a failure, and its requests go to the next upstream or wait for it.", }, { title: "Cost accounting", @@ -170,7 +170,7 @@ export const coreCopy = { }, { title: "首字节前的故障转移", - body: "首字节到达客户端之前,出错的上游由下一个上游替换,客户端无从察觉;此后发生的故障如实报告。出错的上游按原因暂停:余额不足、额度用完(到重置时间为止)与限流(按上游要求的等待时间)各有时长。", + body: "首字节到达客户端之前,出错的上游由下一个上游替换,客户端无从察觉;此后发生的故障如实报告。出错的上游按原因暂停:余额不足与额度用完(到重置时间为止)各有时长。返回 429 的上游按其要求的等待时间暂停,不计为失败,发给它的请求转到下一个上游或等待它恢复。", }, { title: "费用核算", diff --git a/src/lib/import-link-text.ts b/src/lib/import-link-text.ts index ebce733..338c8c2 100644 --- a/src/lib/import-link-text.ts +++ b/src/lib/import-link-text.ts @@ -1,8 +1,9 @@ // Words shared by the import page and the link builder: the names of the // dialog's sections and fields as ThinkWatch Lite writes them, and the reasons a // link is rejected. `{p}` in a problem is the parameter, written with `param` -// or, for a name that is not short and plain, `someParam`. -import { V2_SINCE, type Problem } from "./import-link"; +// or, for a name that is not short and plain, `someParam`. `{v}` in +// `needsVersion` is the version. +import { microsText, type Limit, type Problem } from "./import-link"; export function importLinkText(zh: boolean) { return zh @@ -34,6 +35,7 @@ export function importLinkText(zh: boolean) { headers: "请求头", clientIdentity: "转发客户端身份", concurrency: "并发上限", + limits: "用量上限", balance: "余额来源", models: "手动添加的模型", modelsOnly: "启用范围", @@ -69,7 +71,17 @@ export function importLinkText(zh: boolean) { thinkwatch: "ThinkWatch", }, billings: { "per-token": "按量计费", free: "不计费" }, - needsVersion: `需要 ThinkWatch Lite ${V2_SINCE} 或更高版本。`, + // A usage limit read as a phrase, as ThinkWatch Lite writes it: 「每 5 分钟 10,000,000 token」 + limit: { + units: { minute: "分钟", hour: "小时", day: "天", week: "周", month: "月" }, + unitsMany: { minute: "分钟", hour: "小时", day: "天", week: "周", month: "月" }, + span: " {n} {unit}", + requests: "每{per} {n} 次请求", + tokens: "每{per} {n} token", + cost: "每{per} {n} 费用", + cacheReads: "(含缓存读取)", + }, + needsVersion: "需要 ThinkWatch Lite {v} 或更高版本。", param: "参数「{name}」", someParam: "某个参数", problems: { @@ -113,6 +125,7 @@ export function importLinkText(zh: boolean) { headers: "Headers", clientIdentity: "Forward client identity", concurrency: "Concurrency limit", + limits: "Usage limits", balance: "Balance source", models: "Models added by hand", modelsOnly: "Enabled models", @@ -154,7 +167,16 @@ export function importLinkText(zh: boolean) { thinkwatch: "ThinkWatch", }, billings: { "per-token": "Per token", free: "Free" }, - needsVersion: `Needs ThinkWatch Lite ${V2_SINCE} or later.`, + limit: { + units: { minute: "minute", hour: "hour", day: "day", week: "week", month: "month" }, + unitsMany: { minute: "minutes", hour: "hours", day: "days", week: "weeks", month: "months" }, + span: "{n} {unit}", + requests: "{n} requests per {per}", + tokens: "{n} tokens per {per}", + cost: "{n} per {per}", + cacheReads: " (cache reads included)", + }, + needsVersion: "Needs ThinkWatch Lite {v} or later.", param: "the parameter “{name}”", someParam: "a parameter", problems: { @@ -173,3 +195,18 @@ export function importLinkText(zh: boolean) { } export type ImportLinkText = ReturnType; + +/** A usage limit as a phrase: 「每 5 分钟 10,000,000 token」, "$5.00 per day". */ +export function limitPhrase(t: ImportLinkText["limit"], l: Limit): string { + const per = l.every > 1 ? t.span.replace("{n}", String(l.every)).replace("{unit}", t.unitsMany[l.per]) : t.units[l.per]; + const n = l.measure === "cost" ? `$${dollars(l.max)}` : l.max.toLocaleString("en-US"); + const phrase = t[l.measure].replace("{per}", per).replace("{n}", n); + return l.cacheReads ? phrase + t.cacheReads : phrase; +} + +/** Millionths of a dollar with at least two decimals: 5.00, 0.125. */ +function dollars(micros: number): string { + const text = microsText(micros); + const [whole, frac = ""] = text.split("."); + return `${Number(whole).toLocaleString("en-US")}.${frac.padEnd(2, "0")}`; +} diff --git a/src/lib/import-link.ts b/src/lib/import-link.ts index 451911e..1fd46cd 100644 --- a/src/lib/import-link.ts +++ b/src/lib/import-link.ts @@ -43,6 +43,12 @@ export type Balance = (typeof BALANCES)[number]; export const BILLINGS = ["per-token", "free"] as const; export type Billing = (typeof BILLINGS)[number]; +export const LIMIT_PERS = ["minute", "hour", "day", "week", "month"] as const; +export type LimitPer = (typeof LIMIT_PERS)[number]; + +export const LIMIT_MEASURES = ["requests", "tokens", "cost"] as const; +export type LimitMeasure = (typeof LIMIT_MEASURES)[number]; + /** Every parameter, in the order a link is written. */ export const PARAMS = [ "service", @@ -63,6 +69,7 @@ export const PARAMS = [ "headers", "client_identity", "concurrency", + "limits", "balance", "models", "models_only", @@ -76,9 +83,33 @@ export type Param = (typeof PARAMS)[number]; /** The parameters of the first version. A link with only these also opens in earlier versions of the app. */ export const V1_PARAMS: readonly Param[] = ["name", "url", "protocol", "key", "models"]; -/** The first version of ThinkWatch Lite that reads the other parameters. */ +/** The first version of ThinkWatch Lite that reads the other parameters, unless `SINCE` names a later one. */ export const V2_SINCE = "2026.10.16"; +/** Parameters added after V2_SINCE, with the first version of ThinkWatch Lite that reads them. */ +export const SINCE: Partial> = { limits: "2026.10.26" }; + +/** Compares two versions such as 2026.10.16, part by part. */ +const newer = (a: string, b: string) => { + const x = a.split(".").map(Number); + const y = b.split(".").map(Number); + for (let i = 0; i < Math.max(x.length, y.length); i++) { + if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0); + } + return false; +}; + +/** The first version of ThinkWatch Lite that reads all of these parameters, or undefined when every version does. */ +export function sinceOf(params: readonly Param[]): string | undefined { + let since: string | undefined; + for (const p of params) { + if (V1_PARAMS.includes(p)) continue; + const v = SINCE[p] ?? V2_SINCE; + if (!since || newer(v, since)) since = v; + } + return since; +} + /** * Settings a link can never carry: the outbound proxy and what happens when it is * unavailable, an AWS profile, aliases, routes, the default upstream, weights, the @@ -110,6 +141,11 @@ export const MAX_HEADERS = 16; export const MAX_HEADER_NAME = 64; export const MAX_HEADER_VALUE = 1024; export const MAX_CONCURRENCY = 1000; +export const MAX_LIMITS = 16; +/** The largest request or token count of a usage limit (fifteen digits) */ +export const MAX_LIMIT_COUNT = 999_999_999_999_999; +/** The smallest cost limit, in millionths of a dollar ($0.01) */ +export const LIMIT_COST_MIN = 10_000; export const MAX_SPEC_TOKENS = 10_000_000; const APP_PREFIX = "thinkwatch://import?"; @@ -205,6 +241,7 @@ export function fieldsFor(service: Service, auth: Auth): Set { f.add("url"); f.add("headers"); f.add("concurrency"); + f.add("limits"); if (r.protocol !== null) f.add("protocol"); if (r.clientIdentity) f.add("client_identity"); if (auth === "key" && r.key) f.add("key"); @@ -235,6 +272,20 @@ export type Price = { cacheWrite1h: string; }; +/** + * A usage limit: at most `max` requests, tokens or millionths of a dollar per + * `every` minutes or hours, or per day, week or month. + */ +export type Limit = { + per: LimitPer; + /** Minutes or hours the window spans; 1 for a day, week or month */ + every: number; + measure: LimitMeasure; + /** A count, or for cost millionths of a dollar */ + max: number; + cacheReads: boolean; +}; + /** A validated import: what the New upstream dialog is prefilled with. */ export type ImportLink = { service: Service; @@ -258,6 +309,7 @@ export type ImportLink = { headers: Header[]; clientIdentity: boolean; concurrency?: number; + limits: Limit[]; balance?: Balance; models: string[]; modelsOnly?: string[]; @@ -266,8 +318,8 @@ export type ImportLink = { billing?: Billing; /** Present when the link makes a new price sheet */ priceSheet?: { multiplier: string; prices: Price[] }; - /** Uses a parameter that ThinkWatch Lite reads from V2_SINCE on */ - v2: boolean; + /** The first version of ThinkWatch Lite that reads every parameter; absent when the first version's parameters suffice */ + since?: string; /** The checked parameters, normalized, in link order: what the app is handed */ params: [Param, string][]; }; @@ -544,6 +596,86 @@ function checkPrices(v: string): Price[] | null { return out; } +/** A decimal whole number from 1 to `max`, without a sign or leading zeros (the app's `whole`). */ +function whole(s: string, max: number): number | null { + if (!/^[1-9][0-9]*$/.test(s) || s.length > 15) return null; + const n = Number(s); + return n <= max ? n : null; +} + +/** Dollars as written in a cost limit (`5`, `0.5`, up to six decimals) to millionths of a dollar. */ +function dollarsToMicros(s: string): number | null { + const m = /^(0|[1-9][0-9]{0,8})(?:\.([0-9]{1,6}))?$/.exec(s); + if (!m) return null; + return Number(m[1]) * 1_000_000 + Number((m[2] ?? "").padEnd(6, "0")); +} + +/** Millionths of a dollar written as dollars: up to six decimals, without trailing zeros (`5`, `0.125`). */ +export function microsText(m: number): string { + const frac = String(m % 1_000_000) + .padStart(6, "0") + .replace(/0+$/, ""); + const dollars = String(Math.floor(m / 1_000_000)); + return frac ? `${dollars}.${frac}` : dollars; +} + +/** + * Usage limits: entries separated by `;`, each `per|measure|max|every|cache_reads`. + * `every` (1 to 60 minutes, 1 to 24 hours) is for `minute` and `hour` only and is 1 + * when empty; `cache_reads` is `y`, for `tokens` only. Empty fields at the end may be + * left out. No two entries with the same period, `every`, measure and `cache_reads`. + * Mirrors the app's `check_limits`. + */ +function checkLimits(v: string): Limit[] | null { + const entries = v.split(";"); + if (entries.length > MAX_LIMITS) return null; + const out: Limit[] = []; + for (const e of entries) { + const f = e.split("|"); + if (f.length < 3 || f.length > 5) return null; + const [per, measure, rawMax, rawEvery = "", rawCache = ""] = f as string[]; + if (!includes(LIMIT_PERS, per!) || !includes(LIMIT_MEASURES, measure!)) return null; + const max = + measure === "cost" + ? (() => { + const m = dollarsToMicros(rawMax!); + return m !== null && m >= LIMIT_COST_MIN ? m : null; + })() + : whole(rawMax!, MAX_LIMIT_COUNT); + if (max === null) return null; + let every = 1; + if (rawEvery) { + const most = per === "minute" ? 60 : per === "hour" ? 24 : 0; + const n = most ? whole(rawEvery, most) : null; + if (n === null) return null; + every = n; + } + if (rawCache && !(rawCache === "y" && measure === "tokens")) return null; + const limit: Limit = { per, every, measure, max, cacheReads: rawCache === "y" }; + if ( + out.some( + (x) => x.per === limit.per && x.every === limit.every && x.measure === limit.measure && x.cacheReads === limit.cacheReads, + ) + ) + return null; + out.push(limit); + } + return out; +} + +/** One usage limit as the app writes it: `every` 1 and cache reads off left empty, empty fields at the end dropped. */ +function limitText(l: Limit): string { + const f = [ + l.per, + l.measure, + l.measure === "cost" ? microsText(l.max) : String(l.max), + l.every > 1 ? String(l.every) : "", + l.cacheReads ? "y" : "", + ]; + while (f.length > 3 && f[f.length - 1] === "") f.pop(); + return f.join("|"); +} + function checkMultiplier(v: string): boolean { if (!/^\d{1,3}(\.\d{1,6})?$/.test(v)) return false; const n = Number(v); @@ -745,6 +877,14 @@ function validateFor( concurrency = Number(rawConcurrency); } + let limits: Limit[] = []; + const rawLimits = get("limits"); + if (rawLimits !== undefined) { + const l = checkLimits(rawLimits); + if (!l) return fail("invalid", "limits"); + limits = l; + } + const balance = get("balance"); if (balance !== undefined && !includes(BALANCES, balance)) return fail("invalid", "balance"); @@ -797,6 +937,7 @@ function validateFor( else if (p === "headers") put(p, headers.map((h) => `${h.name}: ${h.value}`).join(LF)); else if (p === "models") put(p, models.join(",")); else if (p === "models_only") put(p, modelsOnly!.join(",")); + else if (p === "limits") put(p, limits.map(limitText).join(";")); else put(p, v); } @@ -826,13 +967,14 @@ function validateFor( headers, clientIdentity: clientIdentity === "on", concurrency, + limits, balance: balance as Balance | undefined, models, modelsOnly, specs, billing: billing as Billing | undefined, priceSheet, - v2: out.some(([p]) => !V1_PARAMS.includes(p)), + since: sinceOf(out.map(([p]) => p)), params: out, }; if (APP_PREFIX.length + encode(link, unknown).length > MAX_LINK) return fail("tooLong");