@dan-ai-studio/dshopencodego 0.1.5

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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +156 -0
  3. package/README.md +156 -0
  4. package/cordis.patch.yml +3 -0
  5. package/lib/build-info.json +11 -0
  6. package/lib/client.js +1215 -0
  7. package/lib/index.js +2036 -0
  8. package/lib/types/adapter.d.ts +84 -0
  9. package/lib/types/adapter.js +311 -0
  10. package/lib/types/catalog/constants.d.ts +16 -0
  11. package/lib/types/catalog/constants.js +16 -0
  12. package/lib/types/catalog/contract.d.ts +26 -0
  13. package/lib/types/catalog/contract.js +131 -0
  14. package/lib/types/catalog/gateway.d.ts +20 -0
  15. package/lib/types/catalog/gateway.js +59 -0
  16. package/lib/types/catalog/index.d.ts +108 -0
  17. package/lib/types/catalog/index.js +288 -0
  18. package/lib/types/catalog/json-response.d.ts +19 -0
  19. package/lib/types/catalog/json-response.js +72 -0
  20. package/lib/types/catalog/metadata.d.ts +73 -0
  21. package/lib/types/catalog/metadata.js +259 -0
  22. package/lib/types/catalog/protocol.d.ts +65 -0
  23. package/lib/types/catalog/protocol.js +87 -0
  24. package/lib/types/catalog/reading.d.ts +41 -0
  25. package/lib/types/catalog/reading.js +68 -0
  26. package/lib/types/catalog/service.d.ts +32 -0
  27. package/lib/types/catalog/service.js +45 -0
  28. package/lib/types/config.d.ts +93 -0
  29. package/lib/types/config.js +76 -0
  30. package/lib/types/conversion/context.d.ts +55 -0
  31. package/lib/types/conversion/context.js +202 -0
  32. package/lib/types/conversion/index.d.ts +9 -0
  33. package/lib/types/conversion/index.js +7 -0
  34. package/lib/types/conversion/replay.d.ts +56 -0
  35. package/lib/types/conversion/replay.js +242 -0
  36. package/lib/types/conversion/stream.d.ts +46 -0
  37. package/lib/types/conversion/stream.js +203 -0
  38. package/lib/types/go-limits.d.ts +41 -0
  39. package/lib/types/go-limits.js +79 -0
  40. package/lib/types/index.d.ts +54 -0
  41. package/lib/types/index.js +195 -0
  42. package/lib/types/models.d.ts +90 -0
  43. package/lib/types/models.js +86 -0
  44. package/lib/types/remotes.d.ts +12 -0
  45. package/lib/types/remotes.js +28 -0
  46. package/lib/types/session-header.d.ts +36 -0
  47. package/lib/types/session-header.js +45 -0
  48. package/lib/types/usage/contract.d.ts +39 -0
  49. package/lib/types/usage/contract.js +106 -0
  50. package/lib/types/usage/index.d.ts +11 -0
  51. package/lib/types/usage/index.js +8 -0
  52. package/lib/types/usage/meter.d.ts +53 -0
  53. package/lib/types/usage/meter.js +65 -0
  54. package/lib/types/usage/service.d.ts +48 -0
  55. package/lib/types/usage/service.js +74 -0
  56. package/lib/types/usage/windows.d.ts +51 -0
  57. package/lib/types/usage/windows.js +84 -0
  58. package/package.json +147 -0
@@ -0,0 +1,195 @@
1
+ /**
2
+ * The `dshopencodego` plugin: one `opencode-go` route with a live catalog and
3
+ * the gateway's mandatory session header.
4
+ *
5
+ * The plugin exists because a generic pi-ai route cannot express two things the
6
+ * OpenCode Go gateway needs: a model list that rotates faster than any shipped
7
+ * catalog, and a per-conversation `x-opencode-session` routing header on every
8
+ * inference request.
9
+ *
10
+ * Route registration is gated on both configuration and credential: a route
11
+ * whose key is missing would otherwise sit in every model picker and read as a
12
+ * usable provider to first-run onboarding. The gate re-evaluates on every
13
+ * credential write and every loader update, and a route another adapter already
14
+ * owns is reported rather than crashing the mount.
15
+ *
16
+ * ```yaml
17
+ * - id: dshopencodego
18
+ * name: '@dan-ai-studio/dshopencodego'
19
+ * config:
20
+ * enabled: true # false withdraws the route only
21
+ * apiKeyEnv: OPENCODE_GO_API_KEY # default
22
+ * baseURL: https://opencode.ai/zen/go/v1 # default
23
+ * refreshMinutes: 60 # live catalog TTL
24
+ * modelProtocols: # last-resort protocol override
25
+ * some-model: openai-responses
26
+ * ```
27
+ *
28
+ * @module @dan-ai-studio/dshopencodego
29
+ */
30
+ import { credentialRef } from '@deepseek-ai/dsh-credentials';
31
+ import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
32
+ import * as llm from '@deepseek-ai/dsh-llm';
33
+ import { LlmError } from '@deepseek-ai/dsh-llm';
34
+ import { OpencodeGoAdapter } from "./adapter.js";
35
+ import { DISPLAY_NAME, PROVIDER_ID, discoverCatalogModels } from "./catalog/index.js";
36
+ import { assertBaseURL, PlainConfig, readConfig } from "./config.js";
37
+ import { registerRemotes } from "./remotes.js";
38
+ import { OpencodeGoCatalogService } from "./catalog/service.js";
39
+ import { OpencodeGoUsageService, UsageMeter } from "./usage/index.js";
40
+ export { OpencodeGoAdapter } from "./adapter.js";
41
+ export { DEFAULT_BASE_URL, DISPLAY_NAME, PROVIDER_ID, OpencodeGoCatalog, discoverCatalogModels } from "./catalog/index.js";
42
+ export { Config, PlainConfig, assertBaseURL, DEFAULT_API_KEY_ENV } from "./config.js";
43
+ export { SESSION_HEADER, opencodeSessionValue, providerHeaders } from "./session-header.js";
44
+ export { isModelEnabled, sortModels } from "./models.js";
45
+ export const name = 'dshopencodego';
46
+ export const inject = ['llm'];
47
+ /** True when a loader handed this plugin its live field references. */
48
+ function isLiveConfig(raw) {
49
+ return typeof raw === 'object' && raw !== null && typeof raw.enabled === 'object'
50
+ && typeof (raw.enabled?.get) === 'function';
51
+ }
52
+ /**
53
+ * Register the route, its discovery, and their teardown for one mount.
54
+ *
55
+ * Configuration is read through a live source so a profile edit reaches the
56
+ * next request without a restart; the adapter re-reads it at every operation.
57
+ * @param ctx - the plugin's Cordis context.
58
+ * @param raw - the loader's live config, or a plain object in tests.
59
+ */
60
+ export function apply(ctx, raw) {
61
+ const live = isLiveConfig(raw) ? raw : undefined;
62
+ const constant = live === undefined ? PlainConfig((raw ?? {})) : undefined;
63
+ const current = () => (live === undefined ? constant : readConfig(live));
64
+ assertBaseURL(current().baseURL);
65
+ /** Resolve the route credential; a named reference that misses fails loud. */
66
+ const resolveApiKey = async () => {
67
+ const ref = current().apiKeyEnv;
68
+ const credentials = ctx.get('credentials');
69
+ const hit = credentials !== undefined
70
+ ? (await credentials.resolve(credentialRef(ref)))?.value
71
+ // Without the credentials seam the process environment is the whole
72
+ // credential plane.
73
+ : launchEnvironmentOf(ctx).get(ref)?.value;
74
+ if (hit !== undefined && hit.length > 0)
75
+ return llm.assertUsableApiKey(hit, name, ref);
76
+ throw new LlmError(`dshopencodego: no credential; the profile resolves ${ref}, which is not set — store ${ref} through the`
77
+ + ' credentials service (the Web Models page writes it) or export it', 'MISSING_CREDENTIAL');
78
+ };
79
+ const meter = new UsageMeter();
80
+ const adapter = new OpencodeGoAdapter({
81
+ config: current,
82
+ resolveApiKey,
83
+ imageAccess: {
84
+ resolveAttachments: () => ctx.get('attachments'),
85
+ resolveImageAccess: (attachments, ref) => llm.resolveImageAttachmentAccess(attachments, hostPath => ctx.get('fs')?.processPathFromHostPath(hostPath), ref),
86
+ },
87
+ onFallback: ({ url, error }) => {
88
+ ctx.logger.warn(`dshopencodego: could not refresh ${url}; using the last known model data (${String(error)})`);
89
+ },
90
+ onUnconfigured: (entries) => {
91
+ ctx.logger.warn(`dshopencodego: gateway models this build cannot configure: ${entries.map(entry => `${entry.id} (${entry.reason})`).join(', ')}`);
92
+ },
93
+ onReplayDegrade: (reason) => {
94
+ ctx.logger.warn(`dshopencodego: unusable replay state on assistant history; sending provider-neutral content (${reason})`);
95
+ },
96
+ onUsage: ({ model, usage }) => { meter.record(model, usage); },
97
+ });
98
+ // The usage Remote is mounted before the route so a picker can read it even
99
+ // while the route is withdrawn for a missing credential.
100
+ registerRemotes(ctx);
101
+ ctx.plugin(OpencodeGoUsageService, {
102
+ baseURL: () => current().baseURL,
103
+ resolveApiKey,
104
+ meter,
105
+ });
106
+ ctx.plugin(OpencodeGoCatalogService, {
107
+ catalog: () => adapter.catalogOf(current()),
108
+ visibility: () => current().modelVisibility,
109
+ });
110
+ let registration;
111
+ let visibilityFacts = JSON.stringify(current().modelVisibility);
112
+ /**
113
+ * Register the route while it is enabled and its credential resolves, and
114
+ * drop it when either says no.
115
+ *
116
+ * Nothing else is torn down with the route: model discovery and the
117
+ * configuration surface stay mounted, so the switch that withdrew the route
118
+ * stays reachable to bring it back.
119
+ */
120
+ const applyRoute = (credentialConfigured) => {
121
+ const enabled = current().enabled;
122
+ if (enabled && credentialConfigured && registration === undefined) {
123
+ try {
124
+ registration = ctx.llm.registerAdapter([PROVIDER_ID], adapter);
125
+ }
126
+ catch (error) {
127
+ // Most likely DUPLICATE_ADAPTER: another adapter family already owns
128
+ // `opencode-go` (an `llm-pi-ai` profile, or the previous plugin).
129
+ // Everything else this mount does keeps working.
130
+ ctx.logger.error(`dshopencodego: not registering the "${PROVIDER_ID}" route — another adapter already owns it;`
131
+ + ` uninstall or disable the other provider first (${String(error)})`);
132
+ }
133
+ }
134
+ else if ((!enabled || !credentialConfigured) && registration !== undefined) {
135
+ registration();
136
+ registration = undefined;
137
+ if (!enabled) {
138
+ ctx.logger.info('dshopencodego: disabled by configuration; the route and its models are withdrawn');
139
+ }
140
+ }
141
+ };
142
+ /** Re-evaluate the route gate against the credential that is in force now. */
143
+ const syncRoute = () => {
144
+ const visibility = JSON.stringify(current().modelVisibility);
145
+ if (visibility !== visibilityFacts) {
146
+ visibilityFacts = visibility;
147
+ // Replacing the owned route notifies every open picker without a restart.
148
+ registration?.replace([PROVIDER_ID]);
149
+ }
150
+ const credentials = ctx.get('credentials');
151
+ const ref = current().apiKeyEnv;
152
+ if (credentials === undefined) {
153
+ applyRoute(launchEnvironmentOf(ctx).get(ref)?.value !== undefined);
154
+ return;
155
+ }
156
+ void credentials.describe(credentialRef(ref))
157
+ .then((info) => {
158
+ // An answer applies only while it still answers for the reference in force.
159
+ if (ref === current().apiKeyEnv)
160
+ applyRoute(info.configured);
161
+ })
162
+ .catch((error) => {
163
+ ctx.logger.error(`dshopencodego: credential describe failed; keeping the previous route state (${String(error)})`);
164
+ });
165
+ };
166
+ syncRoute();
167
+ const undiscover = ctx.llm.registerModelDiscovery(name, async (request) => {
168
+ if (request.provider !== PROVIDER_ID && !(request.baseURL ?? '').includes('opencode.ai')) {
169
+ throw new LlmError('dshopencodego discovers only OpenCode zen/go endpoints; enter this provider\'s models by hand', 'DISCOVERY_UNSUPPORTED');
170
+ }
171
+ return discoverCatalogModels(adapter.catalogOf(current()));
172
+ });
173
+ ctx.effect(() => () => {
174
+ registration?.();
175
+ undiscover();
176
+ });
177
+ // Validate a profile edit before it is persisted, then re-evaluate the gate.
178
+ ctx.on('internal/config', function (_raw, next) {
179
+ const value = next();
180
+ if (this === ctx.fiber)
181
+ assertBaseURL(PlainConfig(value).baseURL);
182
+ return value;
183
+ });
184
+ ctx.on('loader/volatile-update', syncRoute);
185
+ ctx.inject(['credentials'], (credentialsCtx) => {
186
+ credentialsCtx.on('credentials/reference-updated', (ref) => {
187
+ if (ref === current().apiKeyEnv)
188
+ syncRoute();
189
+ });
190
+ // The seam can become visible after this plugin applied, so the boot-time
191
+ // call may have fallen back to the environment: sync again here.
192
+ syncRoute();
193
+ });
194
+ ctx.logger.info(`dshopencodego: route "${PROVIDER_ID}" registered as ${DISPLAY_NAME}`);
195
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The settings-facing model summary and its visibility rules.
3
+ *
4
+ * The adapter's `listModels` and the settings page both need the same facts and
5
+ * the same answer to "is this model offered right now", so the rule lives here
6
+ * once: a model nobody can configure is never offered, an explicit switch
7
+ * always wins, and a deprecated model defaults to off.
8
+ *
9
+ * @module @dan-ai-studio/dshopencodego/models
10
+ */
11
+ import type { ProtocolSource } from './catalog/protocol.ts';
12
+ import type { GoQuota } from './go-limits.ts';
13
+ /** One model as the settings page and the picker describe it. */
14
+ export interface ModelSummary {
15
+ readonly id: string;
16
+ readonly name: string;
17
+ readonly contextWindow?: number;
18
+ /** Maximum input tokens, when the sources state one. */
19
+ readonly maxInputTokens?: number;
20
+ readonly maxTokens?: number;
21
+ /** models.dev marks the model as retained for compatibility only. */
22
+ readonly deprecated?: boolean;
23
+ /** Release date when models.dev states one. */
24
+ readonly releaseDate?: string;
25
+ /** Go's published allowance, transcribed from the provider's docs. */
26
+ readonly goQuota?: GoQuota;
27
+ /**
28
+ * Per-million-token rates from models.dev. Absent when the sources state no
29
+ * positive rate, so a fallback-zero cost never renders as "free".
30
+ */
31
+ readonly cost?: {
32
+ readonly input: number;
33
+ readonly output: number;
34
+ readonly cacheRead?: number;
35
+ readonly cacheWrite?: number;
36
+ };
37
+ /** Which ladder level decided this model's protocol. */
38
+ readonly protocolSource?: ProtocolSource;
39
+ /** True when a capacity came from the route default rather than a source. */
40
+ readonly assumedLimits?: boolean;
41
+ /** Advertised by the gateway but not configurable, with the reason. */
42
+ readonly configurationMissing?: string;
43
+ /**
44
+ * Whether the default configuration keeps this model enabled when nobody set
45
+ * an explicit switch — the top few by published monthly request estimate.
46
+ */
47
+ readonly recommended?: boolean;
48
+ }
49
+ /** How many models the default configuration keeps enabled. */
50
+ export declare const DEFAULT_ENABLED_COUNT = 5;
51
+ /**
52
+ * Whether the model trades training rights for its price — Meta's
53
+ * "Contributor" tiers, whose prompts and completions may train future models.
54
+ * Such a model is never part of the default-enabled few.
55
+ * @param model - the model under test.
56
+ * @returns true when the model belongs to a training-contributor tier.
57
+ */
58
+ export declare function isTrainingTier(model: Pick<ModelSummary, 'id' | 'name'>): boolean;
59
+ /**
60
+ * The default-enabled ids when nobody configured switches: the models with the
61
+ * largest published monthly request estimate first, which is Go's own "most
62
+ * usable" order. Deprecated, unconfigurable, and training-contributor models
63
+ * never qualify, and a model with no published estimate does not displace one
64
+ * that has it.
65
+ *
66
+ * The estimate is looked up here, by id, rather than read from a `goQuota`
67
+ * field: the Host's picker and the settings page build their model lists from
68
+ * different projections, and a caller that forgot to carry the field would
69
+ * silently fall back to an alphabetical default — which is exactly the
70
+ * two-surfaces-disagree defect this signature exists to prevent.
71
+ * @param models - the advertised models, in any order.
72
+ * @returns the ids the default configuration keeps enabled.
73
+ */
74
+ export declare function recommendedIds(models: readonly Pick<ModelSummary, 'id' | 'name' | 'deprecated' | 'configurationMissing'>[]): ReadonlySet<string>;
75
+ /**
76
+ * Whether the picker offers one model.
77
+ * @param model - the summary under test.
78
+ * @param visibility - per-model switches; an absent entry keeps the default.
79
+ * @returns true when the model should be selectable.
80
+ */
81
+ export declare function isModelEnabled(model: Pick<ModelSummary, 'id' | 'deprecated' | 'configurationMissing' | 'recommended'>, visibility?: Readonly<Record<string, boolean>>): boolean;
82
+ /** A calendar date as models.dev states it, without a timezone. */
83
+ export declare function validReleaseDate(value: unknown): value is string;
84
+ /** Whether a model shipped within the last week. */
85
+ export declare function isNewModel(model: ModelSummary, now?: number): boolean;
86
+ /**
87
+ * Order models for display: newly shipped first, then the rest, then deprecated.
88
+ * Newest releases come first within the first group.
89
+ */
90
+ export declare function sortModels(models: readonly ModelSummary[], now?: number): ModelSummary[];
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The settings-facing model summary and its visibility rules.
3
+ *
4
+ * The adapter's `listModels` and the settings page both need the same facts and
5
+ * the same answer to "is this model offered right now", so the rule lives here
6
+ * once: a model nobody can configure is never offered, an explicit switch
7
+ * always wins, and a deprecated model defaults to off.
8
+ *
9
+ * @module @dan-ai-studio/dshopencodego/models
10
+ */
11
+ import { goQuotaFor, monthlyRequestsRank } from "./go-limits.js";
12
+ /** How many models the default configuration keeps enabled. */
13
+ export const DEFAULT_ENABLED_COUNT = 5;
14
+ /**
15
+ * Whether the model trades training rights for its price — Meta's
16
+ * "Contributor" tiers, whose prompts and completions may train future models.
17
+ * Such a model is never part of the default-enabled few.
18
+ * @param model - the model under test.
19
+ * @returns true when the model belongs to a training-contributor tier.
20
+ */
21
+ export function isTrainingTier(model) {
22
+ return /contributor/i.test(model.id) || /contributor/i.test(model.name);
23
+ }
24
+ /**
25
+ * The default-enabled ids when nobody configured switches: the models with the
26
+ * largest published monthly request estimate first, which is Go's own "most
27
+ * usable" order. Deprecated, unconfigurable, and training-contributor models
28
+ * never qualify, and a model with no published estimate does not displace one
29
+ * that has it.
30
+ *
31
+ * The estimate is looked up here, by id, rather than read from a `goQuota`
32
+ * field: the Host's picker and the settings page build their model lists from
33
+ * different projections, and a caller that forgot to carry the field would
34
+ * silently fall back to an alphabetical default — which is exactly the
35
+ * two-surfaces-disagree defect this signature exists to prevent.
36
+ * @param models - the advertised models, in any order.
37
+ * @returns the ids the default configuration keeps enabled.
38
+ */
39
+ export function recommendedIds(models) {
40
+ const ranked = models
41
+ .filter(model => model.configurationMissing === undefined
42
+ && model.deprecated !== true
43
+ && !isTrainingTier(model))
44
+ .toSorted((left, right) => monthlyRequestsRank(goQuotaFor(right.id)) - monthlyRequestsRank(goQuotaFor(left.id))
45
+ || left.id.localeCompare(right.id));
46
+ return new Set(ranked.slice(0, DEFAULT_ENABLED_COUNT).map(model => model.id));
47
+ }
48
+ /**
49
+ * Whether the picker offers one model.
50
+ * @param model - the summary under test.
51
+ * @param visibility - per-model switches; an absent entry keeps the default.
52
+ * @returns true when the model should be selectable.
53
+ */
54
+ export function isModelEnabled(model, visibility) {
55
+ if (model.configurationMissing !== undefined)
56
+ return false;
57
+ const explicit = visibility !== undefined && Object.hasOwn(visibility, model.id)
58
+ ? visibility[model.id]
59
+ : undefined;
60
+ if (typeof explicit === 'boolean')
61
+ return explicit;
62
+ // A marked catalog decides the default; an unmarked one keeps every
63
+ // non-deprecated model, so callers that pass partial facts never lose models.
64
+ return typeof model.recommended === 'boolean' ? model.recommended : model.deprecated !== true;
65
+ }
66
+ /** A calendar date as models.dev states it, without a timezone. */
67
+ export function validReleaseDate(value) {
68
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
69
+ && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
70
+ }
71
+ /** Whether a model shipped within the last week. */
72
+ export function isNewModel(model, now = Date.now()) {
73
+ if (model.deprecated === true || !validReleaseDate(model.releaseDate))
74
+ return false;
75
+ const days = Math.floor(now / 86_400_000) - Date.parse(model.releaseDate) / 86_400_000;
76
+ return days >= 0 && days < 7;
77
+ }
78
+ /**
79
+ * Order models for display: newly shipped first, then the rest, then deprecated.
80
+ * Newest releases come first within the first group.
81
+ */
82
+ export function sortModels(models, now = Date.now()) {
83
+ const rank = (model) => model.deprecated === true ? 2 : isNewModel(model, now) ? 0 : 1;
84
+ return [...models].sort((left, right) => rank(left) - rank(right)
85
+ || (isNewModel(left, now) && isNewModel(right, now) ? right.releaseDate.localeCompare(left.releaseDate) : 0));
86
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Remote registration.
3
+ *
4
+ * One registry owns one contribution per package, so both usage methods are
5
+ * mounted together after whichever registry activation order the composition
6
+ * happens to use.
7
+ *
8
+ * @module @dan-ai-studio/dshopencodego/remotes
9
+ */
10
+ import type { Context } from '@deepseek-ai/cordis';
11
+ /** Register this package's Remote contribution for the mount's lifetime. */
12
+ export declare function registerRemotes(ctx: Context): void;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Remote registration.
3
+ *
4
+ * One registry owns one contribution per package, so both usage methods are
5
+ * mounted together after whichever registry activation order the composition
6
+ * happens to use.
7
+ *
8
+ * @module @dan-ai-studio/dshopencodego/remotes
9
+ */
10
+ import { catalogRemote } from "./catalog/contract.js";
11
+ import { usageRemote } from "./usage/contract.js";
12
+ /** Every Remote method this package owns, mounted as one contribution. */
13
+ const contribution = {
14
+ package: usageRemote.package,
15
+ descriptors: [...usageRemote.descriptors, ...catalogRemote.descriptors],
16
+ };
17
+ /** Register this package's Remote contribution for the mount's lifetime. */
18
+ export function registerRemotes(ctx) {
19
+ ctx.inject(['typert'], (scope) => {
20
+ scope.effect(() => scope.typert.register({
21
+ package: contribution.package,
22
+ face: 'host',
23
+ schemas: [],
24
+ model: { services: [], events: [], objects: [] },
25
+ invocations: contribution.descriptors,
26
+ }));
27
+ });
28
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The session header the OpenCode Go gateway requires.
3
+ *
4
+ * Every inference request must carry `x-opencode-session`; the gateway answers
5
+ * `400 MissingSessionID` without it and routes plus caches by its value. Two
6
+ * rules make that useful rather than merely accepted:
7
+ *
8
+ * 1. The value is the conversation's stable Harness session id, verbatim — the
9
+ * same string the session log records — so one conversation keeps one
10
+ * routing bucket and one prompt cache across turns, resume, compaction,
11
+ * retries, and subagents.
12
+ * 2. A request that carries no session id gets a fresh random value rather than
13
+ * a shared constant, because a constant would merge unrelated traffic into a
14
+ * single cache bucket and evict itself.
15
+ *
16
+ * @module @dan-ai-studio/dshopencodego/session-header
17
+ */
18
+ /** Header name the gateway requires. */
19
+ export declare const SESSION_HEADER = "x-opencode-session";
20
+ /**
21
+ * The header value for one request.
22
+ * @param sessionId - the request's session id, if it names one.
23
+ * @returns the exact session id, or a fresh UUID when there is none.
24
+ */
25
+ export declare function opencodeSessionValue(sessionId: string | undefined): string;
26
+ /**
27
+ * Every header one provider request carries.
28
+ *
29
+ * The attribution `user-agent` is Harness-owned and always present; the session
30
+ * header is added on top of it. A caller-supplied header of the same name can
31
+ * never win, because the gateway's routing decision must follow the session,
32
+ * not a deployment string.
33
+ * @param sessionId - the request's session id, if it names one.
34
+ * @returns headers for the pi-ai request options.
35
+ */
36
+ export declare function providerHeaders(sessionId: string | undefined): Record<string, string>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The session header the OpenCode Go gateway requires.
3
+ *
4
+ * Every inference request must carry `x-opencode-session`; the gateway answers
5
+ * `400 MissingSessionID` without it and routes plus caches by its value. Two
6
+ * rules make that useful rather than merely accepted:
7
+ *
8
+ * 1. The value is the conversation's stable Harness session id, verbatim — the
9
+ * same string the session log records — so one conversation keeps one
10
+ * routing bucket and one prompt cache across turns, resume, compaction,
11
+ * retries, and subagents.
12
+ * 2. A request that carries no session id gets a fresh random value rather than
13
+ * a shared constant, because a constant would merge unrelated traffic into a
14
+ * single cache bucket and evict itself.
15
+ *
16
+ * @module @dan-ai-studio/dshopencodego/session-header
17
+ */
18
+ import { randomUUID } from 'node:crypto';
19
+ import { attributionHeaders } from '@deepseek-ai/dsh-llm';
20
+ /** Header name the gateway requires. */
21
+ export const SESSION_HEADER = 'x-opencode-session';
22
+ /**
23
+ * The header value for one request.
24
+ * @param sessionId - the request's session id, if it names one.
25
+ * @returns the exact session id, or a fresh UUID when there is none.
26
+ */
27
+ export function opencodeSessionValue(sessionId) {
28
+ return sessionId !== undefined && sessionId.length > 0 ? sessionId : randomUUID();
29
+ }
30
+ /**
31
+ * Every header one provider request carries.
32
+ *
33
+ * The attribution `user-agent` is Harness-owned and always present; the session
34
+ * header is added on top of it. A caller-supplied header of the same name can
35
+ * never win, because the gateway's routing decision must follow the session,
36
+ * not a deployment string.
37
+ * @param sessionId - the request's session id, if it names one.
38
+ * @returns headers for the pi-ai request options.
39
+ */
40
+ export function providerHeaders(sessionId) {
41
+ return {
42
+ [SESSION_HEADER]: opencodeSessionValue(sessionId),
43
+ ...attributionHeaders(),
44
+ };
45
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Wire contract for the usage Remote.
3
+ *
4
+ * Two readings are exposed, and they answer different questions: `readWindows`
5
+ * asks the gateway what the account has left, and `readMeter` reports what this
6
+ * process has actually spent since it started. Neither is a per-model quota —
7
+ * the endpoint does not publish one — and the client labels them accordingly.
8
+ *
9
+ * @module @dan-ai-studio/dshopencodego/usage/contract
10
+ */
11
+ import type { RemoteResult, TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol';
12
+ import type { GoMeter } from './meter.ts';
13
+ import type { GoUsageWindows } from './windows.ts';
14
+ export type { GoMeter, MeterModelEntry, MeterTotals } from './meter.ts';
15
+ export type { GoUsageWindows, UsageWindow } from './windows.ts';
16
+ /** Validate a windows reading crossing the wire. */
17
+ export declare function parseUsageWindows(value: unknown): GoUsageWindows;
18
+ /** Validate a meter reading crossing the wire. */
19
+ export declare function parseGoMeter(value: unknown): GoMeter;
20
+ declare module '@deepseek-ai/dsh-typert-protocol' {
21
+ interface RemoteErrorDetailsMap {
22
+ 'dshopencodego/usage-unavailable': {
23
+ /** Whether retrying the same read could succeed. */
24
+ readonly retryable: boolean;
25
+ /** Whether a previously displayed reading stays valid. */
26
+ readonly retainPrevious: boolean;
27
+ /** Identity of the reading the client already holds, when any. */
28
+ readonly source?: string;
29
+ };
30
+ }
31
+ interface TypertRemoteNamespaceMap {
32
+ opencodeGoUsage: {
33
+ readWindows(): Promise<RemoteResult<GoUsageWindows>>;
34
+ readMeter(): Promise<RemoteResult<GoMeter>>;
35
+ };
36
+ }
37
+ }
38
+ /** Remote methods this package owns, mounted together by the plugin. */
39
+ export declare const usageRemote: TypertRemoteContribution;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Wire contract for the usage Remote.
3
+ *
4
+ * Two readings are exposed, and they answer different questions: `readWindows`
5
+ * asks the gateway what the account has left, and `readMeter` reports what this
6
+ * process has actually spent since it started. Neither is a per-model quota —
7
+ * the endpoint does not publish one — and the client labels them accordingly.
8
+ *
9
+ * @module @dan-ai-studio/dshopencodego/usage/contract
10
+ */
11
+ /** Validate one window on the way back from the Host. */
12
+ function parseWindow(value, key) {
13
+ if (value === null || typeof value !== 'object')
14
+ throw new Error(`invalid usage window "${key}"`);
15
+ const row = value;
16
+ if ((row['status'] !== 'ok' && row['status'] !== 'rate-limited')
17
+ || typeof row['percent'] !== 'number' || !Number.isFinite(row['percent'])
18
+ || typeof row['resetsAt'] !== 'string') {
19
+ throw new Error(`invalid usage window "${key}"`);
20
+ }
21
+ return { status: row['status'], percent: row['percent'], resetsAt: row['resetsAt'] };
22
+ }
23
+ /** Validate a windows reading crossing the wire. */
24
+ export function parseUsageWindows(value) {
25
+ if (value === null || typeof value !== 'object')
26
+ throw new Error('invalid usage reading');
27
+ const row = value;
28
+ return {
29
+ ...typeof row['source'] === 'string' ? { source: row['source'] } : {},
30
+ rolling: parseWindow(row['rolling'], 'rolling'),
31
+ weekly: parseWindow(row['weekly'], 'weekly'),
32
+ monthly: parseWindow(row['monthly'], 'monthly'),
33
+ };
34
+ }
35
+ function parseTotals(value) {
36
+ if (value === null || typeof value !== 'object')
37
+ throw new Error('invalid meter totals');
38
+ const row = value;
39
+ const count = (key) => {
40
+ const entry = row[key];
41
+ if (typeof entry !== 'number' || !Number.isFinite(entry) || entry < 0)
42
+ throw new Error(`invalid meter field "${key}"`);
43
+ return entry;
44
+ };
45
+ return {
46
+ calls: count('calls'),
47
+ inputTokens: count('inputTokens'),
48
+ outputTokens: count('outputTokens'),
49
+ cacheReadTokens: count('cacheReadTokens'),
50
+ cacheWriteTokens: count('cacheWriteTokens'),
51
+ totalTokens: count('totalTokens'),
52
+ };
53
+ }
54
+ /** Validate a meter reading crossing the wire. */
55
+ export function parseGoMeter(value) {
56
+ if (value === null || typeof value !== 'object')
57
+ throw new Error('invalid meter reading');
58
+ const row = value;
59
+ if (typeof row['sinceMs'] !== 'number' || typeof row['atMs'] !== 'number' || !Array.isArray(row['models'])) {
60
+ throw new Error('invalid meter reading');
61
+ }
62
+ return {
63
+ sinceMs: row['sinceMs'],
64
+ atMs: row['atMs'],
65
+ totals: parseTotals(row['totals']),
66
+ models: row['models'].map((entry) => {
67
+ if (entry === null || typeof entry !== 'object')
68
+ throw new Error('invalid meter model entry');
69
+ const model = entry['model'];
70
+ if (typeof model !== 'string')
71
+ throw new Error('invalid meter model entry');
72
+ return { model, ...parseTotals(entry) };
73
+ }),
74
+ };
75
+ }
76
+ /** Codec shape accepted by both released and source DSH builds. */
77
+ const codec = (typeSymbol, parse) => ({
78
+ mode: 'strict',
79
+ typeSymbol,
80
+ schema: { parse },
81
+ create: () => ({ parse }),
82
+ });
83
+ /** Remote methods this package owns, mounted together by the plugin. */
84
+ export const usageRemote = {
85
+ package: '@dan-ai-studio/dshopencodego',
86
+ descriptors: [
87
+ {
88
+ id: '@dan-ai-studio/dshopencodego#opencodeGoUsage/readWindows',
89
+ service: 'opencodeGoUsage',
90
+ namespace: 'opencodeGoUsage',
91
+ method: 'readWindows',
92
+ invocation: { kind: 'direct' },
93
+ parameters: [],
94
+ result: codec('@dan-ai-studio/dshopencodego#GoUsageWindows', parseUsageWindows),
95
+ },
96
+ {
97
+ id: '@dan-ai-studio/dshopencodego#opencodeGoUsage/readMeter',
98
+ service: 'opencodeGoUsage',
99
+ namespace: 'opencodeGoUsage',
100
+ method: 'readMeter',
101
+ invocation: { kind: 'direct' },
102
+ parameters: [],
103
+ result: codec('@dan-ai-studio/dshopencodego#GoMeter', parseGoMeter),
104
+ },
105
+ ],
106
+ };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Usage readings for the `opencode-go` route.
3
+ * @module @dan-ai-studio/dshopencodego/usage
4
+ */
5
+ export { UsageMeter } from './meter.ts';
6
+ export type { GoMeter, MeterModelEntry, MeterTotals } from './meter.ts';
7
+ export { parseGoUsage, readUsageWindows } from './windows.ts';
8
+ export type { GoUsageWindows, UsageWindow } from './windows.ts';
9
+ export { parseGoMeter, parseUsageWindows, usageRemote } from './contract.ts';
10
+ export { OpencodeGoUsageService } from './service.ts';
11
+ export type { UsageServiceOptions } from './service.ts';
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Usage readings for the `opencode-go` route.
3
+ * @module @dan-ai-studio/dshopencodego/usage
4
+ */
5
+ export { UsageMeter } from "./meter.js";
6
+ export { parseGoUsage, readUsageWindows } from "./windows.js";
7
+ export { parseGoMeter, parseUsageWindows, usageRemote } from "./contract.js";
8
+ export { OpencodeGoUsageService } from "./service.js";