@audiofool/moondog 0.2.0-beta.6 → 0.2.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/README.md +5 -4
- package/assets/brand/moondog-logo.svg +1 -0
- package/package.json +2 -2
- package/scripts/moondog.mjs +30 -6
- package/src/core/application-update.mjs +5 -3
- package/src/core/first-run.mjs +53 -0
- package/src/i18n/screen/es.mjs +62 -4
- package/src/i18n/screen/ja.mjs +62 -4
- package/src/i18n/screen/pt.mjs +62 -4
- package/src/i18n/screen/zh.mjs +62 -4
- package/src/integrations/netease/ncm-cli-player.mjs +29 -13
- package/src/memory/reflection-worker.mjs +1 -1
- package/src/runtime/pi/authentication.mjs +31 -10
- package/src/runtime/pi/configured-runtime.mjs +3 -3
- package/src/runtime/pi/custom-endpoints.mjs +229 -0
- package/src/runtime/pi/model-catalog.mjs +7 -3
- package/src/runtime/pi/model-list.mjs +122 -0
- package/src/runtime/pi/model-settings.mjs +131 -20
- package/src/runtime/pi/model-transport.mjs +2 -2
- package/src/runtime/pi/persistent-credential-store.mjs +31 -0
- package/src/runtime/pi/provider-registry.mjs +3 -9
- package/src/runtime/pi/public-model-catalog.mjs +12 -4
- package/src/runtime/pi/runtime-selection.mjs +9 -0
- package/src/runtime/pi/runtime-settings.mjs +37 -0
- package/src/surfaces/brand/logo.mjs +237 -0
- package/src/surfaces/cli/auth-command.mjs +25 -56
- package/src/surfaces/cli/brand-components.mjs +1 -1
- package/src/surfaces/cli/command-completions.mjs +14 -0
- package/src/surfaces/cli/endpoint-command.mjs +106 -0
- package/src/surfaces/cli/first-run-view.mjs +138 -0
- package/src/surfaces/cli/format-output.mjs +1 -1
- package/src/surfaces/cli/terminal-art.mjs +24 -66
- package/src/surfaces/cli/tui.mjs +320 -33
- package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
|
@@ -21,14 +21,11 @@ const KNOWN_SONGS = 300;
|
|
|
21
21
|
|
|
22
22
|
const failure = (code, message) => Object.assign(new Error(message), { code });
|
|
23
23
|
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
...EXTRA_FOLDERS,
|
|
27
|
-
])];
|
|
24
|
+
const folders = (value) => String(value ?? "").split(path.delimiter).filter((folder) => path.isAbsolute(folder));
|
|
25
|
+
const searchPath = (environment) => [...new Set([...folders(environment.PATH), ...EXTRA_FOLDERS])];
|
|
28
26
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
for (const folder of searchPath(environment)) {
|
|
27
|
+
async function findIn(places) {
|
|
28
|
+
for (const folder of places) {
|
|
32
29
|
const file = path.join(folder, "ncm-cli");
|
|
33
30
|
try {
|
|
34
31
|
if (!(await stat(file)).isFile()) continue;
|
|
@@ -39,18 +36,37 @@ export async function findNcmCli({ environment = process.env } = {}) {
|
|
|
39
36
|
return null;
|
|
40
37
|
}
|
|
41
38
|
|
|
39
|
+
// Opened from Finder, the app has its login shell's PATH, which lacks the folders ~/.zshrc adds,
|
|
40
|
+
// such as nvm's for npm's global commands. There, the listener's shell as Terminal opens it says.
|
|
41
|
+
function terminalPath(environment) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
execFile(environment.SHELL || "/bin/zsh", ["-ilc", 'printf "\\n__MOONDOG_PATH__%s\\n" "$PATH"'], { env: environment, timeout: 5_000 },
|
|
44
|
+
(_error, stdout) => resolve(folders(String(stdout ?? "").match(/__MOONDOG_PATH__(.+)/u)?.[1])));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** ncm-cli and the PATH to run it with, which has the node it runs on, or null. */
|
|
49
|
+
async function findNcmCli(environment) {
|
|
50
|
+
const places = searchPath(environment);
|
|
51
|
+
const file = await findIn(places);
|
|
52
|
+
if (file) return { file, places };
|
|
53
|
+
const terminal = await terminalPath(environment);
|
|
54
|
+
const found = await findIn(terminal);
|
|
55
|
+
return found ? { file: found, places: [...new Set([...terminal, ...places])] } : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
42
58
|
// Runs one ncm-cli command and parses its JSON. ncm-cli reports most failures as
|
|
43
59
|
// { success: false, message } with exit status 0. play prints no JSON when it starts a song,
|
|
44
60
|
// so its answer is {}; the player's state says whether the song started.
|
|
45
61
|
function cliRunner({ environment }) {
|
|
46
|
-
|
|
62
|
+
// Kept once found. The listener may install ncm-cli while Moondog runs, so a miss is looked for again.
|
|
63
|
+
let found = null;
|
|
47
64
|
return async (args) => {
|
|
48
|
-
found ??= findNcmCli(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const env = { ...environment, PATH: searchPath(environment).join(path.delimiter) };
|
|
65
|
+
found ??= await findNcmCli(environment);
|
|
66
|
+
if (!found) throw failure("netease_cli_missing", "ncm-cli is not installed. Install it with: npm install -g @music163/ncm-cli");
|
|
67
|
+
const env = { ...environment, PATH: found.places.join(path.delimiter) };
|
|
52
68
|
const stdout = await new Promise((resolve, reject) => {
|
|
53
|
-
execFile(
|
|
69
|
+
execFile(found.file, args, { env, timeout: COMMAND_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 }, (error, out) =>
|
|
54
70
|
(error && !out ? reject(failure("netease_failed", `ncm-cli ${args[0]} failed: ${String(error.message).slice(0, 200)}`)) : resolve(String(out ?? ""))));
|
|
55
71
|
});
|
|
56
72
|
const start = stdout.indexOf("{");
|
|
@@ -16,7 +16,7 @@ function reflectionErrorCode(error) {
|
|
|
16
16
|
function offlineRuntimeError(reason) {
|
|
17
17
|
if (reason === "provider_authentication_required") {
|
|
18
18
|
return new Error(
|
|
19
|
-
"The Memory Agent
|
|
19
|
+
"The Memory Agent's model needs a sign-in. Run /auth, or moondog auth login <provider>, first.",
|
|
20
20
|
);
|
|
21
21
|
}
|
|
22
22
|
return new Error(
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
3
|
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
3
5
|
|
|
4
6
|
import { createModels } from "@earendil-works/pi-ai";
|
|
5
7
|
|
|
6
|
-
import { createPersistentCredentialStore, serviceApiKeyIds } from "./persistent-credential-store.mjs";
|
|
7
|
-
import { apiKeyPiProviderIds, loadPiProvider } from "./provider-registry.mjs";
|
|
8
|
+
import { createPersistentCredentialStore, resolveMoondogAuthFile, serviceApiKeyIds } from "./persistent-credential-store.mjs";
|
|
9
|
+
import { apiKeyPiProviderIds, chatgptProviderId, loadPiProvider } from "./provider-registry.mjs";
|
|
8
10
|
|
|
9
11
|
// Keys for services that are not models share the same sign-in and private store;
|
|
10
12
|
// each one also needs its id in the store's serviceApiKeyIds.
|
|
@@ -13,11 +15,25 @@ export const serviceKeyProviders = Object.freeze([
|
|
|
13
15
|
]);
|
|
14
16
|
|
|
15
17
|
export const supportedAuthProviderIds = Object.freeze([
|
|
16
|
-
"openai-codex",
|
|
17
18
|
...apiKeyPiProviderIds,
|
|
18
19
|
...serviceApiKeyIds,
|
|
19
20
|
]);
|
|
20
21
|
|
|
22
|
+
// Sign in with ChatGPT names this installation to OpenAI by a lasting random ID, kept beside auth.json.
|
|
23
|
+
async function installationId(directory) {
|
|
24
|
+
const file = path.join(directory, "installation-id");
|
|
25
|
+
try {
|
|
26
|
+
const saved = (await readFile(file, "utf8")).trim();
|
|
27
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(saved)) return saved;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error?.code !== "ENOENT") throw error;
|
|
30
|
+
}
|
|
31
|
+
const id = randomUUID();
|
|
32
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
33
|
+
await writeFile(file, `${id}\n`, { encoding: "utf8", mode: 0o600 });
|
|
34
|
+
return id;
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
export function createPiAuthContext(environment = process.env) {
|
|
22
38
|
return {
|
|
23
39
|
async env(name) {
|
|
@@ -104,7 +120,8 @@ export async function createPiAuthentication({
|
|
|
104
120
|
};
|
|
105
121
|
|
|
106
122
|
return {
|
|
107
|
-
|
|
123
|
+
// method "oauth" signs in with ChatGPT, for OpenAI only; any other login saves an API key.
|
|
124
|
+
async login(providerId, interaction, { method = "api_key" } = {}) {
|
|
108
125
|
const entry = service(providerId);
|
|
109
126
|
if (entry) {
|
|
110
127
|
interaction.signal?.throwIfAborted();
|
|
@@ -113,12 +130,16 @@ export async function createPiAuthentication({
|
|
|
113
130
|
await credentials.modify(entry.id, async () => ({ type: "api_key", key: typeof key === "string" ? key.trim() : key }));
|
|
114
131
|
return serviceStatus(entry);
|
|
115
132
|
}
|
|
133
|
+
if (method === "oauth" && providerId !== chatgptProviderId) {
|
|
134
|
+
const error = new Error("Only OpenAI signs in with ChatGPT.");
|
|
135
|
+
error.code = "auth_provider_unsupported";
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
116
138
|
await ensureProvider(providerId);
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
);
|
|
139
|
+
const deviceId = method === "oauth"
|
|
140
|
+
? await installationId(credentials.directory ?? path.dirname(resolveMoondogAuthFile(environment))) : null;
|
|
141
|
+
await models.login(providerId, method === "oauth" ? "oauth" : "api_key", interaction,
|
|
142
|
+
method === "oauth" ? { getDeviceId: () => deviceId } : undefined);
|
|
122
143
|
return status(providerId);
|
|
123
144
|
},
|
|
124
145
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
2
2
|
|
|
3
3
|
import { PiAgentRuntime } from "./agent-runtime.mjs";
|
|
4
4
|
import { createPiAuthContext } from "./authentication.mjs";
|
|
5
5
|
import { getPublicPiModelCatalog } from "./public-model-catalog.mjs";
|
|
6
6
|
import { createPersistentCredentialStore } from "./persistent-credential-store.mjs";
|
|
7
|
-
import { EFFORT_LEVELS, readPiRuntimeSelection } from "./runtime-settings.mjs";
|
|
7
|
+
import { clampEffort, EFFORT_LEVELS, readPiRuntimeSelection } from "./runtime-settings.mjs";
|
|
8
8
|
|
|
9
9
|
export class OfflineAgentRuntime {
|
|
10
10
|
constructor(reason = "model_not_configured", selection) {
|
|
@@ -108,7 +108,7 @@ export async function createConfiguredRuntime(
|
|
|
108
108
|
// Without a saved effort, keep Pi's default and send no effort setting.
|
|
109
109
|
effort,
|
|
110
110
|
effortLevels: getSupportedThinkingLevels(model),
|
|
111
|
-
thinkingLevel: effort ?
|
|
111
|
+
thinkingLevel: effort ? clampEffort(model, effort) : undefined,
|
|
112
112
|
...(toolTrace ? { toolTrace } : {}),
|
|
113
113
|
});
|
|
114
114
|
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { createProvider } from "@earendil-works/pi-ai";
|
|
6
|
+
import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
|
|
7
|
+
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
8
|
+
import { builtinModels } from "@earendil-works/pi-ai/providers/all";
|
|
9
|
+
|
|
10
|
+
import { acquirePrivateFileLock, resolveMoondogAuthFile } from "./persistent-credential-store.mjs";
|
|
11
|
+
|
|
12
|
+
// Endpoints the listener adds by address: any service that speaks OpenAI's Chat Completions or
|
|
13
|
+
// Anthropic's Messages, such as DeepSeek, Kimi, GLM, OpenRouter, a company gateway or a model on
|
|
14
|
+
// this Mac. Their keys stay in this private file. auth.json is read with a fixed set of providers
|
|
15
|
+
// by older Moondogs, which would refuse the whole store with an entry they don't know.
|
|
16
|
+
const DOCUMENT_VERSION = 1;
|
|
17
|
+
const MAX_ENDPOINTS = 20;
|
|
18
|
+
const MAX_MODELS = 500;
|
|
19
|
+
const MAX_LISTING_BYTES = 2 * 1024 * 1024;
|
|
20
|
+
const PREFIX = "endpoint-";
|
|
21
|
+
export const ENDPOINT_FORMATS = Object.freeze(["openai", "anthropic"]);
|
|
22
|
+
const apis = { openai: "openai-completions", anthropic: "anthropic-messages" };
|
|
23
|
+
const transports = { openai: openAICompletionsApi, anthropic: anthropicMessagesApi };
|
|
24
|
+
// Local servers often take no key, but the SDKs under Pi always send one.
|
|
25
|
+
const NO_KEY = "moondog-no-key";
|
|
26
|
+
const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
27
|
+
|
|
28
|
+
const printable = (value, max) => typeof value === "string" && value.length > 0 && value.length <= max && !/[\p{C}]/u.test(value);
|
|
29
|
+
const plainId = (value, max) => printable(value, max) && !/\s/u.test(value);
|
|
30
|
+
|
|
31
|
+
function endpointError(code, message, cause) {
|
|
32
|
+
const error = new Error(message, cause ? { cause } : undefined);
|
|
33
|
+
error.name = "MoondogRuntimeSettingsError";
|
|
34
|
+
error.code = code;
|
|
35
|
+
return error;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const isEndpointProvider = (providerId) => typeof providerId === "string" && providerId.startsWith(PREFIX);
|
|
39
|
+
|
|
40
|
+
// The address as the listener copied it, down to the base its format's SDK adds paths to: an
|
|
41
|
+
// operation's own URL loses that operation, and Anthropic's SDK adds /v1 itself.
|
|
42
|
+
export function normalizeBaseUrl(value, format) {
|
|
43
|
+
let url;
|
|
44
|
+
try { url = new URL(String(value ?? "").trim()); } catch { throw endpointError("endpoint_url_invalid", "Enter the endpoint's address, starting with https://."); }
|
|
45
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
46
|
+
throw endpointError("endpoint_url_invalid", "Enter the address without a key, a query or a fragment in it.");
|
|
47
|
+
}
|
|
48
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && LOOPBACK.has(url.hostname))) {
|
|
49
|
+
throw endpointError("endpoint_url_invalid", "Use https://, or http:// only for a server on this Mac.");
|
|
50
|
+
}
|
|
51
|
+
url.pathname = url.pathname.replace(/\/(?:chat\/completions|responses|messages|models)\/?$/u, "").replace(/\/+$/u, "");
|
|
52
|
+
if (format === "anthropic") url.pathname = url.pathname.replace(/\/v1$/u, "");
|
|
53
|
+
return url.href.replace(/\/$/u, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parseModelIds(value) {
|
|
57
|
+
const ids = Array.isArray(value) ? value : String(value ?? "").split(/[\s,]+/u);
|
|
58
|
+
const kept = [...new Set(ids.map((id) => String(id).trim()).filter(Boolean))];
|
|
59
|
+
if (kept.some((id) => !plainId(id, 200))) throw endpointError("endpoint_models_invalid", "A model ID has spaces or characters an endpoint can't take.");
|
|
60
|
+
if (kept.length > MAX_MODELS) throw endpointError("endpoint_models_invalid", `An endpoint can list at most ${MAX_MODELS} models.`);
|
|
61
|
+
return kept;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validEndpoint(value) {
|
|
65
|
+
if (!value || typeof value !== "object" || !isEndpointProvider(value.id) || !plainId(value.id, 60) ||
|
|
66
|
+
!printable(value.name, 60) || !ENDPOINT_FORMATS.includes(value.format) || typeof value.baseUrl !== "string" ||
|
|
67
|
+
(value.key !== undefined && !plainId(value.key, 8192)) || !Array.isArray(value.models)) {
|
|
68
|
+
throw endpointError("endpoint_store_invalid", "Moondog's endpoint file has an entry it can't read. Remove it in Settings, or delete endpoints.json.");
|
|
69
|
+
}
|
|
70
|
+
return { id: value.id, name: value.name, format: value.format, baseUrl: normalizeBaseUrl(value.baseUrl, value.format),
|
|
71
|
+
...(value.key ? { key: value.key } : {}), models: parseModelIds(value.models) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function resolveEndpointsFile(environment = process.env) {
|
|
75
|
+
return path.join(path.dirname(resolveMoondogAuthFile(environment)), "endpoints.json");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function readEndpoints(environment = process.env) {
|
|
79
|
+
let source;
|
|
80
|
+
try {
|
|
81
|
+
source = await readFile(resolveEndpointsFile(environment), "utf8");
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error?.code === "ENOENT") return [];
|
|
84
|
+
throw endpointError("endpoint_store_invalid", "Moondog could not read its endpoints.", error);
|
|
85
|
+
}
|
|
86
|
+
let document;
|
|
87
|
+
try { document = JSON.parse(source); } catch (error) { throw endpointError("endpoint_store_invalid", "Moondog's endpoint file is not valid JSON.", error); }
|
|
88
|
+
if (!document || document.version !== DOCUMENT_VERSION || !Array.isArray(document.endpoints) || document.endpoints.length > MAX_ENDPOINTS) {
|
|
89
|
+
throw endpointError("endpoint_store_invalid", "Moondog's endpoint file uses an unsupported shape.");
|
|
90
|
+
}
|
|
91
|
+
return document.endpoints.map(validEndpoint);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// What a window may see of an endpoint: never its key.
|
|
95
|
+
export function publicEndpoint({ key, ...shown }) {
|
|
96
|
+
return { ...shown, keySaved: Boolean(key) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function writeEndpoints(file, endpoints) {
|
|
100
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
101
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
102
|
+
try {
|
|
103
|
+
await writeFile(temporary, `${JSON.stringify({ version: DOCUMENT_VERSION, endpoints }, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
104
|
+
await rename(temporary, file);
|
|
105
|
+
} finally {
|
|
106
|
+
await rm(temporary, { force: true });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const slug = (name) => name.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 40) || "endpoint";
|
|
111
|
+
|
|
112
|
+
// Adds an endpoint, or changes the one with input.id. A key left blank keeps the saved one only while
|
|
113
|
+
// the address stays the same, so a key never travels to an address it wasn't entered for. Without
|
|
114
|
+
// model IDs, the endpoint is asked for its list.
|
|
115
|
+
export async function saveEndpoint(input, environment = process.env, { discover = discoverModels } = {}) {
|
|
116
|
+
const format = ENDPOINT_FORMATS.includes(input?.format) ? input.format : "openai";
|
|
117
|
+
const name = String(input?.name ?? "").trim();
|
|
118
|
+
if (!printable(name, 60)) throw endpointError("endpoint_name_invalid", "Give the endpoint a short name.");
|
|
119
|
+
const baseUrl = normalizeBaseUrl(input?.baseUrl, format);
|
|
120
|
+
const typedKey = String(input?.key ?? "").trim();
|
|
121
|
+
if (typedKey && !plainId(typedKey, 8192)) throw endpointError("endpoint_key_invalid", "The key has spaces or characters a key can't have.");
|
|
122
|
+
const typedModels = parseModelIds(input?.models);
|
|
123
|
+
|
|
124
|
+
const file = resolveEndpointsFile(environment);
|
|
125
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
126
|
+
const release = await acquirePrivateFileLock(file);
|
|
127
|
+
try {
|
|
128
|
+
const endpoints = await readEndpoints(environment);
|
|
129
|
+
const previous = input?.id ? endpoints.find((endpoint) => endpoint.id === input.id) : undefined;
|
|
130
|
+
if (input?.id && !previous) throw endpointError("endpoint_unknown", "That endpoint is no longer saved.");
|
|
131
|
+
if (!previous && endpoints.length >= MAX_ENDPOINTS) throw endpointError("endpoint_limit", `Moondog keeps at most ${MAX_ENDPOINTS} endpoints.`);
|
|
132
|
+
const key = typedKey || (previous && previous.baseUrl === baseUrl && previous.format === format ? previous.key : undefined);
|
|
133
|
+
const models = typedModels.length ? typedModels : await discover({ format, baseUrl, key });
|
|
134
|
+
if (!models.length) throw endpointError("endpoint_models_missing", "The endpoint listed no models. Enter their IDs.");
|
|
135
|
+
let id = previous?.id;
|
|
136
|
+
if (!id) {
|
|
137
|
+
const base = `${PREFIX}${slug(name)}`;
|
|
138
|
+
id = base;
|
|
139
|
+
for (let count = 2; endpoints.some((endpoint) => endpoint.id === id); count++) id = `${base}-${count}`;
|
|
140
|
+
}
|
|
141
|
+
const saved = { id, name, format, baseUrl, ...(key ? { key } : {}), models };
|
|
142
|
+
const next = previous ? endpoints.map((endpoint) => (endpoint.id === id ? saved : endpoint)) : [...endpoints, saved];
|
|
143
|
+
await writeEndpoints(file, next);
|
|
144
|
+
return { endpoint: publicEndpoint(saved), typedModels: typedModels.length > 0 };
|
|
145
|
+
} finally {
|
|
146
|
+
await release();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function removeEndpoint(id, environment = process.env) {
|
|
151
|
+
const file = resolveEndpointsFile(environment);
|
|
152
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
153
|
+
const release = await acquirePrivateFileLock(file);
|
|
154
|
+
try {
|
|
155
|
+
const endpoints = await readEndpoints(environment);
|
|
156
|
+
if (!endpoints.some((endpoint) => endpoint.id === id)) return false;
|
|
157
|
+
await writeEndpoints(file, endpoints.filter((endpoint) => endpoint.id !== id));
|
|
158
|
+
return true;
|
|
159
|
+
} finally {
|
|
160
|
+
await release();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The model list an endpoint publishes, in its order: OpenAI's /models or Anthropic's /v1/models.
|
|
165
|
+
// Its body and errors never reach the window; only whether it worked.
|
|
166
|
+
export async function discoverModels({ format, baseUrl, key }, { fetchImpl = globalThis.fetch, timeoutMs = 8000 } = {}) {
|
|
167
|
+
const url = format === "anthropic" ? `${baseUrl}/v1/models?limit=1000` : `${baseUrl}/models`;
|
|
168
|
+
const headers = format === "anthropic"
|
|
169
|
+
? { Accept: "application/json", "anthropic-version": "2023-06-01", ...(key ? { "x-api-key": key } : {}) }
|
|
170
|
+
: { Accept: "application/json", ...(key ? { Authorization: `Bearer ${key}` } : {}) };
|
|
171
|
+
let ids;
|
|
172
|
+
try {
|
|
173
|
+
const response = await fetchImpl(url, { headers, redirect: "error", credentials: "omit", signal: AbortSignal.timeout(timeoutMs) });
|
|
174
|
+
if (!response.ok || Number(response.headers.get("content-length")) > MAX_LISTING_BYTES) throw new Error(`HTTP ${response.status}`);
|
|
175
|
+
const text = await response.text();
|
|
176
|
+
if (text.length > MAX_LISTING_BYTES) throw new Error("oversized");
|
|
177
|
+
const document = JSON.parse(text);
|
|
178
|
+
ids = (Array.isArray(document?.data) ? document.data : Array.isArray(document?.models) ? document.models : [])
|
|
179
|
+
.map((entry) => (typeof entry === "string" ? entry : entry?.id)).filter((id) => typeof id === "string");
|
|
180
|
+
} catch (error) {
|
|
181
|
+
throw endpointError("endpoint_models_unavailable", "The endpoint didn't list its models. Check the address and key, or enter the model IDs.", error);
|
|
182
|
+
}
|
|
183
|
+
return parseModelIds(ids.slice(0, MAX_MODELS));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// A model Pi already knows by the same ID, such as deepseek-chat or anthropic/claude-sonnet-5, keeps
|
|
187
|
+
// its name and limits; through the same kind of API it also keeps its thinking levels and request
|
|
188
|
+
// details. Any other model is plain text without thinking, which every compatible server takes.
|
|
189
|
+
let known;
|
|
190
|
+
function knownModels() {
|
|
191
|
+
if (!known) {
|
|
192
|
+
known = new Map();
|
|
193
|
+
for (const model of builtinModels().getModels()) {
|
|
194
|
+
const list = known.get(model.id) ?? [];
|
|
195
|
+
list.push(model);
|
|
196
|
+
known.set(model.id, list);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return known;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function endpointModel(endpoint, id) {
|
|
203
|
+
const api = apis[endpoint.format];
|
|
204
|
+
const matches = knownModels().get(id) ?? [];
|
|
205
|
+
const same = matches.find((model) => model.api === api);
|
|
206
|
+
const match = same ?? matches[0];
|
|
207
|
+
return {
|
|
208
|
+
id, name: match?.name ?? id, provider: endpoint.id, api, baseUrl: endpoint.baseUrl,
|
|
209
|
+
reasoning: same?.reasoning === true,
|
|
210
|
+
input: match?.input ? [...match.input] : ["text"],
|
|
211
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
212
|
+
contextWindow: match?.contextWindow ?? 128_000,
|
|
213
|
+
maxTokens: match?.maxTokens ?? 8192,
|
|
214
|
+
...(same?.thinkingLevelMap ? { thinkingLevelMap: structuredClone(same.thinkingLevelMap) } : {}),
|
|
215
|
+
...(same?.compat ? { compat: structuredClone(same.compat) } : {}),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function addEndpoints(models, endpoints = []) {
|
|
220
|
+
for (const endpoint of endpoints) {
|
|
221
|
+
models.setProvider(createProvider({
|
|
222
|
+
id: endpoint.id, name: endpoint.name, baseUrl: endpoint.baseUrl,
|
|
223
|
+
models: endpoint.models.map((id) => endpointModel(endpoint, id)),
|
|
224
|
+
auth: { apiKey: { name: `${endpoint.name} key`, resolve: async () => ({ auth: { apiKey: endpoint.key ?? NO_KEY }, source: "stored credential" }) } },
|
|
225
|
+
api: transports[endpoint.format](),
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
return models;
|
|
229
|
+
}
|
|
@@ -4,11 +4,10 @@ import { zaiProvider } from "@earendil-works/pi-ai/providers/zai";
|
|
|
4
4
|
|
|
5
5
|
const zaiApiBaseUrl = "https://api.z.ai/api/paas/v4";
|
|
6
6
|
const providerDisplayNames = {
|
|
7
|
-
anthropic: "Anthropic
|
|
7
|
+
anthropic: "Anthropic",
|
|
8
8
|
moonshotai: "Moonshot AI (Kimi)",
|
|
9
9
|
"moonshotai-cn": "Moonshot AI China (Kimi)",
|
|
10
|
-
|
|
11
|
-
xai: "xAI (Grok)",
|
|
10
|
+
xai: "xAI",
|
|
12
11
|
};
|
|
13
12
|
|
|
14
13
|
export function zaiApiProvider() {
|
|
@@ -28,6 +27,9 @@ export function zaiApiProvider() {
|
|
|
28
27
|
});
|
|
29
28
|
}
|
|
30
29
|
|
|
30
|
+
// Saved selections may still name these; lists offer the canonical model instead.
|
|
31
|
+
export const LEGACY_MODEL_ALIASES = Object.freeze(["deepseek-v4-flash", "deepseek-v4-flash-vision-exp"]);
|
|
32
|
+
|
|
31
33
|
// Pi supplies canonical Flash metadata. Keep legacy IDs as aliases so saved
|
|
32
34
|
// Moondog selections continue to resolve without rewriting the listener's settings.
|
|
33
35
|
function currentDeepSeekModels(nativeModels) {
|
|
@@ -39,6 +41,8 @@ function currentDeepSeekModels(nativeModels) {
|
|
|
39
41
|
|
|
40
42
|
export function createPiModels({ catalogModels, ...options } = {}) {
|
|
41
43
|
const models = builtinModels(options);
|
|
44
|
+
// ChatGPT signs in through OpenAI's own provider now; Pi's older Codex route is not offered.
|
|
45
|
+
models.deleteProvider("openai-codex");
|
|
42
46
|
models.setProvider(zaiApiProvider());
|
|
43
47
|
for (const native of models.getProviders()) {
|
|
44
48
|
if (native.id !== "deepseek" && !catalogModels?.has(native.id)) continue;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { resolveMoondogAuthFile } from "./persistent-credential-store.mjs";
|
|
6
|
+
|
|
7
|
+
// The models the listener keeps at hand: the window's model menu shows these, flat, in this order.
|
|
8
|
+
// Which one answers by default stays in settings.json, which older Moondogs read with a fixed shape,
|
|
9
|
+
// so the list lives in its own file beside it.
|
|
10
|
+
const LIST_VERSION = 1;
|
|
11
|
+
const MAX_MODELS = 40;
|
|
12
|
+
const printable = (value, max) => typeof value === "string" && value.length > 0 && value.length <= max && !/[\p{C}\s]/u.test(value);
|
|
13
|
+
|
|
14
|
+
function listError(message, cause) {
|
|
15
|
+
const error = new Error(message, cause ? { cause } : undefined);
|
|
16
|
+
error.name = "MoondogRuntimeSettingsError";
|
|
17
|
+
error.code = "model_list_invalid";
|
|
18
|
+
return error;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const sameModel = (left, right) => Boolean(left && right) && left.provider === right.provider && left.model === right.model;
|
|
22
|
+
|
|
23
|
+
export function normalizeModelList(value) {
|
|
24
|
+
if (!Array.isArray(value)) throw listError("Moondog's model list must be a list.");
|
|
25
|
+
const models = [];
|
|
26
|
+
for (const entry of value) {
|
|
27
|
+
const provider = typeof entry?.provider === "string" ? entry.provider.trim().toLowerCase() : "";
|
|
28
|
+
const model = typeof entry?.model === "string" ? entry.model.trim() : "";
|
|
29
|
+
if (!printable(provider, 80) || !printable(model, 200)) throw listError("Moondog's model list has an entry without a provider and model.");
|
|
30
|
+
if (!models.some((kept) => sameModel(kept, { provider, model }))) models.push({ provider, model });
|
|
31
|
+
}
|
|
32
|
+
if (models.length > MAX_MODELS) throw listError(`Moondog keeps at most ${MAX_MODELS} models in its list.`);
|
|
33
|
+
return models;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resolveModelListFile(environment = process.env) {
|
|
37
|
+
return path.join(path.dirname(resolveMoondogAuthFile(environment)), "models.json");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Undefined until the listener first changes the list; the window then starts it from the default.
|
|
41
|
+
export async function readModelList(environment = process.env) {
|
|
42
|
+
let source;
|
|
43
|
+
try {
|
|
44
|
+
source = await readFile(resolveModelListFile(environment), "utf8");
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error?.code === "ENOENT") return undefined;
|
|
47
|
+
throw listError("Moondog could not read its model list.", error);
|
|
48
|
+
}
|
|
49
|
+
let document;
|
|
50
|
+
try {
|
|
51
|
+
document = JSON.parse(source);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw listError("Moondog's model list is not valid JSON.", error);
|
|
54
|
+
}
|
|
55
|
+
if (!document || typeof document !== "object" || document.version !== LIST_VERSION) {
|
|
56
|
+
throw listError("Moondog's model list uses an unsupported shape.");
|
|
57
|
+
}
|
|
58
|
+
return normalizeModelList(document.models);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function writeModelList(models, environment = process.env) {
|
|
62
|
+
const normalized = normalizeModelList(models);
|
|
63
|
+
const file = resolveModelListFile(environment);
|
|
64
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
65
|
+
// Another Moondog may read the list at any moment, so it never sees half a file.
|
|
66
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
67
|
+
try {
|
|
68
|
+
await writeFile(temporary, `${JSON.stringify({ version: LIST_VERSION, models: normalized }, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
69
|
+
await rename(temporary, file);
|
|
70
|
+
} finally {
|
|
71
|
+
await rm(temporary, { force: true });
|
|
72
|
+
}
|
|
73
|
+
return normalized;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The model each conversation was given in the window's menu, by conversation id, so a conversation
|
|
77
|
+
// keeps its own model across launches; every other one answers with the default. The oldest go first
|
|
78
|
+
// past a few hundred.
|
|
79
|
+
const MAX_CONVERSATIONS = 500;
|
|
80
|
+
const resolveConversationModelsFile = (environment) => path.join(path.dirname(resolveMoondogAuthFile(environment)), "conversation-models.json");
|
|
81
|
+
|
|
82
|
+
export async function readConversationModels(environment = process.env) {
|
|
83
|
+
let document;
|
|
84
|
+
try {
|
|
85
|
+
document = JSON.parse(await readFile(resolveConversationModelsFile(environment), "utf8"));
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error?.code === "ENOENT") return {};
|
|
88
|
+
throw listError("Moondog could not read the models chosen for conversations.", error);
|
|
89
|
+
}
|
|
90
|
+
if (!document || document.version !== LIST_VERSION || !document.conversations || typeof document.conversations !== "object") {
|
|
91
|
+
throw listError("Moondog's conversation models use an unsupported shape.");
|
|
92
|
+
}
|
|
93
|
+
const kept = {};
|
|
94
|
+
for (const [session, value] of Object.entries(document.conversations).slice(-MAX_CONVERSATIONS)) {
|
|
95
|
+
if (!printable(session, 100)) continue;
|
|
96
|
+
const [entry] = normalizeModelList([value]);
|
|
97
|
+
kept[session] = { ...entry, ...(typeof value.effort === "string" && printable(value.effort, 20) ? { effort: value.effort } : {}) };
|
|
98
|
+
}
|
|
99
|
+
return kept;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// One change at a time, so two quick choices never lose either.
|
|
103
|
+
let writing = Promise.resolve();
|
|
104
|
+
export function writeConversationModel(session, selection, environment = process.env) {
|
|
105
|
+
const next = writing.then(async () => {
|
|
106
|
+
const saved = await readConversationModels(environment).catch(() => ({}));
|
|
107
|
+
delete saved[session];
|
|
108
|
+
if (selection) saved[session] = { provider: selection.provider, model: selection.model, ...(selection.effort ? { effort: selection.effort } : {}) };
|
|
109
|
+
const conversations = Object.fromEntries(Object.entries(saved).slice(-MAX_CONVERSATIONS));
|
|
110
|
+
const file = resolveConversationModelsFile(environment);
|
|
111
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
112
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
113
|
+
try {
|
|
114
|
+
await writeFile(temporary, `${JSON.stringify({ version: LIST_VERSION, conversations }, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
115
|
+
await rename(temporary, file);
|
|
116
|
+
} finally {
|
|
117
|
+
await rm(temporary, { force: true });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
writing = next.catch(() => {});
|
|
121
|
+
return next;
|
|
122
|
+
}
|