ftown 0.25.1 → 0.25.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.
Files changed (71) hide show
  1. package/dist/command-rpc.d.ts +3 -0
  2. package/dist/command-rpc.js +41 -0
  3. package/dist/command-rpc.js.map +1 -1
  4. package/dist/ftown-cli.js +5 -2
  5. package/dist/ftown-cli.js.map +1 -1
  6. package/dist/index.js +3 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/quota/agy.d.ts +16 -0
  9. package/dist/quota/agy.js +287 -0
  10. package/dist/quota/agy.js.map +1 -0
  11. package/dist/quota/annotate.d.ts +5 -0
  12. package/dist/quota/annotate.js +56 -0
  13. package/dist/quota/annotate.js.map +1 -0
  14. package/dist/quota/claude.d.ts +27 -0
  15. package/dist/quota/claude.js +265 -0
  16. package/dist/quota/claude.js.map +1 -0
  17. package/dist/quota/codex.d.ts +25 -0
  18. package/dist/quota/codex.js +378 -0
  19. package/dist/quota/codex.js.map +1 -0
  20. package/dist/quota/format.d.ts +6 -0
  21. package/dist/quota/format.js +113 -0
  22. package/dist/quota/format.js.map +1 -0
  23. package/dist/quota/grok.d.ts +97 -0
  24. package/dist/quota/grok.js +563 -0
  25. package/dist/quota/grok.js.map +1 -0
  26. package/dist/quota/models-agy.d.ts +10 -0
  27. package/dist/quota/models-agy.js +144 -0
  28. package/dist/quota/models-agy.js.map +1 -0
  29. package/dist/quota/models-claude.d.ts +10 -0
  30. package/dist/quota/models-claude.js +227 -0
  31. package/dist/quota/models-claude.js.map +1 -0
  32. package/dist/quota/models-codex.d.ts +18 -0
  33. package/dist/quota/models-codex.js +305 -0
  34. package/dist/quota/models-codex.js.map +1 -0
  35. package/dist/quota/models-grok.d.ts +19 -0
  36. package/dist/quota/models-grok.js +272 -0
  37. package/dist/quota/models-grok.js.map +1 -0
  38. package/dist/quota/models-muse.d.ts +13 -0
  39. package/dist/quota/models-muse.js +200 -0
  40. package/dist/quota/models-muse.js.map +1 -0
  41. package/dist/quota/models-opencode.d.ts +51 -0
  42. package/dist/quota/models-opencode.js +400 -0
  43. package/dist/quota/models-opencode.js.map +1 -0
  44. package/dist/quota/muse.d.ts +38 -0
  45. package/dist/quota/muse.js +225 -0
  46. package/dist/quota/muse.js.map +1 -0
  47. package/dist/quota/opencode.d.ts +7 -0
  48. package/dist/quota/opencode.js +47 -0
  49. package/dist/quota/opencode.js.map +1 -0
  50. package/dist/quota/routing-config.d.ts +18 -0
  51. package/dist/quota/routing-config.js +438 -0
  52. package/dist/quota/routing-config.js.map +1 -0
  53. package/dist/quota/routing-service.d.ts +24 -0
  54. package/dist/quota/routing-service.js +118 -0
  55. package/dist/quota/routing-service.js.map +1 -0
  56. package/dist/quota/routing.d.ts +10 -0
  57. package/dist/quota/routing.js +180 -0
  58. package/dist/quota/routing.js.map +1 -0
  59. package/dist/quota/types.d.ts +304 -0
  60. package/dist/quota/types.js +45 -0
  61. package/dist/quota/types.js.map +1 -0
  62. package/dist/quota-cli.d.ts +80 -0
  63. package/dist/quota-cli.js +462 -0
  64. package/dist/quota-cli.js.map +1 -0
  65. package/dist/security/bridge/permissions.js +4 -2
  66. package/dist/security/bridge/permissions.js.map +1 -1
  67. package/dist/types.d.ts +2 -2
  68. package/package.json +3 -2
  69. package/skills/ftown/SKILL.md +6 -4
  70. package/skills/ftown/references/orchestrator.md +5 -0
  71. package/skills/ftown/references/quota.md +177 -0
@@ -0,0 +1,200 @@
1
+ import { readFile, readdir, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ /**
4
+ * Muse (Meta "Muse Code" CLI) model list.
5
+ *
6
+ * Muse has no `models` subcommand. It keeps a catalogue cache under
7
+ * `<home>/.local/share/muse/model-catalog/` (one `*.json` file per profile) and
8
+ * the user's selection in `<home>/.config/muse/settings.json`. Both are read
9
+ * locally: nothing is executed and no network is used.
10
+ */
11
+ const CATALOG_SEGMENTS = ['.local', 'share', 'muse', 'model-catalog'];
12
+ const SETTINGS_SEGMENTS = ['.config', 'muse', 'settings.json'];
13
+ const MODEL_FLAG = '--model <id>';
14
+ const EFFORT_FLAG = '--reasoning-effort <level>';
15
+ const FORMAT_MESSAGE = 'unrecognised muse model catalogue format';
16
+ const MISSING_MESSAGE = 'muse model catalogue not found (open muse once to create it)';
17
+ function isRecord(value) {
18
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
19
+ }
20
+ function nonEmptyString(value) {
21
+ return typeof value === 'string' && value.trim().length > 0 ? value : null;
22
+ }
23
+ function readEfforts(row) {
24
+ const variants = row.reasoning_effort_variants;
25
+ if (!Array.isArray(variants))
26
+ return [];
27
+ const tiers = [];
28
+ for (const variant of variants) {
29
+ if (!isRecord(variant))
30
+ continue;
31
+ const tier = nonEmptyString(variant.tier);
32
+ if (tier !== null && !tiers.includes(tier))
33
+ tiers.push(tier);
34
+ }
35
+ return tiers;
36
+ }
37
+ function readContextTokens(row) {
38
+ const limit = row.context_limit;
39
+ return typeof limit === 'number' && Number.isFinite(limit) ? limit : null;
40
+ }
41
+ function failureModels(status, observedAt, message) {
42
+ return {
43
+ provider: 'muse',
44
+ status,
45
+ models: [],
46
+ source: 'cached',
47
+ observedAt,
48
+ message,
49
+ modelFlag: MODEL_FLAG,
50
+ effortFlag: EFFORT_FLAG,
51
+ };
52
+ }
53
+ /**
54
+ * Pure: maps the parsed JSON of a muse model catalogue file onto a ProviderModels.
55
+ * Only visible rows are listed (a missing `visibility` counts as visible), in
56
+ * the catalogue's order. The default is the row named by `settingsModel`, or the
57
+ * row flagged `is_default` when that id is null or not a visible row. Never throws.
58
+ */
59
+ export function parseMuseModelCatalog(catalogJson, settingsModel, settingsEffort, observedAt) {
60
+ if (!isRecord(catalogJson) || !Array.isArray(catalogJson.rows)) {
61
+ return failureModels('error', observedAt, FORMAT_MESSAGE);
62
+ }
63
+ const visible = [];
64
+ for (const row of catalogJson.rows) {
65
+ if (!isRecord(row))
66
+ continue;
67
+ const id = nonEmptyString(row.model_id);
68
+ if (id === null)
69
+ continue;
70
+ if (row.visibility !== undefined && row.visibility !== null && row.visibility !== 'visible') {
71
+ continue;
72
+ }
73
+ visible.push({ id, row });
74
+ }
75
+ if (visible.length === 0) {
76
+ return failureModels('error', observedAt, FORMAT_MESSAGE);
77
+ }
78
+ const settingsIndex = settingsModel === null ? -1 : visible.findIndex((entry) => entry.id === settingsModel);
79
+ const defaultIndex = settingsIndex >= 0 ? settingsIndex : visible.findIndex((entry) => entry.row.is_default === true);
80
+ const models = visible.map((entry, index) => {
81
+ const efforts = readEfforts(entry.row);
82
+ const isDefault = index === defaultIndex;
83
+ return {
84
+ id: entry.id,
85
+ name: nonEmptyString(entry.row.display_label),
86
+ isDefault,
87
+ description: nonEmptyString(entry.row.description),
88
+ contextTokens: readContextTokens(entry.row),
89
+ efforts,
90
+ defaultEffort: isDefault && settingsEffort !== null && efforts.includes(settingsEffort) ? settingsEffort : null,
91
+ };
92
+ });
93
+ return {
94
+ provider: 'muse',
95
+ status: 'ok',
96
+ models,
97
+ source: 'cached',
98
+ observedAt,
99
+ message: null,
100
+ modelFlag: MODEL_FLAG,
101
+ effortFlag: EFFORT_FLAG,
102
+ };
103
+ }
104
+ function errnoCode(error) {
105
+ const code = isRecord(error) ? error.code : undefined;
106
+ return typeof code === 'string' && /^[A-Z][A-Z0-9_]*$/.test(code) ? code : 'UNKNOWN';
107
+ }
108
+ function observedNow(options) {
109
+ try {
110
+ return options.now().toISOString();
111
+ }
112
+ catch {
113
+ return new Date().toISOString();
114
+ }
115
+ }
116
+ function unavailableModels(observedAt) {
117
+ return {
118
+ provider: 'muse',
119
+ status: 'unavailable',
120
+ models: [],
121
+ source: 'cached',
122
+ observedAt,
123
+ message: MISSING_MESSAGE,
124
+ modelFlag: MODEL_FLAG,
125
+ effortFlag: EFFORT_FLAG,
126
+ };
127
+ }
128
+ async function newestCatalogFile(dir, names) {
129
+ // Newest mtime wins; on equal mtimes the file name sorted later wins (descending).
130
+ let newest = null;
131
+ for (const name of names) {
132
+ if (!name.endsWith('.json'))
133
+ continue;
134
+ const path = join(dir, name);
135
+ const info = await stat(path);
136
+ if (!info.isFile())
137
+ continue;
138
+ const beats = newest === null ||
139
+ info.mtimeMs > newest.mtimeMs ||
140
+ (info.mtimeMs === newest.mtimeMs && name > newest.name);
141
+ if (beats) {
142
+ newest = { path, name, mtimeMs: info.mtimeMs };
143
+ }
144
+ }
145
+ return newest === null ? null : { path: newest.path, mtimeMs: newest.mtimeMs };
146
+ }
147
+ async function readSettings(homeDir) {
148
+ try {
149
+ const parsed = JSON.parse(await readFile(join(homeDir, ...SETTINGS_SEGMENTS), 'utf8'));
150
+ if (!isRecord(parsed))
151
+ return { model: null, effort: null };
152
+ return {
153
+ model: nonEmptyString(parsed.model),
154
+ effort: nonEmptyString(parsed.reasoning_effort),
155
+ };
156
+ }
157
+ catch {
158
+ return { model: null, effort: null };
159
+ }
160
+ }
161
+ async function readMuseModels(options) {
162
+ const catalogDir = join(options.homeDir, ...CATALOG_SEGMENTS);
163
+ const now = observedNow(options);
164
+ let names;
165
+ try {
166
+ names = await readdir(catalogDir);
167
+ }
168
+ catch (error) {
169
+ const code = errnoCode(error);
170
+ if (code === 'ENOENT' || code === 'ENOTDIR')
171
+ return unavailableModels(now);
172
+ return failureModels('error', now, `muse model catalogue read failed (${code})`);
173
+ }
174
+ const newest = await newestCatalogFile(catalogDir, names);
175
+ if (newest === null)
176
+ return unavailableModels(now);
177
+ const raw = await readFile(newest.path, 'utf8');
178
+ let catalogJson;
179
+ try {
180
+ catalogJson = JSON.parse(raw);
181
+ }
182
+ catch {
183
+ catalogJson = undefined;
184
+ }
185
+ const settings = await readSettings(options.homeDir);
186
+ return parseMuseModelCatalog(catalogJson, settings.model, settings.effort, new Date(newest.mtimeMs).toISOString());
187
+ }
188
+ /**
189
+ * Reads the newest muse catalogue file under `homeDir` and the user's selection
190
+ * from settings.json. Never throws or rejects.
191
+ */
192
+ export const fetchMuseModels = async (options) => {
193
+ try {
194
+ return await readMuseModels(options);
195
+ }
196
+ catch (error) {
197
+ return failureModels('error', observedNow(options), `muse model catalogue read failed (${errnoCode(error)})`);
198
+ }
199
+ };
200
+ //# sourceMappingURL=models-muse.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"models-muse.js","sourceRoot":"","sources":["../../src/quota/models-muse.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,51 @@
1
+ import type { ModelsFetcher, ProviderModels } from './types.js';
2
+ /**
3
+ * Providers whose zero-priced models are covered by a subscription plan the
4
+ * user holds (priced 'included' rather than 'free'). Any provider id
5
+ * containing `coding-plan` also counts, see isOpencodePlanProvider.
6
+ */
7
+ export declare const OPENCODE_PLAN_PROVIDERS: readonly string[];
8
+ /** Pure: true for a subscription-plan provider (listed, or id contains `coding-plan`). */
9
+ export declare function isOpencodePlanProvider(providerId: string): boolean;
10
+ /**
11
+ * Pure: the provider ids to list, from FTOWN_QUOTA_OPENCODE_PROVIDERS (comma
12
+ * separated; entries are trimmed, empties and ids that fail the pattern are
13
+ * dropped, duplicates removed). Falls back to the defaults when the variable is
14
+ * unset, empty, or leaves no valid id.
15
+ */
16
+ export declare function opencodeSubscriptionProviders(env: NodeJS.ProcessEnv): string[];
17
+ /**
18
+ * Pure: the `provider/model` lines of `opencode models` stdout, in output
19
+ * order, without duplicates. CRLF and blank lines are tolerated; lines with no
20
+ * slash, an empty provider or model part, or whitespace/control characters are
21
+ * ignored.
22
+ */
23
+ export declare function parseOpencodeModelIds(stdout: string): string[];
24
+ /**
25
+ * Pure: joins the `opencode models` stdout with the parsed catalogue
26
+ * (`models.json`, or null/anything malformed for "no details"). Only ids
27
+ * present in the stdout are returned: first every model of `providers`
28
+ * (provider order, then output order), then every 'free' model of any other
29
+ * provider, sorted by id. No duplicates. Error when the stdout has no ids.
30
+ * Never throws.
31
+ */
32
+ export declare function parseOpencodeModels(idsStdout: string, catalogue: unknown, providers: readonly string[], observedAt: string): ProviderModels;
33
+ /**
34
+ * Resolves the opencode binary: `<homeDir>/.opencode/bin/opencode` when that is
35
+ * an executable regular file, else the first such `opencode` on PATH. An empty
36
+ * homeDir skips the home candidate; relative or empty PATH entries are skipped,
37
+ * so nothing resolves against the working directory. Nothing is spawned.
38
+ * Returns null when neither exists. Never throws.
39
+ */
40
+ export declare function resolveOpencodeBinary(homeDir: string, pathValue: string | undefined): Promise<string | null>;
41
+ /**
42
+ * Reads and parses `<homeDir>/.cache/opencode/models.json`. Returns null when
43
+ * homeDir is empty, or the file is missing, not a regular file, larger than the
44
+ * size bound, or not valid JSON. Never throws.
45
+ */
46
+ export declare function readOpencodeCatalogue(homeDir: string): Promise<unknown>;
47
+ /**
48
+ * Lists the subscription-provider models plus every free model opencode
49
+ * offers. Never throws or rejects.
50
+ */
51
+ export declare const fetchOpencodeModels: ModelsFetcher;
@@ -0,0 +1,400 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { constants } from 'node:fs';
3
+ import { access, open, stat } from 'node:fs/promises';
4
+ import { delimiter, isAbsolute, join } from 'node:path';
5
+ /**
6
+ * opencode model list (opencode 2.3.0).
7
+ *
8
+ * Ids come from plain `opencode models` (no arguments): one `provider/model`
9
+ * per line for every connected provider. Details (name, context, cost,
10
+ * reasoning efforts) are joined from opencode's own catalogue cache,
11
+ * `<home>/.cache/opencode/models.json`, keyed by provider id then model id. A
12
+ * missing or unreadable catalogue only drops the details; the ids are still
13
+ * listed.
14
+ *
15
+ * Returned: every model of the selected subscription providers, then every
16
+ * zero-priced ('free') model of any other listed provider.
17
+ *
18
+ * Nothing here starts a model turn. The binary is `<home>/.opencode/bin/opencode`
19
+ * when that file exists, otherwise `opencode` from PATH.
20
+ */
21
+ const PROVIDERS_ENV = 'FTOWN_QUOTA_OPENCODE_PROVIDERS';
22
+ const DEFAULT_PROVIDERS = ['opencode-go', 'zai-coding-plan'];
23
+ const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
24
+ const BINARY_NAME = 'opencode';
25
+ const MODEL_FLAG = '-m <id>';
26
+ /** opencode 2.3.0 takes the effort as a `#variant` suffix on the model id. */
27
+ const EFFORT_FLAG = '-m <id>#<level>';
28
+ const MAX_BUFFER_BYTES = 16 * 1024 * 1024;
29
+ const MAX_CATALOGUE_BYTES = 64 * 1024 * 1024;
30
+ const CATALOGUE_RELATIVE_PATH = join('.cache', 'opencode', 'models.json');
31
+ const UNEXPECTED_OUTPUT_MESSAGE = 'unexpected output from `opencode models`';
32
+ const NOT_FOUND_MESSAGE = 'opencode CLI not found';
33
+ const FAILED_MESSAGE = '`opencode models` failed';
34
+ const TIMED_OUT_MESSAGE = '`opencode models` timed out';
35
+ const TOO_LARGE_MESSAGE = '`opencode models` output was too large';
36
+ /** A model id line: no whitespace or control characters. */
37
+ const ID_LINE_PATTERN = /^[^\s\u0000-\u001f\u007f]+$/;
38
+ /**
39
+ * Providers whose zero-priced models are covered by a subscription plan the
40
+ * user holds (priced 'included' rather than 'free'). Any provider id
41
+ * containing `coding-plan` also counts, see isOpencodePlanProvider.
42
+ */
43
+ export const OPENCODE_PLAN_PROVIDERS = [];
44
+ function isRecord(value) {
45
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
46
+ }
47
+ function nonEmptyString(value) {
48
+ return typeof value === 'string' && value.trim().length > 0 ? value : null;
49
+ }
50
+ function ownRecord(container, key) {
51
+ if (!Object.prototype.hasOwnProperty.call(container, key))
52
+ return null;
53
+ const value = container[key];
54
+ return isRecord(value) ? value : null;
55
+ }
56
+ /** Pure: true for a subscription-plan provider (listed, or id contains `coding-plan`). */
57
+ export function isOpencodePlanProvider(providerId) {
58
+ return OPENCODE_PLAN_PROVIDERS.includes(providerId) || providerId.includes('coding-plan');
59
+ }
60
+ /**
61
+ * Pure: the provider ids to list, from FTOWN_QUOTA_OPENCODE_PROVIDERS (comma
62
+ * separated; entries are trimmed, empties and ids that fail the pattern are
63
+ * dropped, duplicates removed). Falls back to the defaults when the variable is
64
+ * unset, empty, or leaves no valid id.
65
+ */
66
+ export function opencodeSubscriptionProviders(env) {
67
+ const raw = env[PROVIDERS_ENV];
68
+ const ids = [];
69
+ if (raw !== undefined) {
70
+ for (const part of raw.split(',')) {
71
+ const id = part.trim();
72
+ if (PROVIDER_ID_PATTERN.test(id) && !ids.includes(id))
73
+ ids.push(id);
74
+ }
75
+ }
76
+ return ids.length > 0 ? ids : [...DEFAULT_PROVIDERS];
77
+ }
78
+ /**
79
+ * Pure: the `provider/model` lines of `opencode models` stdout, in output
80
+ * order, without duplicates. CRLF and blank lines are tolerated; lines with no
81
+ * slash, an empty provider or model part, or whitespace/control characters are
82
+ * ignored.
83
+ */
84
+ export function parseOpencodeModelIds(stdout) {
85
+ return modelRefs(stdout).map((ref) => ref.id);
86
+ }
87
+ function modelRefs(stdout) {
88
+ const refs = [];
89
+ const seen = new Set();
90
+ for (const rawLine of stdout.split('\n')) {
91
+ const line = rawLine.trim();
92
+ if (line.length === 0 || !ID_LINE_PATTERN.test(line))
93
+ continue;
94
+ const slash = line.indexOf('/');
95
+ if (slash <= 0 || slash === line.length - 1)
96
+ continue;
97
+ if (seen.has(line))
98
+ continue;
99
+ seen.add(line);
100
+ refs.push({ id: line, provider: line.slice(0, slash), model: line.slice(slash + 1) });
101
+ }
102
+ return refs;
103
+ }
104
+ function finiteNumber(value) {
105
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
106
+ }
107
+ /** Cost class from a catalogue entry's `cost`, or undefined when not stated. */
108
+ function pricingOf(entry, provider) {
109
+ const cost = entry === null ? null : entry.cost;
110
+ if (!isRecord(cost))
111
+ return undefined;
112
+ const input = finiteNumber(cost.input);
113
+ const output = finiteNumber(cost.output);
114
+ if (input === null || output === null || input < 0 || output < 0)
115
+ return undefined;
116
+ if (input > 0 || output > 0)
117
+ return 'paid';
118
+ return isOpencodePlanProvider(provider) ? 'included' : 'free';
119
+ }
120
+ /** Effort levels from `reasoning_options` entries of type `effort`, in catalogue order. */
121
+ function effortsOf(entry) {
122
+ const efforts = [];
123
+ let defaultEffort = null;
124
+ const options = entry === null ? undefined : entry.reasoning_options;
125
+ if (!Array.isArray(options))
126
+ return { efforts, defaultEffort };
127
+ for (const option of options) {
128
+ if (!isRecord(option) || option.type !== 'effort' || !Array.isArray(option.values))
129
+ continue;
130
+ for (const value of option.values) {
131
+ const rawLevel = nonEmptyString(value);
132
+ const level = rawLevel === null ? null : rawLevel.trim();
133
+ if (level !== null && level.length > 0 && !efforts.includes(level))
134
+ efforts.push(level);
135
+ }
136
+ const rawDefault = nonEmptyString(option.default);
137
+ const stated = rawDefault === null ? null : rawDefault.trim();
138
+ if (defaultEffort === null && stated !== null)
139
+ defaultEffort = stated;
140
+ }
141
+ if (defaultEffort !== null && !efforts.includes(defaultEffort))
142
+ defaultEffort = null;
143
+ return { efforts, defaultEffort };
144
+ }
145
+ function catalogueEntry(catalogue, ref) {
146
+ if (!isRecord(catalogue))
147
+ return null;
148
+ const provider = ownRecord(catalogue, ref.provider);
149
+ const models = provider === null ? null : ownRecord(provider, 'models');
150
+ return models === null ? null : ownRecord(models, ref.model);
151
+ }
152
+ function singleLine(value) {
153
+ const text = nonEmptyString(value);
154
+ return text === null ? null : text.replace(/\s+/g, ' ').trim();
155
+ }
156
+ function harnessModel(ref, entry) {
157
+ const limit = entry === null ? undefined : entry.limit;
158
+ const { efforts, defaultEffort } = effortsOf(entry);
159
+ const model = {
160
+ id: ref.id,
161
+ name: entry === null ? null : singleLine(entry.name),
162
+ isDefault: false,
163
+ description: entry === null ? null : singleLine(entry.description),
164
+ contextTokens: isRecord(limit) ? finiteNumber(limit.context) : null,
165
+ efforts,
166
+ defaultEffort,
167
+ };
168
+ const pricing = pricingOf(entry, ref.provider);
169
+ if (pricing !== undefined)
170
+ model.pricing = pricing;
171
+ return model;
172
+ }
173
+ function okModels(models, observedAt) {
174
+ return {
175
+ provider: 'opencode',
176
+ status: 'ok',
177
+ models,
178
+ source: 'live',
179
+ observedAt,
180
+ message: null,
181
+ modelFlag: MODEL_FLAG,
182
+ effortFlag: EFFORT_FLAG,
183
+ };
184
+ }
185
+ function failedModels(status, observedAt, message) {
186
+ return {
187
+ provider: 'opencode',
188
+ status,
189
+ models: [],
190
+ source: 'live',
191
+ observedAt,
192
+ message,
193
+ modelFlag: MODEL_FLAG,
194
+ effortFlag: EFFORT_FLAG,
195
+ };
196
+ }
197
+ /**
198
+ * Pure: joins the `opencode models` stdout with the parsed catalogue
199
+ * (`models.json`, or null/anything malformed for "no details"). Only ids
200
+ * present in the stdout are returned: first every model of `providers`
201
+ * (provider order, then output order), then every 'free' model of any other
202
+ * provider, sorted by id. No duplicates. Error when the stdout has no ids.
203
+ * Never throws.
204
+ */
205
+ export function parseOpencodeModels(idsStdout, catalogue, providers, observedAt) {
206
+ const refs = modelRefs(idsStdout);
207
+ if (refs.length === 0)
208
+ return failedModels('error', observedAt, UNEXPECTED_OUTPUT_MESSAGE);
209
+ const all = refs.map((ref) => harnessModel(ref, catalogueEntry(catalogue, ref)));
210
+ const providerOf = new Map(refs.map((ref) => [ref.id, ref.provider]));
211
+ const selected = [];
212
+ const taken = new Set();
213
+ for (const provider of providers) {
214
+ for (const model of all) {
215
+ if (providerOf.get(model.id) === provider && !taken.has(model.id)) {
216
+ selected.push(model);
217
+ taken.add(model.id);
218
+ }
219
+ }
220
+ }
221
+ const free = all
222
+ .filter((model) => model.pricing === 'free' && !taken.has(model.id))
223
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
224
+ return okModels([...selected, ...free], observedAt);
225
+ }
226
+ function observedNow(options) {
227
+ try {
228
+ return options.now().toISOString();
229
+ }
230
+ catch {
231
+ return new Date().toISOString();
232
+ }
233
+ }
234
+ /** A regular file that the current user may execute (no spawn). */
235
+ async function isExecutableFile(path) {
236
+ try {
237
+ await access(path, constants.X_OK);
238
+ return (await stat(path)).isFile();
239
+ }
240
+ catch {
241
+ return false;
242
+ }
243
+ }
244
+ /**
245
+ * Resolves the opencode binary: `<homeDir>/.opencode/bin/opencode` when that is
246
+ * an executable regular file, else the first such `opencode` on PATH. An empty
247
+ * homeDir skips the home candidate; relative or empty PATH entries are skipped,
248
+ * so nothing resolves against the working directory. Nothing is spawned.
249
+ * Returns null when neither exists. Never throws.
250
+ */
251
+ export async function resolveOpencodeBinary(homeDir, pathValue) {
252
+ if (homeDir.length > 0) {
253
+ const bundled = join(homeDir, '.opencode', 'bin', BINARY_NAME);
254
+ if (await isExecutableFile(bundled))
255
+ return bundled;
256
+ }
257
+ for (const dir of (pathValue ?? '').split(delimiter)) {
258
+ if (dir.length === 0 || !isAbsolute(dir))
259
+ continue;
260
+ const candidate = join(dir, BINARY_NAME);
261
+ if (await isExecutableFile(candidate))
262
+ return candidate;
263
+ }
264
+ return null;
265
+ }
266
+ /**
267
+ * Reads and parses `<homeDir>/.cache/opencode/models.json`. Returns null when
268
+ * homeDir is empty, or the file is missing, not a regular file, larger than the
269
+ * size bound, or not valid JSON. Never throws.
270
+ */
271
+ export async function readOpencodeCatalogue(homeDir) {
272
+ if (homeDir.length === 0)
273
+ return null;
274
+ let handle = null;
275
+ try {
276
+ handle = await open(join(homeDir, CATALOGUE_RELATIVE_PATH), 'r');
277
+ const info = await handle.stat();
278
+ if (!info.isFile() || info.size > MAX_CATALOGUE_BYTES)
279
+ return null;
280
+ const buffer = Buffer.alloc(info.size);
281
+ let offset = 0;
282
+ while (offset < info.size) {
283
+ const { bytesRead } = await handle.read(buffer, offset, info.size - offset, offset);
284
+ if (bytesRead === 0)
285
+ break;
286
+ offset += bytesRead;
287
+ }
288
+ return JSON.parse(buffer.subarray(0, offset).toString('utf8'));
289
+ }
290
+ catch {
291
+ return null;
292
+ }
293
+ finally {
294
+ try {
295
+ await handle?.close();
296
+ }
297
+ catch {
298
+ // ignore
299
+ }
300
+ }
301
+ }
302
+ /** The one `opencode models` call (no arguments). Never rejects. */
303
+ function runModelsCall(binary, timeoutMs) {
304
+ return new Promise((resolve) => {
305
+ try {
306
+ execFile(binary, ['models'], {
307
+ timeout: timeoutMs,
308
+ killSignal: 'SIGKILL',
309
+ encoding: 'utf8',
310
+ maxBuffer: MAX_BUFFER_BYTES,
311
+ windowsHide: true,
312
+ }, (error, stdout) => {
313
+ try {
314
+ if (!error) {
315
+ resolve({ kind: 'ok', stdout: String(stdout) });
316
+ return;
317
+ }
318
+ const failure = error;
319
+ if (failure.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER')
320
+ resolve({ kind: 'too_large' });
321
+ else if (failure.killed === true)
322
+ resolve({ kind: 'timeout' });
323
+ else if (failure.code === 'ENOENT')
324
+ resolve({ kind: 'missing' });
325
+ else
326
+ resolve({ kind: 'failed' });
327
+ }
328
+ catch {
329
+ resolve({ kind: 'failed' });
330
+ }
331
+ });
332
+ }
333
+ catch {
334
+ resolve({ kind: 'failed' });
335
+ }
336
+ });
337
+ }
338
+ /**
339
+ * Resolves the promise, but falls back after `ms` if still pending. The
340
+ * underlying work is left to finish in the background; only the awaited
341
+ * result is bounded. Never rejects.
342
+ */
343
+ function withDeadline(promise, ms, fallback) {
344
+ if (ms <= 0)
345
+ return Promise.resolve(fallback);
346
+ let timer = null;
347
+ const timeoutPromise = new Promise((resolve) => {
348
+ timer = setTimeout(() => resolve(fallback), ms);
349
+ if (typeof timer.unref === 'function')
350
+ timer.unref();
351
+ });
352
+ return Promise.race([promise.catch(() => fallback), timeoutPromise]).finally(() => {
353
+ if (timer !== null)
354
+ clearTimeout(timer);
355
+ });
356
+ }
357
+ async function readOpencodeModels(options) {
358
+ const observedAt = observedNow(options);
359
+ const providers = opencodeSubscriptionProviders(process.env);
360
+ const startedAt = Date.now();
361
+ const capped = Number.isFinite(options.timeoutMs);
362
+ const binary = await resolveOpencodeBinary(options.homeDir, process.env.PATH);
363
+ if (binary === null)
364
+ return failedModels('unavailable', observedAt, NOT_FOUND_MESSAGE);
365
+ const remainingMs = capped ? startedAt + options.timeoutMs - Date.now() : options.timeoutMs;
366
+ if (capped && remainingMs <= 0) {
367
+ return failedModels('error', observedAt, TIMED_OUT_MESSAGE);
368
+ }
369
+ const outcomePromise = runModelsCall(binary, capped ? remainingMs : options.timeoutMs);
370
+ const cataloguePromise = readOpencodeCatalogue(options.homeDir);
371
+ const catalogue = capped
372
+ ? await withDeadline(cataloguePromise, remainingMs, null)
373
+ : await cataloguePromise;
374
+ const outcome = await outcomePromise;
375
+ switch (outcome.kind) {
376
+ case 'ok':
377
+ return parseOpencodeModels(outcome.stdout, catalogue, providers, observedAt);
378
+ case 'missing':
379
+ return failedModels('unavailable', observedAt, NOT_FOUND_MESSAGE);
380
+ case 'timeout':
381
+ return failedModels('error', observedAt, TIMED_OUT_MESSAGE);
382
+ case 'too_large':
383
+ return failedModels('error', observedAt, TOO_LARGE_MESSAGE);
384
+ default:
385
+ return failedModels('error', observedAt, FAILED_MESSAGE);
386
+ }
387
+ }
388
+ /**
389
+ * Lists the subscription-provider models plus every free model opencode
390
+ * offers. Never throws or rejects.
391
+ */
392
+ export const fetchOpencodeModels = async (options) => {
393
+ try {
394
+ return await readOpencodeModels(options);
395
+ }
396
+ catch {
397
+ return failedModels('error', observedNow(options), FAILED_MESSAGE);
398
+ }
399
+ };
400
+ //# sourceMappingURL=models-opencode.js.map