diff --git a/src/components/ImportLinkBuilder.astro b/src/components/ImportLinkBuilder.astro index 956ce09..b87cd4a 100644 --- a/src/components/ImportLinkBuilder.astro +++ b/src/components/ImportLinkBuilder.astro @@ -1,117 +1,281 @@ --- // The link builder on the "Import links" page of the Lite docs. It runs in the -// browser only: the values are checked with the same rules as the app -// (src/lib/import-link.ts) and the two links are written with textContent. -// Nothing is sent anywhere. +// browser only: the form asks what the New upstream dialog asks for the chosen +// service and authentication method, the values are checked with the same rules +// as the app (src/lib/import-link.ts), and the two links are written with +// textContent. Nothing is sent anywhere. import { getLang } from "~/i18n"; -import { PROTOCOLS } from "~/lib/import-link"; +import { BALANCES, BILLINGS, SERVICES, SITES } from "~/lib/import-link"; +import { importLinkText } from "~/lib/import-link-text"; const lang = getLang(Astro); const zh = lang === "zh-CN"; +const shared = importLinkText(zh); +const L = shared.labels; const t = zh ? { heading: "链接生成器", note: "在浏览器中生成,不发送任何内容。", - name: "名称(可选)", - url: "接口地址", - protocol: "接口协议", - auto: "自动识别(不写入链接)", - key: "API 密钥(可选)", - models: "手动添加的模型(可选,逗号分隔)", + optional: "(可选)", + auto: "自动识别", + defaultBalance: "自动识别", + defaultBilling: "服务的默认值", + clientIdentityOn: "开启", + urlOptional: "留空时使用标准地址。", + regionHint: "标准地址按区域生成;其他地址在使用访问密钥时按此区域签名。", + perLine: "每行一项。", + commaSeparated: "以逗号分隔。", appLink: "应用链接", webLink: "网页链接", copy: "复制", copied: "已复制", - fill: "填写接口地址后生成链接。", - problems: { - empty: "填写接口地址后生成链接。", - tooLong: "链接过长。", - unknownParam: "包含不支持的参数。", - duplicateParam: "同一参数出现了多次。", - emptyValue: "参数的值为空。", - missingUrl: "填写接口地址后生成链接。", - name: "名称不符合要求:最多 64 个字符,首尾无空白,不含 / \\ $ { } < > \" `,不以 __ 开头。", - url: "接口地址不符合要求:须为 https 地址(http 仅限 localhost、127.0.0.1、[::1]),不含账号、查询串、片段、空白与 % $ { } 等字符。", - protocol: "接口协议不受支持。", - key: "API 密钥只能包含字母、数字与 - _ . ~ + / = :,最长 512 个字符。", - models: "手动添加的模型不符合要求:以逗号分隔,每项只含字母、数字与 - _ . : / @ +,最多 64 项。", + fill: "选择服务类型并填写设置后生成链接。", + worksBefore: "此链接也适用于更早版本的 ThinkWatch Lite。", + rules: { + name: "最多 64 个字符,首尾无空白,不含 / \\ $ { } < > \" `,不以 __ 开头。", + url: "须为 https 地址(http 仅限本机与内网地址,见上文),不含账号、查询串、片段、空白与 % $ { } 等字符。", + key: "只能包含字母、数字与 - _ . ~ + / = :,最长 512 个字符。", + long: "只能包含字母、数字与 - _ . ~ + / = :,最长 4096 个字符。", + region: "小写字母、数字与连字符,例如 us-east-1。", + awsId: "大写字母与数字,16 到 128 个字符。", + headers: "每行一个「名称: 值」,最多 16 行,名称不重复,值为可打印 ASCII;鉴权、Cookie、Host、内容与连接相关的请求头、User-Agent 及 proxy-*、sec-* 开头的请求头不接受。", + concurrency: "1 到 1000 之间的整数。", + models: "模型 ID 以逗号分隔,只含字母、数字与 - _ . : / @ +,最多 64 项。", + modelsOnly: "模型 ID 或含 * ? 的通配规则,以逗号分隔,最多 64 项。", + specs: "每行一个 model|context|max_output|reasoning|image:数值为 1 到 10000000,reasoning 与 image 为 y 或 n,至少填写一项。", + multiplier: "大于 0、不超过 100 的数,最多 6 位小数。", + prices: "每行一个 model|input|output|cache_read|cache_write_5m|cache_write_1h,单位为美元 / 百万 tokens;缓存单价可省略,省略时等于输入单价。", }, } : { heading: "Link builder", note: "Runs in the browser; nothing is sent.", - name: "Name (optional)", - url: "Base URL", - protocol: "Protocol", - auto: "Auto-detect (left out of the link)", - key: "API key (optional)", - models: "Models added by hand (optional, comma-separated)", + optional: " (optional)", + auto: "Auto-detect", + defaultBalance: "Auto-detect", + defaultBilling: "The service's default", + clientIdentityOn: "On", + urlOptional: "Leave empty for the standard address.", + regionHint: "Standard addresses follow the region; with access keys, another address is signed for this region.", + perLine: "One per line.", + commaSeparated: "Comma-separated.", appLink: "App link", webLink: "Web link", copy: "Copy", copied: "Copied", - fill: "Enter a base URL to generate the links.", - problems: { - empty: "Enter a base URL to generate the links.", - tooLong: "The link is too long.", - unknownParam: "The link contains an unsupported parameter.", - duplicateParam: "A parameter appears more than once.", - emptyValue: "A parameter has an empty value.", - missingUrl: "Enter a base URL to generate the links.", - name: "The name is not accepted: at most 64 characters, no leading or trailing spaces, none of / \\ $ { } < > \" `, and not starting with __.", - url: "The base URL is not accepted: it must be an https address (http only for localhost, 127.0.0.1 and [::1]) without credentials, a query, a fragment, spaces or characters such as % $ { }.", - protocol: "The protocol is not supported.", - key: "The API key may contain only letters, digits and - _ . ~ + / = :, up to 512 characters.", - models: "The models added by hand are not accepted: comma-separated, each entry only letters, digits and - _ . : / @ +, at most 64 entries.", + fill: "Choose the service and fill in the settings to generate the links.", + worksBefore: "This link also works with earlier versions of ThinkWatch Lite.", + rules: { + name: "At most 64 characters, no leading or trailing spaces, none of / \\ $ { } < > \" `, and not starting with __.", + url: "An https address (http only on this computer or a private network, as described above) without credentials, a query, a fragment, spaces or characters such as % $ { }.", + key: "Letters, digits and - _ . ~ + / = : only, up to 512 characters.", + long: "Letters, digits and - _ . ~ + / = : only, up to 4096 characters.", + region: "Lowercase letters, digits and hyphens, such as us-east-1.", + 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.", + 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.", + multiplier: "A number above 0 and at most 100, with up to six decimals.", + prices: "One model|input|output|cache_read|cache_write_5m|cache_write_1h per line, in USD per million tokens; a cache price left out is the input price.", }, }; +/** Which rule explains a rejected value */ +const ruleOf: Record = { + name: "name", + url: "url", + oauth_endpoint: "url", + key: "key", + oauth_client_id: "key", + oauth_client_secret: "key", + aws_secret_access_key: "key", + oauth_refresh: "long", + aws_session_token: "long", + region: "region", + aws_access_key_id: "awsId", + headers: "headers", + concurrency: "concurrency", + models: "models", + models_only: "modelsOnly", + specs: "specs", + price_multiplier: "multiplier", + prices: "prices", +}; + +const strings = { ...shared, ...t, ruleOf }; + const input = "w-full rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-bg)] px-3 py-2 font-mono text-sm text-[var(--color-text)] outline-none focus:border-[var(--color-brand-1)]"; const labelCls = "mb-1.5 block text-sm text-[var(--color-muted)]"; +const hintCls = "mt-1 block text-xs leading-relaxed text-[var(--color-muted)]"; +const legendCls = "mb-3 text-sm font-medium text-[var(--color-text)]"; +const fieldsetCls = "grid gap-4 border-t border-[var(--color-border)] pt-5"; ---

{t.heading}

{t.note}

-
- -
-
- - + + +
+ {shared.groups.connection} + +
+ + + +
+ +
+ + + + + + + +
+
+ +
+ {shared.groups.advanced} + +
+ + + +
+
+ +
+ {shared.groups.models} +
+ + +
+ +
+ +
+ {shared.groups.billing} +
+ + +
+ +
-

{t.fill}

+

{t.fill}

-
diff --git a/src/content/docs-lite/en/compare-cc-switch.md b/src/content/docs-lite/en/compare-cc-switch.md index 63411a3..1f621fa 100644 --- a/src/content/docs-lite/en/compare-cc-switch.md +++ b/src/content/docs-lite/en/compare-cc-switch.md @@ -17,7 +17,7 @@ A dash means the feature is not described in that product's documentation. | | CC Switch | ThinkWatch Lite | |---|---|---| | Clients | Claude Code, Claude Desktop, Codex, Gemini CLI, Grok Build, OpenCode, OpenClaw, Hermes Agent, Pi, MiniMax Code | In one step: Claude Code, Claude Desktop, Codex (also in the ChatGPT desktop app), opencode, Pi, oh-my-pi, Grok Build, Qwen Code, Hermes Agent, Zed, Aider, DeepSeek Harness. With instructions: Cursor, Continue, Antigravity CLI | -| Adding providers | More than 50 presets; `ccswitch://` links import providers, MCP servers, prompts and skills | A new upstream starts from the service, in three groups: model vendors (Anthropic, OpenAI, Google Gemini, DeepSeek, Z.ai / BigModel), platforms and relays (Amazon Bedrock, OpenRouter, ThinkWatch Enterprise, Sub2API, New API / One API), and Ollama or any compatible endpoint; ChatGPT and Z.ai / BigModel accounts sign in inside the dialog; `thinkwatch://import` links from a relay or vendor pre-fill one upstream | +| Adding providers | More than 50 presets; `ccswitch://` links import providers, MCP servers, prompts and skills | A new upstream starts from the service, in three groups: model vendors (Anthropic, OpenAI, Google Gemini, DeepSeek, Z.ai / BigModel), platforms and relays (Amazon Bedrock, OpenRouter, ThinkWatch Enterprise, Sub2API, New API / One API), and Ollama or any compatible endpoint; ChatGPT and Z.ai / BigModel accounts sign in inside the dialog; `thinkwatch://import` links from a relay, a vendor or a ThinkWatch Enterprise console fill in the New upstream dialog for any service, headers, models and prices included | | Routing | In proxy mode, each client's requests go to its current provider; models can be mapped per provider | Ordered rules per key by model, API format, input tokens, tools, images, extended thinking and more; rules can rewrite the model | | Failover | Queue in priority order with a circuit breaker (proxy mode) | Next upstream in the group when an attempt fails before the answer begins; a session stays on one upstream | | Load balancing | — | Group strategies: in order, manual, round robin, lowest latency, lowest cost | diff --git a/src/content/docs-lite/en/features.md b/src/content/docs-lite/en/features.md index 0b539b3..cc4a7bb 100644 --- a/src/content/docs-lite/en/features.md +++ b/src/content/docs-lite/en/features.md @@ -61,7 +61,7 @@ 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 or vendor can hand out an import link, `thinkwatch://import?…` or its web form `https://thinkwat.ch/import#…`, that pre-fills a new upstream with a name, base URL, protocol, API key and model list. The app shows the settings and the host that will receive requests and the key in a confirmation dialog, and writes nothing and contacts nothing before Create is chosen. A link only ever adds one upstream: it cannot change existing ones, headers, proxies, pricing or routing, and a key that refers to an environment variable is rejected. The parameters and a link builder are in [Import links](/docs/lite/import-links). +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). ## Routing and failover diff --git a/src/content/docs-lite/en/import-links.md b/src/content/docs-lite/en/import-links.md index 94dc25e..c8316d2 100644 --- a/src/content/docs-lite/en/import-links.md +++ b/src/content/docs-lite/en/import-links.md @@ -1,8 +1,10 @@ # Import links -ThinkWatch Lite accepts links that pre-fill a new upstream. A relay or a model vendor can place such a link in its dashboard or in a welcome message. A user who has the app installed opens the link, reviews the settings in a confirmation dialog, and creates the upstream in one step. +ThinkWatch Lite accepts links that open the New upstream dialog with the settings already filled in. A relay, a model vendor or a ThinkWatch Enterprise console can place such a link in its dashboard or in a welcome message. The user opens it, checks the settings in the same dialog used to add an upstream by hand, and creates the upstream with a few clicks. -This page is written for relay and vendor operators. It describes the two forms of the link, the parameters, what the app checks, and what a link intentionally cannot do. A link builder is at the end of the page. +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. + +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. ## Link forms @@ -10,85 +12,207 @@ Both forms carry the same parameters. | Form | Description | |---|---| -| `thinkwatch://import?…` | Opens the app directly. The browser asks for permission before it hands the link to the app. | -| `https://thinkwat.ch/import#…` | A web page that shows the settings, opens the app with a button, and offers the download when the app is not installed. The parameters are in the fragment after `#`, which browsers do not send to any server. The page loads no analytics or third-party scripts and removes the fragment from the address bar after reading it. | +| `thinkwatch://import?…` | Opens the app directly. The browser asks for permission before it hands the link to the app. `thinkwatch://import/?…` is accepted too. | +| `https://thinkwat.ch/import#…` | A web page that shows the hosts that will receive requests and the settings, opens the app with a button, and offers the download when the app is not installed. The parameters are in the fragment after `#`, which browsers do not send to any server. The page loads no analytics or third-party scripts and removes the fragment from the address bar after reading it. | The web form suits emails and dashboards whose users may not have the app yet. The page checks the parameters with the same rules as the app and shows nothing for a link that fails them. ## Parameters -| Parameter | Required | Value | +A parameter is accepted only where the New upstream dialog shows its field for the chosen service and authentication method. A value the dialog fills in by itself, such as the address of an official service or the only protocol it speaks, may be repeated in the link but not changed. [By service](#by-service) lists what each service takes. + +### Service + +| Parameter | Value | +|---|---| +| `service` | `anthropic`, `openai`, `gemini`, `deepseek`, `zai`, `bedrock`, `openrouter`, `thinkwatch` (a ThinkWatch Enterprise gateway), `sub2api`, `newapi` (New API / One API), `ollama` or `custom`. When omitted, the app recognizes the service from `url` the way the edit dialog does: official addresses by host, standard Bedrock addresses, port 11434 as Ollama, and any other address as `custom`. When a parameter of the link does not fit the recognized service, the link is a `custom` upstream, which takes every parameter. An explicit `service` is never changed. | +| `auth` | `key` (the default), `oauth`, `account` or `aws-keys`, among the methods the service offers. `account` is the ChatGPT account for `openai` and the account sign-in for `zai`. | +| `site` | `bigmodel` (mainland China) or `zai` (international), for `zai` only. When omitted, it is taken from `url`, or is `bigmodel`. | + +### Connection + +| Parameter | Value | +|---|---| +| `name` | The upstream's name in the app. When omitted, the app chooses one from the service or the address. | +| `url` | The base URL, without endpoint paths such as `/chat/completions` or `/messages`. `https://`, or `http://` on this computer or a private network (see below). No user name or password, query string or fragment. Required for `custom`, `thinkwatch`, `sub2api` and `newapi`. Optional for Bedrock (an endpoint such as a VPC endpoint) and Ollama (another machine). Services with their own address accept only that address. Not accepted with `auth=account`. | +| `protocol` | `anthropic`, `openai-chat`, `openai-responses` or `gemini`, where the dialog offers a choice. When omitted, the app detects it from the address. | +| `region` | The AWS region, such as `eu-west-1`, for Bedrock only. Without `url`, it chooses the standard address of that region. With a standard address, it must match the region in it. With another address, it is the signing region and needs `auth=aws-keys`. | +| `key` | The API key, stored as given (for Bedrock, the Bedrock API key). With `auth=key`. Ollama has no key field. | +| `oauth_endpoint` | The token endpoint, with the same rules as `url` and usually a path. Required with `auth=oauth`. When its host differs from the address, the app names both hosts. | +| `oauth_refresh` | The refresh token. Required with `auth=oauth`. | +| `oauth_client_id` | The client ID, with `auth=oauth`. Optional. | +| `oauth_client_secret` | The client secret, with `auth=oauth`. Optional. | +| `aws_access_key_id` | The access key ID, with `service=bedrock` and `auth=aws-keys`. Required there. | +| `aws_secret_access_key` | The secret access key. Required with `auth=aws-keys`. | +| `aws_session_token` | The session token of temporary credentials, with `auth=aws-keys`. Optional. | + +Plain `http://` is accepted in `url` and `oauth_endpoint` for addresses on this computer or a private network, such as an enterprise gateway on the company intranet. The host must be `localhost`, `127.0.0.1` or `[::1]`; an IPv4 address in `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `100.64.0.0/10` or `169.254.0.0/16`; an IPv6 address in `fc00::/7` or `fe80::/10`; a name without a dot; or a name ending in `.local`, `.lan`, `.internal`, `.intranet`, `.corp`, `.home.arpa` or `.localhost`. Any other `http://` address is rejected. For a plain `http://` address other than this computer, the dialog and the web page state under the host that the key and request content travel unencrypted. + +### Advanced settings + +| Parameter | Value | +|---|---| +| `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`. | +| `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 + +| Parameter | Value | +|---|---| +| `models` | Comma-separated model IDs, added to the upstream by hand. The app offers them next to the models the service lists, so a service that lists only some of its models can name the rest here; when the service lists none, they are its whole list. Repeated IDs are kept once. | +| `models_only` | Comma-separated model IDs or patterns with `*` and `?`. Sets the enabled models to these models only. | +| `specs` | Model specs, entries separated by `;`, each `model\|context\|max_output\|reasoning\|image`. They are saved once the upstream exists, for models on its final list. A spec for a model that is neither in `models` nor listed by the upstream is not saved, and the Models step says so. | + +### Billing + +| Parameter | Value | +|---|---| +| `billing` | `per-token` or `free`. When omitted, the service's default applies: free for Ollama, per token for the others. | +| `price_multiplier` | The multiplier of a new price sheet, above 0 and at most 100, such as `0.8`. Not accepted with free billing. | +| `prices` | Price overrides for the new price sheet, entries separated by `;`, each `model\|input\|output\|cache_read\|cache_write_5m\|cache_write_1h`. Not accepted with free billing. | + +When `price_multiplier` or `prices` is present, the Billing step preselects a new price sheet named after the upstream, with the multiplier (1 when omitted) and the overrides from the link. The sheet can be edited like any new sheet and is created together with the upstream when **Create** is chosen. When a sheet of that name exists, the dialog asks for another name, as it does for a taken upstream name. + +## By service + +| Service | `service` | `auth` | Address | Protocol | +|---|---|---|---|---| +| Anthropic | `anthropic` | `key` | `https://api.anthropic.com` | `anthropic` | +| OpenAI | `openai` | `key`, `account` (ChatGPT account) | `https://api.openai.com` | `openai-chat` (default) or `openai-responses` | +| Google Gemini | `gemini` | `key` | `https://generativelanguage.googleapis.com` | `gemini` | +| DeepSeek | `deepseek` | `key` | `https://api.deepseek.com/anthropic` | `anthropic` | +| Z.ai / BigModel | `zai` | `key`, `account` | `https://open.bigmodel.cn/api/anthropic` or `https://api.z.ai/api/anthropic`, by `site` | `anthropic` | +| Amazon Bedrock | `bedrock` | `key` (Bedrock API key), `aws-keys` | The standard address of `region`, or an endpoint in `url` | Not set by a link | +| OpenRouter | `openrouter` | `key` | `https://openrouter.ai/api` | `openai-chat` | +| ThinkWatch Enterprise | `thinkwatch` | `key` | `url`, required | Not set by a link; the gateway accepts each client's format | +| Sub2API | `sub2api` | `key` | `url`, required | Any, or detected | +| New API / One API | `newapi` | `key` | `url`, required | Any, or detected | +| 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. + +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. + +## Encodings + +- Each value is percent-encoded, for example with `encodeURIComponent`, and decoded once. A literal `+` means a space, so a key that contains `+` is written as `%2B`. +- Each parameter appears at most once, and an empty value is rejected. +- No value may contain `$`, `{` or `}`, control characters or invisible characters. The app reads `${NAME}` from the user's environment, so these characters could make it send a local secret to the link's host. +- A value that fails its rule rejects the whole link. The app records only the reason, never the content. + +Lists and entries: + +| Parameter | Encoding | Example value, before percent-encoding | |---|---|---| -| `url` | Yes | The base URL of the service, without endpoint paths such as `/chat/completions` or `/messages`. `https://` only; `http://` is accepted only for `localhost`, `127.0.0.1` and `[::1]`. No user name or password, query string or fragment. | -| `name` | No | The upstream's name in the app. When omitted, the app derives one from the address. | -| `protocol` | No | `anthropic`, `openai-chat`, `openai-responses` or `gemini`. When omitted, the app detects the protocol from the address. | -| `key` | No | The API key, stored as given. Letters, digits and `- _ . ~ + / = :` only. | -| `models` | No | Comma-separated model IDs, added to the upstream by hand. The app offers them next to the models the service lists, so a service that lists only some of its models can name the rest here; when the service lists none, they are its whole list. | +| `models` | Model IDs separated by `,`; letters, digits and `- _ . : / @ +` | `glm-5,glm-5-air` | +| `models_only` | As `models`, plus `*` and `?` in patterns | `claude-*,gpt-5` | +| `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` | + +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-`. -Encoding rules: +In `specs`, `context` and `max_output` are token counts from 1 to 10,000,000, and `reasoning` and `image` are `y` or `n`. Any of the four may be empty, but not all of them. -- Each value is percent-encoded, for example with `encodeURIComponent`. A literal `+` means a space, so a key that contains `+` is written as `%2B`. -- Each parameter appears at most once. -- A link with any other parameter, an empty value, or a value that fails its rule is rejected as a whole. The app ignores such a link without opening a window. +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. Limits: | Item | Limit | |---|---| | Whole link | 8192 characters | -| `name` | 64 characters; no leading or trailing spaces; none of `/`, `\`, `$`, `{`, `}`, `<`, `>`, `"` or the backtick; not starting with `__`; no control or invisible characters | -| `url` | 2048 characters | -| `key` | 512 characters | -| `models` | 64 entries, 128 characters each; letters, digits and `- _ . : / @ +` | +| `name` | 64 characters; no leading or trailing spaces; none of `/`, `\`, `$`, `{`, `}`, `<`, `>`, `"` or the backtick; not `.` or `..`; not starting with `__` | +| `url`, `oauth_endpoint` | 2048 characters | +| `key`, `oauth_client_id`, `oauth_client_secret`, `aws_secret_access_key` | 512 characters; letters, digits and `- _ . ~ + / = :` | +| `oauth_refresh`, `aws_session_token` | 4096 characters; the same characters as `key` | +| `aws_access_key_id` | 16 to 128 capital letters and digits | +| `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` | ## What the app does with a link -1. **Checks the link.** Every rule above is applied in the app, whatever the page that produced the link has checked. -2. **Shows a confirmation dialog.** The dialog states the host that will receive request content and the API key, in ASCII: an internationalized domain name is shown as punycode (`xn--…`), so a look-alike domain cannot pass for a familiar one. It also shows the base URL, the protocol, the key (hidden until revealed) and the models added by hand. Only the name can be changed. Values are displayed as plain text. -3. **Saves nothing before confirmation.** Until **Create** is chosen, the configuration is not written and the app makes no network request to the address: no connection test and no model listing. -4. **Creates one new upstream.** An import never changes, replaces or deletes an existing upstream. When the name is already in use, a different name must be entered; there is no option to overwrite. The new upstream is not made the default and is not added to any route. After creation it behaves like an upstream added by hand, including fetching its model list. -5. **Handles one link at a time.** Links that arrive while the dialog is open, or within a few seconds after it closes, are ignored and do not bring the window to the front. +1. **Checks the link.** Every rule on this page is applied in the app, whatever the page that produced the link has checked. +2. **Opens the New upstream dialog, filled in.** The dialog opens on its second step, Connection, or Account for an account sign-in. A note under the steps names the host that will receive requests and keys, and the token endpoint's host when it differs. Hosts are shown in ASCII: an internationalized domain name appears as punycode (`xn--…`), so a look-alike domain cannot pass for a familiar one. Every value can be changed, and going back to the first step to pick another service drops the values that service does not take. +3. **Writes and sends nothing before the user acts.** Until **Next** is chosen on the second step, which checks the connection, or a sign-in is started, the configuration is not written and no request leaves the machine. +4. **Creates one new upstream.** An import never changes, replaces or deletes an existing upstream, never makes the new one the default and never adds it to a route. When the name is taken, the dialog asks for another. Several upstreams on one address with different keys are normal. +5. **Handles one link at a time.** Links that arrive while the dialog is open are ignored. When a New upstream or Edit upstream dialog is already open, an arriving link is dropped, so a form being filled in is never replaced. + +## Links from a newer version + +A parameter that the installed app does not know no longer fails silently. The app shows **Cannot import this link**, names the parameter, and offers to install an update when one is available. The web form behaves the same way: it lists the settings it recognizes, states that a newer ThinkWatch Lite is needed, and still offers to open the link. A parameter name is shown only when it consists of up to 32 lowercase letters, digits and underscores. + +## What a link can never set + +Any web page can open a link, so a link is limited to what a user can judge in the dialog. A link that tries to set one of the following is rejected as a whole. + +| Not accepted | Reason | +|---|---| +| Outbound proxy, and what happens when it is unavailable | The sender does not know the user's network. The user picks them in the dialog. | +| AWS profile, and environment variable references (`${NAME}`) | They would make the app read local credentials or the environment and send them to the link's host. A link with `key=${OPENAI_API_KEY}` would send the user's own key to the relay. | +| Aliases, routes, the default upstream and route weights | They would redirect clients that are already configured. | +| The disabled switch and the access token used only for checking | A new upstream starts enabled, and the access token is obtained with the refresh token. | +| Credentials for an account sign-in | The sign-in happens in the app, with the service. | +| Authentication, cookie and connection headers | A header must not replace the key or the identity the gateway sends. | +| Plain `http://` to public hosts | The key would cross the internet unencrypted. On this computer and private networks, plain `http://` is accepted and marked as unencrypted. | +| MCP servers and client configuration | They run commands or change the settings of other applications. | +| Icons and other presentation | Not needed to connect, and a way to imitate a familiar service. | + +These settings can be changed in the app after the upstream is created. ## Examples -An Anthropic-compatible relay: +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): ```text -thinkwatch://import?name=example-relay&url=https%3A%2F%2Fapi.relay.example&protocol=anthropic&key=sk-relay-EXAMPLE +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 ``` +The same link in its web form: + ```text -https://thinkwat.ch/import#name=example-relay&url=https%3A%2F%2Fapi.relay.example&protocol=anthropic&key=sk-relay-EXAMPLE +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 ``` -An OpenAI-compatible relay that does not list its models: +Amazon Bedrock in `eu-west-1` with access keys: ```text -thinkwatch://import?name=example-openai&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-relay-EXAMPLE&models=gpt-5%2Cgpt-5-mini +thinkwatch://import?service=bedrock&auth=aws-keys®ion=eu-west-1&aws_access_key_id=AKIAIOSFODNN7EXAMPLE&aws_secret_access_key=wJalrXUtnFEMI%2FK7MDENG%2FbPxRfiCYEXAMPLEKEY ``` +A ThinkWatch Enterprise gateway. The **Add to ThinkWatch Lite** button in the ThinkWatch Enterprise console writes this link with the gateway's address and the new key: + ```text -https://thinkwat.ch/import#name=example-openai&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-relay-EXAMPLE&models=gpt-5%2Cgpt-5-mini +thinkwatch://import?service=thinkwatch&name=thinkwatch&url=https%3A%2F%2Fai.example.com&key=tw-EXAMPLE ``` -## What a link cannot set +A ChatGPT account. The link opens the sign-in; it carries no credential: -Any web page can open a link, so a link is limited to what a user can judge in one dialog. The following are intentionally not supported: +```text +thinkwatch://import?service=openai&auth=account&name=chatgpt +``` -| Not supported | Reason | -|---|---| -| Environment variable references in the key (`${NAME}`) | The app reads `${NAME}` from the user's environment. A link with `key=${OPENAI_API_KEY}` would send the user's own key to the relay's host. Keys containing `$`, `{` or `}` are rejected. | -| Request headers | Header values can reference environment variables and credentials, and can change how requests are authenticated in ways the dialog cannot show clearly. | -| Outbound proxy | A link must not route traffic through a third party. | -| Pricing and billing | How cost is counted is the user's decision. | -| Routing rules and the default upstream | A link must not redirect the traffic of clients that are already configured. | -| MCP servers and client configuration | They run commands or change the settings of other applications. | -| Icons and other presentation | Not needed to connect, and a way to imitate a familiar service. | -| Plain `http://` to other hosts | The key would travel unencrypted. | -| ChatGPT account sign-in and Amazon Bedrock | They need an interactive sign-in or AWS credentials, which a link cannot carry. | +A link written for earlier versions, still valid: -These settings can be changed in the app after the upstream is created. +```text +thinkwatch://import?name=example-relay&url=https%3A%2F%2Fapi.relay.example&protocol=anthropic&key=sk-relay-EXAMPLE +``` + +Links written for earlier versions keep working even where the recognized service would not take them: an official address with another path such as `https://api.openai.com/v1`, an Ollama address with a `key`, or `https://api.deepseek.com` with `protocol=openai-chat`. Such a link becomes a custom upstream, as it did before. + +## On a New API site + +A New API or One API site can offer ThinkWatch Lite for every key without changing its code. In the site's system settings, add a chat preset named ThinkWatch Lite with this URL: + +```text +thinkwatch://import?service=newapi&url={address}&key={key} +``` + +The site fills in its server address and the key of the row, and the preset appears in the chat menu of each key. Opening it needs ThinkWatch Lite 2026.10.16 or later; with an earlier version, or without the app, nothing happens. ## Keys in links -A link that contains a key is a credential. Generate a separate link for each customer and deliver it through a private channel, such as the customer's own dashboard. A link without `key` creates the upstream without a key; the key is then added by editing the upstream in the app. +A link that contains a key, a refresh token or a secret access key is a credential. Generate a separate link for each customer and deliver it through a private channel, such as the customer's own dashboard. A link without a key leaves the key field empty, to be filled in the dialog. diff --git a/src/content/docs-lite/zh-CN/compare-cc-switch.md b/src/content/docs-lite/zh-CN/compare-cc-switch.md index 33d87f5..37f01c1 100644 --- a/src/content/docs-lite/zh-CN/compare-cc-switch.md +++ b/src/content/docs-lite/zh-CN/compare-cc-switch.md @@ -17,7 +17,7 @@ ThinkWatch Lite 在本机运行网关 ThinkWatch Core。在客户端页把每个 | | CC Switch | ThinkWatch Lite | |---|---|---| | 客户端 | Claude Code、Claude Desktop、Codex、Gemini CLI、Grok Build、OpenCode、OpenClaw、Hermes Agent、Pi、MiniMax Code | 一键接管:Claude Code、Claude Desktop、Codex(含 ChatGPT 桌面版中的 Codex)、opencode、Pi、oh-my-pi、Grok Build、Qwen Code、Hermes Agent、Zed、Aider、DeepSeek Harness;提供配置方法:Cursor、Continue、Antigravity CLI | -| 添加供应商 | 50 余个供应商预设;`ccswitch://` 链接可导入供应商、MCP 服务器、提示词与技能 | 新建上游先选择服务,分三组:模型厂商(Anthropic、OpenAI、Google Gemini、DeepSeek、Z.ai / BigModel)、平台与中转(Amazon Bedrock、OpenRouter、ThinkWatch 企业版、Sub2API、New API / One API),以及 Ollama 与任何兼容接口;ChatGPT 与 Z.ai / BigModel 账号在对话框内登录;中转站或服务商可通过 `thinkwatch://import` 链接预填一个上游 | +| 添加供应商 | 50 余个供应商预设;`ccswitch://` 链接可导入供应商、MCP 服务器、提示词与技能 | 新建上游先选择服务,分三组:模型厂商(Anthropic、OpenAI、Google Gemini、DeepSeek、Z.ai / BigModel)、平台与中转(Amazon Bedrock、OpenRouter、ThinkWatch 企业版、Sub2API、New API / One API),以及 Ollama 与任何兼容接口;ChatGPT 与 Z.ai / BigModel 账号在对话框内登录;中转站、服务商或 ThinkWatch 企业版控制台可通过 `thinkwatch://import` 链接为任一服务类型预填「新建上游」对话框,包括请求头、模型与价目表 | | 路由 | 代理模式下,每个客户端的请求发往它当前的供应商;可按供应商映射模型 | 每把密钥的规则按顺序匹配模型、API 格式、输入 token、工具、图片、扩展思考等条件;规则可以改写模型 | | 故障转移 | 按优先级排列的故障转移队列,带熔断(代理模式) | 回答开始之前尝试失败时,转到策略组中的下一个上游;同一会话默认保持在同一个上游 | | 负载均衡 | — | 策略组:按顺序、手动选择、轮询、延迟最低、费用最低 | diff --git a/src/content/docs-lite/zh-CN/features.md b/src/content/docs-lite/zh-CN/features.md index 1bded9f..24bfcdc 100644 --- a/src/content/docs-lite/zh-CN/features.md +++ b/src/content/docs-lite/zh-CN/features.md @@ -61,7 +61,7 @@ WSL 2 默认使用 NAT 网络,此时 Windows 上的网关无法从 WSL 内访 API 密钥和请求头的值可以写成 `${变量名}`,读取系统环境变量。macOS 上读的是登录 shell 里的环境变量,`~/.zshrc` 等文件中 `export` 的变量都会生效,Linux 同理(`~/.bashrc`、`~/.profile` 等);Windows 上读的是系统设置里配置的环境变量。修改变量后,重新打开应用即可生效。代理相关的变量(`HTTPS_PROXY` 等)和 `PATH` 不会被读取。 -中转站或服务商可以提供导入链接(`thinkwatch://import?…`,或网页形式 `https://thinkwat.ch/import#…`),预填新上游的名称、接口地址、接口协议、API 密钥与模型清单。应用在确认对话框中列出这些设置,并写明请求与密钥将发往的主机;选择「创建」之前不写入配置,也不连接该地址。一条链接只能新增一个上游,不能修改已有的上游、请求头、代理、价目表或路由,引用环境变量的密钥一律拒绝。参数说明与链接生成器见[导入链接](/zh-CN/docs/lite/import-links)。 +中转站、服务商或 ThinkWatch 企业版控制台可以提供导入链接(`thinkwatch://import?…`,或网页形式 `https://thinkwat.ch/import#…`),打开预填好的「新建上游」对话框,服务类型、地址、密钥、请求头、并发上限、模型、模型规格与价目表都可以预先填写。对话框写明接收请求与密钥的主机;选择「下一步」或开始登录之前,不写入配置,也不发出任何请求。一条链接只能新增一个上游,不能设置代理、模型别名、路由或环境变量引用;需要更新版本应用的链接会提示更新。各服务类型的参数与链接生成器见[导入链接](/zh-CN/docs/lite/import-links)。 ## 路由与故障转移 diff --git a/src/content/docs-lite/zh-CN/import-links.md b/src/content/docs-lite/zh-CN/import-links.md index 525fb63..54011d4 100644 --- a/src/content/docs-lite/zh-CN/import-links.md +++ b/src/content/docs-lite/zh-CN/import-links.md @@ -1,8 +1,10 @@ # 导入链接 -ThinkWatch Lite 支持通过链接预填一个新的上游。中转站或模型服务商可以把这样的链接放在控制台或开通通知中;已安装应用的用户打开链接后,在确认对话框中核对设置,一步即可创建上游。 +ThinkWatch Lite 支持通过链接打开预填好设置的「新建上游」对话框。中转站、模型服务商或 ThinkWatch 企业版控制台可以把这样的链接放在控制台或开通通知中;用户打开链接后,在手动添加上游时所用的同一个对话框中核对设置,点几下即可创建上游。 -本页面向中转站与服务商的运营人员,说明链接的两种形式、参数、应用所做的校验,以及链接刻意不支持的内容。页面末尾附有链接生成器。 +本页面向中转站与服务商的运营人员,说明链接的两种形式、按对话框分组的参数、各服务类型接受哪些参数、编码方式,以及链接刻意不能设置的内容。页面末尾附有链接生成器。 + +`name`、`url`、`protocol`、`key`、`models` 以外的参数需要 ThinkWatch Lite 2026.10.16 或更高版本。只用这五个参数的链接在更早的版本中同样可以打开,为更早版本编写的链接全部继续有效。 ## 链接形式 @@ -10,85 +12,207 @@ ThinkWatch Lite 支持通过链接预填一个新的上游。中转站或模型 | 形式 | 说明 | |---|---| -| `thinkwatch://import?…` | 直接打开应用。浏览器在把链接交给应用之前会先请求许可。 | -| `https://thinkwat.ch/import#…` | 一个网页:显示将要导入的设置,通过按钮打开应用,未安装应用时提供下载。参数位于 `#` 之后的片段中,浏览器不会把片段发送给任何服务器。该页面不加载统计或第三方脚本,读取参数后会从地址栏中移除片段。 | +| `thinkwatch://import?…` | 直接打开应用。浏览器在把链接交给应用之前会先请求许可。也接受 `thinkwatch://import/?…`。 | +| `https://thinkwat.ch/import#…` | 一个网页:显示接收请求的主机与将要导入的设置,通过按钮打开应用,未安装应用时提供下载。参数位于 `#` 之后的片段中,浏览器不会把片段发送给任何服务器。该页面不加载统计或第三方脚本,读取参数后会从地址栏中移除片段。 | 网页形式适合用于邮件和控制台,因为收件人可能尚未安装应用。网页按与应用相同的规则校验参数,校验不通过的链接不显示任何内容。 ## 参数 -| 参数 | 必填 | 取值 | +只有在「新建上游」对话框针对所选服务类型与认证方式显示相应栏位时,链接才能设置该参数。对话框自行填写的值,例如官方服务的地址或服务唯一使用的协议,链接可以重复写出,但不能改为其他值。各服务类型接受哪些参数见[按服务类型](#按服务类型)。 + +### 服务类型 + +| 参数 | 取值 | +|---|---| +| `service` | `anthropic`、`openai`、`gemini`、`deepseek`、`zai`、`bedrock`、`openrouter`、`thinkwatch`(ThinkWatch 企业网关)、`sub2api`、`newapi`(New API / One API)、`ollama` 或 `custom`。省略时由应用按 `url` 识别,与编辑对话框的识别方式相同:官方地址按主机识别,标准 Bedrock 地址识别为 Bedrock,端口 11434 识别为 Ollama,其余地址一律为 `custom`。链接中有参数不适用于识别出的服务类型时,按 `custom` 处理,自定义上游接受全部参数。明确写出的 `service` 不会被改变。 | +| `auth` | `key`(默认)、`oauth`、`account` 或 `aws-keys`,须为该服务类型提供的认证方式。`openai` 的 `account` 是 ChatGPT 账号,`zai` 的 `account` 是账号登录。 | +| `site` | `bigmodel`(中国大陆)或 `zai`(国际),仅用于 `zai`。省略时按 `url` 确定,没有 `url` 时为 `bigmodel`。 | + +### 连接 + +| 参数 | 取值 | +|---|---| +| `name` | 上游在应用中的名称。省略时由应用按服务类型或地址生成。 | +| `url` | 服务的 Base URL,不含 `/chat/completions`、`/messages` 等接口路径。须为 `https://`,本机与内网地址也可以使用 `http://`(见下文)。不得包含用户名或密码、查询串或片段。`custom`、`thinkwatch`、`sub2api`、`newapi` 必须提供;Bedrock 可选(VPC 端点等),Ollama 可选(其他机器);自带地址的服务只接受该地址。`auth=account` 时不接受。 | +| `protocol` | `anthropic`、`openai-chat`、`openai-responses` 或 `gemini`,仅用于对话框提供选择的服务类型。省略时由应用按地址识别。 | +| `region` | AWS 区域,例如 `eu-west-1`,仅用于 Bedrock。没有 `url` 时,按该区域生成标准地址;`url` 为标准地址时,须与其中的区域一致;`url` 为其他地址时,它是签名所用的区域,须配合 `auth=aws-keys`。 | +| `key` | API 密钥,按原样保存(Bedrock 为 Bedrock API 密钥)。配合 `auth=key`。Ollama 没有密钥栏位。 | +| `oauth_endpoint` | Token 端点,规则与 `url` 相同,通常带路径。`auth=oauth` 时必须提供。主机与接口地址不同时,应用会同时列出两个主机。 | +| `oauth_refresh` | Refresh Token。`auth=oauth` 时必须提供。 | +| `oauth_client_id` | Client ID,配合 `auth=oauth`,可选。 | +| `oauth_client_secret` | Client Secret,配合 `auth=oauth`,可选。 | +| `aws_access_key_id` | 访问密钥 ID,配合 `service=bedrock` 与 `auth=aws-keys`,此时必须提供。 | +| `aws_secret_access_key` | 私有访问密钥。`auth=aws-keys` 时必须提供。 | +| `aws_session_token` | 临时凭证的会话令牌,配合 `auth=aws-keys`,可选。 | + +`url` 与 `oauth_endpoint` 指向本机或内网时也接受 `http://`,例如部署在公司内网的企业网关。主机须为 `localhost`、`127.0.0.1` 或 `[::1]`;`10.0.0.0/8`、`172.16.0.0/12`、`192.168.0.0/16`、`100.64.0.0/10`、`169.254.0.0/16` 内的 IPv4 地址;`fc00::/7`、`fe80::/10` 内的 IPv6 地址;不含点的主机名;或以 `.local`、`.lan`、`.internal`、`.intranet`、`.corp`、`.home.arpa`、`.localhost` 结尾的主机名。其他 `http://` 地址一律拒绝。本机以外的 `http://` 地址,对话框与网页会在主机下方写明密钥和请求内容以明文传输。 + +### 高级设置 + +| 参数 | 取值 | +|---|---| +| `headers` | 额外的请求头,每行一个 `名称: 值`,编码方式见[编码](#编码)。`auth=account` 时不接受。 | +| `client_identity` | `on` 表示开启「转发客户端身份」,用于只接受特定客户端的中转站。仅用于 Sub2API、New API 与自定义上游。 | +| `concurrency` | 并发上限:1 到 1000 之间的整数,不带正负号,不以 0 开头。`auth=account` 时不接受。 | +| `balance` | 余额的读取来源:`off`、`openrouter`、`deepseek`、`moonshot`、`sub2api`、`newapi` 或 `thinkwatch`。省略时自动识别。仅用于自定义上游,其他服务类型自行决定来源。 | + +### 模型 + +| 参数 | 取值 | +|---|---| +| `models` | 以逗号分隔的模型 ID,作为手动添加的模型写入上游。应用把它们与服务自己列出的模型一起使用,只列出部分模型的服务可以在这里写上其余的模型;服务不提供模型列表时,它们就是全部模型。重复的 ID 只保留一个。 | +| `models_only` | 以逗号分隔的模型 ID 或含 `*`、`?` 的通配规则。启用范围设为「指定模型」,只包含这些模型。 | +| `specs` | 模型规格,各项以 `;` 分隔,每项为 `model\|context\|max_output\|reasoning\|image`。上游创建之后,为最终模型列表中的模型保存。既不在 `models` 中、也不在上游模型列表中的模型,其规格不会保存,「模型」一步会写明。 | + +### 计费 + +| 参数 | 取值 | +|---|---| +| `billing` | `per-token`(按量计费)或 `free`(不计费)。省略时使用服务类型的默认值:Ollama 不计费,其他按量计费。 | +| `price_multiplier` | 新价目表的倍率,大于 0、不超过 100,例如 `0.8`。不计费时不接受。 | +| `prices` | 新价目表中的模型价格覆盖,各项以 `;` 分隔,每项为 `model\|input\|output\|cache_read\|cache_write_5m\|cache_write_1h`。不计费时不接受。 | + +链接含有 `price_multiplier` 或 `prices` 时,「计费」一步预先选中一张与上游同名的新价目表,倍率取自链接(省略时为 1),模型覆盖取自 `prices`。这张价目表可以像其他新建的价目表一样修改,在选择「创建」时与上游一同创建。同名的价目表已存在时,对话框会要求改用其他名称,与上游名称被占用时的处理相同。 + +## 按服务类型 + +| 服务类型 | `service` | `auth` | 地址 | 接口协议 | +|---|---|---|---|---| +| Anthropic | `anthropic` | `key` | `https://api.anthropic.com` | `anthropic` | +| OpenAI | `openai` | `key`、`account`(ChatGPT 账号) | `https://api.openai.com` | `openai-chat`(默认)或 `openai-responses` | +| Google Gemini | `gemini` | `key` | `https://generativelanguage.googleapis.com` | `gemini` | +| DeepSeek | `deepseek` | `key` | `https://api.deepseek.com/anthropic` | `anthropic` | +| Z.ai / BigModel | `zai` | `key`、`account` | 按 `site` 为 `https://open.bigmodel.cn/api/anthropic` 或 `https://api.z.ai/api/anthropic` | `anthropic` | +| Amazon Bedrock | `bedrock` | `key`(Bedrock API 密钥)、`aws-keys` | `region` 的标准地址,或 `url` 中的端点 | 链接不能设置 | +| OpenRouter | `openrouter` | `key` | `https://openrouter.ai/api` | `openai-chat` | +| ThinkWatch 企业版 | `thinkwatch` | `key` | `url`,必须提供 | 链接不能设置;企业网关接受各客户端自己的格式 | +| Sub2API | `sub2api` | `key` | `url`,必须提供 | 任一协议,或自动识别 | +| New API / One API | `newapi` | `key` | `url`,必须提供 | 任一协议,或自动识别 | +| Ollama | `ollama` | `key`,没有密钥栏位 | `http://127.0.0.1:11434`,或 `url` 中的其他地址 | `openai-chat` | +| 自定义 | `custom` | `key`、`oauth` | `url`,必须提供 | 任一协议,或自动识别 | + +`headers` 与 `concurrency` 适用于账号登录以外的所有服务类型;`client_identity` 适用于 Sub2API、New API 与自定义上游;`balance` 只适用于自定义上游。「模型」与「计费」两组参数适用于所有服务类型,包括账号登录。 + +`auth=account` 的链接不含任何凭据,登录在应用中进行。这样的链接只接受 `service`、`auth`、`site`、`name` 以及「模型」与「计费」两组参数。 + +## 编码 + +- 每个值都要经过百分号编码,例如使用 `encodeURIComponent`,应用只解码一次。未编码的 `+` 表示空格,因此含 `+` 的密钥应写作 `%2B`。 +- 每个参数最多出现一次,值为空的参数一律拒绝。 +- 任何值都不得包含 `$`、`{`、`}`、控制字符或不可见字符。应用会从用户的环境变量中读取 `${NAME}`,这些字符可能让应用把本机的机密发往链接所指的主机。 +- 任一值不符合规则时,整条链接作废。应用只记录原因,不记录内容。 + +列表与条目: + +| 参数 | 编码 | 示例值(百分号编码之前) | |---|---|---| -| `url` | 是 | 服务的 Base URL,不含 `/chat/completions`、`/messages` 等接口路径。只接受 `https://`;`http://` 仅限 `localhost`、`127.0.0.1` 与 `[::1]`。不得包含用户名或密码、查询串或片段。 | -| `name` | 否 | 上游在应用中的名称。省略时由应用按地址生成。 | -| `protocol` | 否 | `anthropic`、`openai-chat`、`openai-responses` 或 `gemini`。省略时由应用按地址识别。 | -| `key` | 否 | API 密钥,按原样保存。只能包含字母、数字与 `- _ . ~ + / = :`。 | -| `models` | 否 | 以逗号分隔的模型 ID,作为手动添加的模型写入上游。应用把它们与服务自己列出的模型一起使用,只列出部分模型的服务可以在这里写上其余的模型;服务不提供模型列表时,它们就是全部模型。 | +| `models` | 以 `,` 分隔的模型 ID;只含字母、数字与 `- _ . : / @ +` | `glm-5,glm-5-air` | +| `models_only` | 同 `models`,通配规则另可使用 `*` 与 `?` | `claude-*,gpt-5` | +| `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` | -编码规则: +`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-` 开头的名称。 -- 每个值都要经过百分号编码,例如使用 `encodeURIComponent`。未编码的 `+` 表示空格,因此含 `+` 的密钥应写作 `%2B`。 -- 每个参数最多出现一次。 -- 链接中出现其他参数、空值,或任一值不符合规则时,整条链接作废。应用会忽略这样的链接,不打开窗口。 +`specs` 中,`context` 与 `max_output` 为 1 到 10,000,000 之间的 token 数,`reasoning` 与 `image` 为 `y` 或 `n`。四项都可以留空,但不能全部留空。 + +`prices` 中,各单价以美元 / 百万 tokens 计,整数部分与小数部分各最多 6 位。`input` 与 `output` 必须填写;缓存单价留空或省略时,等于输入单价。 长度与字符限制: | 项目 | 限制 | |---|---| | 整条链接 | 8192 个字符 | -| `name` | 64 个字符;首尾无空白;不含 `/`、`\`、`$`、`{`、`}`、`<`、`>`、`"` 与反引号;不以 `__` 开头;不含控制字符或不可见字符 | -| `url` | 2048 个字符 | -| `key` | 512 个字符 | -| `models` | 最多 64 项,每项最多 128 个字符;只含字母、数字与 `- _ . : / @ +` | +| `name` | 64 个字符;首尾无空白;不含 `/`、`\`、`$`、`{`、`}`、`<`、`>`、`"` 与反引号;不能是 `.` 或 `..`;不以 `__` 开头 | +| `url`、`oauth_endpoint` | 2048 个字符 | +| `key`、`oauth_client_id`、`oauth_client_secret`、`aws_secret_access_key` | 512 个字符;只含字母、数字与 `- _ . ~ + / = :` | +| `oauth_refresh`、`aws_session_token` | 4096 个字符;字符范围与 `key` 相同 | +| `aws_access_key_id` | 16 到 128 个大写字母与数字 | +| `region` | 32 个字符;只含小写字母、数字与 `-` | +| `headers` | 最多 16 行;名称最长 64 个字符,值最长 1024 个字符 | +| `models`、`models_only`、`specs`、`prices` | 各最多 64 项;模型 ID 最长 128 个字符;`specs` 与 `prices` 中同一模型只能出现一次 | ## 应用如何处理链接 -1. **校验链接。**上述规则全部在应用中执行,与生成链接的页面做过哪些校验无关。 -2. **显示确认对话框。**对话框写明接收请求内容与 API 密钥的主机,以 ASCII 显示:国际化域名显示为 punycode(`xn--…`),外形相近的域名无法冒充熟悉的域名。对话框同时显示 Base URL、接口协议、密钥(默认隐藏,可切换显示)与手动添加的模型,其中只有名称可以修改。所有值都按纯文本显示。 -3. **确认之前不保存任何内容。**选择「创建」之前,应用不写入配置,也不向该地址发出任何网络请求:不检测连接,不获取模型列表。 -4. **只创建一个新的上游。**导入不会修改、替换或删除已有的上游。名称已被使用时须改用其他名称,不提供覆盖选项。新上游不会被设为默认,也不会加入任何路由。创建之后,它与手动添加的上游相同,包括获取模型列表。 -5. **一次只处理一条链接。**对话框打开期间,以及关闭后的几秒内到达的链接会被忽略,也不会把窗口调到前台。 +1. **校验链接。**本页的规则全部在应用中执行,与生成链接的页面做过哪些校验无关。 +2. **打开预填好的「新建上游」对话框。**对话框直接停在第二步「连接」,账号登录时为「账号」。步骤条下方的一行说明写明接收请求与密钥的主机,Token 端点的主机不同时一并写明。主机以 ASCII 显示:国际化域名显示为 punycode(`xn--…`),外形相近的域名无法冒充熟悉的域名。所有值都可以修改;退回第一步改选其他服务类型时,新服务类型不接受的值会被舍弃。 +3. **用户操作之前不写入、不发送。**在第二步选择「下一步」(此时检测连接)或开始登录之前,应用不写入配置,也不向外发出任何请求。 +4. **只创建一个新的上游。**导入不会修改、替换或删除已有的上游,不会把新上游设为默认,也不会把它加入任何路由。名称已被使用时,对话框会要求改用其他名称。同一地址上使用不同密钥的多个上游属于正常用法。 +5. **一次只处理一条链接。**对话框打开期间到达的链接会被忽略。已有「新建上游」或「编辑上游」对话框打开时,到达的链接会被舍弃,正在填写的表单不会被替换。 + +## 较新版本的链接 + +已安装的应用不认识某个参数时,不再静默忽略:应用显示「无法导入这条链接」,写明该参数;有可用更新时,提供安装更新的按钮。网页形式的处理相同:列出能识别的设置,说明需要更新版本的 ThinkWatch Lite,并仍然提供打开链接的按钮。参数名只在由最多 32 个小写字母、数字与下划线组成时才会显示。 + +## 链接不能设置的内容 + +任何网页都能打开链接,因此链接只包含用户在对话框中能够判断的内容。链接试图设置以下任一项时,整条链接作废。 + +| 不接受 | 原因 | +|---|---| +| 出站代理,以及代理不可用时的处理方式 | 链接的提供方不了解用户的网络环境,这两项由用户在对话框中选择。 | +| AWS profile 与环境变量引用(`${NAME}`) | 它们会让应用读取本机的凭据或环境变量,再发往链接所指的主机。一条 `key=${OPENAI_API_KEY}` 的链接会把用户自己的密钥发送给中转站。 | +| 模型别名、路由、默认上游与路由权重 | 它们会改变已配置客户端的流量去向。 | +| 停用开关,以及仅用于检测的 Access Token | 新上游创建后即为启用状态,Access Token 由 Refresh Token 换取。 | +| 账号登录的凭据 | 登录在应用中与服务直接进行。 | +| 鉴权、Cookie 与连接相关的请求头 | 请求头不得取代密钥,也不得改变网关发出的身份。 | +| 指向公网主机的 `http://` | 密钥将以明文经过互联网传输。本机与内网地址接受 `http://`,并标明不加密。 | +| MCP 服务与客户端配置 | 它们会执行命令或修改其他应用的设置。 | +| 图标等展示内容 | 连接不需要它们,且可被用于仿冒熟悉的服务。 | + +这些设置可以在上游创建之后在应用中修改。 ## 示例 -兼容 Anthropic 接口的中转站: +需要两个请求头、并发上限为 4、只列出部分模型、按公开价格的 0.8 倍计费并覆盖一个模型价格的中转站(省略 `service`:该地址识别为自定义上游): ```text -thinkwatch://import?name=example-relay&url=https%3A%2F%2Fapi.relay.example&protocol=anthropic&key=sk-relay-EXAMPLE +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 ``` +同一条链接的网页形式: + ```text -https://thinkwat.ch/import#name=example-relay&url=https%3A%2F%2Fapi.relay.example&protocol=anthropic&key=sk-relay-EXAMPLE +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 ``` -兼容 OpenAI 接口、不提供模型列表的中转站: +使用访问密钥的 Amazon Bedrock,区域为 `eu-west-1`: ```text -thinkwatch://import?name=example-openai&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-relay-EXAMPLE&models=gpt-5%2Cgpt-5-mini +thinkwatch://import?service=bedrock&auth=aws-keys®ion=eu-west-1&aws_access_key_id=AKIAIOSFODNN7EXAMPLE&aws_secret_access_key=wJalrXUtnFEMI%2FK7MDENG%2FbPxRfiCYEXAMPLEKEY ``` +ThinkWatch 企业网关。ThinkWatch 企业版控制台中的「添加到 ThinkWatch Lite」按钮会用网关地址与新密钥写出这样的链接: + ```text -https://thinkwat.ch/import#name=example-openai&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-relay-EXAMPLE&models=gpt-5%2Cgpt-5-mini +thinkwatch://import?service=thinkwatch&name=thinkwatch&url=https%3A%2F%2Fai.example.com&key=tw-EXAMPLE ``` -## 链接不能设置的内容 +ChatGPT 账号。链接打开登录界面,不含任何凭据: -任何网页都能打开链接,因此链接只包含用户在一个对话框中能够判断的内容。以下内容刻意不支持: +```text +thinkwatch://import?service=openai&auth=account&name=chatgpt +``` -| 不支持 | 原因 | -|---|---| -| 密钥中的环境变量引用(`${NAME}`) | 应用会从用户的环境变量中读取 `${NAME}`。一条 `key=${OPENAI_API_KEY}` 的链接会把用户自己的密钥发送到中转站的主机。含 `$`、`{` 或 `}` 的密钥一律拒绝。 | -| 请求头 | 请求头的值可以引用环境变量与凭据,并可改变请求的鉴权方式,这些无法在对话框中清楚呈现。 | -| 出站代理 | 链接不得让流量经由第三方转发。 | -| 价目表与计费方式 | 费用如何计算由用户决定。 | -| 路由规则与默认上游 | 链接不得改变已配置客户端的流量去向。 | -| MCP 服务与客户端配置 | 它们会执行命令或修改其他应用的设置。 | -| 图标等展示内容 | 连接不需要它们,且可被用于仿冒熟悉的服务。 | -| 指向其他主机的 `http://` | 密钥将以明文传输。 | -| ChatGPT 账号登录与 Amazon Bedrock | 需要交互式登录或 AWS 凭据,无法通过链接携带。 | +为更早版本编写、仍然有效的链接: -这些设置可以在上游创建之后在应用中修改。 +```text +thinkwatch://import?name=example-relay&url=https%3A%2F%2Fapi.relay.example&protocol=anthropic&key=sk-relay-EXAMPLE +``` + +即使识别出的服务类型不接受,为更早版本编写的链接仍然有效:例如官方地址带有其他路径(`https://api.openai.com/v1`)、Ollama 地址带有 `key`,或 `https://api.deepseek.com` 配合 `protocol=openai-chat`。这样的链接与以前一样创建为自定义上游。 + +## 在 New API 站点上 + +New API 或 One API 站点不用改代码,就能为每一把密钥提供 ThinkWatch Lite。在站点的系统设置中添加一个聊天预设,名称为 ThinkWatch Lite,地址为: + +```text +thinkwatch://import?service=newapi&url={address}&key={key} +``` + +站点会填入自己的服务器地址和这一行的密钥,每把密钥的聊天菜单中随之出现这一项。打开它需要 ThinkWatch Lite 2026.10.16 或更新版本;版本更早或没有安装应用时,点击没有反应。 ## 链接中的密钥 -含有密钥的链接本身就是凭据。应为每位客户单独生成链接,并通过私密渠道提供,例如客户自己的控制台。不含 `key` 的链接会创建一个没有密钥的上游,之后在应用中编辑该上游补充密钥。 +含有密钥、Refresh Token 或私有访问密钥的链接本身就是凭据。应为每位客户单独生成链接,并通过私密渠道提供,例如客户自己的控制台。不含密钥的链接会让密钥栏位留空,由用户在对话框中填写。 diff --git a/src/content/docs/_meta.ts b/src/content/docs/_meta.ts index a1f23d7..77a8733 100644 --- a/src/content/docs/_meta.ts +++ b/src/content/docs/_meta.ts @@ -318,8 +318,8 @@ export const products: Product[] = [ locales: both, group: "reference", summary: { - en: "For relays and vendors: links that pre-fill a new upstream in the app, their parameters, what the app checks, and a link builder.", - "zh-CN": "面向中转站与服务商:在应用中预填新上游的链接、参数、应用所做的校验,以及链接生成器。", + en: "For relays, vendors and ThinkWatch Enterprise: links that open the New upstream dialog filled in, the parameters each service takes, what the app checks, and a link builder.", + "zh-CN": "面向中转站、服务商与 ThinkWatch 企业版:打开预填好的「新建上游」对话框的链接、各服务类型接受的参数、应用所做的校验,以及链接生成器。", }, }, { diff --git a/src/content/docs/en/api-reference.md b/src/content/docs/en/api-reference.md index 01a7674..9083fb6 100644 --- a/src/content/docs/en/api-reference.md +++ b/src/content/docs/en/api-reference.md @@ -162,7 +162,7 @@ curl -X POST http://localhost:3000/v1/chat/completions \ ### GET /v1/models -List all models available through the gateway. +List the models available through the gateway. With an API key, the list holds only the models that key may use. **Authentication:** API Key or JWT diff --git a/src/content/docs/en/deployment-guide.md b/src/content/docs/en/deployment-guide.md index 644e1ee..416238f 100644 --- a/src/content/docs/en/deployment-guide.md +++ b/src/content/docs/en/deployment-guide.md @@ -700,7 +700,7 @@ These tasks run within the server process and do not require external schedulers ## 8. Client Configuration Guide -ThinkWatch includes a built-in **Configuration Guide** page in the web console at `/gateway/guide`. This page provides copy-paste setup instructions for connecting various AI client tools to your gateway and auto-detects the gateway URL. +ThinkWatch includes a built-in **Configuration Guide** page in the web console at `/guide`. This page provides copy-paste setup instructions for connecting various AI client tools to your gateway and auto-detects the gateway URL. ### Supported Client Tools @@ -713,6 +713,13 @@ The Configuration Guide provides ready-to-use instructions for: - **OpenAI SDK** -- set `OPENAI_BASE_URL` and `OPENAI_API_KEY` environment variables (uses `/v1/chat/completions` or `/v1/responses`) - **Anthropic SDK** -- set `ANTHROPIC_BASE_URL` and `ANTHROPIC_API_KEY` environment variables (uses `/v1/messages`) - **cURL** -- example commands for all three API formats +- **ThinkWatch Lite** -- the **Add to ThinkWatch Lite** button adds the gateway to the desktop app (see below) + +### Adding the Gateway to ThinkWatch Lite + +The **Add to ThinkWatch Lite** button opens the New upstream dialog of [ThinkWatch Lite](/lite) with the gateway's address and the key already filled in, so a user connects the desktop app in one step. It appears after a key is created or rotated, at the end of the first-run setup, and on the ThinkWatch Lite tab of the Configuration Guide. + +The button needs ThinkWatch Lite 2026.10.16 or later. The address comes from the **Public Gateway URL** setting, and the link format is described in [Import links](/docs/lite/import-links). A gateway served over plain `http://` on the company network works too; ThinkWatch Lite states that the key travels unencrypted. ### Gateway API Endpoints @@ -723,7 +730,7 @@ The gateway (port 3000) serves three API formats on a single port: | `POST /v1/chat/completions` | OpenAI Chat Completions | Cursor, Continue, Cline, OpenAI SDK | | `POST /v1/messages` | Anthropic Messages API | Claude Code, Anthropic SDK | | `POST /v1/responses` | OpenAI Responses API | OpenAI SDK (2025 format) | -| `GET /v1/models` | OpenAI Models list | All clients | +| `GET /v1/models` | Models the calling key may use | All clients | | `GET /v1/usage` | Usage and limits of the calling key | Any client with a gateway API key | All endpoints accept `tw-` API keys via the `Authorization: Bearer` header. diff --git a/src/content/docs/en/secret-rotation.md b/src/content/docs/en/secret-rotation.md index adb7bb2..3e29ee7 100644 --- a/src/content/docs/en/secret-rotation.md +++ b/src/content/docs/en/secret-rotation.md @@ -101,7 +101,7 @@ curl -X POST /api/keys/{key_id}/rotate \ 1. Navigate to **AI Gateway → API Keys** 2. Click the **Rotate** button on the key row 3. Confirm the rotation in the dialog -4. Copy the new key immediately (shown only once) +4. Copy the new key immediately (shown only once), or choose **Add to ThinkWatch Lite** to put it into the desktop app 5. Update your applications with the new key 6. The old key remains active for the configured grace period (default: 24 hours) diff --git a/src/content/docs/zh-CN/api-reference.md b/src/content/docs/zh-CN/api-reference.md index dddfb3d..1368980 100644 --- a/src/content/docs/zh-CN/api-reference.md +++ b/src/content/docs/zh-CN/api-reference.md @@ -162,7 +162,7 @@ curl -X POST http://localhost:3000/v1/chat/completions \ ### GET /v1/models -列出通过网关可用的所有模型。 +列出通过网关可用的模型。使用 API 密钥调用时,只列出该密钥可以使用的模型。 **认证:** API Key 或 JWT diff --git a/src/content/docs/zh-CN/deployment-guide.md b/src/content/docs/zh-CN/deployment-guide.md index e4ddd8c..66a7188 100644 --- a/src/content/docs/zh-CN/deployment-guide.md +++ b/src/content/docs/zh-CN/deployment-guide.md @@ -700,7 +700,7 @@ ThinkWatch 运行多个自动执行的后台任务: ## 8. 客户端配置指南 -ThinkWatch 在 Web 控制台中内置了**配置指南**页面,位于 `/gateway/guide`。该页面提供了将各种 AI 客户端工具连接到网关的一键复制配置说明,并自动检测网关 URL。 +ThinkWatch 在 Web 控制台中内置了**配置指南**页面,位于 `/guide`。该页面提供了将各种 AI 客户端工具连接到网关的一键复制配置说明,并自动检测网关 URL。 ### 支持的客户端工具 @@ -713,6 +713,13 @@ ThinkWatch 在 Web 控制台中内置了**配置指南**页面,位于 `/gatewa - **OpenAI SDK** -- 设置 `OPENAI_BASE_URL` 和 `OPENAI_API_KEY` 环境变量(使用 `/v1/chat/completions` 或 `/v1/responses`) - **Anthropic SDK** -- 设置 `ANTHROPIC_BASE_URL` 和 `ANTHROPIC_API_KEY` 环境变量(使用 `/v1/messages`) - **cURL** -- 三种 API 格式的示例命令 +- **ThinkWatch Lite** -- 「添加到 ThinkWatch Lite」按钮把网关添加到桌面应用(见下文) + +### 把网关添加到 ThinkWatch Lite + +「添加到 ThinkWatch Lite」按钮打开 [ThinkWatch Lite](/zh-CN/lite) 的「新建上游」对话框,网关地址与密钥都已填好,用户一步即可接入桌面应用。这个按钮出现在创建或轮换密钥之后、首次运行设置的最后一步,以及配置指南的 ThinkWatch Lite 标签页。 + +该按钮需要 ThinkWatch Lite 2026.10.16 或更高版本。地址取自「网关公开地址」设置,链接格式见[导入链接](/zh-CN/docs/lite/import-links)。部署在公司内网、使用 `http://` 的网关同样可用,ThinkWatch Lite 会写明密钥以明文传输。 ### 网关 API 端点 @@ -723,7 +730,7 @@ ThinkWatch 在 Web 控制台中内置了**配置指南**页面,位于 `/gatewa | `POST /v1/chat/completions` | OpenAI Chat Completions | Cursor、Continue、Cline、OpenAI SDK | | `POST /v1/messages` | Anthropic Messages API | Claude Code、Anthropic SDK | | `POST /v1/responses` | OpenAI Responses API | OpenAI SDK(2025 格式) | -| `GET /v1/models` | OpenAI Models 列表 | 所有客户端 | +| `GET /v1/models` | 调用方密钥可用的模型 | 所有客户端 | | `GET /v1/usage` | 调用方密钥的用量与限额 | 持有网关 API 密钥的任何客户端 | 所有端点通过 `Authorization: Bearer` 请求头接受 `tw-` API 密钥。 diff --git a/src/content/docs/zh-CN/secret-rotation.md b/src/content/docs/zh-CN/secret-rotation.md index 086d8fa..cb9e304 100644 --- a/src/content/docs/zh-CN/secret-rotation.md +++ b/src/content/docs/zh-CN/secret-rotation.md @@ -103,7 +103,7 @@ curl -X POST /api/keys/{key_id}/rotate \ 1. 进入 **AI Gateway → API Keys** 2. 在目标密钥行点击 **Rotate** 按钮 3. 在弹窗中确认轮换 -4. 立即复制新密钥(仅显示一次) +4. 立即复制新密钥(仅显示一次),或点击「添加到 ThinkWatch Lite」将其添加到桌面应用 5. 用新密钥更新你的应用 6. 旧密钥在配置的宽限期内继续有效(默认:24 小时) diff --git a/src/lib/import-link-text.ts b/src/lib/import-link-text.ts new file mode 100644 index 0000000..ebce733 --- /dev/null +++ b/src/lib/import-link-text.ts @@ -0,0 +1,175 @@ +// 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"; + +export function importLinkText(zh: boolean) { + return zh + ? { + groups: { + service: "服务类型", + connection: "连接", + account: "账号", + advanced: "高级设置", + models: "模型", + billing: "计费", + }, + labels: { + service: "服务类型", + auth: "认证方式", + site: "站点", + name: "名称", + url: "接口地址", + protocol: "接口协议", + region: "区域", + key: "API 密钥", + oauthEndpoint: "Token 端点", + oauthRefresh: "Refresh Token", + oauthClientId: "Client ID", + oauthClientSecret: "Client Secret", + awsKeyId: "访问密钥 ID", + awsSecret: "私有访问密钥", + awsToken: "会话令牌", + headers: "请求头", + clientIdentity: "转发客户端身份", + concurrency: "并发上限", + balance: "余额来源", + models: "手动添加的模型", + modelsOnly: "启用范围", + specs: "模型规格", + billing: "计费方式", + priceSheet: "价目表", + priceMultiplier: "价目表倍率", + prices: "模型单价", + }, + services: { + anthropic: "Anthropic", + openai: "OpenAI", + gemini: "Google Gemini", + deepseek: "DeepSeek", + zai: "Z.ai / BigModel", + bedrock: "Amazon Bedrock", + openrouter: "OpenRouter", + thinkwatch: "ThinkWatch 企业网关", + sub2api: "Sub2API", + newapi: "New API / One API", + ollama: "Ollama", + custom: "自定义", + }, + auths: { key: "API 密钥", oauth: "OAuth", "aws-keys": "访问密钥", account: "账号登录", chatgpt: "ChatGPT 账号" }, + sites: { bigmodel: "BigModel(中国大陆)", zai: "Z.ai(国际)" }, + balances: { + off: "不读取", + openrouter: "OpenRouter", + deepseek: "DeepSeek", + moonshot: "Moonshot", + sub2api: "Sub2API", + newapi: "New API", + thinkwatch: "ThinkWatch", + }, + billings: { "per-token": "按量计费", free: "不计费" }, + needsVersion: `需要 ThinkWatch Lite ${V2_SINCE} 或更高版本。`, + param: "参数「{name}」", + someParam: "某个参数", + problems: { + empty: "链接中没有参数。", + tooLong: "链接过长。", + malformed: "链接的编码不正确。", + duplicateParam: "链接中{p}出现了多次。", + emptyValue: "链接中{p}的值为空。", + neverAccepted: "链接中的{p}属于导入链接不能设置的内容。", + invalid: "{p}的值不符合要求。", + notApplicable: "{p}不适用于此服务类型或认证方式。", + mismatch: "{p}的值与 ThinkWatch Lite 为此服务填写的值不一致。", + missing: "此服务类型或认证方式须提供{p}。", + } satisfies Record, + } + : { + groups: { + service: "Service", + connection: "Connection", + account: "Account", + advanced: "Advanced settings", + models: "Models", + billing: "Billing", + }, + labels: { + service: "Service", + auth: "Authentication", + site: "Site", + name: "Name", + url: "Base URL", + protocol: "Protocol", + region: "Region", + key: "API key", + oauthEndpoint: "Token endpoint", + oauthRefresh: "Refresh token", + oauthClientId: "Client ID", + oauthClientSecret: "Client secret", + awsKeyId: "Access key ID", + awsSecret: "Secret access key", + awsToken: "Session token", + headers: "Headers", + clientIdentity: "Forward client identity", + concurrency: "Concurrency limit", + balance: "Balance source", + models: "Models added by hand", + modelsOnly: "Enabled models", + specs: "Model specs", + billing: "Billing", + priceSheet: "Price sheet", + priceMultiplier: "Price multiplier", + prices: "Model prices", + }, + services: { + anthropic: "Anthropic", + openai: "OpenAI", + gemini: "Google Gemini", + deepseek: "DeepSeek", + zai: "Z.ai / BigModel", + bedrock: "Amazon Bedrock", + openrouter: "OpenRouter", + thinkwatch: "ThinkWatch enterprise gateway", + sub2api: "Sub2API", + newapi: "New API / One API", + ollama: "Ollama", + custom: "Custom", + }, + auths: { + key: "API key", + oauth: "OAuth", + "aws-keys": "Access keys", + account: "Account sign-in", + chatgpt: "ChatGPT account", + }, + sites: { bigmodel: "BigModel (mainland China)", zai: "Z.ai (international)" }, + balances: { + off: "Not read", + openrouter: "OpenRouter", + deepseek: "DeepSeek", + moonshot: "Moonshot", + sub2api: "Sub2API", + newapi: "New API", + thinkwatch: "ThinkWatch", + }, + billings: { "per-token": "Per token", free: "Free" }, + needsVersion: `Needs ThinkWatch Lite ${V2_SINCE} or later.`, + param: "the parameter “{name}”", + someParam: "a parameter", + problems: { + empty: "The link has no parameters.", + tooLong: "The link is too long.", + malformed: "The link is not encoded correctly.", + duplicateParam: "The link contains {p} more than once.", + emptyValue: "The link gives {p} an empty value.", + neverAccepted: "The link contains {p}, which sets something an import link can never set.", + invalid: "The value of {p} is not accepted.", + notApplicable: "The link contains {p}, which does not apply to this service or authentication method.", + mismatch: "The value of {p} differs from what ThinkWatch Lite fills in for this service.", + missing: "This service or authentication method needs {p}.", + } satisfies Record, + }; +} + +export type ImportLinkText = ReturnType; diff --git a/src/lib/import-link.ts b/src/lib/import-link.ts index 31087b9..451911e 100644 --- a/src/lib/import-link.ts +++ b/src/lib/import-link.ts @@ -1,61 +1,359 @@ // Import links for ThinkWatch Lite: `thinkwatch://import?…` and its web form -// `https://thinkwat.ch/import#…`. A relay hands one to its users to pre-fill a -// new upstream in the app. +// `https://thinkwat.ch/import#…`. A relay, a vendor or a ThinkWatch Enterprise +// console hands one out to open the New upstream dialog in the app, prefilled. // -// The rules mirror the app's parser (src-tauri/src/import_link.rs in the Lite -// repository). The app validates every link again and is the authority; this -// copy lets the web page and the link builder reject a bad link before handing -// it on, instead of passing along something the app would drop. +// Version 2 of the link can set what the New upstream dialog lets a user set, +// for each service type, and nothing the dialog does not show for that service +// and authentication method. The rules mirror the app's parser +// (src-tauri/src/import_link.rs) and its presets (src/upstreams/presets.ts and +// upstreamForm.ts) in the Lite repository. The app validates every link again +// and is the authority; this copy lets the web page and the link builder reject +// a bad link before handing it on. // // Every value is treated as hostile: any web page can produce a link. -export const PARAMS = ["name", "url", "protocol", "key", "models"] as const; -export type Param = (typeof PARAMS)[number]; +export const SERVICES = [ + "anthropic", + "openai", + "gemini", + "deepseek", + "zai", + "bedrock", + "openrouter", + "thinkwatch", + "sub2api", + "newapi", + "ollama", + "custom", +] as const; +export type Service = (typeof SERVICES)[number]; + +export const AUTHS = ["key", "oauth", "account", "aws-keys"] as const; +export type Auth = (typeof AUTHS)[number]; + +export const SITES = ["bigmodel", "zai"] as const; +export type Site = (typeof SITES)[number]; export const PROTOCOLS = ["anthropic", "openai-chat", "openai-responses", "gemini"] as const; export type Protocol = (typeof PROTOCOLS)[number]; +export const BALANCES = ["off", "openrouter", "deepseek", "moonshot", "sub2api", "newapi", "thinkwatch"] as const; +export type Balance = (typeof BALANCES)[number]; + +export const BILLINGS = ["per-token", "free"] as const; +export type Billing = (typeof BILLINGS)[number]; + +/** Every parameter, in the order a link is written. */ +export const PARAMS = [ + "service", + "auth", + "site", + "name", + "url", + "protocol", + "key", + "oauth_endpoint", + "oauth_refresh", + "oauth_client_id", + "oauth_client_secret", + "region", + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + "headers", + "client_identity", + "concurrency", + "balance", + "models", + "models_only", + "specs", + "billing", + "price_multiplier", + "prices", +] as const; +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. */ +export const V2_SINCE = "2026.10.16"; + +/** + * 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 + * disabled switch and the OAuth access token used only for checking. A link naming + * one is rejected, not taken for a parameter of a newer version. + */ +export const NEVER = [ + "proxy", + "on_proxy_fail", + "aws_profile", + "alias", + "aliases", + "route", + "routes", + "default", + "weight", + "disabled", + "oauth_access", +] as const; + export const MAX_LINK = 8192; export const MAX_NAME = 64; export const MAX_URL = 2048; export const MAX_KEY = 512; +export const MAX_LONG_SECRET = 4096; export const MAX_MODEL = 128; export const MAX_MODELS = 64; +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_SPEC_TOKENS = 10_000_000; -/** A validated import: what the app is asked to create. */ +const APP_PREFIX = "thinkwatch://import?"; +const LF = String.fromCharCode(10); + +/** Z.ai / BigModel: the site decides the address. */ +export const ZAI_ADDRESS: Record = { + zai: "https://api.z.ai/api/anthropic", + bigmodel: "https://open.bigmodel.cn/api/anthropic", +}; + +const BEDROCK_HOST = /^bedrock-runtime(?:-fips)?\.([a-z0-9-]+)\.amazonaws\.com$/; +export const bedrockAddress = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com`; +export const DEFAULT_BEDROCK_REGION = "us-east-1"; + +/** + * What the New upstream dialog asks for each service (Lite: `PRESETS`, `fieldRules`). + * + * - `url`: `required` — the dialog has no address of its own; `own` — the service has + * one, and a `url` in the link must equal it; `any` — optional, any address + * (Bedrock: an endpoint such as a VPC endpoint; Ollama: another machine). + * - `protocol`: the choices the dialog offers, or the one it fills in by itself + * (a link may only repeat it), or null where no protocol value can be written. + */ +type ServiceRules = { + auth: readonly Auth[]; + url: "required" | "own" | "any"; + address?: string; + protocol: readonly Protocol[] | Protocol | null; + key: boolean; + clientIdentity: boolean; + billing: Billing; +}; + +const base = { auth: ["key"], key: true, clientIdentity: false, billing: "per-token" } as const; + +export const SERVICE_RULES: Record = { + anthropic: { ...base, url: "own", address: "https://api.anthropic.com", protocol: "anthropic" }, + openai: { + ...base, + auth: ["key", "account"], + url: "own", + address: "https://api.openai.com", + protocol: ["openai-chat", "openai-responses"], + }, + gemini: { ...base, url: "own", address: "https://generativelanguage.googleapis.com", protocol: "gemini" }, + deepseek: { ...base, url: "own", address: "https://api.deepseek.com/anthropic", protocol: "anthropic" }, + zai: { ...base, auth: ["key", "account"], url: "own", protocol: "anthropic" }, + bedrock: { ...base, auth: ["key", "aws-keys"], url: "any", protocol: null }, + openrouter: { ...base, url: "own", address: "https://openrouter.ai/api", protocol: "openai-chat" }, + thinkwatch: { ...base, url: "required", protocol: null }, + sub2api: { ...base, url: "required", protocol: PROTOCOLS, clientIdentity: true }, + newapi: { ...base, url: "required", protocol: PROTOCOLS, clientIdentity: true }, + ollama: { ...base, url: "any", address: "http://127.0.0.1:11434", protocol: "openai-chat", key: false, billing: "free" }, + custom: { ...base, auth: ["key", "oauth"], url: "required", protocol: PROTOCOLS, clientIdentity: true }, +}; + +/** Official hosts, recognized by host alone, the way the app's edit dialog recognizes an address. */ +const HOSTS: Record = { + "api.anthropic.com": "anthropic", + "api.openai.com": "openai", + "generativelanguage.googleapis.com": "gemini", + "api.deepseek.com": "deepseek", + "openrouter.ai": "openrouter", + "api.z.ai": "zai", + "open.bigmodel.cn": "zai", +}; + +/** The service of a link without `service`: from the address, falling back to custom. */ +export function serviceOfUrl(url: string): Service { + let u: URL; + try { + u = new URL(url); + } catch { + return "custom"; + } + const host = u.hostname.toLowerCase(); + const known = HOSTS[host]; + if (known) return known; + if (BEDROCK_HOST.test(host)) return "bedrock"; + if (u.port === "11434") return "ollama"; + return "custom"; +} + +/** The fields the New upstream dialog shows (or fills in) for a service and authentication method. */ +export function fieldsFor(service: Service, auth: Auth): Set { + const r = SERVICE_RULES[service]; + const f = new Set(["service", "auth", "name", "models", "models_only", "specs", "billing", "price_multiplier", "prices"]); + if (service === "zai") f.add("site"); + if (service === "bedrock") f.add("region"); + if (auth === "account") return f; + if (service === "custom") f.add("balance"); + f.add("url"); + f.add("headers"); + f.add("concurrency"); + if (r.protocol !== null) f.add("protocol"); + if (r.clientIdentity) f.add("client_identity"); + if (auth === "key" && r.key) f.add("key"); + if (auth === "oauth") { + for (const p of ["oauth_endpoint", "oauth_refresh", "oauth_client_id", "oauth_client_secret"] as const) f.add(p); + } + if (auth === "aws-keys") { + for (const p of ["aws_access_key_id", "aws_secret_access_key", "aws_session_token"] as const) f.add(p); + } + return f; +} + +export type Header = { name: string; value: string }; +export type Spec = { + model: string; + context?: number; + maxOutput?: number; + reasoning?: boolean; + image?: boolean; +}; +/** USD per million tokens, as written in the link. An omitted cache price is the input price. */ +export type Price = { + model: string; + input: string; + output: string; + cacheRead: string; + cacheWrite5m: string; + cacheWrite1h: string; +}; + +/** A validated import: what the New upstream dialog is prefilled with. */ export type ImportLink = { + service: Service; + /** Recognized from `url` because the link has no `service` */ + recognized: boolean; + auth: Auth; + /** Z.ai / BigModel only */ + site?: Site; name?: string; - /** Normalized: the host in ASCII (IDN as punycode), no trailing slash */ - baseUrl: string; - /** Where requests and the key are sent, in ASCII, with the port when there is one */ - host: string; + /** Where requests and the key go: the link's `url` or the service's own address. Absent for an account sign-in. */ + address?: string; + /** The receiving hosts, in ASCII, with the port when there is one: the address, then the token endpoint when it differs */ + hosts: string[]; + /** Those of `hosts` reached over plain http, other than this computer */ + plainHosts: string[]; protocol?: Protocol; + region?: string; key?: string; + oauth?: { endpoint: string; refresh: string; clientId?: string; clientSecret?: string }; + aws?: { accessKeyId: string; secretAccessKey: string; sessionToken?: string }; + headers: Header[]; + clientIdentity: boolean; + concurrency?: number; + balance?: Balance; models: string[]; + modelsOnly?: string[]; + specs: Spec[]; + /** As given; absent means the service's default */ + 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 checked parameters, normalized, in link order: what the app is handed */ + params: [Param, string][]; }; export type Problem = | "empty" | "tooLong" - | "unknownParam" + | "malformed" | "duplicateParam" | "emptyValue" - | "missingUrl" - | "name" - | "url" - | "protocol" - | "key" - | "models"; + | "neverAccepted" + | "invalid" + | "notApplicable" + | "mismatch" + | "missing"; + +/** + * `unknown`: parameters this copy of the rules does not know. A newer app may read + * them, so the link is not rejected for them; the page says a newer ThinkWatch Lite + * is needed and still offers to open it. + */ +export type Result = + | { ok: true; link: ImportLink; unknown: [string, string][] } + | { ok: false; problem: Problem; param?: string }; + +// Zero-width and direction-changing characters, and the replacement character: +// the same set as the app's `invisible`. +const INVISIBLE: [number, number][] = [ + [0x00ad, 0x00ad], + [0x034f, 0x034f], + [0x061c, 0x061c], + [0x115f, 0x1160], + [0x17b4, 0x17b5], + [0x180b, 0x180f], + [0x200b, 0x200f], + [0x202a, 0x202e], + [0x2060, 0x206f], + [0x3164, 0x3164], + [0xfe00, 0xfe0f], + [0xfeff, 0xfeff], + [0xffa0, 0xffa0], + [0xfff0, 0xffff], +]; -export type Result = { ok: true; link: ImportLink } | { ok: false; problem: Problem; param?: string }; +const isControl = (cp: number) => cp < 0x20 || (cp >= 0x7f && cp <= 0x9f); +const isInvisible = (cp: number) => INVISIBLE.some(([a, b]) => cp >= a && cp <= b); -/** Zero-width and direction-changing characters, and the replacement character. */ -const INVISIBLE = - /[­͏؜ᅟᅠ឴឵᠋-᠏​-‏‪-‮⁠-ㅤ︀-️ᅠ￰-￿]/; -// eslint-disable-next-line no-control-regex -const CONTROL = /[\u0000-\u001F\u007F-\u009F]/; +/** + * Control and invisible characters, and `$`, `{`, `}` (the app reads `${NAME}` from + * the environment). Line feeds are allowed only where a value is made of lines. + */ +function unsafe(s: string, lines = false): boolean { + for (const ch of s) { + const cp = ch.codePointAt(0)!; + if (lines && cp === 10) continue; + if (isControl(cp) || isInvisible(cp) || ch === "$" || ch === "{" || ch === "}") return true; + } + return false; +} -const unsafe = (s: string) => CONTROL.test(s) || INVISIBLE.test(s); +/** One layer of percent-decoding, `+` is a space. A broken escape or invalid UTF-8 is an error. */ +function decode(s: string): string | null { + const out: number[] = []; + const enc = new TextEncoder(); + for (let i = 0; i < s.length; ) { + const c = s[i]; + if (c === "%") { + const hex = s.slice(i + 1, i + 3); + if (!/^[0-9A-Fa-f]{2}$/.test(hex)) return null; + out.push(parseInt(hex, 16)); + i += 3; + } else if (c === "+") { + out.push(0x20); + i += 1; + } else { + const cp = s.codePointAt(i)!; + out.push(...enc.encode(String.fromCodePoint(cp))); + i += cp > 0xffff ? 2 : 1; + } + } + try { + return new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array(out)); + } catch { + return null; + } +} function checkName(n: string): boolean { return ( @@ -64,13 +362,56 @@ function checkName(n: string): boolean { n !== "." && n !== ".." && !n.startsWith("__") && - !unsafe(n) && - !/[/\\${}<>"`]/.test(n) + !/[/\\$\{\}<>"`]/.test(n) ); } -function checkUrl(raw: string): { baseUrl: string; host: string } | null { - if (raw.length > MAX_URL || unsafe(raw) || /[\s\\${}%"<>`^|@#?]/.test(raw)) return null; +const isLoopback = (hostname: string) => hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"; + +const PRIVATE_V4: [number, number, number][] = [ + // first octet, second octet from, second octet to + [10, 0, 255], + [172, 16, 31], + [192, 168, 168], + [100, 64, 127], + [169, 254, 254], +]; +const PRIVATE_SUFFIXES = [".local", ".lan", ".internal", ".intranet", ".corp", ".home.arpa", ".localhost"]; + +/** + * A host on this computer or a private network, where plain `http://` is accepted: + * loopback, private IPv4 ranges (10/8, 172.16/12, 192.168/16, 100.64/10, 169.254/16), + * unique-local and link-local IPv6 (fc00::/7, fe80::/10), names without a dot, and + * names under the intranet-style suffixes. `hostname` is as `URL` gives it: lowercase, + * IPv4 in dotted decimal, IPv6 in brackets and compressed. + */ +export function onLocalNetwork(hostname: string): boolean { + if (isLoopback(hostname)) return true; + const v4 = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(hostname); + if (v4) { + const [a, b] = [Number(v4[1]), Number(v4[2])]; + return PRIVATE_V4.some(([first, from, to]) => a === first && b >= from && b <= to); + } + if (hostname.startsWith("[")) { + const head = hostname.slice(1).split(":")[0]; + const first = head ? parseInt(head, 16) : 0; + return (first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80; + } + return !hostname.includes(".") || PRIVATE_SUFFIXES.some((x) => hostname.endsWith(x)); +} + +/** A plain `http://` address that is not on this computer: the key and requests travel unencrypted. */ +export function plainHttp(url: string): boolean { + const u = new URL(url); + return u.protocol === "http:" && !isLoopback(u.hostname); +} + +/** + * An https address without credentials, query or fragment; plain http only for this + * computer and private networks (`onLocalNetwork`). + */ +function checkUrl(raw: string, keepPath = false): { url: string; host: string } | null { + if (raw.length > MAX_URL || /[\s\\$\{\}%"<>`^|@#?]/.test(raw)) return null; const lower = raw.slice(0, 8).toLowerCase(); if (!lower.startsWith("https://") && !lower.startsWith("http://")) return null; let u: URL; @@ -80,98 +421,441 @@ function checkUrl(raw: string): { baseUrl: string; host: string } | null { return null; } if (u.username || u.password || u.search || u.hash || !u.hostname) return null; - const loopback = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]"; - if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback)) return null; + if (u.protocol !== "https:" && !(u.protocol === "http:" && onLocalNetwork(u.hostname))) return null; // `URL` gives the host in ASCII: an internationalized name comes out as punycode. if (!/^[\x21-\x7E]+$/.test(u.host)) return null; - const baseUrl = u.href.replace(/\/+$/, ""); - if (baseUrl.length > MAX_URL) return null; - return { baseUrl, host: u.host }; + const url = keepPath ? u.href : u.href.replace(/\/+$/, ""); + if (url.length > MAX_URL) return null; + return { url, host: u.host }; } -/** Only characters that keys use. No `$`, `{` or `}`: the app would read `${NAME}` from the environment. */ -function checkKey(k: string): boolean { - return k.length <= MAX_KEY && /^[A-Za-z0-9\-_.~+/=:]+$/.test(k); -} +/** Only characters that keys use. */ +const keyLike = (k: string, max: number) => k.length <= max && /^[A-Za-z0-9\-_.~+/=:]+$/.test(k); + +const MODEL_ID = /^[A-Za-z0-9\-_.:/@+]+$/; +const MODEL_GLOB = /^[A-Za-z0-9\-_.:/@+*?]+$/; -function checkModels(m: string): string[] | null { +function checkModelList(m: string, pattern: RegExp): string[] | null { const out: string[] = []; for (const id of m.split(",")) { - if (!id || id.length > MAX_MODEL || !/^[A-Za-z0-9\-_.:/@+]+$/.test(id)) return null; + if (!id || id.length > MAX_MODEL || !pattern.test(id)) return null; if (!out.includes(id)) out.push(id); } return out.length > MAX_MODELS ? null : out; } -/** Validates the parameters of a link: the fragment of the web form, or the query of the app link. */ +/** RFC 7230 token characters. */ +const TOKEN = /^[!#%&'*+\-.^_`|~0-9A-Za-z]+$/; +const DENIED_HEADERS = new Set([ + "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", +]); + +export const deniedHeader = (name: string) => { + const n = name.toLowerCase(); + return DENIED_HEADERS.has(n) || n.startsWith("proxy-") || n.startsWith("sec-"); +}; + +function checkHeaders(v: string): Header[] | null { + const out: Header[] = []; + const lines = v.split(LF); + if (lines.length > MAX_HEADERS) return null; + for (const line of lines) { + const i = line.indexOf(":"); + if (i <= 0) return null; + const name = line.slice(0, i); + const value = line.slice(i + 1).trim(); + if (name.length > MAX_HEADER_NAME || !TOKEN.test(name) || deniedHeader(name)) return null; + if (!value || value.length > MAX_HEADER_VALUE || !/^[\x20-\x7E]+$/.test(value)) return null; + if (out.some((h) => h.name.toLowerCase() === name.toLowerCase())) return null; + out.push({ name, value }); + } + return out; +} + +const tokens = (s: string) => /^[1-9][0-9]*$/.test(s) && Number(s) <= MAX_SPEC_TOKENS; +const yesNo = (s: string) => s === "y" || s === "n"; + +function checkSpecs(v: string): Spec[] | null { + const out: Spec[] = []; + const entries = v.split(";"); + if (entries.length > MAX_MODELS) return null; + for (const e of entries) { + const f = e.split("|"); + if (f.length !== 5) return null; + const [model, context, maxOutput, reasoning, image] = f as [string, string, string, string, string]; + if (!model || model.length > MAX_MODEL || !MODEL_ID.test(model)) return null; + if (out.some((s) => s.model === model)) return null; + if (context && !tokens(context)) return null; + if (maxOutput && !tokens(maxOutput)) return null; + if (reasoning && !yesNo(reasoning)) return null; + if (image && !yesNo(image)) return null; + if (!context && !maxOutput && !reasoning && !image) return null; + out.push({ + model, + context: context ? Number(context) : undefined, + maxOutput: maxOutput ? Number(maxOutput) : undefined, + reasoning: reasoning ? reasoning === "y" : undefined, + image: image ? image === "y" : undefined, + }); + } + return out; +} + +const PRICE = /^\d{1,6}(\.\d{1,6})?$/; + +/** `model|input|output|cache_read|cache_write_5m|cache_write_1h`; the cache prices may be empty or left out. */ +function checkPrices(v: string): Price[] | null { + const out: Price[] = []; + const entries = v.split(";"); + if (entries.length > MAX_MODELS) return null; + for (const e of entries) { + const f = e.split("|"); + if (f.length < 3 || f.length > 6) return null; + const [model, input, output, cacheRead = "", w5 = "", w1 = ""] = f as string[]; + if (!model || model.length > MAX_MODEL || !MODEL_ID.test(model)) return null; + if (out.some((p) => p.model === model)) return null; + if (!PRICE.test(input!) || !PRICE.test(output!)) return null; + for (const c of [cacheRead, w5, w1]) if (c && !PRICE.test(c)) return null; + out.push({ + model, + input: input!, + output: output!, + cacheRead: cacheRead || input!, + cacheWrite5m: w5 || input!, + cacheWrite1h: w1 || input!, + }); + } + return out; +} + +function checkMultiplier(v: string): boolean { + if (!/^\d{1,3}(\.\d{1,6})?$/.test(v)) return false; + const n = Number(v); + return n > 0 && n <= 100; +} + +type Fail = { ok: false; problem: Problem; param?: string }; +const fail = (problem: Problem, param?: string): Fail => ({ ok: false, problem, param }); +const includes = (list: readonly T[], v: string): v is T => (list as readonly string[]).includes(v); + +/** + * Splits and decodes the parameters of a link: the fragment of the web form, or + * the query of the app link. Each parameter at most once, no empty values. + */ +export function splitParams(query: string): { ok: true; params: Map } | Fail { + if (APP_PREFIX.length + query.length > MAX_LINK) return fail("tooLong"); + if (!query) return fail("empty"); + if (query.includes("#")) return fail("malformed"); + const params = new Map(); + for (const pair of query.split("&")) { + const i = pair.indexOf("="); + if (i < 0) return fail("malformed"); + const k = decode(pair.slice(0, i)); + const v = decode(pair.slice(i + 1)); + if (k === null || v === null) return fail("malformed"); + if (params.has(k)) return fail("duplicateParam", k); + if (v === "") return fail("emptyValue", k); + params.set(k, v); + } + return { ok: true, params }; +} + +/** Validates the parameters of a link. */ export function parseParams(query: string): Result { - if (query.length > MAX_LINK) return { ok: false, problem: "tooLong" }; - if (!query) return { ok: false, problem: "empty" }; - const params = new URLSearchParams(query); - const seen = new Set(); - for (const [k, v] of params) { - if (!(PARAMS as readonly string[]).includes(k)) return { ok: false, problem: "unknownParam", param: k }; - if (seen.has(k)) return { ok: false, problem: "duplicateParam", param: k }; - seen.add(k); - if (v === "") return { ok: false, problem: "emptyValue", param: k }; - } - return validate({ - name: params.get("name") ?? undefined, - url: params.get("url") ?? "", - protocol: params.get("protocol") ?? undefined, - key: params.get("key") ?? undefined, - models: params.get("models") ?? undefined, - }); -} - -/** Validates values as they are entered in the link builder. Empty optional values are left out. */ -export function validate(v: { name?: string; url: string; protocol?: string; key?: string; models?: string }): Result { - if (!v.url) return { ok: false, problem: "missingUrl" }; - const url = checkUrl(v.url); - if (!url) return { ok: false, problem: "url" }; - if (v.name !== undefined && !checkName(v.name)) return { ok: false, problem: "name" }; - if (v.protocol !== undefined && !(PROTOCOLS as readonly string[]).includes(v.protocol)) - return { ok: false, problem: "protocol" }; - if (v.key !== undefined && !checkKey(v.key)) return { ok: false, problem: "key" }; + const split = splitParams(query); + return split.ok ? validateParams(split.params) : split; +} + +/** + * Validates decoded parameters: the parsed link, or the values entered in the link + * builder. The first problem found is returned, in parameter order. + * + * Without `service`, the service is the one the address is recognized as, as long + * as every parameter fits it; otherwise the link is a custom upstream, which takes + * every parameter. So a link written for the first version, such as an official + * address with another path, stays valid as it was. An explicit `service` is strict. + */ +export function validateParams(input: Map): Result { + const unknown: [string, string][] = []; + for (const [k, v] of input) { + if (includes(NEVER, k)) return fail("neverAccepted", k); + if (v === "") return fail("emptyValue", k); + if (unsafe(v, k === "headers")) return fail("invalid", k); + if (!includes(PARAMS, k)) unknown.push([k, v]); + } + + const rawService = input.get("service"); + const rawUrl = input.get("url"); + let url: { url: string; host: string } | null = null; + if (rawUrl !== undefined) { + url = checkUrl(rawUrl); + if (!url) return fail("invalid", "url"); + } + if (rawService !== undefined) { + if (!includes(SERVICES, rawService)) return fail("invalid", "service"); + return validateFor(input, rawService, false, url, unknown); + } + if (!url) return fail("missing", "url"); + const recognized = serviceOfUrl(url.url); + const r = validateFor(input, recognized, true, url, unknown); + return r.ok || recognized === "custom" ? r : validateFor(input, "custom", true, url, unknown); +} + +/** Validates the parameters for one service. `recognized`: the link has no `service`. */ +function validateFor( + input: Map, + service: Service, + recognized: boolean, + url: { url: string; host: string } | null, + unknown: [string, string][], +): Result { + const get = (p: Param) => input.get(p); + const out: [Param, string][] = []; + const put = (p: Param, v: string | undefined) => { + if (v !== undefined) out.push([p, v]); + }; + const rules = SERVICE_RULES[service]; + + // Authentication method + const rawAuth = get("auth"); + if (rawAuth !== undefined && !includes(AUTHS, rawAuth)) return fail("invalid", "auth"); + const auth: Auth = (rawAuth as Auth | undefined) ?? "key"; + if (!rules.auth.includes(auth)) return fail("notApplicable", "auth"); + const fields = fieldsFor(service, auth); + for (const p of PARAMS) if (input.has(p) && !fields.has(p)) return fail("notApplicable", p); + + // Site (Z.ai / BigModel): from the link, else from the address, else BigModel + let site: Site | undefined; + if (service === "zai") { + const rawSite = get("site"); + if (rawSite !== undefined && !includes(SITES, rawSite)) return fail("invalid", "site"); + const fromUrl = url ? (Object.keys(ZAI_ADDRESS) as Site[]).find((s) => ZAI_ADDRESS[s] === url!.url) : undefined; + if (url && !fromUrl) return fail("mismatch", "url"); + if (rawSite !== undefined && fromUrl && rawSite !== fromUrl) return fail("mismatch", "site"); + site = (rawSite as Site | undefined) ?? fromUrl ?? "bigmodel"; + } + + // Address + let address: string | undefined; + let region: string | undefined; + const rawRegion = get("region"); + if (rawRegion !== undefined && !/^[a-z0-9-]{1,32}$/.test(rawRegion)) return fail("invalid", "region"); + if (auth !== "account") { + if (service === "zai") address = ZAI_ADDRESS[site!]; + else if (rules.url === "own") { + if (url && url.url !== rules.address) return fail("mismatch", "url"); + address = rules.address; + } else if (rules.url === "required") { + if (!url) return fail("missing", "url"); + address = url.url; + } else if (service === "bedrock") { + // A standard Bedrock address carries its region; the dialog asks for a signing + // region only for another address when it signs requests (access keys). + const inUrl = url ? BEDROCK_HOST.exec(new URL(url.url).hostname)?.[1] : undefined; + if (url && !inUrl) { + if (rawRegion !== undefined && auth === "key") return fail("notApplicable", "region"); + region = rawRegion; + address = url.url; + } else { + if (inUrl && rawRegion !== undefined && rawRegion !== inUrl) return fail("mismatch", "region"); + region = inUrl ?? rawRegion ?? DEFAULT_BEDROCK_REGION; + address = url ? url.url : bedrockAddress(region); + } + } else address = url?.url ?? rules.address; + } + + // Protocol: only where the dialog offers a choice; a value it fills in by itself may only be repeated + const rawProtocol = get("protocol"); + let protocol: Protocol | undefined; + if (rawProtocol !== undefined) { + if (!includes(PROTOCOLS, rawProtocol)) return fail("invalid", "protocol"); + const allowed = rules.protocol; + const ok = typeof allowed === "string" ? rawProtocol === allowed : allowed !== null && allowed.includes(rawProtocol); + if (!ok) return fail("mismatch", "protocol"); + protocol = rawProtocol; + } + + const name = get("name"); + if (name !== undefined && !checkName(name)) return fail("invalid", "name"); + + const key = get("key"); + if (key !== undefined && !keyLike(key, MAX_KEY)) return fail("invalid", "key"); + + let oauth: ImportLink["oauth"]; + if (auth === "oauth") { + const e = get("oauth_endpoint"); + if (e === undefined) return fail("missing", "oauth_endpoint"); + const endpoint = checkUrl(e, true); + if (!endpoint) return fail("invalid", "oauth_endpoint"); + const refresh = get("oauth_refresh"); + if (refresh === undefined) return fail("missing", "oauth_refresh"); + if (!keyLike(refresh, MAX_LONG_SECRET)) return fail("invalid", "oauth_refresh"); + const clientId = get("oauth_client_id"); + if (clientId !== undefined && !keyLike(clientId, MAX_KEY)) return fail("invalid", "oauth_client_id"); + const clientSecret = get("oauth_client_secret"); + if (clientSecret !== undefined && !keyLike(clientSecret, MAX_KEY)) return fail("invalid", "oauth_client_secret"); + oauth = { endpoint: endpoint.url, refresh, clientId, clientSecret }; + } + + let aws: ImportLink["aws"]; + if (auth === "aws-keys") { + const id = get("aws_access_key_id"); + if (id === undefined) return fail("missing", "aws_access_key_id"); + if (!/^[A-Z0-9]{16,128}$/.test(id)) return fail("invalid", "aws_access_key_id"); + const secret = get("aws_secret_access_key"); + if (secret === undefined) return fail("missing", "aws_secret_access_key"); + if (!keyLike(secret, MAX_KEY)) return fail("invalid", "aws_secret_access_key"); + const token = get("aws_session_token"); + if (token !== undefined && !keyLike(token, MAX_LONG_SECRET)) return fail("invalid", "aws_session_token"); + aws = { accessKeyId: id, secretAccessKey: secret, sessionToken: token }; + } + + let headers: Header[] = []; + const rawHeaders = get("headers"); + if (rawHeaders !== undefined) { + const h = checkHeaders(rawHeaders); + if (!h) return fail("invalid", "headers"); + headers = h; + } + + const clientIdentity = get("client_identity"); + if (clientIdentity !== undefined && clientIdentity !== "on") return fail("invalid", "client_identity"); + + const rawConcurrency = get("concurrency"); + let concurrency: number | undefined; + if (rawConcurrency !== undefined) { + if (!/^[1-9][0-9]{0,3}$/.test(rawConcurrency) || Number(rawConcurrency) > MAX_CONCURRENCY) + return fail("invalid", "concurrency"); + concurrency = Number(rawConcurrency); + } + + const balance = get("balance"); + if (balance !== undefined && !includes(BALANCES, balance)) return fail("invalid", "balance"); + let models: string[] = []; - if (v.models !== undefined) { - const m = checkModels(v.models); - if (!m) return { ok: false, problem: "models" }; + const rawModels = get("models"); + if (rawModels !== undefined) { + const m = checkModelList(rawModels, MODEL_ID); + if (!m) return fail("invalid", "models"); models = m; } - return { - ok: true, - link: { - name: v.name, - baseUrl: url.baseUrl, - host: url.host, - protocol: v.protocol as Protocol | undefined, - key: v.key, - models, - }, + let modelsOnly: string[] | undefined; + const rawOnly = get("models_only"); + if (rawOnly !== undefined) { + const m = checkModelList(rawOnly, MODEL_GLOB); + if (!m) return fail("invalid", "models_only"); + modelsOnly = m; + } + let specs: Spec[] = []; + const rawSpecs = get("specs"); + if (rawSpecs !== undefined) { + const s = checkSpecs(rawSpecs); + if (!s) return fail("invalid", "specs"); + specs = s; + } + + const billing = get("billing"); + if (billing !== undefined && !includes(BILLINGS, billing)) return fail("invalid", "billing"); + const multiplier = get("price_multiplier"); + const rawPrices = get("prices"); + let priceSheet: ImportLink["priceSheet"]; + if (multiplier !== undefined || rawPrices !== undefined) { + // A price sheet is chosen only for per-token billing + if ((billing ?? rules.billing) === "free") return fail("notApplicable", multiplier !== undefined ? "price_multiplier" : "prices"); + if (multiplier !== undefined && !checkMultiplier(multiplier)) return fail("invalid", "price_multiplier"); + let prices: Price[] = []; + if (rawPrices !== undefined) { + const p = checkPrices(rawPrices); + if (!p) return fail("invalid", "prices"); + prices = p; + } + priceSheet = { multiplier: multiplier ?? "1", prices }; + } + + // The parameters handed to the app, normalized, in link order + for (const p of PARAMS) { + const v = get(p); + if (v === undefined) continue; + if (p === "url") put(p, url!.url); + else if (p === "oauth_endpoint") put(p, oauth!.endpoint); + 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 put(p, v); + } + + const hosts: string[] = []; + const plainHosts: string[] = []; + for (const u of [address, oauth?.endpoint]) { + if (!u) continue; + const host = new URL(u).host; + if (!hosts.includes(host)) hosts.push(host); + if (plainHttp(u) && !plainHosts.includes(host)) plainHosts.push(host); + } + + const link: ImportLink = { + service, + recognized, + auth, + site, + name, + address, + hosts, + plainHosts, + protocol, + region, + key, + oauth, + aws, + headers, + clientIdentity: clientIdentity === "on", + concurrency, + balance: balance as Balance | undefined, + models, + modelsOnly, + specs, + billing: billing as Billing | undefined, + priceSheet, + v2: out.some(([p]) => !V1_PARAMS.includes(p)), + params: out, }; + if (APP_PREFIX.length + encode(link, unknown).length > MAX_LINK) return fail("tooLong"); + return { ok: true, link, unknown }; } -/** The parameters of a validated import, encoded, in a fixed order. */ -export function encode(l: ImportLink): string { - const parts: string[] = []; - const add = (k: Param, v: string | undefined) => { - if (v) parts.push(`${k}=${encodeURIComponent(v)}`); - }; - add("name", l.name); - add("url", l.baseUrl); - add("protocol", l.protocol); - add("key", l.key); - add("models", l.models.join(",")); - return parts.join("&"); +/** + * The parameters of a validated import, encoded, in a fixed order. Parameters this + * copy does not know are passed on after them, re-encoded, for a newer app to read. + */ +export function encode(l: ImportLink, unknown: [string, string][] = []): string { + return [...l.params, ...unknown].map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&"); } -/** The link that opens the app. Built only from validated values. */ -export function appLink(l: ImportLink): string { - return `thinkwatch://import?${encode(l)}`; +/** The link that opens the app. Built only from checked values. */ +export function appLink(l: ImportLink, unknown: [string, string][] = []): string { + return `${APP_PREFIX}${encode(l, unknown)}`; } /** The web form: the parameters stay in the fragment, which browsers do not send to the server. */ export function webLink(l: ImportLink, origin = "https://thinkwat.ch"): string { return `${origin}/import#${encode(l)}`; } + +/** A parameter name is shown to people only when it is short and plain. */ +export const showableParam = (name: string | undefined): name is string => !!name && /^[a-z0-9_]{1,32}$/.test(name);