Skip to content
Open
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
6 changes: 4 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,15 @@ jobs:
export NODE_AUTH_TOKEN=${{ secrets.GITHUB_TOKEN }}
PUBLISH_REGISTRY=https://npm.pkg.github.com node --experimental-strip-types scripts/publish-scoped.ts
# The release claim, checked instead of assumed. Without this the job
# reported success while `@viztor/dsh-opencode*` sat four versions behind
# reported success while `@viztor/dsh-opencode-patch` sat four versions behind
# the canonical package, because a publisher that was never registered
# fails with the same E404 as a transient fault.
- name: verify every published target
run: |
VER=$(node -p "require('./package.json').version")
pending="dsh-opencode-patch@$VER @viztor/dsh-opencode-patch@$VER @viztor/dsh-opencode@$VER"
# One line: scripts/check.ts reads it to prove this list equals what
# publish-scoped.ts publishes (scripts/scoped-targets.ts).
pending="dsh-opencode-patch@$VER @viztor/dsh-opencode-patch@$VER"

# Polls, because a successful `npm publish` does not mean the version
# is readable yet: npm answers "Your package is being processed and
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ A file's name does not decide whether it is public — its content does. Inciden

Host bundle (`lib/index.mjs`), a thin `apply` barrel over small modules:

- `src/index.ts` · `src/identity.ts` · `src/lifecycle.ts` — the public barrel, the identity constants, and the `apply()` composition (one installer per side effect, in load-bearing order).
- `src/index.ts` · `src/identity.ts` · `src/lifecycle.ts` — the public barrel, the identity constants, and the `apply()` composition (one installer per side effect, in load-bearing order). The barrel is the host's contract and nothing more: `apply`, `Config`, `inject`, `name` and the config types. Everything else is internal, and `test/public-api.test.ts` pins the list, so widening it is a reviewed change.
- `test/support/src.ts` — the one door tests use into `src`: each name re-exported from the module that DEFINES it, never through the barrel, so a module move edits one file. `test/architecture.test.ts` enforces both halves — every name is declared in its target, and no test outside the named exemptions (`vi.mock` targets, fresh re-imports after `vi.resetModules()`, the architecture and public-API tests, the jsdom client tests, the `reset-process-state.ts` setup file) imports a `src` path, and no jsdom test loads the door.
- `src/config.ts` · `src/config-values.ts` · `src/utils/guards.ts` — the schema and its defaults; dependency-free readers and type guards. The client bundle may import these, and it does — along with the contract and presentation modules (`usage-contract.ts`, `login-contract.ts`, `settings-copy.ts`, `settings-fields.ts`, `usage-ui.ts`, `go-limits.ts`). The rule is not a list of names: **the client may import a module that reaches nothing host-only** — no cordis context, no credential seam, no node built-in — and the bundle gate is what enforces it, by failing the build rather than by anyone remembering. An earlier version of this line named two files as the only ones allowed, which the tree had already outgrown.

## `src/utils/` — pure by construction, enforced by test
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ The web profile wires this checkout with `link:`, so builds are picked up like t
- **Restore globals.** Tests that touch `globalThis.fetch` or `process.env` must restore them in `afterEach` (`vi.unstubAllGlobals()`, `delete process.env.…`).
- **Offline by construction.** `test/support/offline.ts` refuses every non-loopback request in the unit and mock suites. The catalog's background revalidation used to reach `models.opencode.ai` dozens of times per run, so a networked runner's results could depend on the vendor's live catalog. A case that means to fetch stubs `fetch` itself.
- **No order dependence.** `test/support/reset-process-state.ts` clears the plugin's module-level stores (captured keys, session spend, the live catalog) after every test. A new module-level store gets a reset next to its siblings. `pnpm run test:shuffle` is how to check, and CI runs it.
- **Tests reach `src` through `test/support/src.ts`.** It re-exports, from each defining module, everything a test needs, so a module move or a barrel change edits one file instead of thirty. A new test imports from `./support/src.ts` (`../support/src.ts` under `test/mock` and `test/e2e`); add a missing name there rather than importing a `src` path. The exceptions are `vi.mock("../src/x.ts")` targets (and their `importOriginal<typeof import(…)>` type), which must name the module; the architecture tests, whose subject is the `src` layout itself; and the jsdom (client) tests, which import the defining module directly, because `src.ts` re-exports the host graph (`node:sqlite`, pi, `lifecycle.ts`) and the next rule keeps that out of client tests. Importing the door loads the whole `src` graph, host and client, so a module that fails to load fails every converted file; a client-safe door, limited to what the bundle gate admits, is P2's to add if that cost shows. The remaining pre-existing direct imports are converted in P2.
- **Tests reach `src` through `test/support/src.ts`.** It re-exports, from each defining module, everything a test needs, so a module move or a barrel change edits one file instead of thirty. A new test imports from `./support/src.ts` (`../support/src.ts` under `test/mock` and `test/e2e`); add a missing name there rather than importing a `src` path. The exceptions are `vi.mock("../src/x.ts")` targets (and their `importOriginal<typeof import(…)>` type), which must name the module; fresh-module re-imports after `vi.resetModules()`, whose subject is module identity; the architecture and public-API tests, whose subject is the `src` layout and the barrel; the jsdom (client) tests, which import the defining module directly, because `src.ts` re-exports the host graph (`node:sqlite`, pi, `lifecycle.ts`) and the next rule keeps that out of client tests; and the setup file `test/support/reset-process-state.ts`, which runs before the jsdom tests too. `test/architecture.test.ts` fails on a static or side-effect `src` import anywhere else, on a dynamic `import("../src/…")` in a file that never calls `vi.resetModules()`, and on a jsdom test that value-imports the door. Importing the door loads the whole `src` graph, host and client, so a module that fails to load fails every test that comes through it.
- **Host-only fixtures stay out of shared helpers.** `test/test-helpers.ts` is imported by the jsdom (client) tests, so anything that needs `node:sqlite` or the filesystem belongs in `test/support/`, as `opencode-store-fixture.ts` does.
- **A test for a fix fails without it.** Before committing a regression test, revert the fix locally and watch it fail.
- **Meaningful coverage.** Every config field in the `SPECS` register (`src/settings-fields.ts`) needs both the on and off path where it has one; every passthrough claim needs a non-OpenCode URL test proving headers are untouched; helper functions exported from `usage-ui.ts` are asserted directly, not re-derived in the test.
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ cd ~/.dsh/profiles/web
npm install dsh-opencode-patch
```

The package is `dsh-opencode-patch` under every name it is used by: the dependency you install, the bundle entry in your profile, and the row the host resolves.
The package is `dsh-opencode-patch` under every name it is used by: the dependency you install, the bundle entry in your profile, and the row the host resolves. It is also published as [`@viztor/dsh-opencode-patch`](https://www.npmjs.com/package/@viztor/dsh-opencode-patch); the retired name `@viztor/dsh-opencode` is no longer published, so an install still on it should move to either of those.

**2. Enable the bundle** — add the package to the profile's `dsh.profile.bundles` array:

Expand Down Expand Up @@ -423,6 +423,13 @@ These exist in the schema but render no control — each is a literal, a marker,

---

## Changed in 1.x

- **A smaller public API.** The package's entry exports `apply`, `Config`, `inject` and `name` — the names the host reads — and the config types (`PluginConfig`, `ResolvedPluginConfig`, `ModelOverrideConfig`, `ModelRouteConfig`, `EndpointFamily`, `KeySourcePolicy`). Everything else it used to re-export is internal now.
- **`./src/*` is no longer an export.** `src/` was never in the published files, so the subpath resolved in the repository and nowhere else.
- **`internalRoutes` was removed.** The per-protocol routes always mount. A profile row that still carries `internalRoutes` validates and the key is ignored.
- **`@viztor/dsh-opencode` is no longer published.** Its last version's "Renamed to @viztor/dsh-opencode-patch" deprecation stands as the final message; install `dsh-opencode-patch` or `@viztor/dsh-opencode-patch` instead.

## 📜 Compatibility & Verification

**Verified on DeepSeek Harness `0.2.1` — both the `alpha` and `rc` channels (Node 24+)**
Expand Down
9 changes: 8 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ cd ~/.dsh/profiles/web
npm install dsh-opencode-patch
```

包名在所有用到它的地方都是 `dsh-opencode-patch`:你安装的依赖、profile 里的 bundle 条目,以及宿主解析的那一行。
包名在所有用到它的地方都是 `dsh-opencode-patch`:你安装的依赖、profile 里的 bundle 条目,以及宿主解析的那一行。它也以 [`@viztor/dsh-opencode-patch`](https://www.npmjs.com/package/@viztor/dsh-opencode-patch) 发布;旧名 `@viztor/dsh-opencode` 已不再发布,仍在使用它的安装请改用上述任一名称。

**2. 启用 bundle**——把该包加入 profile 的 `dsh.profile.bundles` 数组:

Expand Down Expand Up @@ -412,6 +412,13 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的

---

## 1.x 变更

- **更小的公开 API。** 包入口只导出 `apply`、`Config`、`inject` 和 `name`(宿主读取的名称),以及配置类型(`PluginConfig`、`ResolvedPluginConfig`、`ModelOverrideConfig`、`ModelRouteConfig`、`EndpointFamily`、`KeySourcePolicy`)。此前额外重新导出的其余内容现在都是内部实现。
- **`./src/*` 不再是导出路径。** `src/` 从未包含在发布文件中,所以这个子路径只在仓库内可解析,安装后并不存在。
- **`internalRoutes` 已移除。** 按协议划分的内部路由总是会挂载。仍带有 `internalRoutes` 的 profile 配置行可以通过校验,该键会被忽略。
- **`@viztor/dsh-opencode` 不再发布。** 其最后一个版本上的 "Renamed to @viztor/dsh-opencode-patch" 弃用提示即为最终信息;请改装 `dsh-opencode-patch` 或 `@viztor/dsh-opencode-patch`。

## 📜 兼容性与验证

**已在 DeepSeek Harness `0.2.1` 上验证 —— `alpha` 与 `rc` 两个通道(Node 24+)**
Expand Down
2 changes: 0 additions & 2 deletions cordis.patch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,6 @@
# maxContextWindow: number optional ceiling clamping model context windows (e.g. 131072).
# modelOverrides: object per-model overrides for contextWindow, maxTokens and metadata (default {}).
# modelRoutes: object per-model wire routing: { <id>: { api, baseURL } } (default {}).
# internalRoutes: boolean mount the internal per-protocol routes; off for a deployment that
# declares its own providers for the other protocols (default true).
# debug: true|false log every streamed call that receives the header.
# debugFile: path optional append target for stream debug JSONL; written only while debug is on.
- insert:
Expand Down
2 changes: 1 addition & 1 deletion docs/credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ The two modules existed and were tested before anything called them. The wiring
2. `src/go-discovery.ts` — one more entry in the step map `firstResolved` takes, and **last in every policy**, because the user's own declaration wins: `opencodeStore: () => readFromStore(ctx, plane)`. It resolves the path with `opencodeDatabasePath({ channel: process.env.OPENCODE_CHANNEL, env: process.env, home: os.homedir() }, os.tmpdir())`, reads with `readOpencodeCredentials`, picks the entry whose `integrationId` matches the plane, and answers `undefined` for everything else — a missing file included.
3. `src/settings-fields.ts` — one `CARD_FIELDS` control, which is what makes the switch reachable.
4. **Which of the two mechanisms to use, now that both have been read.** `discoverGoConfig` walks `ctx.loader.entries()` and reads `entry.options.config`, but that is the PROVIDER REGISTRY — the rows that describe `opencode-go` and `opencode` — not this plugin's own configuration. This plugin's own config arrives as a VALUE: `fetch-patch.ts` receives `config: ResolvedPluginConfig` as a parameter, and `lifecycle.ts` is what passes it. So the new step should take the flag as an argument threaded from `lifecycle` rather than dig for it in the loader, and that means `resolveGoApiKey` needs one more parameter (it currently takes `ctx`, `targetProvider` and `policy`).
5. The config travels as VALUES rather than as a config object: `lifecycle.ts` resolves it with `resolveConfig` and passes fields down. `usage.ts:285` calls `resolveGoApiKey(...)` for the meter and `fetch-patch.ts:253` reads `config.keySource` directly for the headers, which is the precedent for how a field arrives. **The model-call path is now traced, and it is `fetch-patch.ts`**: the interceptor's Authorization fallback is what fills the `Bearer unused` sentinel the internal routes carry, so that is where a request's own key is resolved. The other one is `google-bridge.ts`'s `resolveApiKey`, the per-request resolver for the `opencode-google` route specifically — `responses-provider.ts` calls neither `resolveRoutedKey` nor `resolveApiKey`, as the grep said; it hands the bridge its inputs, and the flag travels that way. Both consult the store only after the captured key and the declared environment, so they agree with the meter.
5. The config travels as VALUES rather than as a config object: `lifecycle.ts` resolves it with `resolveConfig` and passes fields down. `usage.ts:285` calls `resolveGoApiKey(...)` for the meter and `fetch-patch.ts:253` reads `config.keySource` directly for the headers, which is the precedent for how a field arrives. **The model-call path is now traced, and it is `fetch-patch.ts`**: the interceptor's Authorization fallback is what fills the `Bearer unused` sentinel the internal routes carry, so that is where a request's own key is resolved. The other one is `google-bridge.ts`'s `resolveApiKey`, the per-request resolver for the `opencode-google` route specifically — `responses-provider.ts` calls neither `resolveRoutedKey` (since deleted in 1.x: it was exported and never called from `src/`) nor `resolveApiKey`, as the grep said; it hands the bridge its inputs, and the flag travels that way. Both consult the store only after the captured key and the declared environment, so they agree with the meter.
6. Tests: the step answers a key from a store this suite builds, answers `undefined` with the switch off, and still loses to a declared credential and to a captured one; the fetch patch fills its Authorization header from the store, per plane; and the bridge carries a stored login over the store.

**One bug the wiring found, worth recording because the login reader masked it.** `google-bridge.ts` derived the plane with `String(route).startsWith("opencode-go")`, and `"opencode-google"` starts with `"opencode-go"` — so the Zen route was read as the Go one. `loginTokenReader` hid that by falling back to the other plane; an integration-id lookup does not, so the derivation is now an exact route comparison.
Expand Down
2 changes: 1 addition & 1 deletion docs/deep-dive.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ Behind the **Enable Composer Usage Meter** switch (on by default):

### 9. Key resolution per routed model & overage behavior

Different models may route to different accounts (a corporate Go subscription alongside a personal Zen key). `resolveRoutedKey(ctx, provider)` inspects the loaded Cordis rows for the `apiKeyEnv` / literal `apiKey` assigned to each route, and **the route decides which account is queried: not the shape of the key**. The prefix test that used to sit here was removed once it was measured: OpenCode issues `oc_sk_…` for Go credentials too, so the string says nothing about the route in use. The meter queries accordingly:
Different models may route to different accounts (a corporate Go subscription alongside a personal Zen key). `discoverGoConfig(ctx, provider)` reads the loaded Cordis row for the `apiKeyEnv` / literal `apiKey` assigned to the route, `resolveGoApiKey` resolves it under this plugin's own `keySource` setting, and **the route decides which account is queried: not the shape of the key**. The prefix test that used to sit here was removed once it was measured: OpenCode issues `oc_sk_…` for Go credentials too, so the string says nothing about the route in use. The meter queries accordingly:

- OpenCode Go (`opencode-go`): fixed subscription quotas across three windows (5-hour rolling, weekly, monthly %). At 100% the gateway answers `GoUsageLimitError` (HTTP 402/429). Server-side overflow into Zen balance works only if that Go account has _Use balance_ enabled ([opencode.ai/workspace/go](https://opencode.ai/workspace/go)): a Zen key on a _separate_ account is never debited automatically.
- OpenCode Zen (`opencode`): per-token pay-as-you-go against the account balance; no rolling windows. At $0.00 the gateway returns `HTTP 402 Insufficient account funds`: top up via the Console link in the popover.
Expand Down
2 changes: 1 addition & 1 deletion docs/improvement-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Phases land as **stacked pull requests**. Each one is based on the previous phas
| P0 | suite hardening, 12 defect fixes, this plan | in review |
| P1a | `fix:` shipped breakage | in review |
| P1b | `chore:` hygiene | in review |
| P2 | `feat:` a smaller public API | planned |
| P2 | `feat:` a smaller public API | in review |
| P-T1a | `build:` pin the host | planned |
| P-T1b | `test:` a harness that is the real host | planned |
| P-H | `refactor:` one way to patch a host method | planned |
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@
"./client": {
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json",
"./cordis.patch.yml": "./cordis.patch.yml",
"./locale/*.json": "./locale/*.json"
Expand Down
43 changes: 43 additions & 0 deletions scripts/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

import { en as cardCopy } from "../src/settings-copy.ts";
import {
exportsOutsideFiles,
publishListMismatch,
releasePendingTargets,
} from "./package-checks.ts";
import { SCOPED_TARGETS } from "./scoped-targets.ts";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const failures: string[] = [];
Expand Down Expand Up @@ -458,6 +464,43 @@ if (releaseYml.includes("scripts/publish-scoped.ts")) {
);
}

/* ----------------- 10b. what exports names ships, and what ships is checked */

// An `exports` target outside `files` resolves in this repository and nowhere
// else: `./src/*` was exported for releases while `src/` was never packed.
const outside = exportsOutsideFiles(pkg);
if (outside.length === 0) {
ok("every exports target is inside files");
} else {
for (const target of outside) {
fail(
`exports names ${target}, which files does not pack; a consumer resolving ` +
`${target} from the tarball gets ENOENT`
);
}
}

// The verify step polls its own list. A name publish-scoped.ts publishes and
// the step never polls goes unchecked; a name it polls and nothing publishes
// fails every release.
const pending = releasePendingTargets(releaseYml);
if (pending === undefined) {
fail(
'release.yml must carry exactly one pending="…" verify line (none, or more than one, found); the publish list cannot be checked'
);
} else {
const mismatch = publishListMismatch([pkg.name, ...SCOPED_TARGETS], pending);
if (mismatch.length === 0) {
ok(
`release.yml verifies exactly what is published (${pending.join(", ")})`
);
} else {
for (const message of mismatch) {
fail(message);
}
}
}

/* ------------------------------------------ 11. the client stays lean */

/*
Expand Down
Loading
Loading