@audiofool/moondog 0.2.0-beta.7 → 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.
@@ -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 (Claude)",
7
+ anthropic: "Anthropic",
8
8
  moonshotai: "Moonshot AI (Kimi)",
9
9
  "moonshotai-cn": "Moonshot AI China (Kimi)",
10
- openai: "OpenAI (GPT)",
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
+ }
@@ -6,15 +6,22 @@ import {
6
6
  createPiAuthentication,
7
7
  supportedAuthProviderIds,
8
8
  } from "./authentication.mjs";
9
+ import { isEndpointProvider, removeEndpoint, saveEndpoint } from "./custom-endpoints.mjs";
10
+ import { LEGACY_MODEL_ALIASES } from "./model-catalog.mjs";
11
+ import { readConversationModels, readModelList, sameModel, writeConversationModel, writeModelList } from "./model-list.mjs";
12
+ import { chatgptProviderId } from "./provider-registry.mjs";
9
13
  import { createPersistentCredentialStore } from "./persistent-credential-store.mjs";
10
14
  import { getPublicPiModelCatalog } from "./public-model-catalog.mjs";
11
- import { readPiRuntimeSelection } from "./runtime-settings.mjs";
15
+ import { clampEffort, readPiRuntimeSelection } from "./runtime-settings.mjs";
12
16
 
13
17
  // The desktop window picks models and signs in through this service, as the
14
18
  // terminal does with /model and /auth. It returns status shapes only: never a
15
19
  // credential, a prompt answer or raw provider text.
16
20
 
17
- const OAUTH_PROVIDER = "openai-codex";
21
+ // The providers every listener is offered, in this order: OpenAI by a ChatGPT subscription or an API
22
+ // key, then Anthropic, Google and xAI. Any other service is reached as an endpoint by address; Pi's
23
+ // other providers show only once a key for one is saved or in the environment.
24
+ const FEATURED = ["openai", "anthropic", "google", "xai"];
18
25
  // Moondog's own errors carry fixed text. Pi wraps them, and its own text may not be safe.
19
26
  const moondogErrorNames = new Set(["MoondogCredentialStoreError", "MoondogRuntimeSettingsError"]);
20
27
 
@@ -33,7 +40,7 @@ function moondogError(error) {
33
40
  const aborted = () => new DOMException("The sign-in was cancelled.", "AbortError");
34
41
 
35
42
  function signInKind(providerId) {
36
- if (providerId === OAUTH_PROVIDER) return "oauth";
43
+ if (isEndpointProvider(providerId)) return "endpoint";
37
44
  return supportedAuthProviderIds.includes(providerId) ? "api_key" : "environment_only";
38
45
  }
39
46
 
@@ -88,25 +95,23 @@ export function createModelSettings({
88
95
  }
89
96
  };
90
97
 
91
- // The same list as the terminal's /model picker: providers with models, the
92
- // current one first, then ChatGPT sign-in, then by name.
93
- const listed = (source, currentProvider) => source.providers()
94
- .filter(provider => provider.modelCount > 0)
95
- .sort((left, right) => {
96
- const priority = provider => provider.id === currentProvider ? 0 : provider.id === OAUTH_PROVIDER ? 1 : 2;
97
- return priority(left) - priority(right) || left.name.localeCompare(right.name);
98
- });
98
+ const listed = (source) => source.providers().filter(provider => provider.modelCount > 0);
99
+ const withAuth = (source) => source.createModels({
100
+ credentials: createPersistentCredentialStore(environment),
101
+ authContext: createPiAuthContext(environment),
102
+ });
99
103
 
100
104
  const rows = async (source, providers) => {
101
105
  try {
102
- const models = source.createModels({
103
- credentials: createPersistentCredentialStore(environment),
104
- authContext: createPiAuthContext(environment),
105
- });
106
+ const models = withAuth(source);
106
107
  return await Promise.all(providers.map(async ({ id, name, modelCount }) => {
107
108
  // checkAuth reads only local state. It never refreshes OAuth or calls a provider.
108
109
  const auth = await models.checkAuth(id);
109
- return { id, name, modelCount, signIn: signInKind(id), signedIn: Boolean(auth), source: signInSource(auth) };
110
+ const endpoint = source.endpoints?.find(entry => entry.id === id);
111
+ // OpenAI takes a ChatGPT sign-in as well as a key; method says which one it has.
112
+ return { id, name, modelCount, signIn: signInKind(id), signedIn: Boolean(auth), source: signInSource(auth),
113
+ ...(id === chatgptProviderId ? { chatgpt: true } : {}), ...(auth ? { method: auth.type === "oauth" ? "oauth" : "api_key" } : {}),
114
+ ...(endpoint ? { endpoint: { format: endpoint.format, baseUrl: endpoint.baseUrl, keySaved: Boolean(endpoint.key), models: [...endpoint.models] } } : {}) };
110
115
  }));
111
116
  } catch (error) {
112
117
  throw moondogError(error) ?? failure("auth_status_failed", "Moondog could not read its saved sign-ins.");
@@ -118,6 +123,20 @@ export function createModelSettings({
118
123
  return (await rows(source, listed(source).filter(provider => provider.id === providerId)))[0];
119
124
  };
120
125
 
126
+ // The models the window's menu offers: the saved list, or the default alone until the listener
127
+ // changes it. The default is always among them.
128
+ const menu = async (selection) => {
129
+ let entries;
130
+ try {
131
+ entries = await readModelList(environment);
132
+ } catch (error) {
133
+ throw moondogError(error) ?? failure("model_list_invalid", "Moondog could not read its model list.");
134
+ }
135
+ entries ??= [];
136
+ if (selection && !entries.some(entry => sameModel(entry, selection))) entries = [{ provider: selection.provider, model: selection.model }, ...entries];
137
+ return entries;
138
+ };
139
+
121
140
  const assertSignInProvider = (providerId) => {
122
141
  if (!supportedAuthProviderIds.includes(providerId)) {
123
142
  throw failure("auth_provider_unsupported", "Moondog can't save a sign-in for this provider. Set its API key in the environment instead.");
@@ -127,10 +146,101 @@ export function createModelSettings({
127
146
  return {
128
147
  current,
129
148
 
149
+ // Settings' providers: the featured ones in their order, then any other provider that has a key or
150
+ // is in use, by name, then the listener's endpoints in the order they were added.
130
151
  async providers({ current: currentProvider } = {}) {
131
152
  currentProvider ??= (await current().catch(() => null))?.provider;
132
153
  const source = await catalog();
133
- return rows(source, listed(source, currentProvider));
154
+ const all = await rows(source, listed(source));
155
+ const featured = FEATURED.map(id => all.find(provider => provider.id === id)).filter(Boolean);
156
+ const others = all.filter(provider => !FEATURED.includes(provider.id) && provider.signIn !== "endpoint" &&
157
+ (provider.signedIn || provider.id === currentProvider)).sort((left, right) => left.name.localeCompare(right.name));
158
+ const endpoints = (source.endpoints ?? []).map(endpoint => all.find(provider => provider.id === endpoint.id)).filter(Boolean);
159
+ return [...featured, ...others, ...endpoints];
160
+ },
161
+
162
+ // The menu's models, each with its provider's name and whether it can answer now, and the default
163
+ // with the thinking level it asks for and the one its model gets.
164
+ async list() {
165
+ const selection = await current();
166
+ const source = await catalog();
167
+ const entries = await menu(selection);
168
+ let models;
169
+ try {
170
+ models = withAuth(source);
171
+ } catch (error) {
172
+ throw moondogError(error) ?? failure("auth_status_failed", "Moondog could not read its saved sign-ins.");
173
+ }
174
+ const names = new Map(source.providers().map(provider => [provider.id, provider.name]));
175
+ const ready = new Map();
176
+ for (const provider of new Set(entries.map(entry => entry.provider))) {
177
+ ready.set(provider, Boolean(await models.checkAuth(provider).catch(() => undefined)));
178
+ }
179
+ const thinks = model => Boolean(model) && getSupportedThinkingLevels(model).length > 1;
180
+ const defaultModel = selection && models.getModel(selection.provider, selection.model);
181
+ return {
182
+ models: entries.map(entry => {
183
+ const model = models.getModel(entry.provider, entry.model);
184
+ return { provider: entry.provider, model: entry.model, name: model?.name ?? entry.model,
185
+ providerName: names.get(entry.provider) ?? entry.provider, ready: Boolean(model) && ready.get(entry.provider) === true,
186
+ reasoning: thinks(model) };
187
+ }),
188
+ default: selection ? { provider: selection.provider, model: selection.model, effort: selection.effort ?? null,
189
+ reasoning: thinks(defaultModel), thinking: selection.effort && defaultModel ? clampEffort(defaultModel, selection.effort) : null } : null,
190
+ };
191
+ },
192
+
193
+ // The model each conversation was given in the window's menu, and a change to one: null gives a
194
+ // conversation back to the default.
195
+ conversationModels: () => readConversationModels(environment),
196
+ setConversationModel: (session, selection) => writeConversationModel(String(session), selection ?? null, environment),
197
+
198
+ // Adds a model to the menu or takes it off. The default stays.
199
+ async setListed(provider, model, listed) {
200
+ const selection = await current();
201
+ const entry = { provider: String(provider ?? "").trim().toLowerCase(), model: String(model ?? "").trim() };
202
+ if (!listed && sameModel(entry, selection)) return;
203
+ const entries = await menu(selection);
204
+ const next = listed ? (entries.some(kept => sameModel(kept, entry)) ? entries : [...entries, entry])
205
+ : entries.filter(kept => !sameModel(kept, entry));
206
+ try {
207
+ await writeModelList(next, environment);
208
+ } catch (error) {
209
+ throw moondogError(error) ?? failure("model_list_invalid", "Moondog could not save its model list.");
210
+ }
211
+ },
212
+
213
+ // A new endpoint whose model IDs were typed goes straight into the menu with them; listed ones
214
+ // wait to be chosen, since an endpoint such as OpenRouter lists hundreds.
215
+ async saveEndpoint(input) {
216
+ let saved;
217
+ try {
218
+ saved = await saveEndpoint(input, environment);
219
+ } catch (error) {
220
+ throw moondogError(error) ?? failure("endpoint_save_failed", "Moondog could not save that endpoint.");
221
+ }
222
+ if (!input?.id && saved.typedModels) {
223
+ const entries = await menu(await current());
224
+ const added = saved.endpoint.models.map(model => ({ provider: saved.endpoint.id, model }));
225
+ await writeModelList([...entries, ...added.filter(entry => !entries.some(kept => sameModel(kept, entry)))].slice(0, 40), environment);
226
+ }
227
+ await (await catalog()).readEndpoints?.();
228
+ return saved.endpoint;
229
+ },
230
+
231
+ // Removes an endpoint, its key and its models from the menu. A default on it stays saved, and asks
232
+ // for another model until one is chosen.
233
+ async removeEndpoint(id) {
234
+ try {
235
+ await removeEndpoint(id, environment);
236
+ } catch (error) {
237
+ throw moondogError(error) ?? failure("endpoint_remove_failed", "Moondog could not remove that endpoint.");
238
+ }
239
+ const selection = await current();
240
+ const entries = await menu(null);
241
+ const kept = entries.filter(entry => entry.provider !== id || sameModel(entry, selection));
242
+ if (kept.length !== entries.length) await writeModelList(kept, environment);
243
+ await (await catalog()).readEndpoints?.();
134
244
  },
135
245
 
136
246
  async models(providerId, { force = false, signal } = {}) {
@@ -150,7 +260,7 @@ export function createModelSettings({
150
260
  return {
151
261
  provider: providerId,
152
262
  refresh,
153
- models: source.models(providerId).map(({ id, name, contextWindow, reasoning, catalogNote }) => {
263
+ models: source.models(providerId).filter(({ id }) => !LEGACY_MODEL_ALIASES.includes(id)).map(({ id, name, contextWindow, reasoning, catalogNote }) => {
154
264
  const model = full.getModel(providerId, id);
155
265
  return { id, name, ...(contextWindow ? { contextWindow } : {}), reasoning,
156
266
  effortLevels: model ? getSupportedThinkingLevels(model) : [],
@@ -159,7 +269,8 @@ export function createModelSettings({
159
269
  };
160
270
  },
161
271
 
162
- async signIn(providerId, interaction) {
272
+ // method "oauth" signs in to OpenAI with ChatGPT; anything else asks for a key.
273
+ async signIn(providerId, interaction, { method } = {}) {
163
274
  assertSignInProvider(providerId);
164
275
  if (typeof interaction?.prompt !== "function" || typeof interaction.notify !== "function") {
165
276
  throw failure("auth_interaction_invalid", "Sign-in needs a way to ask and to show progress.");
@@ -180,7 +291,7 @@ export function createModelSettings({
180
291
  if (!shape) return;
181
292
  try { interaction.notify(shape); } catch { /* A closed window cannot stop the sign-in. */ }
182
293
  },
183
- });
294
+ }, { method: method === "oauth" ? "oauth" : "api_key" });
184
295
  } catch (error) {
185
296
  const safe = wrapLoginError(signal?.aborted ? aborted() : moondogError(error) ?? error, providerId);
186
297
  throw failure(safe.code, safe.message);
@@ -25,7 +25,7 @@ export function safeProviderErrorMessage(value, provider) {
25
25
  const message = typeof value === "string" && value.trim()
26
26
  ? value.trim() : "The model provider request failed.";
27
27
  if (credentialBearingErrorPattern.test(message)) {
28
- return `${provider === "openai-codex" ? "OpenAI Codex" : provider} authentication failed. Run moondog auth login ${provider} and try again.`;
28
+ return `${provider === "openai" ? "OpenAI" : provider} authentication failed. Run moondog auth login ${provider}${provider === "openai" ? " --chatgpt, or with an API key," : ""} and try again.`;
29
29
  }
30
30
  return message;
31
31
  }
@@ -66,7 +66,7 @@ export function isModelConnectionFailure(value) {
66
66
 
67
67
  export function createModelConnectionError({ provider, cause, toolsExecuted = false }) {
68
68
  const label = ({
69
- "openai-codex": "OpenAI Codex", openai: "OpenAI", anthropic: "Anthropic",
69
+ openai: "OpenAI", anthropic: "Anthropic",
70
70
  deepseek: "DeepSeek", moonshotai: "Moonshot AI", "moonshotai-cn": "Moonshot AI China",
71
71
  xai: "xAI", zai: "Z.AI",
72
72
  })[provider] ?? (/^[a-z0-9][a-z0-9._-]{0,63}$/iu.test(provider ?? "") ? provider : "The model provider");