@sigrun/provider-opencode 0.0.0-stage → 0.1.1
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/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/README.md +78 -2
- package/dist/index.d.ts +116 -0
- package/dist/index.js +249 -0
- package/package.json +50 -5
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.1] - 2026-10-27
|
|
9
|
+
### Changed
|
|
10
|
+
- Re-released through CI trusted publishing; this version carries SLSA
|
|
11
|
+
provenance. No functional change from 0.1.0.
|
|
12
|
+
|
|
13
|
+
## [Unreleased]
|
|
14
|
+
|
|
15
|
+
## [0.1.0] - 2026-10-18
|
|
16
|
+
### Changed
|
|
17
|
+
- Renamed to `@sigrun/provider-opencode` for the 0.1.0 release (GitHub
|
|
18
|
+
repo `sigrun-ai/sigrun-provider-opencode`): published package metadata
|
|
19
|
+
(`publishConfig` access/provenance), host modules
|
|
20
|
+
(`@earendil-works/pi-ai`, `@earendil-works/pi-durable`) moved to
|
|
21
|
+
`peerDependencies` mirrored in `devDependencies` at exact `1.3.0`,
|
|
22
|
+
CHANGELOG.md joined the files whitelist, and a manifest hygiene check
|
|
23
|
+
wired into `npm test`. The contributor identity follows the package
|
|
24
|
+
slug: the factory result's `name` becomes `provider-opencode` and the
|
|
25
|
+
scoped settings file `settings/provider-opencode.json` (the loader
|
|
26
|
+
pre-keys the two-phase host from the package name; a stale declared
|
|
27
|
+
name would split the factory-time settings scope).
|
|
28
|
+
### Added
|
|
29
|
+
- Deployment-configurable base URL: the (now async) factory reads
|
|
30
|
+
`baseUrl` from the plugin's scoped settings
|
|
31
|
+
(`settings/provider-opencode.json`) with the constant as fallback; a
|
|
32
|
+
malformed override fails the factory loudly instead of silently
|
|
33
|
+
falling back (mirrors sigrun-pareto; restart-required).
|
|
34
|
+
### Added
|
|
35
|
+
- Sigrun provider plugin: OpenCode's paid Go tier
|
|
36
|
+
(`https://opencode.ai/zen/go/v1`) as one dynamic OpenAI-compatible
|
|
37
|
+
provider (`provider: "opencode"` stamped on every catalog entry so
|
|
38
|
+
persisted catalogs survive cold-boot restore). The `/zen/v1` free tier
|
|
39
|
+
is client-gated and out of scope.
|
|
40
|
+
- Provider-only contributor factory: `{ name: "provider-opencode",
|
|
41
|
+
providers: [{ provider, readiness }], health }` — no pi-durable
|
|
42
|
+
extension surface.
|
|
43
|
+
- Credential resolution through sigrun's auth.json bridge with
|
|
44
|
+
`OPENCODE_GO_API_KEY` as the ambient fallback (bridge credential
|
|
45
|
+
always wins).
|
|
46
|
+
- Three-state readiness tracker (`not_configured` / `degraded` /
|
|
47
|
+
`ready`) seeded from credential resolution and updated per refresh
|
|
48
|
+
outcome; `console.warn` on state transitions only. `health()` maps
|
|
49
|
+
`not_configured` to `"ok"` and configured-and-failing to
|
|
50
|
+
`"degraded"`.
|
|
51
|
+
- `fetchModels` throws on unconfigured and on failure — never returns
|
|
52
|
+
`[]` (an empty array would publish and wipe the persisted catalog).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael MacLeod
|
|
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
CHANGED
|
@@ -1,3 +1,79 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sigrun/provider-opencode
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A [sigrun](https://github.com/sigrun-ai) provider plugin:
|
|
4
|
+
[OpenCode](https://opencode.ai)'s **paid Go tier**
|
|
5
|
+
(`https://opencode.ai/zen/go/v1`, OpenAI-compatible) as one dynamic chat
|
|
6
|
+
provider. The `/zen/v1` free tier is client-gated by design (FreeTierError
|
|
7
|
+
for non-CLI requests) and is out of scope.
|
|
8
|
+
|
|
9
|
+
## What it contributes
|
|
10
|
+
|
|
11
|
+
A **provider-only contributor** — the default export is a factory
|
|
12
|
+
returning `{ name: "provider-opencode", providers: [{ provider, readiness }],
|
|
13
|
+
health }` with no pi-durable extension surface. Sigrun's loader merges
|
|
14
|
+
the provider into its `Models` store at boot.
|
|
15
|
+
|
|
16
|
+
- **Provider** `opencode` — dynamic catalog fetched from
|
|
17
|
+
`<base>/models` (OpenAI `[OI]` list shape; entries carry
|
|
18
|
+
`id`/`object`/`created`/`owned_by` and no capability metadata) over
|
|
19
|
+
`openai-completions`; boots with an empty baseline and publishes the
|
|
20
|
+
fetched catalog.
|
|
21
|
+
- **Readiness** (`not_configured` / `degraded` / `ready`, surfaced on
|
|
22
|
+
sigrun's `/v1/auth/check`) — seeded from credential resolution and
|
|
23
|
+
updated per refresh outcome; state transitions log via `console.warn`
|
|
24
|
+
(transitions only, never per failure). On the first configured boot
|
|
25
|
+
the provider announces `startup → ready` at credential *resolution* —
|
|
26
|
+
before any refresh has run (`ready` there means "credential resolved,
|
|
27
|
+
refresh not yet attempted", not "catalog verified").
|
|
28
|
+
- **Health** — `degraded` only when the provider is configured and its
|
|
29
|
+
last refresh failed; an unconfigured optional plugin reports `ok`.
|
|
30
|
+
|
|
31
|
+
## Credentials
|
|
32
|
+
|
|
33
|
+
Resolved per request through sigrun's auth.json bridge, falling back to
|
|
34
|
+
the ambient environment:
|
|
35
|
+
|
|
36
|
+
1. the bridged auth.json credential (`"default"` entry, then the
|
|
37
|
+
`"opencode"` entry), then
|
|
38
|
+
2. `OPENCODE_GO_API_KEY`.
|
|
39
|
+
|
|
40
|
+
The plugin never reads auth.json or `process.env` itself. Rotating the
|
|
41
|
+
key applies on the next resolution — no restart needed (there are no
|
|
42
|
+
plugin settings; the endpoint is fixed).
|
|
43
|
+
|
|
44
|
+
## Failure semantics
|
|
45
|
+
|
|
46
|
+
`fetchModels` **throws** when unconfigured or when the catalog fetch
|
|
47
|
+
fails — it never returns an empty array, which pi-ai would publish and
|
|
48
|
+
persist, wiping the catalog. A failed refresh retains the previous
|
|
49
|
+
catalog and records a per-provider error.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```jsonc
|
|
54
|
+
// settings.json (sigrun config dir) — the UNIFIED extensions list:
|
|
55
|
+
// install + load in one place; npm and git specifiers are the same list
|
|
56
|
+
// npm ("@sigrun/provider-opencode@^0.1.0"), git
|
|
57
|
+
// ("git:github.com/sigrun-ai/sigrun-provider-opencode", or any
|
|
58
|
+
// https git URL with an optional @ref), or the file: form below.
|
|
59
|
+
{
|
|
60
|
+
"extensions": ["file:/path/to/sigrun-opencode"]
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The committed `dist/` is the load target for git installs; pi-ai and
|
|
65
|
+
pi-durable are peerDependencies (sigrun supplies them at runtime);
|
|
66
|
+
they are mirrored in devDependencies for this repo's own build and tests.
|
|
67
|
+
|
|
68
|
+
## Development
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
npm install # also builds dist/ via prepare
|
|
72
|
+
npm test # vitest — hermetic fixture HTTP server, no network
|
|
73
|
+
npm run typecheck
|
|
74
|
+
npm run build
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sigrun/provider-opencode — a sigrun provider plugin (provider plugins Task 8):
|
|
3
|
+
* OpenCode's PAID Go tier (`https://opencode.ai/zen/go/v1`) as one
|
|
4
|
+
* dynamic OpenAI-compatible provider. The `/zen/v1` free tier is
|
|
5
|
+
* client-gated by design (FreeTierError for non-CLI requests) and is out
|
|
6
|
+
* of scope.
|
|
7
|
+
*
|
|
8
|
+
* Structurally identical to sigrun-ollama (the Task 7 reference plugin):
|
|
9
|
+
* the default export is the sigrun extension-package factory returning a
|
|
10
|
+
* PROVIDER-ONLY contributor — `{ name, providers, health }` with no
|
|
11
|
+
* `extension` field — consumed structurally by sigrun's loader. The only
|
|
12
|
+
* intended deltas from the reference are the provider id, the base URL,
|
|
13
|
+
* and the ambient env key. Credentials resolve through sigrun's auth.json
|
|
14
|
+
* bridge (the stored credential arrives as `credential` on the auth
|
|
15
|
+
* resolver, ambient env as `ctx.env`) with `OPENCODE_GO_API_KEY` as the
|
|
16
|
+
* fallback; the plugin never reads auth.json or `process.env` itself.
|
|
17
|
+
*
|
|
18
|
+
* Runtime imports from pi-ai are deliberate (`createProvider` + the
|
|
19
|
+
* OpenAI-completions api); the package still declares pi-ai/pi-durable as
|
|
20
|
+
* devDependencies only, because sigrun (the host) ships them and
|
|
21
|
+
* `syncPackages` installs with `--omit=dev` — the provider-plugins spec's
|
|
22
|
+
* dependency posture.
|
|
23
|
+
*/
|
|
24
|
+
import { type Provider } from "@earendil-works/pi-ai/models";
|
|
25
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
26
|
+
/** The provider id models are registered (and stamped) under. */
|
|
27
|
+
export declare const OPENCODE_PROVIDER_ID = "opencode";
|
|
28
|
+
/**
|
|
29
|
+
* OpenCode's paid Go tier (OpenAI-compatible endpoint). The operator can
|
|
30
|
+
* override per deployment via the plugin's scoped settings (`baseUrl`) —
|
|
31
|
+
* see {@link resolveOpencodeBaseUrl}.
|
|
32
|
+
*/
|
|
33
|
+
export declare const OPENCODE_BASE_URL = "https://opencode.ai/zen/go/v1";
|
|
34
|
+
/** Ambient fallback when auth.json holds no credential. */
|
|
35
|
+
export declare const OPENCODE_API_KEY_ENV = "OPENCODE_GO_API_KEY";
|
|
36
|
+
/**
|
|
37
|
+
* Structural slice of sigrun's seam host (src/harness/seam-host.ts) a
|
|
38
|
+
* provider plugin may consume. Declared here — structurally, like the
|
|
39
|
+
* bridge's pi-ai re-declarations — because the plugin cannot import
|
|
40
|
+
* sigrun core. The scoped settings store is async-only, which is why the
|
|
41
|
+
* loader awaits factory results.
|
|
42
|
+
*/
|
|
43
|
+
export interface SigrunPluginHost {
|
|
44
|
+
readonly stores: {
|
|
45
|
+
/** This plugin's own `settings/<slug>.json`; absent file → `{}`. */
|
|
46
|
+
readonly settings: {
|
|
47
|
+
get(): Promise<Record<string, unknown>>;
|
|
48
|
+
set(value: Record<string, unknown>): Promise<void>;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Sigrun's three-state per-provider readiness vocabulary (Task 6). */
|
|
53
|
+
export type ProviderReadinessStatus = "not_configured" | "degraded" | "ready";
|
|
54
|
+
/** One tracker report: the status plus optional operator-facing detail. */
|
|
55
|
+
export interface ProviderReadiness {
|
|
56
|
+
readonly status: ProviderReadinessStatus;
|
|
57
|
+
readonly detail?: string;
|
|
58
|
+
}
|
|
59
|
+
/** Sigrun's §3 health vocabulary. */
|
|
60
|
+
export type HealthStatus = "ok" | "degraded";
|
|
61
|
+
/**
|
|
62
|
+
* The provider-plugin factory result (the loader's Task 1 contract):
|
|
63
|
+
* a provider-only contributor — `{ name, providers, health? }`, no
|
|
64
|
+
* `extension`. Structural, like the host slice above.
|
|
65
|
+
*/
|
|
66
|
+
export interface ProviderPluginResult {
|
|
67
|
+
readonly name: string;
|
|
68
|
+
readonly providers: readonly {
|
|
69
|
+
readonly provider: Provider;
|
|
70
|
+
readonly readiness?: () => ProviderReadiness;
|
|
71
|
+
}[];
|
|
72
|
+
readonly health?: () => HealthStatus;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Parse an OpenAI-compatible `[OI]` `/models` body into pi-ai chat
|
|
76
|
+
* models. Every entry is stamped `provider: "opencode"` — pi-ai's
|
|
77
|
+
* restore path drops stored models whose `provider` field differs from
|
|
78
|
+
* the registered id, so an unstamped catalog silently vanishes on cold
|
|
79
|
+
* boot. Malformed bodies throw (a refresh failure retains the previous
|
|
80
|
+
* catalog; garbage must never publish). Extra entry fields (the live
|
|
81
|
+
* endpoint serves id/object/created/owned_by) are ignored.
|
|
82
|
+
*/
|
|
83
|
+
export declare function parseOpencodeCatalog(body: unknown, baseUrl: string): Model<"openai-completions">[];
|
|
84
|
+
/** One built plugin: the contribution's parts, sharing the tracker closure. */
|
|
85
|
+
export interface OpencodePlugin {
|
|
86
|
+
readonly provider: Provider;
|
|
87
|
+
/** Synchronous read over plugin-owned state (never network/storage). */
|
|
88
|
+
readonly readiness: () => ProviderReadiness;
|
|
89
|
+
readonly health: () => HealthStatus;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Build the OpenCode Go provider plus its readiness/health trackers.
|
|
93
|
+
* `baseUrl` is overridable so hermetic tests drive a fixture server; the
|
|
94
|
+
* default is the real Go-tier endpoint.
|
|
95
|
+
*/
|
|
96
|
+
export declare function createOpencodePlugin(baseUrl?: string): OpencodePlugin;
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the plugin's base URL from its scoped settings: `baseUrl`
|
|
99
|
+
* (string) overrides the {@link OPENCODE_BASE_URL} constant. Absent → the
|
|
100
|
+
* constant; present-but-not-a-non-empty-string → throw (a malformed
|
|
101
|
+
* override must quarantine loudly, not silently fall back to a possibly
|
|
102
|
+
* wrong constant).
|
|
103
|
+
*/
|
|
104
|
+
export declare function resolveOpencodeBaseUrl(settings: Record<string, unknown>): string;
|
|
105
|
+
/**
|
|
106
|
+
* The sigrun extension-package factory (provider-only contributor).
|
|
107
|
+
* Arity 1 declares the host-taking signature — sigrun's loader passes the
|
|
108
|
+
* seam host only to factories that declare one (`factory.length > 0`) —
|
|
109
|
+
* and the factory is ASYNC because the scoped settings store is
|
|
110
|
+
* async-only; sigrun's loader awaits factory results. The base URL is
|
|
111
|
+
* deployment-configurable through the scoped settings (`baseUrl` in
|
|
112
|
+
* `settings/provider-opencode.json`, constant fallback): deployments behind
|
|
113
|
+
* a proxy or mirror — and hermetic test fixtures — redirect the endpoint
|
|
114
|
+
* that way (restart-required, exactly like sigrun-pareto/sigrun-bifrost).
|
|
115
|
+
*/
|
|
116
|
+
export default function sigrunOpencode(host: SigrunPluginHost): Promise<ProviderPluginResult>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sigrun/provider-opencode — a sigrun provider plugin (provider plugins Task 8):
|
|
3
|
+
* OpenCode's PAID Go tier (`https://opencode.ai/zen/go/v1`) as one
|
|
4
|
+
* dynamic OpenAI-compatible provider. The `/zen/v1` free tier is
|
|
5
|
+
* client-gated by design (FreeTierError for non-CLI requests) and is out
|
|
6
|
+
* of scope.
|
|
7
|
+
*
|
|
8
|
+
* Structurally identical to sigrun-ollama (the Task 7 reference plugin):
|
|
9
|
+
* the default export is the sigrun extension-package factory returning a
|
|
10
|
+
* PROVIDER-ONLY contributor — `{ name, providers, health }` with no
|
|
11
|
+
* `extension` field — consumed structurally by sigrun's loader. The only
|
|
12
|
+
* intended deltas from the reference are the provider id, the base URL,
|
|
13
|
+
* and the ambient env key. Credentials resolve through sigrun's auth.json
|
|
14
|
+
* bridge (the stored credential arrives as `credential` on the auth
|
|
15
|
+
* resolver, ambient env as `ctx.env`) with `OPENCODE_GO_API_KEY` as the
|
|
16
|
+
* fallback; the plugin never reads auth.json or `process.env` itself.
|
|
17
|
+
*
|
|
18
|
+
* Runtime imports from pi-ai are deliberate (`createProvider` + the
|
|
19
|
+
* OpenAI-completions api); the package still declares pi-ai/pi-durable as
|
|
20
|
+
* devDependencies only, because sigrun (the host) ships them and
|
|
21
|
+
* `syncPackages` installs with `--omit=dev` — the provider-plugins spec's
|
|
22
|
+
* dependency posture.
|
|
23
|
+
*/
|
|
24
|
+
import { createProvider } from "@earendil-works/pi-ai/models";
|
|
25
|
+
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
26
|
+
/** The provider id models are registered (and stamped) under. */
|
|
27
|
+
export const OPENCODE_PROVIDER_ID = "opencode";
|
|
28
|
+
/**
|
|
29
|
+
* OpenCode's paid Go tier (OpenAI-compatible endpoint). The operator can
|
|
30
|
+
* override per deployment via the plugin's scoped settings (`baseUrl`) —
|
|
31
|
+
* see {@link resolveOpencodeBaseUrl}.
|
|
32
|
+
*/
|
|
33
|
+
export const OPENCODE_BASE_URL = "https://opencode.ai/zen/go/v1";
|
|
34
|
+
/** Ambient fallback when auth.json holds no credential. */
|
|
35
|
+
export const OPENCODE_API_KEY_ENV = "OPENCODE_GO_API_KEY";
|
|
36
|
+
/**
|
|
37
|
+
* Conservative placeholders for catalog entries the Go tier's `/models`
|
|
38
|
+
* shape gives no metadata for (no window, no limits, no rates — entries
|
|
39
|
+
* carry only id/object/created/owned_by). Same values as the reference
|
|
40
|
+
* plugin and sigrun's degenerate env provider, keeping context
|
|
41
|
+
* management deterministic against unknown windows.
|
|
42
|
+
*/
|
|
43
|
+
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
44
|
+
const DEFAULT_MAX_TOKENS = 16_384;
|
|
45
|
+
/**
|
|
46
|
+
* Parse an OpenAI-compatible `[OI]` `/models` body into pi-ai chat
|
|
47
|
+
* models. Every entry is stamped `provider: "opencode"` — pi-ai's
|
|
48
|
+
* restore path drops stored models whose `provider` field differs from
|
|
49
|
+
* the registered id, so an unstamped catalog silently vanishes on cold
|
|
50
|
+
* boot. Malformed bodies throw (a refresh failure retains the previous
|
|
51
|
+
* catalog; garbage must never publish). Extra entry fields (the live
|
|
52
|
+
* endpoint serves id/object/created/owned_by) are ignored.
|
|
53
|
+
*/
|
|
54
|
+
export function parseOpencodeCatalog(body, baseUrl) {
|
|
55
|
+
if (typeof body !== "object" || body === null) {
|
|
56
|
+
throw new Error("opencode /models body is not a JSON object");
|
|
57
|
+
}
|
|
58
|
+
const data = body.data;
|
|
59
|
+
if (!Array.isArray(data)) {
|
|
60
|
+
throw new Error("opencode /models body has no data array");
|
|
61
|
+
}
|
|
62
|
+
return data.map((entry) => {
|
|
63
|
+
const id = entry?.id;
|
|
64
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
65
|
+
throw new Error("opencode /models entry without a string id");
|
|
66
|
+
}
|
|
67
|
+
const model = {
|
|
68
|
+
id,
|
|
69
|
+
name: id,
|
|
70
|
+
api: "openai-completions",
|
|
71
|
+
// THE stamp — see the doc comment above.
|
|
72
|
+
provider: OPENCODE_PROVIDER_ID,
|
|
73
|
+
baseUrl,
|
|
74
|
+
// The Go tier's /models carries no modality metadata; text is
|
|
75
|
+
// the safe floor (claiming image without evidence would ship
|
|
76
|
+
// image parts to models that cannot read them).
|
|
77
|
+
input: ["text"],
|
|
78
|
+
// Unknown rates: zeroed, usage tracking stays structural.
|
|
79
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
80
|
+
reasoning: false,
|
|
81
|
+
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
|
82
|
+
maxTokens: DEFAULT_MAX_TOKENS,
|
|
83
|
+
};
|
|
84
|
+
return model;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Build the OpenCode Go provider plus its readiness/health trackers.
|
|
89
|
+
* `baseUrl` is overridable so hermetic tests drive a fixture server; the
|
|
90
|
+
* default is the real Go-tier endpoint.
|
|
91
|
+
*/
|
|
92
|
+
export function createOpencodePlugin(baseUrl = OPENCODE_BASE_URL) {
|
|
93
|
+
// Tracker state: seeded from credential resolution (pi-ai never
|
|
94
|
+
// refreshes an unconfigured provider, so refresh outcomes alone cannot
|
|
95
|
+
// express not_configured), updated per refresh outcome. Readiness and
|
|
96
|
+
// health are synchronous derivations over this state.
|
|
97
|
+
let credentialResolved = false;
|
|
98
|
+
let lastRefreshFailed = false;
|
|
99
|
+
let lastRefreshDetail;
|
|
100
|
+
let lastAnnounced;
|
|
101
|
+
function status() {
|
|
102
|
+
if (!credentialResolved)
|
|
103
|
+
return "not_configured";
|
|
104
|
+
return lastRefreshFailed ? "degraded" : "ready";
|
|
105
|
+
}
|
|
106
|
+
/** Apply a state change; `console.warn` on transitions only. */
|
|
107
|
+
function announce() {
|
|
108
|
+
const next = status();
|
|
109
|
+
if (next === lastAnnounced)
|
|
110
|
+
return;
|
|
111
|
+
console.warn(`provider-opencode: opencode ${lastAnnounced ?? "startup"} → ${next}` +
|
|
112
|
+
(lastRefreshDetail !== undefined && next === "degraded"
|
|
113
|
+
? ` (${lastRefreshDetail})`
|
|
114
|
+
: ""));
|
|
115
|
+
lastAnnounced = next;
|
|
116
|
+
}
|
|
117
|
+
function noteCredential(resolved) {
|
|
118
|
+
credentialResolved = resolved;
|
|
119
|
+
announce();
|
|
120
|
+
}
|
|
121
|
+
function noteRefresh(ok, detail) {
|
|
122
|
+
lastRefreshFailed = !ok;
|
|
123
|
+
lastRefreshDetail = ok ? undefined : detail;
|
|
124
|
+
announce();
|
|
125
|
+
}
|
|
126
|
+
const provider = createProvider({
|
|
127
|
+
id: OPENCODE_PROVIDER_ID,
|
|
128
|
+
name: "OpenCode Go",
|
|
129
|
+
baseUrl,
|
|
130
|
+
// REQUIRED baseline: createProvider iterates it (omitting throws
|
|
131
|
+
// "baselineModels is not iterable"); empty = purely dynamic.
|
|
132
|
+
models: [],
|
|
133
|
+
auth: {
|
|
134
|
+
apiKey: {
|
|
135
|
+
name: "OpenCode Go API key (auth.json or OPENCODE_GO_API_KEY)",
|
|
136
|
+
resolve: async ({ credential, ctx }) => {
|
|
137
|
+
// Bridged auth.json credential (sigrun's bridge: the
|
|
138
|
+
// "default" entry first, then the provider's own) wins;
|
|
139
|
+
// the env var is the ambient fallback.
|
|
140
|
+
const stored = credential?.key?.trim() || undefined;
|
|
141
|
+
if (stored !== undefined) {
|
|
142
|
+
noteCredential(true);
|
|
143
|
+
return { auth: { apiKey: stored }, source: "auth.json" };
|
|
144
|
+
}
|
|
145
|
+
const envKey = (await ctx.env(OPENCODE_API_KEY_ENV))?.trim() || undefined;
|
|
146
|
+
if (envKey !== undefined) {
|
|
147
|
+
noteCredential(true);
|
|
148
|
+
return { auth: { apiKey: envKey }, source: OPENCODE_API_KEY_ENV };
|
|
149
|
+
}
|
|
150
|
+
// Unconfigured: undefined makes pi-ai skip the refresh
|
|
151
|
+
// phase entirely and requests fail loud.
|
|
152
|
+
noteCredential(false);
|
|
153
|
+
return undefined;
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
fetchModels: async (context) => {
|
|
158
|
+
// The context credential is whatever auth resolution produced —
|
|
159
|
+
// for this plugin always the merged api key; narrow rather than
|
|
160
|
+
// trust the union.
|
|
161
|
+
const key = context.credential?.type === "api_key"
|
|
162
|
+
? context.credential.key?.trim() || undefined
|
|
163
|
+
: undefined;
|
|
164
|
+
if (key === undefined) {
|
|
165
|
+
noteCredential(false);
|
|
166
|
+
// NEVER return [] — the createProvider wrapper publishes +
|
|
167
|
+
// persists whatever fetchModels returns; an empty array
|
|
168
|
+
// would wipe the catalog.
|
|
169
|
+
throw new Error(`provider-opencode: OpenCode Go is not configured ` +
|
|
170
|
+
`(no auth.json credential and no ${OPENCODE_API_KEY_ENV}); ` +
|
|
171
|
+
"refusing to publish an empty catalog");
|
|
172
|
+
}
|
|
173
|
+
const effectiveBase = context.baseUrl ?? baseUrl;
|
|
174
|
+
// URL-join needs the trailing slash ("models" against a
|
|
175
|
+
// slash-less base would resolve to the host root).
|
|
176
|
+
const root = effectiveBase.endsWith("/") ? effectiveBase : `${effectiveBase}/`;
|
|
177
|
+
try {
|
|
178
|
+
const response = await fetch(new URL("models", root), {
|
|
179
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
180
|
+
signal: context.signal,
|
|
181
|
+
});
|
|
182
|
+
if (!response.ok) {
|
|
183
|
+
throw new Error(`HTTP ${response.status}`);
|
|
184
|
+
}
|
|
185
|
+
const models = parseOpencodeCatalog(await response.json(), effectiveBase);
|
|
186
|
+
noteRefresh(true);
|
|
187
|
+
return models;
|
|
188
|
+
}
|
|
189
|
+
catch (cause) {
|
|
190
|
+
// An aborted refresh is not a failed one: pi-ai discards
|
|
191
|
+
// the outcome anyway, so the tracker must not move.
|
|
192
|
+
if (context.signal.aborted)
|
|
193
|
+
throw cause;
|
|
194
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
195
|
+
noteRefresh(false, message);
|
|
196
|
+
throw new Error(`provider-opencode: OpenCode Go catalog refresh failed: ${message}`);
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
api: openAICompletionsApi(),
|
|
200
|
+
});
|
|
201
|
+
return {
|
|
202
|
+
provider,
|
|
203
|
+
readiness: () => ({
|
|
204
|
+
status: status(),
|
|
205
|
+
...(lastRefreshDetail !== undefined && status() === "degraded"
|
|
206
|
+
? { detail: lastRefreshDetail }
|
|
207
|
+
: {}),
|
|
208
|
+
}),
|
|
209
|
+
// not_configured → ok (an unconfigured optional plugin is not a
|
|
210
|
+
// degraded process); configured-and-failing → degraded; else ok.
|
|
211
|
+
health: () => (status() === "degraded" ? "degraded" : "ok"),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Resolve the plugin's base URL from its scoped settings: `baseUrl`
|
|
216
|
+
* (string) overrides the {@link OPENCODE_BASE_URL} constant. Absent → the
|
|
217
|
+
* constant; present-but-not-a-non-empty-string → throw (a malformed
|
|
218
|
+
* override must quarantine loudly, not silently fall back to a possibly
|
|
219
|
+
* wrong constant).
|
|
220
|
+
*/
|
|
221
|
+
export function resolveOpencodeBaseUrl(settings) {
|
|
222
|
+
if (!("baseUrl" in settings))
|
|
223
|
+
return OPENCODE_BASE_URL;
|
|
224
|
+
const value = settings.baseUrl;
|
|
225
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
226
|
+
throw new Error("provider-opencode: settings/provider-opencode.json baseUrl must be a non-empty string when present");
|
|
227
|
+
}
|
|
228
|
+
return value.trim();
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* The sigrun extension-package factory (provider-only contributor).
|
|
232
|
+
* Arity 1 declares the host-taking signature — sigrun's loader passes the
|
|
233
|
+
* seam host only to factories that declare one (`factory.length > 0`) —
|
|
234
|
+
* and the factory is ASYNC because the scoped settings store is
|
|
235
|
+
* async-only; sigrun's loader awaits factory results. The base URL is
|
|
236
|
+
* deployment-configurable through the scoped settings (`baseUrl` in
|
|
237
|
+
* `settings/provider-opencode.json`, constant fallback): deployments behind
|
|
238
|
+
* a proxy or mirror — and hermetic test fixtures — redirect the endpoint
|
|
239
|
+
* that way (restart-required, exactly like sigrun-pareto/sigrun-bifrost).
|
|
240
|
+
*/
|
|
241
|
+
export default async function sigrunOpencode(host) {
|
|
242
|
+
const settings = await host.stores.settings.get();
|
|
243
|
+
const plugin = createOpencodePlugin(resolveOpencodeBaseUrl(settings));
|
|
244
|
+
return {
|
|
245
|
+
name: "provider-opencode",
|
|
246
|
+
providers: [{ provider: plugin.provider, readiness: plugin.readiness }],
|
|
247
|
+
health: plugin.health,
|
|
248
|
+
};
|
|
249
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@sigrun/provider-opencode",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Sigrun provider plugin: OpenCode's paid Go tier (OpenAI-compatible /zen/go/v1) as a dynamic provider",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.19.0"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"types": "dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"CHANGELOG.md"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node scripts/check-manifest.mjs --expect-name @sigrun/provider-opencode && vitest run",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"build": "tsc -p tsconfig.build.json",
|
|
25
|
+
"prepare": "npm run build"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@earendil-works/pi-ai": "1.3.0",
|
|
29
|
+
"@earendil-works/pi-durable": "1.3.0",
|
|
30
|
+
"@types/node": "^26.6.4",
|
|
31
|
+
"typescript": "^7.0.2",
|
|
32
|
+
"vitest": "^5.0.3"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "github",
|
|
37
|
+
"url": "git+https://github.com/sigrun-ai/sigrun-provider-opencode.git"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/sigrun-ai/sigrun-provider-opencode/issues"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/sigrun-ai/sigrun-provider-opencode#readme",
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public",
|
|
45
|
+
"provenance": true
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@earendil-works/pi-ai": "1.3.0",
|
|
49
|
+
"@earendil-works/pi-durable": "1.3.0"
|
|
50
|
+
}
|
|
51
|
+
}
|