dsh-opencode 0.1.0
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/LICENSE +21 -0
- package/README.md +108 -0
- package/cordis.patch.yml +20 -0
- package/lib/adapter.js +109 -0
- package/lib/cache.js +171 -0
- package/lib/catalog.js +499 -0
- package/lib/commands.js +177 -0
- package/lib/config.d.ts +38 -0
- package/lib/config.js +142 -0
- package/lib/credentials.js +88 -0
- package/lib/index.d.ts +19 -0
- package/lib/index.js +208 -0
- package/lib/normalize.d.ts +10 -0
- package/lib/normalize.js +386 -0
- package/lib/snapshot.js +100 -0
- package/lib/transport.js +199 -0
- package/package.json +79 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 askdkc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# dsh-opencode — OpenCode Zen / Go live catalog plugin
|
|
2
|
+
|
|
3
|
+
An out-of-tree DSH bundle that serves the OpenCode **Zen** and **Go** products as two independent live LLM routes with a **dynamic model catalog**: models the official lists publish become selectable and executable without a plugin update, rebuild, or DSH restart, as long as they use a known wire protocol and publish the metadata the route needs.
|
|
4
|
+
|
|
5
|
+
- `opencode-zen-live` — **OpenCode Zen (Live)**
|
|
6
|
+
- `opencode-go-live` — **OpenCode Go (Live)**
|
|
7
|
+
|
|
8
|
+
The plugin reuses DSH's public `PiAiAdapter` and pi-ai's public `createProvider()` for all message conversion, streaming, tool calls, images, replay state, usage, cancellation, and idle-timeout behavior. It does **not** port OAuth, does not modify the existing `llm-pi-ai` configuration, does not fail over between Zen and Go automatically, and never sends an inference request as part of a catalog refresh.
|
|
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
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pnpm dsh plugin --profile web add github:askdkc/dsh-opencode
|
|
21
|
+
pnpm dsh --profile web --dump-config
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The dump should contain the `opencode-live` row with both provider routes. A restart applies the bundle once; after that, catalog updates need no restart.
|
|
25
|
+
|
|
26
|
+
To remove it:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pnpm dsh plugin --profile web remove dsh-opencode
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## API key
|
|
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:
|
|
43
|
+
|
|
44
|
+
| State | Meaning | Selector | Diagnostics |
|
|
45
|
+
|---|---|---|---|
|
|
46
|
+
| `ready` | officially listed with complete metadata and a known wire API | shown | — |
|
|
47
|
+
| `metadata-pending` | officially listed; missing name/context/output/input/api | hidden | shown with the missing fields |
|
|
48
|
+
| `unsupported-protocol` | metadata names an unknown SDK | hidden | shown with the SDK id |
|
|
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 |
|
|
51
|
+
|
|
52
|
+
Wire APIs are chosen from the model-level `provider.npm` (falling back to the provider default) through a fixed allowlist: `@ai-sdk/openai-compatible` → Chat Completions, `@ai-sdk/openai` → Responses, `@ai-sdk/anthropic` → Anthropic Messages, `@ai-sdk/google` → Google Generative AI. Unknown SDK identifiers are never executed and nothing fetched is ever imported or installed. Reasoning-effort controls are offered only where the source publishes a verified effort list; toggle-only models keep the provider's default behavior.
|
|
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 |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `/dsh-opencode` | After the key is stored securely, refreshes both catalogs and enables Zen/Go immediately; never accepts key values |
|
|
61
|
+
| `/opencode-refresh [all\|zen\|go]` | Force a catalog refresh (joins in-flight fetches) |
|
|
62
|
+
| `/opencode-status` | Freshness, per-source errors, ready/pending counts, credential presence |
|
|
63
|
+
| `/opencode-models <zen\|go> [--all]` | Ready models, or every candidate with its state and reason |
|
|
64
|
+
|
|
65
|
+
None of these commands displays key values or fragments, and none registers a model-visible tool.
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
Namespace `opencode-live` (composition defaults in `cordis.patch.yml`):
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
providers:
|
|
73
|
+
opencode-zen-live:
|
|
74
|
+
product: zen # must match the route key
|
|
75
|
+
apiKeyEnv: OPENCODE_API_KEY
|
|
76
|
+
opencode-go-live:
|
|
77
|
+
product: go
|
|
78
|
+
apiKeyEnv: OPENCODE_API_KEY
|
|
79
|
+
catalog:
|
|
80
|
+
refreshIntervalMs: 900000 # periodic refresh (with jitter)
|
|
81
|
+
listRevalidateAfterMs: 60000 # re-check TTL on display/selection
|
|
82
|
+
timeoutMs: 15000 # per-request HTTP timeout
|
|
83
|
+
maxStaleMs: 604800000 # 7 days; staleness display bound
|
|
84
|
+
requireFresh: false # true refuses requests while the official list is stale
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`cachePath` (optional) relocates the validated-source cache; it defaults to `cache/opencode-live/catalog.json` under the DSH home. The cache stores source payloads and freshness facts only — never normalized output, never secrets.
|
|
88
|
+
|
|
89
|
+
`limit.output` sizes the model's capability; it is never materialized as a per-request token default. `configuredMaxTokens` stays empty by design: only explicit per-model configuration belongs there, and this plugin defines none.
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
pnpm install
|
|
95
|
+
pnpm typecheck # tsc --noEmit
|
|
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
|
|
100
|
+
```
|
|
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/cordis.patch.yml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Add the two live OpenCode routes and this plugin's composition entry.
|
|
2
|
+
# This patch never touches llm-pi-ai, openai-codex, or any other existing row:
|
|
3
|
+
# unlike a targeted replace, an insert cannot lose another bundle's config.
|
|
4
|
+
- insert:
|
|
5
|
+
- id: opencode-live
|
|
6
|
+
name: './lib/index.js'
|
|
7
|
+
config:
|
|
8
|
+
providers:
|
|
9
|
+
opencode-zen-live:
|
|
10
|
+
product: zen
|
|
11
|
+
apiKeyEnv: OPENCODE_API_KEY
|
|
12
|
+
opencode-go-live:
|
|
13
|
+
product: go
|
|
14
|
+
apiKeyEnv: OPENCODE_API_KEY
|
|
15
|
+
catalog:
|
|
16
|
+
refreshIntervalMs: 900000
|
|
17
|
+
listRevalidateAfterMs: 60000
|
|
18
|
+
timeoutMs: 15000
|
|
19
|
+
maxStaleMs: 604800000
|
|
20
|
+
requireFresh: false
|
package/lib/adapter.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { PRODUCT_BY_ROUTE, describeNonReadyState } from "./normalize.js";
|
|
2
|
+
import { LlmError } from "@deepseek-ai/dsh-llm";
|
|
3
|
+
import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
|
|
4
|
+
//#region src/adapter.ts
|
|
5
|
+
/**
|
|
6
|
+
* The live OpenCode adapter: catalog awareness on top of the public
|
|
7
|
+
* `PiAiAdapter`.
|
|
8
|
+
*
|
|
9
|
+
* Message conversion, streaming, tool calls, images, replay state, usage,
|
|
10
|
+
* cancellation, and idle timeouts stay entirely delegated. This subclass adds
|
|
11
|
+
* only what a dynamic catalog requires: waiting for the first catalog data,
|
|
12
|
+
* revalidating on display, forcing one refresh for an unknown model, refusing
|
|
13
|
+
* non-ready or stale candidates with their reason, and refusing tool-bearing
|
|
14
|
+
* requests on models known not to support tools.
|
|
15
|
+
*
|
|
16
|
+
* Every refusal names the candidate state; nothing is silently dropped and no
|
|
17
|
+
* model is substituted.
|
|
18
|
+
*
|
|
19
|
+
* @module opencode-live/adapter
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* pi-ai-backed adapter over the two live OpenCode routes.
|
|
23
|
+
*
|
|
24
|
+
* Each operation reads the current catalog snapshot, so a published catalog
|
|
25
|
+
* change reaches the next request; a prepared call keeps the snapshot its
|
|
26
|
+
* generation captured, because the stream it returns is bound to that
|
|
27
|
+
* generation's provider.
|
|
28
|
+
*/
|
|
29
|
+
var LiveOpenCodeAdapter = class extends PiAiAdapter {
|
|
30
|
+
catalog;
|
|
31
|
+
initialWaitMs;
|
|
32
|
+
requireFresh;
|
|
33
|
+
constructor(options) {
|
|
34
|
+
super(options);
|
|
35
|
+
this.catalog = options.catalog;
|
|
36
|
+
this.initialWaitMs = options.initialWaitMs;
|
|
37
|
+
this.requireFresh = options.requireFresh;
|
|
38
|
+
}
|
|
39
|
+
/** Apply updated catalog-related settings without re-registering. */
|
|
40
|
+
updateOptions(options) {
|
|
41
|
+
this.initialWaitMs = options.initialWaitMs;
|
|
42
|
+
this.requireFresh = options.requireFresh;
|
|
43
|
+
}
|
|
44
|
+
/** The product behind one owned route, or the not-owned failure. */
|
|
45
|
+
productOf(provider) {
|
|
46
|
+
const product = PRODUCT_BY_ROUTE[provider];
|
|
47
|
+
if (product === void 0) throw new LlmError(`opencode-live: adapter does not own provider "${provider}"`, "NO_ADAPTER");
|
|
48
|
+
return product;
|
|
49
|
+
}
|
|
50
|
+
/** The candidate for one route/model pair in the current snapshot. */
|
|
51
|
+
candidateOf(provider, model) {
|
|
52
|
+
const snapshot = this.catalog.current;
|
|
53
|
+
if (snapshot === void 0) return void 0;
|
|
54
|
+
return snapshot.products[this.productOf(provider)].candidates.get(model);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Make one route/model pair selectable: wait for initial data, revalidate
|
|
58
|
+
* the list if its TTL expired, and force exactly one refresh for an unknown
|
|
59
|
+
* id before refusing with the candidate's state.
|
|
60
|
+
* @param provider - the route the request names.
|
|
61
|
+
* @param model - the exact model id the request names.
|
|
62
|
+
*/
|
|
63
|
+
async ensureSelectable(provider, model) {
|
|
64
|
+
const product = this.productOf(provider);
|
|
65
|
+
await this.catalog.ensureInitial(this.initialWaitMs);
|
|
66
|
+
this.catalog.revalidateIfNeeded(product);
|
|
67
|
+
let candidate = this.candidateOf(provider, model);
|
|
68
|
+
if (candidate === void 0) {
|
|
69
|
+
await this.catalog.forceRefreshOnce(product);
|
|
70
|
+
candidate = this.candidateOf(provider, model);
|
|
71
|
+
}
|
|
72
|
+
if (candidate === void 0) throw new LlmError(`opencode-live: product "${product}" has no model "${model}" in the current catalog`, "UNKNOWN_MODEL");
|
|
73
|
+
if (candidate.state !== "ready") throw new LlmError(`opencode-live: model "${model}" on "${product}" is not executable: ${describeNonReadyState(candidate)}`, "UNKNOWN_MODEL");
|
|
74
|
+
const view = this.catalog.current?.products[product];
|
|
75
|
+
if (this.requireFresh && view?.stale === true) throw new LlmError(`opencode-live: the official "${product}" list is stale and catalog.requireFresh is set; refresh the catalog or relax the setting`, "STALE_CATALOG");
|
|
76
|
+
}
|
|
77
|
+
/** Refuse tool-bearing requests on models known not to support tools. */
|
|
78
|
+
guardTools(options) {
|
|
79
|
+
if (options.tools === void 0 || options.tools.length === 0) return;
|
|
80
|
+
if (this.candidateOf(options.provider, options.model)?.tools === false) throw new LlmError(`opencode-live: model "${options.model}" on "${options.provider}" does not support tool calls; send this request without tools or select a tool-capable model`, "UNSUPPORTED_CAPABILITY");
|
|
81
|
+
}
|
|
82
|
+
async listModels(provider) {
|
|
83
|
+
const product = this.productOf(provider);
|
|
84
|
+
await this.catalog.ensureInitial(this.initialWaitMs);
|
|
85
|
+
this.catalog.revalidateIfNeeded(product);
|
|
86
|
+
return super.listModels(provider);
|
|
87
|
+
}
|
|
88
|
+
async resolveModel(provider, model, signal) {
|
|
89
|
+
await this.ensureSelectable(provider, model);
|
|
90
|
+
return super.resolveModel(provider, model, signal);
|
|
91
|
+
}
|
|
92
|
+
async prepareCall(provider, model, signal) {
|
|
93
|
+
await this.ensureSelectable(provider, model);
|
|
94
|
+
const prepared = await super.prepareCall(provider, model, signal);
|
|
95
|
+
return {
|
|
96
|
+
model: prepared.model,
|
|
97
|
+
stream: (options) => {
|
|
98
|
+
this.guardTools(options);
|
|
99
|
+
return prepared.stream(options);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
stream(options) {
|
|
104
|
+
this.guardTools(options);
|
|
105
|
+
return super.stream(options);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
//#endregion
|
|
109
|
+
export { LiveOpenCodeAdapter };
|
package/lib/cache.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
|
|
2
|
+
/** Whether one stored source state carries only the bounded display facts. */
|
|
3
|
+
function isSourceState(value) {
|
|
4
|
+
if (typeof value !== "object" || value === null) return false;
|
|
5
|
+
const record = value;
|
|
6
|
+
return [...Object.values(record)].every((member) => typeof member === "string" || typeof member === "number") && Object.keys(record).every((key) => [
|
|
7
|
+
"lastCheckedAt",
|
|
8
|
+
"lastSuccessfulAt",
|
|
9
|
+
"etag",
|
|
10
|
+
"lastErrorCode",
|
|
11
|
+
"lastErrorMessage"
|
|
12
|
+
].includes(key));
|
|
13
|
+
}
|
|
14
|
+
/** Whether one stored official list is structurally valid. */
|
|
15
|
+
function isCachedOfficialList(value) {
|
|
16
|
+
if (typeof value !== "object" || value === null) return false;
|
|
17
|
+
const record = value;
|
|
18
|
+
return isSourceState(record.state) && typeof record.fetchedAt === "number" && Number.isFinite(record.fetchedAt) && Array.isArray(record.ids) && record.ids.every((id) => typeof id === "string" && id.length > 0);
|
|
19
|
+
}
|
|
20
|
+
/** Whether one stored Models.dev slice is structurally valid. */
|
|
21
|
+
function isCachedModelsDev(value) {
|
|
22
|
+
if (typeof value !== "object" || value === null) return false;
|
|
23
|
+
const record = value;
|
|
24
|
+
if (!isSourceState(record.state) || typeof record.fetchedAt !== "number" || !Number.isFinite(record.fetchedAt) || typeof record.providers !== "object" || record.providers === null) return false;
|
|
25
|
+
return Object.values(record.providers).every((provider) => typeof provider === "object" && provider !== null && typeof provider.models === "object");
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Load and validate the cache file. Any structural defect — wrong version,
|
|
29
|
+
* wrong normalizer, malformed source entry — refuses the whole file rather
|
|
30
|
+
* than trusting a partial restore.
|
|
31
|
+
* @param path - the cache file path.
|
|
32
|
+
* @returns the cached sources, or `undefined` when absent or invalid.
|
|
33
|
+
*/
|
|
34
|
+
async function loadCache(path) {
|
|
35
|
+
let raw;
|
|
36
|
+
try {
|
|
37
|
+
const { readFile } = await import("node:fs/promises");
|
|
38
|
+
raw = JSON.parse(await readFile(path, "utf8"));
|
|
39
|
+
} catch {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (typeof raw !== "object" || raw === null) return void 0;
|
|
43
|
+
const file = raw;
|
|
44
|
+
if (file.version !== 1 || file.normalizer !== 1) return void 0;
|
|
45
|
+
if (typeof file.savedAt !== "number" || !Number.isFinite(file.savedAt)) return void 0;
|
|
46
|
+
if (typeof file.sources !== "object" || file.sources === null) return void 0;
|
|
47
|
+
const sources = file.sources;
|
|
48
|
+
const cached = {};
|
|
49
|
+
if (isCachedOfficialList(sources["zen-list"])) cached.zenList = sources["zen-list"];
|
|
50
|
+
if (isCachedOfficialList(sources["go-list"])) cached.goList = sources["go-list"];
|
|
51
|
+
if (isCachedModelsDev(sources["models-dev"])) cached.modelsDev = sources["models-dev"];
|
|
52
|
+
if (cached.zenList === void 0 && cached.goList === void 0 && cached.modelsDev === void 0) return;
|
|
53
|
+
return cached;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Persist the payload under a file lock and an atomic replacement, merging
|
|
57
|
+
* per source so a slower writer can never erase a fresher entry another
|
|
58
|
+
* completion already stored: each source keeps whichever entry — the stored
|
|
59
|
+
* one or the incoming one — validated more recently, and a source the caller
|
|
60
|
+
* does not name leaves the stored entry untouched.
|
|
61
|
+
*
|
|
62
|
+
* A write failure is reported to the caller — the in-memory catalog stays
|
|
63
|
+
* usable — and the caller decides what the loss means for later restarts.
|
|
64
|
+
* @param path - the cache file path.
|
|
65
|
+
* @param payload - the validated source payloads to store.
|
|
66
|
+
* @param savedAt - the wall-clock stamp for the entry.
|
|
67
|
+
*/
|
|
68
|
+
async function saveCache(path, payload, savedAt) {
|
|
69
|
+
const incoming = buildFile(payload, savedAt);
|
|
70
|
+
await withFileLock(path, async () => {
|
|
71
|
+
const existing = await readCacheFile(path);
|
|
72
|
+
const merged = existing === void 0 ? incoming : mergeFiles(existing, incoming);
|
|
73
|
+
await writeFileAtomic(path, JSON.stringify(merged), {
|
|
74
|
+
mode: 384,
|
|
75
|
+
dirMode: 448
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/** Assemble one cache file image from a payload. */
|
|
80
|
+
function buildFile(payload, savedAt) {
|
|
81
|
+
return {
|
|
82
|
+
version: 1,
|
|
83
|
+
normalizer: 1,
|
|
84
|
+
savedAt,
|
|
85
|
+
sources: {
|
|
86
|
+
...payload.zenList === void 0 ? {} : { "zen-list": {
|
|
87
|
+
state: payload.zenList.state,
|
|
88
|
+
fetchedAt: savedAt,
|
|
89
|
+
ids: payload.zenList.ids
|
|
90
|
+
} },
|
|
91
|
+
...payload.goList === void 0 ? {} : { "go-list": {
|
|
92
|
+
state: payload.goList.state,
|
|
93
|
+
fetchedAt: savedAt,
|
|
94
|
+
ids: payload.goList.ids
|
|
95
|
+
} },
|
|
96
|
+
...payload.modelsDev === void 0 ? {} : { "models-dev": {
|
|
97
|
+
state: payload.modelsDev.state,
|
|
98
|
+
fetchedAt: savedAt,
|
|
99
|
+
providers: payload.modelsDev.providers
|
|
100
|
+
} }
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** The success stamp ordering one merge decision. */
|
|
105
|
+
function successAt(entry) {
|
|
106
|
+
return entry.state.lastSuccessfulAt ?? -1;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Merge two cache file images per source: the entry whose source confirmed
|
|
110
|
+
* more recently wins; an equally fresh entry is taken from the incoming file
|
|
111
|
+
* because its state carries the most recent check outcome. Sources absent
|
|
112
|
+
* from the incoming image keep the stored entry, which is what stops a
|
|
113
|
+
* partial or failed refresh from erasing another source's data.
|
|
114
|
+
*/
|
|
115
|
+
function mergeFiles(existing, incoming) {
|
|
116
|
+
const sources = {};
|
|
117
|
+
const target = sources;
|
|
118
|
+
for (const key of [
|
|
119
|
+
"zen-list",
|
|
120
|
+
"go-list",
|
|
121
|
+
"models-dev"
|
|
122
|
+
]) {
|
|
123
|
+
const prior = existing.sources[key];
|
|
124
|
+
const next = incoming.sources[key];
|
|
125
|
+
if (next === void 0 && prior === void 0) continue;
|
|
126
|
+
if (next === void 0) {
|
|
127
|
+
target[key] = prior;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (prior === void 0 || successAt(next) >= successAt(prior)) {
|
|
131
|
+
target[key] = next;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
target[key] = prior;
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
version: incoming.version,
|
|
138
|
+
normalizer: incoming.normalizer,
|
|
139
|
+
savedAt: Math.max(incoming.savedAt, existing.savedAt),
|
|
140
|
+
sources
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** Read and parse the cache file without validation judgment. */
|
|
144
|
+
async function readCacheFile(path) {
|
|
145
|
+
try {
|
|
146
|
+
const { readFile } = await import("node:fs/promises");
|
|
147
|
+
const raw = JSON.parse(await readFile(path, "utf8"));
|
|
148
|
+
if (typeof raw !== "object" || raw === null) return void 0;
|
|
149
|
+
const file = raw;
|
|
150
|
+
if (file.version !== 1 || file.normalizer !== 1) return void 0;
|
|
151
|
+
if (typeof file.savedAt !== "number" || !Number.isFinite(file.savedAt)) return void 0;
|
|
152
|
+
if (typeof file.sources !== "object" || file.sources === null) return void 0;
|
|
153
|
+
return file;
|
|
154
|
+
} catch {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Rebuild a validated official list from stored ids. The ids were validated
|
|
160
|
+
* when fetched; restoring keeps them exact.
|
|
161
|
+
* @param product - which product's list this is (used only by the caller).
|
|
162
|
+
* @param cached - the stored entry.
|
|
163
|
+
* @returns the official list shape the normalizer consumes.
|
|
164
|
+
*/
|
|
165
|
+
function restoreOfficialList(_product, cached) {
|
|
166
|
+
const models = /* @__PURE__ */ new Map();
|
|
167
|
+
for (const id of cached.ids) models.set(id, { id });
|
|
168
|
+
return { models };
|
|
169
|
+
}
|
|
170
|
+
//#endregion
|
|
171
|
+
export { loadCache, restoreOfficialList, saveCache };
|