ftown 0.25.2 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/command-rpc.d.ts +3 -0
- package/dist/command-rpc.js +41 -0
- package/dist/command-rpc.js.map +1 -1
- package/dist/coordination/board.d.ts +12 -0
- package/dist/coordination/board.js +240 -0
- package/dist/coordination/board.js.map +1 -0
- package/dist/coordination/card-store.d.ts +9 -0
- package/dist/coordination/card-store.js +188 -0
- package/dist/coordination/card-store.js.map +1 -0
- package/dist/coordination/contract.d.ts +236 -0
- package/dist/coordination/contract.js +63 -0
- package/dist/coordination/contract.js.map +1 -0
- package/dist/coordination/format.d.ts +5 -0
- package/dist/coordination/format.js +60 -0
- package/dist/coordination/format.js.map +1 -0
- package/dist/coordination/tree-events.d.ts +31 -0
- package/dist/coordination/tree-events.js +226 -0
- package/dist/coordination/tree-events.js.map +1 -0
- package/dist/coordination/wiring.d.ts +48 -0
- package/dist/coordination/wiring.js +143 -0
- package/dist/coordination/wiring.js.map +1 -0
- package/dist/coordination-cli.d.ts +34 -0
- package/dist/coordination-cli.js +460 -0
- package/dist/coordination-cli.js.map +1 -0
- package/dist/ftown-cli.js +12 -2
- package/dist/ftown-cli.js.map +1 -1
- package/dist/index.js +33 -7
- package/dist/index.js.map +1 -1
- package/dist/local-api-server.d.ts +6 -1
- package/dist/local-api-server.js +282 -5
- package/dist/local-api-server.js.map +1 -1
- package/dist/mail-delivery.d.ts +16 -2
- package/dist/mail-delivery.js +331 -77
- package/dist/mail-delivery.js.map +1 -1
- package/dist/mail-store.d.ts +7 -0
- package/dist/mail-store.js +71 -1
- package/dist/mail-store.js.map +1 -1
- package/dist/pull-request-events.d.ts +5 -0
- package/dist/pull-request-events.js +21 -1
- package/dist/pull-request-events.js.map +1 -1
- package/dist/quota/agy.d.ts +16 -0
- package/dist/quota/agy.js +287 -0
- package/dist/quota/agy.js.map +1 -0
- package/dist/quota/annotate.d.ts +5 -0
- package/dist/quota/annotate.js +56 -0
- package/dist/quota/annotate.js.map +1 -0
- package/dist/quota/claude.d.ts +27 -0
- package/dist/quota/claude.js +265 -0
- package/dist/quota/claude.js.map +1 -0
- package/dist/quota/codex.d.ts +25 -0
- package/dist/quota/codex.js +378 -0
- package/dist/quota/codex.js.map +1 -0
- package/dist/quota/format.d.ts +6 -0
- package/dist/quota/format.js +113 -0
- package/dist/quota/format.js.map +1 -0
- package/dist/quota/grok.d.ts +97 -0
- package/dist/quota/grok.js +563 -0
- package/dist/quota/grok.js.map +1 -0
- package/dist/quota/models-agy.d.ts +10 -0
- package/dist/quota/models-agy.js +144 -0
- package/dist/quota/models-agy.js.map +1 -0
- package/dist/quota/models-claude.d.ts +10 -0
- package/dist/quota/models-claude.js +227 -0
- package/dist/quota/models-claude.js.map +1 -0
- package/dist/quota/models-codex.d.ts +18 -0
- package/dist/quota/models-codex.js +305 -0
- package/dist/quota/models-codex.js.map +1 -0
- package/dist/quota/models-grok.d.ts +19 -0
- package/dist/quota/models-grok.js +272 -0
- package/dist/quota/models-grok.js.map +1 -0
- package/dist/quota/models-muse.d.ts +13 -0
- package/dist/quota/models-muse.js +200 -0
- package/dist/quota/models-muse.js.map +1 -0
- package/dist/quota/models-opencode.d.ts +51 -0
- package/dist/quota/models-opencode.js +400 -0
- package/dist/quota/models-opencode.js.map +1 -0
- package/dist/quota/muse.d.ts +38 -0
- package/dist/quota/muse.js +225 -0
- package/dist/quota/muse.js.map +1 -0
- package/dist/quota/opencode.d.ts +7 -0
- package/dist/quota/opencode.js +47 -0
- package/dist/quota/opencode.js.map +1 -0
- package/dist/quota/routing-config.d.ts +18 -0
- package/dist/quota/routing-config.js +438 -0
- package/dist/quota/routing-config.js.map +1 -0
- package/dist/quota/routing-service.d.ts +24 -0
- package/dist/quota/routing-service.js +118 -0
- package/dist/quota/routing-service.js.map +1 -0
- package/dist/quota/routing.d.ts +10 -0
- package/dist/quota/routing.js +180 -0
- package/dist/quota/routing.js.map +1 -0
- package/dist/quota/types.d.ts +304 -0
- package/dist/quota/types.js +45 -0
- package/dist/quota/types.js.map +1 -0
- package/dist/quota-cli.d.ts +80 -0
- package/dist/quota-cli.js +462 -0
- package/dist/quota-cli.js.map +1 -0
- package/dist/security/bridge/permissions.js +24 -2
- package/dist/security/bridge/permissions.js.map +1 -1
- package/dist/types.d.ts +7 -3
- package/dist/wire-types.d.ts +17 -1
- package/dist/wire-types.js +1 -1
- package/dist/wire-types.js.map +1 -1
- package/package.json +3 -2
- package/skills/ftown/SKILL.md +31 -5
- package/skills/ftown/references/orchestrator.md +17 -7
- package/skills/ftown/references/quota.md +177 -0
- package/skills/ftown/references/sessions.md +45 -3
|
@@ -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
|