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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,6 @@ opencode-responses:
| **模型与免费额度** | 用 OpenCode 官方目录补全模型信息(默认开启) | `on` | 把目录的上下文上限、模态与思考档位补进模型信息,并让网关 `/models` 列表带上完整目录。**不会**往选择器里添加模型——选择器只显示已声明的;要添加请用卡片的**检查目录** |
| **模型与免费额度** | 自动补全核心工具(默认开启) | `on` | 补上免费层 `/responses` 请求体所需的 `read` + `bash` schema |
| **配额计量** | 开启输入区用量计量(默认开启) | `on` | 在输入框停靠栏挂载实时计量表——Go 额度环,或 Zen 会话消耗 |
| **配额计量** | 显示会话消耗与模型费率(默认开启) | `on` | 追加显示本会话累计花费与当前模型每百万 Token 费率 |
| **账户与端点** | API 端点 | `auto` | 请求发往哪个端点家族:**自动**(跟随目录) · **旧端点**(`zen/v1`) · **新端点**(`inference/…`) |
| **账户与端点** | 凭据来源 | `auto` | 多个密钥同时可用时以哪个为准:**自动** · **优先实时请求** · **优先已声明密钥** |
| **账户与端点** | 读取 OpenCode 安装版的密钥(默认关闭) | `off` | 允许本插件读取 OpenCode 自己保存的凭据,且仅作最后手段——默认关闭 |
Expand Down Expand Up @@ -362,6 +361,7 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的
| `sessionIdEnv` | `OPENCODE_SESSION_ID` | 指定一个仅当调用方提供会话 id 时才使用的环境变量名 |
| `freeModelMarker` | `free` | 模型 id 子串;`*` 强制启用回退,`''` 关闭回退 |
| `usageBaseURL` | `https://opencode.ai/zen/go/v1` | 端点覆盖;从组合中自动发现 |
| `credentialOrg` | — | 账户凭据所属的组织 id,在部署从带外提供一个时使用 |
| `debug` / `debugFile` | `false` / — | 诊断用 JSONL 日志,不是谁会在 UI 里调节的行为 |

```yaml
Expand Down
5 changes: 2 additions & 3 deletions docs/quota-meter.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,5 @@ The token is generated at runtime by `dsh web` and never written to disk, so it

## 8. Two design rules

A number that cannot tell the truth is not rendered; the explanation of why it is absent takes its place. An estimate wearing a precise outfit is worse than a sentence, because it reads as a measurement.

An explanation can hide behind a hover; an instruction cannot.
- **A number that cannot tell the truth is not rendered; the explanation of why it is absent takes its place.** An estimate wearing a precise outfit is worse than a sentence, because it reads as a measurement.
- **An explanation can hide behind a hover; an instruction cannot.**
8 changes: 5 additions & 3 deletions docs/quota-meter.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,11 @@ composer 输入框右侧的小控件。它只回答一个问题:**你当前所

**三种"没有数字"的情况,它们不一样:**

1. **本会话暂无记录** — 累加器是 Host 进程内存里的,**重启 `dsh web` 会清空**;而且它只统计走 OpenCode 路由的轮次。发一轮就有了。
2. **读不到配额** — 凭据缺失或请求失败。此时面板**不画**那三个窗口,而不是画三个 `0%`:编造的零比空白更糟。
3. **额度用尽** — Go 窗口到 100%,面板转为显示 Zen 承接状态。
| 状态 | 含义 |
| :-- | :-- |
| 本会话暂无记录 | 累加器是 Host 进程内存里的,**重启 `dsh web` 会清空**;而且它只统计走 OpenCode 路由的轮次。发一轮就有了。 |
| 读不到配额 | 凭据缺失或请求失败。此时面板**不画**那三个窗口,而不是画三个 `0%`:编造的零比空白更糟。 |
| 额度用尽 | Go 窗口到 100%,面板转为显示 Zen 承接状态。 |

## 六、设置页上的用量摘要

Expand Down
137 changes: 137 additions & 0 deletions test/docs-pairing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* The language pairs: one document each, two languages.
*
* `README` and `docs/quota-meter` are the documents this repository publishes
* twice. Every count and every table in them is already held to the code — but
* nothing held the two LANGUAGES to each other, and that is the drift that
* actually happens: a row added to one language and not the other.
*
* It had, when this check was first run, in both directions:
*
* - the Chinese README's settings table carried a control that no longer exists
* (its own prose said nine while the table listed ten);
* - the Chinese README's configuration table was missing `credentialOrg`.
*
* The check is deliberately about STRUCTURE, not prose. A translator may
* rephrase, and the two languages are allowed their own idiom — but a section, a
* code block or a table row is a FACT, and a fact present in one language and
* absent from the other is invisible to whoever reads only one of them.
*
* @module test/docs-pairing.test
*/

import { readFileSync } from "node:fs";
import { join } from "node:path";

import { describe, expect, it } from "vitest";

/** Every document published in two languages, by its base name. */
const PAIRS = ["README", "docs/quota-meter"] as const;

/** Read one document of the repository. */
const read = (base: string, suffix: string): string =>
readFileSync(join(process.cwd(), `${base}${suffix}.md`), "utf8");

/** The heading levels, in order — the document's skeleton. */
const headings = (text: string): number[] =>
[...text.matchAll(/^(#{1,6}) /gmu)].map((match) => match[1]?.length ?? 0);

/** How many fenced blocks a document carries. */
const fences = (text: string): number =>
(text.match(/^```/gmu) ?? []).length / 2;

/**
* The row count of each table, in order.
*
* The header and the separator are the table's SHAPE; the body rows are its
* facts. A fact is one row in one language and one row in the other — unlike a
* prose sentence, which may be one clause or three.
*/
const tableRows = (text: string): number[] => {
const counts: number[] = [];
let rows = 0;
/** Whether the previous line belonged to the same table. */
let inTable = false;
for (const line of text.split("\n")) {
const isRow = /^\s*\|/u.test(line);
const isSeparator = /^\s*\|[\s:|-]+\|\s*$/u.test(line);
if (!isRow) {
if (inTable) {
counts.push(rows);
rows = 0;
inTable = false;
}
continue;
}
if (!inTable) {
// The first line of a table is its header: shape, not a fact.
inTable = true;
continue;
}
if (!isSeparator) {
rows += 1;
}
}
if (inTable) {
counts.push(rows);
}
return counts;
};

/** How many top-level list items a document carries. */
const listItems = (text: string): number =>
(text.match(/^(?:\d+\.|[-*]) /gmu) ?? []).length;

describe("the documents published in two languages", () => {
for (const base of PAIRS) {
it(`${base}: the two languages carry the same sections`, () => {
// A section added to one language is the drift a reader cannot see: they
// read one language and never learn the other says more.
const en = headings(read(base, ""));
const zh = headings(read(base, ".zh-CN"));
expect(en.length).toBeGreaterThan(0);
expect(zh, `${base}.zh-CN`).toEqual(en);
});

it(`${base}: the two languages carry the same code blocks`, () => {
// A command that exists in one language only is worse than a typo: the
// reader following that language cannot run it.
const en = fences(read(base, ""));
const zh = fences(read(base, ".zh-CN"));
expect(en).toBeGreaterThan(0);
expect(zh, `${base}.zh-CN`).toBe(en);
});

it(`${base}: every table has the same rows in both languages`, () => {
// Row for row: a row may be rephrased, not removed. This is what found
// the stale settings row and the missing `credentialOrg` row.
const en = tableRows(read(base, ""));
const zh = tableRows(read(base, ".zh-CN"));
expect(en.length).toBeGreaterThan(0);
expect(zh, `${base}.zh-CN`).toEqual(en);
});

it(`${base}: the two languages carry the same list items`, () => {
// The other fact form. Counted rather than compared, because a list item
// is prose with a marker and the prose is the translator's.
const en = listItems(read(base, ""));
const zh = listItems(read(base, ".zh-CN"));
expect(zh, `${base}.zh-CN`).toBe(en);
});
}

it("finds the pairs it claims to check", () => {
// A loop over an empty list passes silently, which is how a gate stops being
// one. Each pair is read, and each read has to produce structure.
for (const base of PAIRS) {
for (const suffix of ["", ".zh-CN"]) {
const text = read(base, suffix);
expect(text.length, `${base}${suffix}.md`).toBeGreaterThan(1000);
expect(headings(text).length, `${base}${suffix}.md`).toBeGreaterThan(1);
expect(tableRows(text).length, `${base}${suffix}.md`).toBeGreaterThan(
0
);
}
}
});
});
74 changes: 11 additions & 63 deletions test/e2e/protocol-routing.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ import {
internalRouteFor,
isServableSdk,
} from "../../src/index.ts";
import {
accessDisabled,
freeTierClosed,
gatewayBusy,
usableAsSubject,
} from "./verdict.ts";

const LIVE = process.env.OPENCODE_E2E === "1";
const ZEN_KEY = process.env.OPENCODE_API_KEY;
Expand Down Expand Up @@ -165,10 +171,7 @@ const PLUGIN_HEADERS: Readonly<Record<string, string>> = {
"x-opencode-session": "ses_e2e0000000000abcdefghij",
};

interface Probe {
readonly status: number;
readonly summary: string;
}
import type { Probe } from "./verdict.ts";

/** Ask one endpoint about one model, optionally with a credential. */
const probe = async (
Expand Down Expand Up @@ -241,57 +244,6 @@ const otherProtocols = (expected: string) =>
* A skip is the honest answer in both: it says the routing was not verified,
* which is not the same as saying it was verified and passed.
*/
/**
* Whether the gateway is refusing the FREE TIER rather than the route.
*
* Measured, one request each, on `step-5-preview-free` — which was the canonical
* free-tier id when those runs were made and has since been retired, so the
* cases below now sample `beam-free`: the deployment's `oc_…` key answers
* `403 FreeTierError` on both `…/zen/v1` and the new `…/inference/openai/v1`,
* with and without `x-opencode-org-id`; the literal `public` answers the same;
* and the real client — whose captured request carries an `st_…` session token —
* is served.
*
* **Read that as a statement about the CLIENT, not the credential.** The
* gateway's own message is "OpenCode's free tier can only be used from within
* OpenCode", and presenting as that client is what this plugin does — so a free
* model is OFFERED now, and a refusal names its own reason rather than being
* pre-empted by an exclusion. What the older runs establish still holds: an
* org id and a key do not unlock it.
*
* `docs/opencode-1.19-api.md` has the endpoints, the captured headers and the
* login flow.
*/
const freeTierClosed = (result: Probe): boolean =>
result.summary.includes("FreeTierError");

const accessDisabled = (result: Probe): boolean =>
result.status === 402 ||
result.summary.includes("Insufficient account funds") ||
(result.status === 403 &&
result.summary.includes("Model access is disabled")) ||
result.status === 429 ||
// No answer at all is the third shape of "cannot tell": the gateway is busy,
// so nothing about the route was observed either way.
result.status === 0;

/**
* The gateway says this model is retired.
*
* **The registry does not always know.** The catalog's deprecation flag is the
* vendor's own bookkeeping, and the gateway's answer is the authority: a model
* can still be listed, still be priced, and still be in the catalog's active
* slice while the gateway answers `410 ModelDeprecated` for it. Measured: the
* registry listed `gemini-3-flash` as active while the gateway refused it.
*
* Such a model is not a subject to assert on — the case would fail on a
* retirement the plugin had nothing to do with — so the picker walks past it.
*/
const retiredAtTheGateway = (result: Probe): boolean =>
result.status === 410 || result.summary.includes("ModelDeprecated");

/** The gateway is busy: it answered, but not about the route. */
const gatewayBusy = (result: Probe): boolean => result.status === 429;

/**
* The protocol the model is SERVED ON, per the shipped catalog on the Zen plane.
Expand Down Expand Up @@ -364,14 +316,10 @@ describe.skipIf(!LIVE)("live protocol routing", () => {
): Promise<string | undefined> => {
for (const model of candidates) {
const answer = await probe(protocol, model, key, base);
// A model the gateway has retired is walked past too: it answers, so it is
// not an access refusal, but asserting on it would fail on a retirement
// the plugin had nothing to do with.
if (
!accessDisabled(answer) &&
answer.status !== 0 &&
!retiredAtTheGateway(answer)
) {
// The rule is in `verdict.ts`, where it is unit-tested: a refusal and a
// retirement are both vendor-side facts, and a case built on either fails
// for a reason that is not its own.
if (usableAsSubject(answer)) {
return model;
}
}
Expand Down
103 changes: 103 additions & 0 deletions test/e2e/verdict.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* How a live probe's answer is READ — the rules that decide skip, and the rules
* that decide failure.
*
* These live here, outside the e2e file, for the reason every other pure
* predicate in this repository was extracted: **a predicate that lives in a
* closure cannot be exercised alone**. The e2e suite only runs with a key
* against the live gateway, so a rule in there is tested by the vendor's
* schedule — and the rules are exactly what decides whether a red run means
* "the plugin is wrong" or "the vendor moved".
*
* The distinction is not academic. Two of the last four rotations turned a live
* case red for a vendor-side reason (a retired model answered `410`, a loaded
* gateway answered `429`), and each time the fix was to the RULE, not to the
* plugin. Those rules are now unit-tested.
*
* @module test/e2e/verdict
*/

/** What one endpoint said about one model. */
export interface Probe {
/** The HTTP status, or `0` when nothing answered at all. */
readonly status: number;
/** The body, truncated — the gateway names its refusals in it. */
readonly summary: string;
}

/**
* Whether the gateway is refusing the FREE TIER rather than the route.
*
* Measured: the deployment's key answers `403 FreeTierError` on every endpoint,
* with and without an org id, and the literal `public` answers the same — while
* a real client, whose captured request carries a session token, is served.
*
* Read that as a statement about the CLIENT, not the credential: the gateway's
* message is "OpenCode's free tier can only be used from within OpenCode", and
* presenting as that client is what this plugin does. So a refusal here is not
* the plugin's failure to route — it is a gate this repository cannot open.
*
* @param result - the probe's answer.
* @returns whether the free tier closed the request.
*/
export const freeTierClosed = (result: Probe): boolean =>
result.summary.includes("FreeTierError");

/**
* Whether the account cannot spend on this model — an entitlement answer, not a
* routing answer.
*
* Four shapes, and they are not the same refusal: `402` is "no funds", a `403`
* naming `Model access is disabled` is a per-model entitlement, `429` is the
* gateway's own load and says nothing about the route, and `0` is silence.
*
* @param result - the probe's answer.
* @returns whether the account (or the gateway) refused for a reason other than
* routing.
*/
export const accessDisabled = (result: Probe): boolean =>
result.status === 402 ||
result.summary.includes("Insufficient account funds") ||
(result.status === 403 &&
result.summary.includes("Model access is disabled")) ||
result.status === 429 ||
// No answer at all is the third shape of "cannot tell": the gateway is busy,
// so nothing about the route was observed either way.
result.status === 0;

/**
* The gateway says this model is retired.
*
* **The registry does not always know.** The catalog's deprecation flag is the
* vendor's own bookkeeping and the gateway's answer is the authority: a model
* can still be listed, still be priced, and still sit in the catalog's active
* slice while the gateway answers `410 ModelDeprecated`. Measured: the registry
* listed `gemini-3-flash` as active while the gateway refused it.
*
* Such a model is not a subject to assert on — the case would fail on a
* retirement the plugin had nothing to do with.
*
* @param result - the probe's answer.
* @returns whether the answer is a retirement.
*/
export const retiredAtTheGateway = (result: Probe): boolean =>
result.status === 410 || result.summary.includes("ModelDeprecated");

/** The gateway is busy: it answered, but not about the route. */
export const gatewayBusy = (result: Probe): boolean => result.status === 429;

/**
* Whether a candidate model may be USED as a case's subject.
*
* The rule the pickers apply: a candidate that answered is a subject, except
* when the answer was about the account (a refusal) or about the model's
* retirement. Both are vendor-side facts, and a case built on either fails for
* a reason that is not its own.
*
* @param result - the probe's answer for this candidate.
* @returns whether the case may proceed with this model.
*/
export const usableAsSubject = (result: Probe): boolean =>
!accessDisabled(result) &&
!retiredAtTheGateway(result) &&
result.status !== 0;
Loading
Loading