cortico 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/boot.ts +29 -0
- package/src/bot.ts +40 -5
- package/src/core/README.md +12 -2
- package/src/core/blobs.ts +23 -2
- package/src/core/config-schema.ts +1 -1
- package/src/core/config.ts +2 -16
- package/src/core/core.ts +18 -6
- package/src/core/generation.ts +66 -9
- package/src/core/instance-lock.ts +6 -0
- package/src/core/loop.ts +62 -32
- package/src/core/secrets.ts +2 -4
- package/src/core/session.ts +3 -2
- package/src/core/timers.ts +8 -6
- package/src/core/types.ts +23 -4
- package/src/core/util.ts +15 -0
- package/src/deploy.ts +5 -4
- package/src/extensions/README.md +6 -4
- package/src/extensions/dry-mount.ts +34 -6
- package/src/extensions/manifest.ts +21 -11
- package/src/extensions.ts +147 -23
- package/src/launcher.ts +30 -22
- package/src/protocol/open-responses/context-helpers.ts +3 -1
- package/src/protocol/open-responses/context-log.ts +37 -9
- package/src/providers/README.md +44 -12
- package/src/providers/base.ts +2 -0
- package/src/providers/console/config.ts +36 -0
- package/src/providers/console/hub.ts +307 -0
- package/src/providers/console/settings.ts +30 -26
- package/src/providers/console/strings.ts +14 -0
- package/src/providers/console/types.ts +5 -0
- package/src/providers/hub-api.ts +3 -0
- package/src/providers/llamacpp/config.ts +38 -0
- package/src/providers/llamacpp/console/models-panel.ts +87 -4
- package/src/providers/llamacpp/console/runtime-panel.ts +36 -73
- package/src/providers/llamacpp/console/server.ts +47 -4
- package/src/providers/llamacpp/huggingface.ts +93 -0
- package/src/providers/llamacpp/index.ts +5 -1
- package/src/providers/llamacpp/native.ts +10 -5
- package/src/providers/llamacpp/options.ts +2 -0
- package/src/providers/llamacpp/strings.ts +42 -0
- package/src/providers/name.ts +13 -0
- package/src/providers/openai-responses-compat/config.ts +33 -0
- package/src/providers/openai-responses-compat/console/client.ts +5 -0
- package/src/providers/openai-responses-compat/console/reasoning-panel.ts +84 -0
- package/src/providers/openai-responses-compat/console/server.ts +135 -0
- package/src/providers/openai-responses-compat/index.ts +40 -12
- package/src/providers/openai-responses-compat/native.ts +15 -5
- package/src/providers/openai-responses-compat/strings.ts +62 -1
- package/src/providers/pricebook.ts +5 -5
- package/src/providers/registry.ts +24 -10
- package/src/providers/strings.ts +2 -0
- package/src/providers/transport/errors.ts +9 -0
- package/src/providers/transport/response-http.ts +7 -3
- package/src/providers/transport/responses-input.ts +59 -10
- package/src/web/README.md +15 -3
- package/src/web/auth.ts +80 -0
- package/src/web/client/console-pages/builtins/llm-settings/panel.ts +42 -35
- package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +22 -8
- package/src/web/client/console-pages/builtins/llm-settings/strings.ts +2 -4
- package/src/web/client/console-pages/host.ts +22 -5
- package/src/web/client/core/api.ts +5 -1
- package/src/web/client/core/router.ts +15 -0
- package/src/web/client/features/extensions/index.ts +276 -41
- package/src/web/client/features/extensions/strings.ts +84 -10
- package/src/web/client/features/feature.ts +1 -1
- package/src/web/client/features/live/diagnostics.ts +32 -0
- package/src/web/client/features/live/index.ts +27 -11
- package/src/web/client/features/live/protocol.ts +1 -0
- package/src/web/client/features/live/strings.ts +9 -3
- package/src/web/client/features/providers/detail.ts +309 -0
- package/src/web/client/features/providers/drafts.ts +23 -0
- package/src/web/client/features/providers/index.ts +179 -87
- package/src/web/client/features/providers/strings.ts +48 -17
- package/src/web/client/features/providers/types.ts +15 -0
- package/src/web/client/features/settings/general.ts +12 -0
- package/src/web/client/features/settings/strings.ts +6 -0
- package/src/web/client/main.ts +4 -0
- package/src/web/client/shell/index.ts +1 -1
- package/src/web/client/ui/fields.ts +3 -1
- package/src/web/client/ui/icons.ts +9 -1
- package/src/web/client/ui/prompt-input.tsx +17 -5
- package/src/web/client/ui/strings.ts +0 -2
- package/src/web/diagnostics.ts +133 -0
- package/src/web/public/login.html +67 -0
- package/src/web/public/styles.css +130 -18
- package/src/web/server.ts +229 -21
- package/src/web/shared/client-panel.ts +3 -0
- package/src/web/shared/console-protocol.ts +16 -1
- package/src/worlds/bilibili/README.md +1 -1
- package/src/worlds/bilibili/overlay/server.ts +4 -2
- package/src/worlds/minecraft/ADAPT.md +66 -0
- package/src/worlds/minecraft/README.md +8 -0
- package/src/worlds/minecraft/client-launch.ts +11 -2
- package/src/worlds/minecraft/client.ts +4 -0
- package/src/worlds/minecraft/mineflayer-fixes.ts +55 -1
- package/src/worlds/qq/normalize.ts +14 -0
- package/src/worlds/qq/world.ts +56 -10
- package/src/worlds/terminal/world.ts +3 -4
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { coerceGroupValues, getByPath, setByPath } from '../../core/config-schema.ts';
|
|
2
|
+
/** Connection configuration transactions serialize writers across deployments and roll back all touched files on failure. */
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
6
|
+
import type { CoreConfig, LLMProviderEntry } from '../../core/types.ts';
|
|
7
|
+
import type { Language } from '../../core/language.ts';
|
|
8
|
+
import { readJsonObject, updateJsonObject } from '../../config-file.ts';
|
|
9
|
+
import { readTextFile } from '../../core/util.ts';
|
|
10
|
+
import type { ProviderModule } from '../base.ts';
|
|
11
|
+
import { validateEntry } from '../configuration.ts';
|
|
12
|
+
import { validateProviderName, defaultSecretName } from '../name.ts';
|
|
13
|
+
import { providerModules, type ProviderRegistry } from '../registry.ts';
|
|
14
|
+
import type { ProviderSettings } from './settings.ts';
|
|
15
|
+
import { quotePrices } from '../pricebook.ts';
|
|
16
|
+
import { connectionGroup } from './config.ts';
|
|
17
|
+
import { instanceIsRunning } from '../../core/instance-lock.ts';
|
|
18
|
+
import { CORE_DEFAULTS } from '../../core/config.ts';
|
|
19
|
+
|
|
20
|
+
export class ProviderHubError extends Error {
|
|
21
|
+
constructor(message: string, readonly status = 400) { super(message); }
|
|
22
|
+
}
|
|
23
|
+
export interface ConnectionSave {
|
|
24
|
+
name: string;
|
|
25
|
+
copyFrom?: { name: string; revision: string };
|
|
26
|
+
entry: LLMProviderEntry;
|
|
27
|
+
expectedRevision?: string;
|
|
28
|
+
secretValue?: string;
|
|
29
|
+
}
|
|
30
|
+
export class ProviderHub {
|
|
31
|
+
private readonly revisions = new Map<string, string>();
|
|
32
|
+
constructor(private readonly config: CoreConfig, private readonly registry: ProviderRegistry,
|
|
33
|
+
private readonly settings: ProviderSettings, private readonly file: string,
|
|
34
|
+
private readonly root: string, private readonly modules: readonly ProviderModule[] = providerModules) {}
|
|
35
|
+
|
|
36
|
+
private path(name: string): string {
|
|
37
|
+
const path = resolve(this.root, name);
|
|
38
|
+
if (!name || dirname(path) !== resolve(this.root)) throw new ProviderHubError('Invalid provider identifier.');
|
|
39
|
+
return path;
|
|
40
|
+
}
|
|
41
|
+
private revision(name: string): string {
|
|
42
|
+
const dir = this.path(name);
|
|
43
|
+
return createHash('sha256').update(['config.json', '.env'].map(file =>
|
|
44
|
+
existsSync(join(dir, file)) ? readFileSync(join(dir, file)).toString('base64') : '').join(':')).digest('hex');
|
|
45
|
+
}
|
|
46
|
+
private refresh(): void {
|
|
47
|
+
const entries: Record<string, LLMProviderEntry> = Object.create(null);
|
|
48
|
+
if (existsSync(this.root)) for (const dir of readdirSync(this.root, { withFileTypes: true })) {
|
|
49
|
+
if (!dir.isDirectory() || (dir.name === '.write-lock' || dir.name.startsWith('.transaction-') || dir.name.startsWith('.deleted-'))) continue;
|
|
50
|
+
const file = join(this.root, dir.name, 'config.json');
|
|
51
|
+
if (existsSync(file)) entries[dir.name] = JSON.parse(readTextFile(file));
|
|
52
|
+
}
|
|
53
|
+
for (const name of new Set([...Object.keys(entries), ...Object.keys(this.config.providers)])) {
|
|
54
|
+
const revision = entries[name] ? this.revision(name) : '';
|
|
55
|
+
if (JSON.stringify(entries[name]) !== JSON.stringify(this.config.providers[name]) || this.revisions.get(name) !== revision) this.registry.invalidate(name);
|
|
56
|
+
this.revisions.set(name, revision);
|
|
57
|
+
}
|
|
58
|
+
this.config.providers = entries;
|
|
59
|
+
const disk = readJsonObject(this.file);
|
|
60
|
+
if (typeof disk.activeProvider === 'string') this.config.activeProvider = disk.activeProvider;
|
|
61
|
+
}
|
|
62
|
+
moduleList(language: Language) {
|
|
63
|
+
return this.modules.map(module => ({ id: module.id, title: module.title,
|
|
64
|
+
description: module.localize?.(language)?.description ?? module.description ?? module.title, defaultBaseUrl: module.defaultBaseUrl ?? '',
|
|
65
|
+
reasoningTiers: module.localize?.(language)?.reasoningTiers ?? module.reasoningTiers,
|
|
66
|
+
effortSuggestions: module.effortSuggestions ?? [], serviceTiers: module.localize?.(language)?.serviceTiers ?? module.serviceTiers,
|
|
67
|
+
temperatureNote: module.localize?.(language)?.temperatureNote ?? module.temperatureNote,
|
|
68
|
+
sections: this.sections(module.id, language),
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
/** The editor's sections for one module, in order: the page's panels minus the endpoint table. */
|
|
72
|
+
private sections(kind: string, language: Language) {
|
|
73
|
+
const source = this.settings.sources().find(source => source.id === `llm:${kind}`);
|
|
74
|
+
return (source?.contribute(language).panels ?? []).filter(panel => panel.id !== 'settings')
|
|
75
|
+
.map(({ id, title, description, builtin }) => ({ id, title, ...(description ? { description } : {}), ...(builtin ? { builtin } : {}) }));
|
|
76
|
+
}
|
|
77
|
+
private readiness(name: string, entry: LLMProviderEntry, language: Language) {
|
|
78
|
+
const module = this.modules.find(m => m.id === entry.kind);
|
|
79
|
+
if (!module) return { state: 'module-missing', reason: language === 'zh' ? '供应商模块不可用。' : 'Provider module is unavailable.' };
|
|
80
|
+
try { validateEntry(module, entry, language); }
|
|
81
|
+
catch (error) { return { state: 'invalid', reason: String(error) }; }
|
|
82
|
+
if (!entry.spec?.model || (entry.secret && this.settings.secretStatus(name, entry) === 'none'))
|
|
83
|
+
return { state: 'needs-setup', reason: !entry.spec?.model ? (language === 'zh' ? '请选择模型。' : 'Model is required.') : (language === 'zh' ? '请配置 API Key。' : 'API Key is required.') };
|
|
84
|
+
const available = module.availability?.(name, entry, language);
|
|
85
|
+
if (available && !available.ready) return { state: 'runtime-unavailable', reason: available.reason };
|
|
86
|
+
return { state: 'ready' };
|
|
87
|
+
}
|
|
88
|
+
current(language: Language) {
|
|
89
|
+
const name = this.config.activeProvider;
|
|
90
|
+
const entry = this.config.providers[name];
|
|
91
|
+
return entry ? { name, model: entry.spec?.model ?? null, module: entry.kind,
|
|
92
|
+
moduleTitle: this.modules.find(module => module.id === entry.kind)?.title ?? entry.kind,
|
|
93
|
+
baseUrl: entry.baseUrl, ready: this.readiness(name, entry, language).state === 'ready' } : null;
|
|
94
|
+
}
|
|
95
|
+
list(language: Language) {
|
|
96
|
+
this.refresh();
|
|
97
|
+
return { scope: createHash('sha256').update(resolve(this.file)).digest('hex'), active: this.config.activeProvider, providers: Object.entries(this.config.providers).map(([name, entry]) => ({
|
|
98
|
+
id: name, name, module: entry.kind, moduleTitle: this.modules.find(m => m.id === entry.kind)?.title ?? entry.kind,
|
|
99
|
+
model: entry.spec?.model ?? null, baseUrl: entry.baseUrl, active: name === this.config.activeProvider,
|
|
100
|
+
readiness: this.readiness(name, entry, language), revision: this.revision(name),
|
|
101
|
+
usage: this.references(name).filter(file => resolve(file) !== resolve(this.file)).map(file => {
|
|
102
|
+
const config = readJsonObject(file);
|
|
103
|
+
const paths = config.paths as { data?: string } | undefined;
|
|
104
|
+
return { name: basename(dirname(file)), running: instanceIsRunning(resolve(dirname(file), paths?.data ?? CORE_DEFAULTS.paths.data)) };
|
|
105
|
+
}),
|
|
106
|
+
})) };
|
|
107
|
+
}
|
|
108
|
+
detail(name: string, language: Language) {
|
|
109
|
+
this.refresh();
|
|
110
|
+
const entry = this.config.providers[name];
|
|
111
|
+
if (!entry) throw new ProviderHubError('Provider does not exist.', 404);
|
|
112
|
+
return { name, entry: structuredClone(entry), revision: this.revision(name),
|
|
113
|
+
secretConfigured: this.settings.secretStatus(name, entry), readiness: this.readiness(name, entry, language),
|
|
114
|
+
references: this.references(name).map(file => dirname(file).split(/[\\/]/).at(-1)),
|
|
115
|
+
config: this.groups(name, entry, language),
|
|
116
|
+
quotes: entry.spec ? [{ model: entry.spec.model, quotes: this.quotes(entry) }] : [],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
private quotes(entry: LLMProviderEntry) {
|
|
120
|
+
const module = this.modules.find(module => module.id === entry.kind);
|
|
121
|
+
const request = { model: entry.spec?.model };
|
|
122
|
+
const at = { startedAt: new Date().toISOString(), requestedServiceTier: entry.serviceTier ?? null };
|
|
123
|
+
return quotePrices(entry, request, at, module?.prices?.(entry, request, at) ?? []);
|
|
124
|
+
}
|
|
125
|
+
groups(name: string, entry: LLMProviderEntry, language: Language) {
|
|
126
|
+
const module = this.modules.find(m => m.id === entry.kind);
|
|
127
|
+
return [connectionGroup(name, entry, language), ...(module?.config?.(name, entry, language) ?? [])];
|
|
128
|
+
}
|
|
129
|
+
async preview(name: string, entry: LLMProviderEntry, panel: string, method: string, args: unknown[], language: Language) {
|
|
130
|
+
this.path(name);
|
|
131
|
+
const module = this.modules.find(module => module.id === entry.kind);
|
|
132
|
+
if (!module?.console) throw new ProviderHubError('Module panel unavailable.');
|
|
133
|
+
this.refresh();
|
|
134
|
+
const current = this.config.providers[name];
|
|
135
|
+
const persisted = !!current && JSON.stringify(current) === JSON.stringify(entry);
|
|
136
|
+
let draft = structuredClone(entry);
|
|
137
|
+
const contribution = module.console({ language, editing: !persisted,
|
|
138
|
+
entries: () => [{ name, entry: draft }],
|
|
139
|
+
instance: requested => {
|
|
140
|
+
if (requested !== name) throw new ProviderHubError('Foreign provider.');
|
|
141
|
+
return persisted ? this.registry.resolve(name) : this.registry.preview(name, draft);
|
|
142
|
+
},
|
|
143
|
+
save: (requested, next) => {
|
|
144
|
+
if (requested !== name || next.kind !== entry.kind) throw new ProviderHubError('Foreign provider.');
|
|
145
|
+
draft = structuredClone(next);
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
const result = await contribution.invoke?.(panel, method, args);
|
|
149
|
+
return { result, entry: draft };
|
|
150
|
+
}
|
|
151
|
+
private references(name: string): string[] {
|
|
152
|
+
const files = new Set([this.file]);
|
|
153
|
+
const parent = dirname(this.root);
|
|
154
|
+
for (const dir of readdirSync(parent, { withFileTypes: true })) {
|
|
155
|
+
if (!dir.isDirectory() || resolve(parent, dir.name) === resolve(this.root)) continue;
|
|
156
|
+
const file = join(parent, dir.name, 'config.json');
|
|
157
|
+
if (existsSync(file) && existsSync(join(parent, dir.name, 'deployment.json'))) files.add(file);
|
|
158
|
+
}
|
|
159
|
+
return [...files].filter(file => readJsonObject(file).activeProvider === name);
|
|
160
|
+
}
|
|
161
|
+
private locked<T>(work: () => T): T {
|
|
162
|
+
mkdirSync(this.root, { recursive: true });
|
|
163
|
+
const lock = join(this.root, '.write-lock');
|
|
164
|
+
try { mkdirSync(lock); } catch { throw new ProviderHubError('Another provider transaction is in progress. Retry after it completes.', 409); }
|
|
165
|
+
try { this.refresh(); return work(); } finally { rmSync(lock, { recursive: true, force: true }); }
|
|
166
|
+
}
|
|
167
|
+
private checkRevision(name: string, expected: unknown): void {
|
|
168
|
+
if (typeof expected !== 'string' || expected !== this.revision(name))
|
|
169
|
+
throw new ProviderHubError('Configuration changed in another page or bot. Reload before saving.', 409);
|
|
170
|
+
}
|
|
171
|
+
save(original: string | null, input: ConnectionSave, language: Language) {
|
|
172
|
+
return this.locked(() => {
|
|
173
|
+
const { name } = input;
|
|
174
|
+
if (typeof name !== 'string') throw new ProviderHubError('Provider name is required.');
|
|
175
|
+
const prior = original ? this.config.providers[original] : undefined;
|
|
176
|
+
if (original && !prior) throw new ProviderHubError('Provider does not exist.', 404);
|
|
177
|
+
if (original) this.checkRevision(original, input.expectedRevision);
|
|
178
|
+
if (name !== original) {
|
|
179
|
+
const problem = validateProviderName(name);
|
|
180
|
+
if (problem) throw new ProviderHubError(problem);
|
|
181
|
+
if (readdirSync(this.root).some(value => value.toLowerCase() === name.toLowerCase()))
|
|
182
|
+
throw new ProviderHubError('Provider name or directory already exists.', 409);
|
|
183
|
+
}
|
|
184
|
+
if (!input.entry || typeof input.entry !== 'object') throw new ProviderHubError('Provider configuration is required.');
|
|
185
|
+
if (prior && prior.kind !== input.entry.kind) throw new ProviderHubError('Provider module cannot be changed.');
|
|
186
|
+
const module = this.modules.find(m => m.id === input.entry.kind);
|
|
187
|
+
if (!module) throw new ProviderHubError('Provider module is unavailable.');
|
|
188
|
+
const requested = withTypedSecret(name, structuredClone(input.entry), input.secretValue);
|
|
189
|
+
if (!requested.spec?.model) throw new ProviderHubError('Model is required.');
|
|
190
|
+
const entry = validateEntry(module, requested, language);
|
|
191
|
+
const prefix = `providers.${name}.`;
|
|
192
|
+
for (const group of this.groups(name, entry, language)) {
|
|
193
|
+
const values = Object.fromEntries(Object.keys(group.schema.properties).filter(path => path.startsWith(prefix))
|
|
194
|
+
.map(path => [path, getByPath(entry as unknown as Record<string, unknown>, path.slice(prefix.length))])
|
|
195
|
+
.filter(([, value]) => value !== undefined));
|
|
196
|
+
const result = coerceGroupValues(group, values, language);
|
|
197
|
+
if ('error' in result) throw new ProviderHubError(result.error);
|
|
198
|
+
for (const [path, value] of Object.entries(result.values)) setByPath(entry as unknown as Record<string, unknown>, path.slice(prefix.length), value);
|
|
199
|
+
}
|
|
200
|
+
validateEntry(module, entry, language);
|
|
201
|
+
let copiedSecret: Buffer | null = null;
|
|
202
|
+
let credentialSource = original;
|
|
203
|
+
if (!original && input.copyFrom) {
|
|
204
|
+
const source = this.config.providers[input.copyFrom.name];
|
|
205
|
+
if (!source) throw new ProviderHubError('Copy source no longer exists.', 409);
|
|
206
|
+
this.checkRevision(input.copyFrom.name, input.copyFrom.revision);
|
|
207
|
+
if (source.secret === entry.secret) {
|
|
208
|
+
credentialSource = input.copyFrom.name;
|
|
209
|
+
const file = join(this.path(input.copyFrom.name), '.env');
|
|
210
|
+
if (existsSync(file)) copiedSecret = readFileSync(file);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (entry.secret && !input.secretValue && (!credentialSource || this.settings.secretStatus(credentialSource, entry) === 'none'))
|
|
214
|
+
throw new ProviderHubError('API Key is required.');
|
|
215
|
+
const target = this.path(name);
|
|
216
|
+
const source = original ? this.path(original) : null;
|
|
217
|
+
const stage = join(this.root, `.transaction-${randomUUID()}`);
|
|
218
|
+
const refs = original && name !== original ? this.references(original) : [];
|
|
219
|
+
const backups = new Map<string, Buffer | null>();
|
|
220
|
+
for (const file of [...refs, ...(source ? [join(source, 'config.json'), join(source, '.env')] : [])])
|
|
221
|
+
backups.set(file, existsSync(file) ? readFileSync(file) : null);
|
|
222
|
+
let moved = false;
|
|
223
|
+
try {
|
|
224
|
+
if (!source) mkdirSync(stage);
|
|
225
|
+
const dir = source ?? stage;
|
|
226
|
+
updateJsonObject(join(dir, 'config.json'), raw => { for (const key of Object.keys(raw)) delete raw[key]; Object.assign(raw, entry); });
|
|
227
|
+
if (copiedSecret) writeFileSync(join(dir, '.env'), copiedSecret);
|
|
228
|
+
if (input.secretValue) {
|
|
229
|
+
const file = join(dir, '.env');
|
|
230
|
+
const lines = (existsSync(file) ? readTextFile(file) : '').split(/\r?\n/).filter(line => line.split('=')[0]?.trim() !== entry.secret);
|
|
231
|
+
lines.push(`${entry.secret}=${input.secretValue}`);
|
|
232
|
+
writeFileSync(file, lines.filter(Boolean).join('\n') + '\n');
|
|
233
|
+
}
|
|
234
|
+
if (source !== target) { renameSync(dir, target); moved = true; }
|
|
235
|
+
for (const file of refs) updateJsonObject(file, raw => { raw.activeProvider = name; });
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (moved) renameSync(target, source ?? stage);
|
|
238
|
+
for (const [file, contents] of backups) {
|
|
239
|
+
if (contents === null) rmSync(file, { force: true }); else writeFileSync(file, contents);
|
|
240
|
+
}
|
|
241
|
+
throw error;
|
|
242
|
+
} finally { if (existsSync(stage)) rmSync(stage, { recursive: true, force: true }); }
|
|
243
|
+
if (original) this.registry.invalidate(original);
|
|
244
|
+
this.registry.invalidate(name);
|
|
245
|
+
this.refresh();
|
|
246
|
+
return this.detail(name, language);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
activate(name: string, language: Language): void {
|
|
250
|
+
this.locked(() => {
|
|
251
|
+
const entry = this.config.providers[name];
|
|
252
|
+
if (!entry) throw new ProviderHubError('Provider does not exist.', 404);
|
|
253
|
+
const readiness = this.readiness(name, entry, language);
|
|
254
|
+
if (readiness.state !== 'ready') throw new ProviderHubError(readiness.reason ?? readiness.state);
|
|
255
|
+
updateJsonObject(this.file, raw => { raw.activeProvider = name; });
|
|
256
|
+
this.config.activeProvider = name;
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
delete(name: string, revision: string): void {
|
|
260
|
+
this.locked(() => {
|
|
261
|
+
if (!this.config.providers[name]) throw new ProviderHubError('Provider does not exist.', 404);
|
|
262
|
+
this.checkRevision(name, revision);
|
|
263
|
+
const refs = this.references(name);
|
|
264
|
+
if (this.config.activeProvider === name || refs.length)
|
|
265
|
+
throw new ProviderHubError(`Provider is used by: ${refs.map(file => dirname(file).split(/[\\/]/).at(-1)).join(', ') || 'current bot'}`, 409);
|
|
266
|
+
const tomb = join(this.root, `.deleted-${randomUUID()}`);
|
|
267
|
+
renameSync(this.path(name), tomb);
|
|
268
|
+
this.registry.invalidate(name);
|
|
269
|
+
delete this.config.providers[name];
|
|
270
|
+
rmSync(tomb, { recursive: true, force: true });
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Probe (`test`) or list models (`models`). With `draft`, the request goes through an instance
|
|
275
|
+
* built from the browser's entry and typed key; nothing is written, and `name` may not exist yet.
|
|
276
|
+
*/
|
|
277
|
+
async action(name: string, action: 'test' | 'models', language: Language, draft?: { entry: LLMProviderEntry; secretValue?: string }) {
|
|
278
|
+
this.refresh();
|
|
279
|
+
if (!draft) {
|
|
280
|
+
const entry = this.config.providers[name];
|
|
281
|
+
if (!entry) throw new ProviderHubError('Provider does not exist.', 404);
|
|
282
|
+
const source = this.settings.sources().find(source => source.id === `llm:${entry.kind}`);
|
|
283
|
+
if (!source) throw new ProviderHubError('Provider module is unavailable.');
|
|
284
|
+
return source.contribute(language).invoke!('settings', action === 'test' ? 'probe' : 'models', [{ name }]);
|
|
285
|
+
}
|
|
286
|
+
this.path(name);
|
|
287
|
+
if (!draft.entry || typeof draft.entry !== 'object') throw new ProviderHubError('Provider configuration is required.');
|
|
288
|
+
const module = this.modules.find(m => m.id === draft.entry.kind);
|
|
289
|
+
if (!module) throw new ProviderHubError('Provider module is unavailable.');
|
|
290
|
+
const entry = validateEntry(module, withTypedSecret(name, draft.entry, draft.secretValue), language);
|
|
291
|
+
const registry = this.registry.previewRegistry(name, entry, entry.secret && draft.secretValue ? { [entry.secret]: draft.secretValue } : {});
|
|
292
|
+
if (action === 'models') {
|
|
293
|
+
const instance = registry.resolve(name);
|
|
294
|
+
if (!instance.listModels) throw new ProviderHubError('This module does not list models.');
|
|
295
|
+
return { models: await instance.listModels() };
|
|
296
|
+
}
|
|
297
|
+
if (!entry.spec) throw new ProviderHubError('Model is required.');
|
|
298
|
+
return this.settings.probeClient(registry.bind(name), entry.spec, language);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** A typed key with no variable name declared is stored under the name derived from the endpoint name. */
|
|
303
|
+
function withTypedSecret(name: string, entry: LLMProviderEntry, secretValue: string | undefined): LLMProviderEntry {
|
|
304
|
+
if (secretValue !== undefined && (typeof secretValue !== 'string' || !secretValue || /\s/.test(secretValue)))
|
|
305
|
+
throw new ProviderHubError('API Key must be nonempty and contain no whitespace.');
|
|
306
|
+
return secretValue && !entry.secret ? { ...entry, secret: defaultSecretName(name) } : entry;
|
|
307
|
+
}
|
|
@@ -15,25 +15,23 @@ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:
|
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { updateJsonObject } from '../../config-file.ts';
|
|
17
17
|
import type { Language } from '../../core/language.ts';
|
|
18
|
-
import type
|
|
18
|
+
import { isConnectionEditorBlock, type ConsoleLamp, type ConsolePageContribution } from '../../web/shared/console-protocol.ts';
|
|
19
19
|
import type { ConsolePageSource } from '../../web/console-pages.ts';
|
|
20
20
|
import { providerModules, type ProviderRegistry } from '../registry.ts';
|
|
21
21
|
import type { ProviderAvailability, ProviderModule } from '../base.ts';
|
|
22
22
|
import { endpointAvailability, validateEntry } from '../configuration.ts';
|
|
23
23
|
import { quotePrices, validatePrices, type PriceDefinition } from '../pricebook.ts';
|
|
24
|
-
import { GenerationError } from '../../core/generation.ts';
|
|
24
|
+
import { GenerationError, type ResponseClient } from '../../core/generation.ts';
|
|
25
25
|
import { readTextFile } from '../../core/util.ts';
|
|
26
26
|
import { responseRequest } from '../../protocol/open-responses/context-helpers.ts';
|
|
27
27
|
import { record } from '../../protocol/open-responses/context.ts';
|
|
28
28
|
import { text } from './strings.ts';
|
|
29
|
-
import type
|
|
29
|
+
import { PROBE_MAX_OUTPUT_TOKENS, type ProviderConsoleHost } from './types.ts';
|
|
30
|
+
import { connectionBlocks, connectionGroup } from './config.ts';
|
|
30
31
|
|
|
31
32
|
export type SecretStatus = 'env' | 'file' | 'none';
|
|
32
33
|
|
|
33
|
-
/**
|
|
34
|
-
const PROBE_MAX_OUTPUT_TOKENS = 256;
|
|
35
|
-
|
|
36
|
-
/** 密钥变量名由操作员自由填写,拼进正则前按字面转义。 */
|
|
34
|
+
/** 密钥变量名由操作员自由填写,也可能是直接写在磁盘上的任意名字,拼进正则前按字面转义。 */
|
|
37
35
|
function escapeRegExp(s: string): string {
|
|
38
36
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
39
37
|
}
|
|
@@ -70,7 +68,8 @@ export class ProviderSettings {
|
|
|
70
68
|
private declaredGroups(language: Language) {
|
|
71
69
|
return this.modules.flatMap((module) =>
|
|
72
70
|
this.entries(module).flatMap(({ name, entry }) =>
|
|
73
|
-
(module.config?.(name, entry, language) ?? [])
|
|
71
|
+
[connectionGroup(name, entry, language), ...(module.config?.(name, entry, language) ?? [])]
|
|
72
|
+
.map((group) => ({ name, group })),
|
|
74
73
|
),
|
|
75
74
|
);
|
|
76
75
|
}
|
|
@@ -200,18 +199,24 @@ export class ProviderSettings {
|
|
|
200
199
|
return readdirSync(dir).filter((file) => file !== 'config.json');
|
|
201
200
|
}
|
|
202
201
|
|
|
203
|
-
private
|
|
202
|
+
private probe(name: string, language: Language) {
|
|
204
203
|
const S = text(language);
|
|
205
204
|
const entry = this.config.providers[name];
|
|
206
205
|
if (!entry) throw new Error(S.unknownInstance);
|
|
207
206
|
if (!entry.spec) throw new Error(S.specRequired);
|
|
207
|
+
return this.probeClient(this.registry.bind(name), entry.spec, language);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** One diagnostic request through `client`; the receipt carries status, latency, usage and charges, or the failure and a hint. */
|
|
211
|
+
async probeClient(client: ResponseClient, spec: ModelSpec, language: Language) {
|
|
212
|
+
const S = text(language);
|
|
208
213
|
const request = {
|
|
209
|
-
...responseRequest(
|
|
210
|
-
max_output_tokens: Math.min(
|
|
214
|
+
...responseRequest(spec, [record({ type: 'message', role: 'user', content: 'ping' })]),
|
|
215
|
+
max_output_tokens: Math.min(spec.maxTokens ?? PROBE_MAX_OUTPUT_TOKENS, PROBE_MAX_OUTPUT_TOKENS),
|
|
211
216
|
};
|
|
212
217
|
const started = Date.now();
|
|
213
218
|
try {
|
|
214
|
-
const generation = await
|
|
219
|
+
const generation = await client.respond(request, { diagnostic: true, nativeSpec: spec, role: 'probe' });
|
|
215
220
|
const attempt = generation.attempts.at(-1);
|
|
216
221
|
return {
|
|
217
222
|
ok: true,
|
|
@@ -281,7 +286,7 @@ export class ProviderSettings {
|
|
|
281
286
|
return { label: S.availableLamp, state: 'offline', hint: reason };
|
|
282
287
|
}
|
|
283
288
|
|
|
284
|
-
/**
|
|
289
|
+
/** 所有模块合起来有没有一个可用端点。控制台左栏「模型提供商」那一行点的就是它。 */
|
|
285
290
|
providersLamp(language: Language = 'zh'): ConsoleLamp {
|
|
286
291
|
return this.availableLamp(
|
|
287
292
|
this.modules.flatMap((module) => this.entries(module)),
|
|
@@ -306,22 +311,19 @@ export class ProviderSettings {
|
|
|
306
311
|
},
|
|
307
312
|
};
|
|
308
313
|
const extra = module.console?.(host) ?? {};
|
|
314
|
+
// A module that places none of the editor's blocks gets the default order, its own sections between model and pricing.
|
|
315
|
+
const declared = extra.panels ?? [];
|
|
316
|
+
const blocks = connectionBlocks(language);
|
|
317
|
+
const sections = declared.some((panel) => isConnectionEditorBlock(panel.builtin))
|
|
318
|
+
? declared
|
|
319
|
+
: [blocks.endpoint, blocks.model, ...declared, blocks.pricing, blocks.protocol];
|
|
309
320
|
return {
|
|
310
321
|
...extra,
|
|
311
322
|
id: `llm:${module.id}`,
|
|
312
323
|
kind: 'llm',
|
|
313
324
|
label: module.title,
|
|
314
325
|
availability: 'active',
|
|
315
|
-
lamps: [
|
|
316
|
-
{
|
|
317
|
-
label: S.activeInstanceLamp,
|
|
318
|
-
state: entries.some((value) => value.name === this.config.activeProvider)
|
|
319
|
-
? 'online'
|
|
320
|
-
: 'offline',
|
|
321
|
-
},
|
|
322
|
-
this.availableLamp(this.entries(module), language),
|
|
323
|
-
...(extra.lamps ?? []),
|
|
324
|
-
],
|
|
326
|
+
lamps: [],
|
|
325
327
|
badges: [{ label: S.instancesBadge, value: String(entries.length) }, ...(extra.badges ?? [])],
|
|
326
328
|
panels: [
|
|
327
329
|
{
|
|
@@ -332,9 +334,9 @@ export class ProviderSettings {
|
|
|
332
334
|
// 使用控制台内建端点面板,操作由下方 invoke 提供。
|
|
333
335
|
builtin: 'llm-settings',
|
|
334
336
|
},
|
|
335
|
-
...
|
|
337
|
+
...sections,
|
|
336
338
|
],
|
|
337
|
-
config: entries.flatMap(
|
|
339
|
+
config: extra.config ?? entries.flatMap(
|
|
338
340
|
({ name, entry }) => module.config?.(name, entry, language) ?? [],
|
|
339
341
|
),
|
|
340
342
|
invoke: async (panel, method, args) => {
|
|
@@ -358,6 +360,8 @@ export class ProviderSettings {
|
|
|
358
360
|
instances: this.entries(module).map(({ name, entry }) => ({
|
|
359
361
|
name,
|
|
360
362
|
entry,
|
|
363
|
+
config: this.declaredGroups(language).filter((item) => item.name === name)
|
|
364
|
+
.map(({ group }) => ({ group, values: this.values(group.id, language) })),
|
|
361
365
|
secretConfigured: this.secretStatus(name, entry),
|
|
362
366
|
quotes: (entry.spec ? [entry.spec] : []).map((spec) => ({
|
|
363
367
|
model: spec.model,
|
|
@@ -383,7 +387,7 @@ export class ProviderSettings {
|
|
|
383
387
|
const name = body.name;
|
|
384
388
|
if (method === 'create') {
|
|
385
389
|
this.assertNewName(name, language);
|
|
386
|
-
//
|
|
390
|
+
// 报价留空:不覆盖模块价目;模块也没给价目时,这条端点的调用只记 token,不记金额。
|
|
387
391
|
this.save(name, {
|
|
388
392
|
kind: module.id,
|
|
389
393
|
baseUrl: String(body.baseUrl || module.defaultBaseUrl || ''),
|
|
@@ -14,6 +14,13 @@ const zh = {
|
|
|
14
14
|
instancesBadge: '实例',
|
|
15
15
|
settingsPanel: '实例与模型',
|
|
16
16
|
settingsPanelDescription: "保存端点模型、价目及当前部署使用的端点。",
|
|
17
|
+
endpointBlock: '连接',
|
|
18
|
+
endpointBlockDescription: '模型服务的地址与 API Key。',
|
|
19
|
+
modelBlock: '模型与生成',
|
|
20
|
+
modelBlockDescription: '模型名与生成参数;模型列表从端点拉取。',
|
|
21
|
+
pricingBlock: '成本与计价',
|
|
22
|
+
protocolBlock: '高级协议',
|
|
23
|
+
protocolBlockDescription: '仅在使用自定义 API 网关或兼容服务时调整。',
|
|
17
24
|
saved: '参数已保存;下一次请求生效。',
|
|
18
25
|
unknownGroup: '未知 Provider 参数组',
|
|
19
26
|
groupOutOfScope: 'Provider 参数组只能修改所属实例',
|
|
@@ -49,6 +56,13 @@ const en: typeof zh = {
|
|
|
49
56
|
instancesBadge: 'Instances',
|
|
50
57
|
settingsPanel: 'Instances and models',
|
|
51
58
|
settingsPanelDescription: "Save the endpoint model, pricing and the active endpoint for this deployment.",
|
|
59
|
+
endpointBlock: 'Connection',
|
|
60
|
+
endpointBlockDescription: 'Provider URL and API Key.',
|
|
61
|
+
modelBlock: 'Model and generation',
|
|
62
|
+
modelBlockDescription: 'Model name and generation parameters; the model list comes from the endpoint.',
|
|
63
|
+
pricingBlock: 'Pricing',
|
|
64
|
+
protocolBlock: 'Advanced protocol',
|
|
65
|
+
protocolBlockDescription: 'Adjust only for custom API gateways or compatible services.',
|
|
52
66
|
saved: 'Parameters saved; they apply from the next request.',
|
|
53
67
|
unknownGroup: 'Unknown provider parameter group',
|
|
54
68
|
groupOutOfScope: 'A provider parameter group can only modify its own instance',
|
|
@@ -2,9 +2,14 @@ import type { LLMProviderEntry } from '../../core/types.ts';
|
|
|
2
2
|
import type { Language } from '../../core/language.ts';
|
|
3
3
|
import type { ProviderInstance } from '../base.ts';
|
|
4
4
|
|
|
5
|
+
/** Output token limit for the connectivity probe and module probes. */
|
|
6
|
+
export const PROBE_MAX_OUTPUT_TOKENS = 256;
|
|
7
|
+
|
|
5
8
|
export interface ProviderConsoleHost {
|
|
6
9
|
/** Console language for panel titles, receipts and error texts. */
|
|
7
10
|
readonly language: Language;
|
|
11
|
+
/** Configuration edits return to the browser draft; runtime mutations require a saved connection. */
|
|
12
|
+
readonly editing?: boolean;
|
|
8
13
|
entries(): Array<{ name: string; entry: LLMProviderEntry }>;
|
|
9
14
|
instance(name: string): ProviderInstance;
|
|
10
15
|
save(name: string, entry: LLMProviderEntry): void;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ConfigGroup, ConfigProperty } from '../../core/config-schema.ts';
|
|
2
|
+
import type { Language } from '../../core/language.ts';
|
|
3
|
+
import type { LLMProviderEntry } from '../../core/types.ts';
|
|
4
|
+
import { backendChoices, llamacppOptions } from './options.ts';
|
|
5
|
+
import { panel } from './strings.ts';
|
|
6
|
+
|
|
7
|
+
export function runtimeConfig(name: string, entry: LLMProviderEntry, language: Language): ConfigGroup[] {
|
|
8
|
+
if (!llamacppOptions(entry).runtime) return [];
|
|
9
|
+
const S = panel[language];
|
|
10
|
+
const group = (
|
|
11
|
+
id: string, title: string, description: string, properties: Record<string, ConfigProperty>,
|
|
12
|
+
): ConfigGroup => ({
|
|
13
|
+
id: `llm.llamacpp.${name}.${id}`,
|
|
14
|
+
owner: 'provider:llamacpp',
|
|
15
|
+
schema: {
|
|
16
|
+
type: 'object', title, description,
|
|
17
|
+
properties: Object.fromEntries(Object.entries(properties).map(([path, property]) => [
|
|
18
|
+
`providers.${name}.options.${path}`, { ...property, 'x-hot': true },
|
|
19
|
+
])),
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
return [
|
|
23
|
+
group('runtime', S.runtimeSection, S.runtimeSectionDesc, {
|
|
24
|
+
'runtime.release': { type: 'string', title: S.release, description: S.releaseHint },
|
|
25
|
+
'runtime.backend': { type: 'string', title: S.backend, enum: backendChoices(), description: S.backendHint },
|
|
26
|
+
'runtime.runtimeDir': {
|
|
27
|
+
type: 'string', title: S.runtimeDir, description: S.runtimeDirHint, 'x-path': { kind: 'directory' },
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
group('launch', S.launchSection, S.launchSectionDesc, {
|
|
31
|
+
'launch.contextSize': { type: 'integer', title: S.contextSize, minimum: 1 },
|
|
32
|
+
'launch.nGpuLayers': { type: 'integer', title: S.nGpuLayers, minimum: 0 },
|
|
33
|
+
'launch.parallel': { type: 'integer', title: S.parallel, minimum: 1 },
|
|
34
|
+
'launch.extraArgs': { type: 'string', title: S.extraArgs, description: S.extraArgsHint },
|
|
35
|
+
autoStart: { type: 'boolean', title: S.autoStart },
|
|
36
|
+
}),
|
|
37
|
+
];
|
|
38
|
+
}
|