dsh-opencode 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -74
- package/lib/commands.js +27 -39
- package/lib/index.js +4 -5
- package/lib/keyring.js +109 -0
- package/lib/transport.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,108 +1,64 @@
|
|
|
1
|
-
# dsh-opencode
|
|
1
|
+
# dsh-opencode
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
OpenCode Zen / Go を動的カタログ付きのライブ LLM ルートとして DSH に追加するプラグイン。
|
|
4
4
|
|
|
5
|
-
- `opencode-zen-live` —
|
|
6
|
-
- `opencode-go-live` —
|
|
5
|
+
- `opencode-zen-live` — OpenCode Zen (Live)
|
|
6
|
+
- `opencode-go-live` — OpenCode Go (Live)
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
## Compatibility
|
|
11
|
-
|
|
12
|
-
- DSH packages: `0.1.2-rc.1` (published release; peer range `^0.1.2-rc.1`)
|
|
13
|
-
- pi-ai: pinned to the installed DSH adapter's range (`>=0.84.2 <0.86`, verified on `0.84.2`)
|
|
14
|
-
- Cordis: `4.0.2`
|
|
15
|
-
- Node: `^22.19.0 || >=24.0.0`
|
|
16
|
-
|
|
17
|
-
## Installation
|
|
8
|
+
## インストール
|
|
18
9
|
|
|
19
10
|
```sh
|
|
20
11
|
pnpm dsh plugin --profile web add github:askdkc/dsh-opencode
|
|
21
|
-
pnpm dsh --profile web --dump-config
|
|
22
12
|
```
|
|
23
13
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
To remove it:
|
|
14
|
+
削除:
|
|
27
15
|
|
|
28
16
|
```sh
|
|
29
17
|
pnpm dsh plugin --profile web remove dsh-opencode
|
|
30
18
|
```
|
|
31
19
|
|
|
32
|
-
##
|
|
33
|
-
|
|
34
|
-
Both routes resolve the credential reference `apiKeyEnv` per request through the DSH credentials service (or the launch environment when no credential service is mounted). The default reference is `OPENCODE_API_KEY` for both products; set a different one per route in settings, e.g. `DSH_OPENCODE_ZEN_API_KEY` / `DSH_OPENCODE_GO_API_KEY`, and store the value through the standard credentials UI (the web Models page) or export it in the launch environment.
|
|
35
|
-
|
|
36
|
-
- A missing or unusable key fails the request with `MISSING_CREDENTIAL`; the plugin never falls back to an unrelated ambient key (`OPENAI_API_KEY`, `GEMINI_API_KEY`, …).
|
|
37
|
-
- Catalog fetches are unauthenticated and share no client with inference; keys never appear in URLs, logs, diagnostics, command output, or the cache.
|
|
38
|
-
- Go requests carry an honest `x-opencode-client: opencode-live/<version>` header and an opaque, stable `x-opencode-session` value per DSH session; the plugin never impersonates OpenCode itself.
|
|
39
|
-
|
|
40
|
-
## How the catalog works
|
|
41
|
-
|
|
42
|
-
Three public sources feed the catalog: the official Zen list, the official Go list, and the Models.dev `api.json` metadata for the `opencode` / `opencode-go` providers. The plugin joins them by exact model id and keeps every candidate visible:
|
|
20
|
+
## 使い方
|
|
43
21
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
| `catalog-only` | in Models.dev but absent from the official list | hidden | shown |
|
|
50
|
-
| `removed` | was officially listed before; no longer present | hidden (history keeps the name) | shown |
|
|
22
|
+
1. DSH を再起動すると両ルートが登録される(以降のカタログ更新に再起動は不要)
|
|
23
|
+
2. `/dsh-opencode` を実行して API キーを設定する
|
|
24
|
+
- キー未設定ならコマンドの入力フィールドにキーを貼って再度実行
|
|
25
|
+
- DSH の opencode キー保存先(`OPENCODE_API_KEY`)に書き込み、設定や他の参照を変えていないか検証
|
|
26
|
+
3. モデルセレクターから OpenCode のモデルを選んで使う
|
|
51
27
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
Refreshes run single-flight per source (Models.dev once for both products) with conditional GET/304 handling, timeout and decoded-body caps, empty-list protection, and per-source failure isolation. An executable-set change re-registers the same routes in place, which publishes `llm/adapters-updated` so an open selection UI re-fetches. Snapshots are immutable and generation-bound; a prepared call cannot switch providers mid-request.
|
|
55
|
-
|
|
56
|
-
## Commands
|
|
57
|
-
|
|
58
|
-
| Command | Effect |
|
|
28
|
+
| コマンド | 効果 |
|
|
59
29
|
|---|---|
|
|
60
|
-
| `/dsh-opencode [<api-key>]` |
|
|
61
|
-
| `/opencode-refresh [all\|zen\|go]` |
|
|
62
|
-
| `/opencode-status` |
|
|
63
|
-
| `/opencode-models <zen\|go> [--all]` |
|
|
30
|
+
| `/dsh-opencode [<api-key>]` | API キーを保存して Zen/Go を有効化(未入力なら状態表示) |
|
|
31
|
+
| `/opencode-refresh [all\|zen\|go]` | カタログを強制更新 |
|
|
32
|
+
| `/opencode-status` | 更新時刻・エラー・モデル数・キー設定状況 |
|
|
33
|
+
| `/opencode-models <zen\|go> [--all]` | モデル一覧(`--all` で非対応含む) |
|
|
64
34
|
|
|
65
|
-
|
|
35
|
+
- API キーはコマンドの入力フィールドで受け取り、`recordInput: false` でログに残さない
|
|
36
|
+
- 保存後、設定セクションと他の認証参照が変わっていないことを検証してから成功を報告する
|
|
37
|
+
- 環境変数 `OPENCODE_API_KEY`(両ルート既定参照)でも可
|
|
66
38
|
|
|
67
|
-
##
|
|
39
|
+
## 設定
|
|
68
40
|
|
|
69
|
-
|
|
41
|
+
名前空間 `opencode-live`(デフォルトは `cordis.patch.yml` 参照):
|
|
70
42
|
|
|
71
43
|
```yaml
|
|
72
44
|
providers:
|
|
73
45
|
opencode-zen-live:
|
|
74
|
-
product: zen
|
|
75
|
-
apiKeyEnv: OPENCODE_API_KEY
|
|
46
|
+
product: zen
|
|
47
|
+
apiKeyEnv: OPENCODE_API_KEY # 両ルート既定は同一参照
|
|
76
48
|
opencode-go-live:
|
|
77
49
|
product: go
|
|
78
50
|
apiKeyEnv: OPENCODE_API_KEY
|
|
79
51
|
catalog:
|
|
80
|
-
refreshIntervalMs: 900000
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
maxStaleMs: 604800000 # 7 days; staleness display bound
|
|
84
|
-
requireFresh: false # true refuses requests while the official list is stale
|
|
52
|
+
refreshIntervalMs: 900000
|
|
53
|
+
timeoutMs: 15000
|
|
54
|
+
requireFresh: false
|
|
85
55
|
```
|
|
86
56
|
|
|
87
|
-
`cachePath` (
|
|
57
|
+
- `cachePath` でキャッシュ位置を変更可能(既定は DSH ホーム下 `cache/opencode-live/catalog.json`)
|
|
88
58
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
## Development
|
|
59
|
+
## 開発
|
|
92
60
|
|
|
93
61
|
```sh
|
|
94
62
|
pnpm install
|
|
95
|
-
pnpm typecheck
|
|
96
|
-
pnpm test # vitest run (offline only; live tests skip)
|
|
97
|
-
pnpm test:live # opt-in: hits the public GET endpoints, no key, no inference
|
|
98
|
-
pnpm build # tsdown -> lib/
|
|
99
|
-
pnpm pack:check # npm pack --dry-run
|
|
63
|
+
pnpm typecheck && pnpm test && pnpm build
|
|
100
64
|
```
|
|
101
|
-
|
|
102
|
-
Tests mount a real Cordis context with the published DSH packages, stub the three catalog URLs, and cover the candidate states, refresh failure classes, wire-API selection, session headers, credential semantics, registry replacement, unload safety, and packaging.
|
|
103
|
-
|
|
104
|
-
## Scope notes
|
|
105
|
-
|
|
106
|
-
- Catalog refreshes never generate inference traffic, and a Go auth/limit error never switches the request to Zen.
|
|
107
|
-
- Model `limit.context` / `limit.output` come only from a validated source; unknown capacities never become fabricated numbers. Source pricing maps into pi-ai's descriptor; absent pricing is the absence of a fact, not a $0 claim.
|
|
108
|
-
- The repository commits the built `lib/` so installation needs no runtime TypeScript compilation.
|
package/lib/commands.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PRODUCT_BY_ROUTE, ROUTE_BY_PRODUCT, describeNonReadyState } from "./normalize.js";
|
|
2
|
-
import
|
|
2
|
+
import "./keyring.js";
|
|
3
3
|
//#region src/commands.ts
|
|
4
4
|
const USAGE_REFRESH = "Usage: /opencode-refresh [all|zen|go]";
|
|
5
5
|
const USAGE_MODELS = "Usage: /opencode-models <zen|go> [--all]";
|
|
@@ -37,10 +37,6 @@ function parseProduct(raw) {
|
|
|
37
37
|
if (raw === "zen") return "zen";
|
|
38
38
|
if (raw === "go") return "go";
|
|
39
39
|
}
|
|
40
|
-
/** Distinct credential references across the configured routes. */
|
|
41
|
-
function configuredRefs(services) {
|
|
42
|
-
return [...new Set([...services.config().providers.values()].map((provider) => provider.apiKeyEnv))];
|
|
43
|
-
}
|
|
44
40
|
/** Build the three command definitions. */
|
|
45
41
|
function commandDefinitions(ctx, services) {
|
|
46
42
|
return [
|
|
@@ -134,46 +130,38 @@ function commandDefinitions(ctx, services) {
|
|
|
134
130
|
},
|
|
135
131
|
{
|
|
136
132
|
name: "dsh-opencode",
|
|
137
|
-
description: "Store the OpenCode API key
|
|
133
|
+
description: "Store the OpenCode API key, or report status",
|
|
134
|
+
input: { hint: "Paste the OpenCode API key here (required only when unset)" },
|
|
138
135
|
recordInput: false,
|
|
139
136
|
handler: async (invocation) => {
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
const failures = [];
|
|
152
|
-
for (const ref of configuredRefs(services)) try {
|
|
153
|
-
await services.storeCredential(ref, raw);
|
|
154
|
-
} catch (error) {
|
|
155
|
-
failures.push(`${ref}: ${error.message}`);
|
|
156
|
-
}
|
|
157
|
-
if (failures.length > 0) return {
|
|
137
|
+
const routes = [ROUTE_BY_PRODUCT.zen, ROUTE_BY_PRODUCT.go];
|
|
138
|
+
const key = invocation.rawInput.trim();
|
|
139
|
+
if (key.length > 0) {
|
|
140
|
+
if (invocation.signal.aborted) return {
|
|
141
|
+
kind: "success",
|
|
142
|
+
text: "Storing cancelled."
|
|
143
|
+
};
|
|
144
|
+
const failure = await services.storeApiKey(key);
|
|
145
|
+
if (failure !== void 0) return {
|
|
158
146
|
kind: "error",
|
|
159
|
-
text:
|
|
147
|
+
text: failure
|
|
160
148
|
};
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (!stored) {
|
|
165
|
-
if (!(await Promise.all(routes.map(async (route) => {
|
|
166
|
-
return (await services.describeCredential(route))?.configured === true;
|
|
167
|
-
}))).some((configured) => configured)) return {
|
|
149
|
+
const lines = ["OpenCode API key stored. Zen and Go are enabled:"];
|
|
150
|
+
for (const route of routes) lines.push(await productStatus(ctx, services, PRODUCT_BY_ROUTE[route]));
|
|
151
|
+
return {
|
|
168
152
|
kind: "success",
|
|
169
|
-
text:
|
|
170
|
-
"No OpenCode API key is configured yet.",
|
|
171
|
-
"Run /dsh-opencode <your-api-key> to store it securely and enable both routes at once,",
|
|
172
|
-
"or store it through the standard credentials input (the web Models page writes it) and run /dsh-opencode again.",
|
|
173
|
-
"This command never displays key values."
|
|
174
|
-
].join("\n")
|
|
153
|
+
text: lines.join("\n")
|
|
175
154
|
};
|
|
176
155
|
}
|
|
156
|
+
if (!await services.keyConfigured()) return {
|
|
157
|
+
kind: "success",
|
|
158
|
+
text: [
|
|
159
|
+
"No OpenCode API key is configured yet.",
|
|
160
|
+
"Type the key into this command's input field and run again, or export it in the launch environment.",
|
|
161
|
+
...await services.keyReadonly() ? ["The credential reference is read-only in this deployment."] : [],
|
|
162
|
+
"This command never displays key values."
|
|
163
|
+
].join("\n")
|
|
164
|
+
};
|
|
177
165
|
if (invocation.signal.aborted) return {
|
|
178
166
|
kind: "success",
|
|
179
167
|
text: "Refresh cancelled."
|
|
@@ -184,7 +172,7 @@ function commandDefinitions(ctx, services) {
|
|
|
184
172
|
signal: invocation.signal
|
|
185
173
|
});
|
|
186
174
|
} catch {}
|
|
187
|
-
const lines = [
|
|
175
|
+
const lines = ["OpenCode API key configured. Zen and Go are enabled:"];
|
|
188
176
|
for (const route of routes) lines.push(await productStatus(ctx, services, PRODUCT_BY_ROUTE[route]));
|
|
189
177
|
return {
|
|
190
178
|
kind: "success",
|
package/lib/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { readySetHash } from "./snapshot.js";
|
|
|
4
4
|
import { CatalogManager } from "./catalog.js";
|
|
5
5
|
import { Config, DEFAULT_MAX_REQUEST_IMAGE_BYTES, DEFAULT_REQUEST_IMAGE_MAX_BYTES, DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, assertServiceable, resolveConfig } from "./config.js";
|
|
6
6
|
import { apiKeyOnlyAuth, describeCredential, resolveApiKeyFor, staticAuthBridge } from "./credentials.js";
|
|
7
|
+
import { keyConfigured, keyReadonly, storeApiKey } from "./keyring.js";
|
|
7
8
|
import { registerCommands } from "./commands.js";
|
|
8
9
|
import { buildRouteProvider } from "./transport.js";
|
|
9
10
|
import { resolveImageAttachmentAccess, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
@@ -155,11 +156,9 @@ function apply(ctx, config = {}) {
|
|
|
155
156
|
if (provider === void 0) return void 0;
|
|
156
157
|
return describeCredential(ctx, provider.apiKeyEnv);
|
|
157
158
|
},
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
await credentials.set(ref, value);
|
|
162
|
-
}
|
|
159
|
+
storeApiKey: (value) => storeApiKey(ctx, currentConfig.providers.values(), value),
|
|
160
|
+
keyConfigured: () => keyConfigured(ctx, currentConfig.providers.values()),
|
|
161
|
+
keyReadonly: () => keyReadonly(ctx, currentConfig.providers.values())
|
|
163
162
|
});
|
|
164
163
|
});
|
|
165
164
|
let source = () => config;
|
package/lib/keyring.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { SETTINGS_NAMESPACE } from "./index.js";
|
|
2
|
+
import { assertUsableApiKey } from "@deepseek-ai/dsh-llm";
|
|
3
|
+
//#region src/keyring.ts
|
|
4
|
+
/**
|
|
5
|
+
* Read one reference's presence, treating an absent credential service as a
|
|
6
|
+
* stable `false` rather than a throw: the caller wants facts, not a diagnosis.
|
|
7
|
+
*/
|
|
8
|
+
async function refState(ctx, ref) {
|
|
9
|
+
const credentials = ctx.get("credentials");
|
|
10
|
+
if (credentials === void 0) return {
|
|
11
|
+
configured: false,
|
|
12
|
+
writable: false
|
|
13
|
+
};
|
|
14
|
+
const described = await credentials.describe(ref);
|
|
15
|
+
return described === void 0 ? {
|
|
16
|
+
configured: false,
|
|
17
|
+
writable: false
|
|
18
|
+
} : {
|
|
19
|
+
configured: described.configured,
|
|
20
|
+
writable: described.writable
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Read the state a key store is about to operate against.
|
|
25
|
+
* @param ctx - the plugin context.
|
|
26
|
+
* @param providers - the currently resolved provider profiles.
|
|
27
|
+
* @returns the immutable snapshot for before/after comparison.
|
|
28
|
+
*/
|
|
29
|
+
async function snapshotKeyring(ctx, providers) {
|
|
30
|
+
const settings = ctx.get("settings");
|
|
31
|
+
const sectionValue = settings === void 0 ? void 0 : settings.get(SETTINGS_NAMESPACE);
|
|
32
|
+
const refs = /* @__PURE__ */ new Map();
|
|
33
|
+
for (const { apiKeyEnv } of providers) if (!refs.has(apiKeyEnv)) refs.set(apiKeyEnv, await refState(ctx, apiKeyEnv));
|
|
34
|
+
return {
|
|
35
|
+
section: JSON.stringify(sectionValue ?? null),
|
|
36
|
+
refs
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Verify a key store left no stray change. Compares the section snapshot and
|
|
41
|
+
* every reference the plugin knows with a stable JSON text, so a change in
|
|
42
|
+
* either is caught regardless of ordering.
|
|
43
|
+
* @param before - the snapshot taken before the store.
|
|
44
|
+
* @param after - the snapshot taken after the store.
|
|
45
|
+
* @param refs - the references the store wrote (the only ones allowed to move).
|
|
46
|
+
* @returns a failure line, or `undefined` when only the intended refs changed.
|
|
47
|
+
*/
|
|
48
|
+
function verifyNoStrayChange(before, after, refs) {
|
|
49
|
+
if (before.section !== after.section) return `opencode-live: the settings section changed while storing the key (${SETTINGS_NAMESPACE})`;
|
|
50
|
+
for (const [ref, state] of before.refs) {
|
|
51
|
+
const next = after.refs.get(ref);
|
|
52
|
+
if (next === void 0) return `opencode-live: credential ${ref} vanished after storing the key`;
|
|
53
|
+
if (state.configured === next.configured && state.writable === next.writable) continue;
|
|
54
|
+
if (!refs.includes(ref)) return `opencode-live: an unrelated credential reference changed (${ref})`;
|
|
55
|
+
}
|
|
56
|
+
for (const [ref, next] of after.refs) {
|
|
57
|
+
if (before.refs.has(ref)) continue;
|
|
58
|
+
if (!refs.includes(ref)) return `opencode-live: an unrelated credential reference appeared (${ref})`;
|
|
59
|
+
if (!next.configured) return `opencode-live: the stored reference ${ref} did not become configured`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Store one OpenCode key through the DSH credential seam.
|
|
64
|
+
*
|
|
65
|
+
* This is the whole write surface a command uses. Validation runs before any
|
|
66
|
+
* write, so a key that would be refused is never persisted. The write is
|
|
67
|
+
* fenced by `snapshotKeyring` before and after; a stray settings or unrelated
|
|
68
|
+
* credential change fails the call with a diagnostic instead of silently
|
|
69
|
+
* committing alongside the key.
|
|
70
|
+
* @param ctx - the plugin context.
|
|
71
|
+
* @param providers - the currently resolved provider profiles.
|
|
72
|
+
* @param value - the key typed into the command's input field.
|
|
73
|
+
* @returns `undefined` on success, or a failure line naming the exact problem.
|
|
74
|
+
*/
|
|
75
|
+
async function storeApiKey(ctx, providers, value) {
|
|
76
|
+
try {
|
|
77
|
+
assertUsableApiKey(value, "opencode-live", "input");
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return error.message;
|
|
80
|
+
}
|
|
81
|
+
const credentials = ctx.get("credentials");
|
|
82
|
+
if (credentials === void 0) return "opencode-live: no credentials service is mounted; export the API key in the launch environment instead";
|
|
83
|
+
const before = await snapshotKeyring(ctx, providers);
|
|
84
|
+
const targets = [...new Set([...providers].map(({ apiKeyEnv }) => apiKeyEnv))];
|
|
85
|
+
try {
|
|
86
|
+
for (const ref of targets) await credentials.set(ref, value);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return `opencode-live: could not store the API key (${error.message})`;
|
|
89
|
+
}
|
|
90
|
+
return verifyNoStrayChange(before, await snapshotKeyring(ctx, providers), targets);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Presence facts for the status path: whether a key is configured, without
|
|
94
|
+
* ever exposing a value.
|
|
95
|
+
* @param ctx - the plugin context.
|
|
96
|
+
* @param providers - the currently resolved provider profiles.
|
|
97
|
+
* @returns whether the shared reference is configured.
|
|
98
|
+
*/
|
|
99
|
+
async function keyConfigured(ctx, providers) {
|
|
100
|
+
const refs = [...new Set([...providers].map(({ apiKeyEnv }) => apiKeyEnv))];
|
|
101
|
+
return (await Promise.all(refs.map((ref) => refState(ctx, ref)))).some((state) => state.configured);
|
|
102
|
+
}
|
|
103
|
+
/** Whether any reference is read-only, so the status can say so. */
|
|
104
|
+
async function keyReadonly(ctx, providers) {
|
|
105
|
+
const refs = [...new Set([...providers].map(({ apiKeyEnv }) => apiKeyEnv))];
|
|
106
|
+
return (await Promise.all(refs.map((ref) => refState(ctx, ref)))).some((state) => !state.writable);
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
export { keyConfigured, keyReadonly, snapshotKeyring, storeApiKey, verifyNoStrayChange };
|
package/lib/transport.js
CHANGED
|
@@ -27,7 +27,7 @@ import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.l
|
|
|
27
27
|
*/
|
|
28
28
|
/** The plugin's honest client identification value. */
|
|
29
29
|
const PLUGIN_ID = "opencode-live";
|
|
30
|
-
const PLUGIN_VERSION = "0.1.
|
|
30
|
+
const PLUGIN_VERSION = "0.1.2";
|
|
31
31
|
/** Header OpenCode Go documents for coding-agent session identification. */
|
|
32
32
|
const SESSION_HEADER = "x-opencode-session";
|
|
33
33
|
/** Header carrying this plugin's honest client identity alongside DSH attribution. */
|