cortico 0.1.1 → 0.1.2
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 +18 -0
- package/src/bot.ts +40 -5
- package/src/core/README.md +4 -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 +15 -5
- package/src/core/loop.ts +55 -27
- package/src/core/session.ts +3 -2
- package/src/core/timers.ts +8 -6
- package/src/core/types.ts +18 -2
- 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 +23 -2
- package/src/extensions/manifest.ts +21 -11
- package/src/extensions.ts +147 -23
- package/src/launcher.ts +23 -9
- package/src/protocol/open-responses/context-log.ts +37 -9
- package/src/providers/README.md +32 -7
- package/src/providers/base.ts +2 -0
- package/src/providers/console/config.ts +21 -0
- package/src/providers/console/hub.ts +270 -0
- package/src/providers/console/settings.ts +11 -19
- 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/runtime-panel.ts +36 -73
- package/src/providers/llamacpp/console/server.ts +23 -2
- 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/name.ts +8 -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 +127 -0
- package/src/providers/openai-responses-compat/index.ts +40 -12
- package/src/providers/openai-responses-compat/native.ts +9 -4
- package/src/providers/openai-responses-compat/strings.ts +62 -1
- package/src/providers/registry.ts +5 -0
- package/src/providers/transport/responses-input.ts +59 -10
- package/src/web/README.md +12 -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 +262 -0
- package/src/web/client/features/providers/drafts.ts +23 -0
- package/src/web/client/features/providers/index.ts +173 -87
- package/src/web/client/features/providers/strings.ts +44 -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/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 +122 -18
- package/src/web/server.ts +227 -21
- package/src/web/shared/client-panel.ts +3 -0
- package/src/web/shared/console-protocol.ts +3 -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/mineflayer-fixes.ts +55 -1
- package/src/worlds/qq/normalize.ts +14 -0
- package/src/worlds/qq/world.ts +53 -7
- package/src/worlds/terminal/world.ts +3 -4
|
@@ -0,0 +1,270 @@
|
|
|
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 { 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 } 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
|
+
|
|
18
|
+
export class ProviderHubError extends Error {
|
|
19
|
+
constructor(message: string, readonly status = 400) { super(message); }
|
|
20
|
+
}
|
|
21
|
+
export interface ConnectionSave {
|
|
22
|
+
name: string;
|
|
23
|
+
copyFrom?: { name: string; revision: string };
|
|
24
|
+
entry: LLMProviderEntry;
|
|
25
|
+
expectedRevision?: string;
|
|
26
|
+
secretValue?: string;
|
|
27
|
+
}
|
|
28
|
+
export class ProviderHub {
|
|
29
|
+
private readonly revisions = new Map<string, string>();
|
|
30
|
+
constructor(private readonly config: CoreConfig, private readonly registry: ProviderRegistry,
|
|
31
|
+
private readonly settings: ProviderSettings, private readonly file: string,
|
|
32
|
+
private readonly root: string, private readonly modules: readonly ProviderModule[] = providerModules) {}
|
|
33
|
+
|
|
34
|
+
private path(name: string): string {
|
|
35
|
+
const path = resolve(this.root, name);
|
|
36
|
+
if (!name || dirname(path) !== resolve(this.root)) throw new ProviderHubError('Invalid provider identifier.');
|
|
37
|
+
return path;
|
|
38
|
+
}
|
|
39
|
+
private revision(name: string): string {
|
|
40
|
+
const dir = this.path(name);
|
|
41
|
+
return createHash('sha256').update(['config.json', '.env'].map(file =>
|
|
42
|
+
existsSync(join(dir, file)) ? readFileSync(join(dir, file)).toString('base64') : '').join(':')).digest('hex');
|
|
43
|
+
}
|
|
44
|
+
private refresh(): void {
|
|
45
|
+
const entries: Record<string, LLMProviderEntry> = Object.create(null);
|
|
46
|
+
if (existsSync(this.root)) for (const dir of readdirSync(this.root, { withFileTypes: true })) {
|
|
47
|
+
if (!dir.isDirectory() || (dir.name === '.write-lock' || dir.name.startsWith('.transaction-') || dir.name.startsWith('.deleted-'))) continue;
|
|
48
|
+
const file = join(this.root, dir.name, 'config.json');
|
|
49
|
+
if (existsSync(file)) entries[dir.name] = JSON.parse(readTextFile(file));
|
|
50
|
+
}
|
|
51
|
+
for (const name of new Set([...Object.keys(entries), ...Object.keys(this.config.providers)])) {
|
|
52
|
+
const revision = entries[name] ? this.revision(name) : '';
|
|
53
|
+
if (JSON.stringify(entries[name]) !== JSON.stringify(this.config.providers[name]) || this.revisions.get(name) !== revision) this.registry.invalidate(name);
|
|
54
|
+
this.revisions.set(name, revision);
|
|
55
|
+
}
|
|
56
|
+
this.config.providers = entries;
|
|
57
|
+
const disk = readJsonObject(this.file);
|
|
58
|
+
if (typeof disk.activeProvider === 'string') this.config.activeProvider = disk.activeProvider;
|
|
59
|
+
}
|
|
60
|
+
moduleList(language: Language) {
|
|
61
|
+
return this.modules.map(module => ({ id: module.id, title: module.title,
|
|
62
|
+
description: module.localize?.(language)?.description ?? module.description ?? module.title, defaultBaseUrl: module.defaultBaseUrl ?? '',
|
|
63
|
+
reasoningTiers: module.localize?.(language)?.reasoningTiers ?? module.reasoningTiers,
|
|
64
|
+
effortSuggestions: module.effortSuggestions ?? [], serviceTiers: module.localize?.(language)?.serviceTiers ?? module.serviceTiers,
|
|
65
|
+
temperatureNote: module.localize?.(language)?.temperatureNote ?? module.temperatureNote,
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
private readiness(name: string, entry: LLMProviderEntry, language: Language) {
|
|
69
|
+
const module = this.modules.find(m => m.id === entry.kind);
|
|
70
|
+
if (!module) return { state: 'module-missing', reason: language === 'zh' ? '供应商模块不可用。' : 'Provider module is unavailable.' };
|
|
71
|
+
try { validateEntry(module, entry, language); }
|
|
72
|
+
catch (error) { return { state: 'invalid', reason: String(error) }; }
|
|
73
|
+
if (!entry.spec?.model || (entry.secret && this.settings.secretStatus(name, entry) === 'none'))
|
|
74
|
+
return { state: 'needs-setup', reason: !entry.spec?.model ? (language === 'zh' ? '请选择模型。' : 'Model is required.') : (language === 'zh' ? '请配置 API Key。' : 'API Key is required.') };
|
|
75
|
+
const available = module.availability?.(name, entry, language);
|
|
76
|
+
if (available && !available.ready) return { state: 'runtime-unavailable', reason: available.reason };
|
|
77
|
+
return { state: 'ready' };
|
|
78
|
+
}
|
|
79
|
+
current(language: Language) {
|
|
80
|
+
const name = this.config.activeProvider;
|
|
81
|
+
const entry = this.config.providers[name];
|
|
82
|
+
return entry ? { name, model: entry.spec?.model ?? null, module: entry.kind,
|
|
83
|
+
moduleTitle: this.modules.find(module => module.id === entry.kind)?.title ?? entry.kind,
|
|
84
|
+
baseUrl: entry.baseUrl, ready: this.readiness(name, entry, language).state === 'ready' } : null;
|
|
85
|
+
}
|
|
86
|
+
list(language: Language) {
|
|
87
|
+
this.refresh();
|
|
88
|
+
return { scope: createHash('sha256').update(resolve(this.file)).digest('hex'), active: this.config.activeProvider, providers: Object.entries(this.config.providers).map(([name, entry]) => ({
|
|
89
|
+
id: name, name, module: entry.kind, moduleTitle: this.modules.find(m => m.id === entry.kind)?.title ?? entry.kind,
|
|
90
|
+
model: entry.spec?.model ?? null, baseUrl: entry.baseUrl, active: name === this.config.activeProvider,
|
|
91
|
+
readiness: this.readiness(name, entry, language), revision: this.revision(name),
|
|
92
|
+
})) };
|
|
93
|
+
}
|
|
94
|
+
detail(name: string, language: Language) {
|
|
95
|
+
this.refresh();
|
|
96
|
+
const entry = this.config.providers[name];
|
|
97
|
+
if (!entry) throw new ProviderHubError('Provider does not exist.', 404);
|
|
98
|
+
return { name, entry: structuredClone(entry), revision: this.revision(name),
|
|
99
|
+
secretConfigured: this.settings.secretStatus(name, entry), readiness: this.readiness(name, entry, language),
|
|
100
|
+
references: this.references(name).map(file => dirname(file).split(/[\\/]/).at(-1)),
|
|
101
|
+
config: this.groups(name, entry, language),
|
|
102
|
+
quotes: entry.spec ? [{ model: entry.spec.model, quotes: this.quotes(entry) }] : [],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
private quotes(entry: LLMProviderEntry) {
|
|
106
|
+
const module = this.modules.find(module => module.id === entry.kind);
|
|
107
|
+
const request = { model: entry.spec?.model };
|
|
108
|
+
const at = { startedAt: new Date().toISOString(), requestedServiceTier: entry.serviceTier ?? null };
|
|
109
|
+
return quotePrices(entry, request, at, module?.prices?.(entry, request, at) ?? []);
|
|
110
|
+
}
|
|
111
|
+
groups(name: string, entry: LLMProviderEntry, language: Language) {
|
|
112
|
+
const module = this.modules.find(m => m.id === entry.kind);
|
|
113
|
+
return [connectionGroup(name, entry, language), ...(module?.config?.(name, entry, language) ?? [])];
|
|
114
|
+
}
|
|
115
|
+
async preview(name: string, entry: LLMProviderEntry, panel: string, method: string, args: unknown[], language: Language) {
|
|
116
|
+
this.path(name);
|
|
117
|
+
const module = this.modules.find(module => module.id === entry.kind);
|
|
118
|
+
if (!module?.console) throw new ProviderHubError('Module panel unavailable.');
|
|
119
|
+
this.refresh();
|
|
120
|
+
const current = this.config.providers[name];
|
|
121
|
+
const persisted = !!current && JSON.stringify(current) === JSON.stringify(entry);
|
|
122
|
+
let draft = structuredClone(entry);
|
|
123
|
+
const contribution = module.console({ language, editing: !persisted,
|
|
124
|
+
entries: () => [{ name, entry: draft }],
|
|
125
|
+
instance: requested => {
|
|
126
|
+
if (requested !== name) throw new ProviderHubError('Foreign provider.');
|
|
127
|
+
return persisted ? this.registry.resolve(name) : this.registry.preview(name, draft);
|
|
128
|
+
},
|
|
129
|
+
save: (requested, next) => {
|
|
130
|
+
if (requested !== name || next.kind !== entry.kind) throw new ProviderHubError('Foreign provider.');
|
|
131
|
+
draft = structuredClone(next);
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
const result = await contribution.invoke?.(panel, method, args);
|
|
135
|
+
return { result, entry: draft };
|
|
136
|
+
}
|
|
137
|
+
private references(name: string): string[] {
|
|
138
|
+
const files = new Set([this.file]);
|
|
139
|
+
const parent = dirname(this.root);
|
|
140
|
+
for (const dir of readdirSync(parent, { withFileTypes: true })) {
|
|
141
|
+
if (!dir.isDirectory() || resolve(parent, dir.name) === resolve(this.root)) continue;
|
|
142
|
+
const file = join(parent, dir.name, 'config.json');
|
|
143
|
+
if (existsSync(file) && existsSync(join(parent, dir.name, 'deployment.json'))) files.add(file);
|
|
144
|
+
}
|
|
145
|
+
return [...files].filter(file => readJsonObject(file).activeProvider === name);
|
|
146
|
+
}
|
|
147
|
+
private locked<T>(work: () => T): T {
|
|
148
|
+
mkdirSync(this.root, { recursive: true });
|
|
149
|
+
const lock = join(this.root, '.write-lock');
|
|
150
|
+
try { mkdirSync(lock); } catch { throw new ProviderHubError('Another provider transaction is in progress. Retry after it completes.', 409); }
|
|
151
|
+
try { this.refresh(); return work(); } finally { rmSync(lock, { recursive: true, force: true }); }
|
|
152
|
+
}
|
|
153
|
+
private checkRevision(name: string, expected: unknown): void {
|
|
154
|
+
if (typeof expected !== 'string' || expected !== this.revision(name))
|
|
155
|
+
throw new ProviderHubError('Configuration changed in another page or bot. Reload before saving.', 409);
|
|
156
|
+
}
|
|
157
|
+
save(original: string | null, input: ConnectionSave, language: Language) {
|
|
158
|
+
return this.locked(() => {
|
|
159
|
+
const { name } = input;
|
|
160
|
+
if (typeof name !== 'string') throw new ProviderHubError('Provider name is required.');
|
|
161
|
+
const prior = original ? this.config.providers[original] : undefined;
|
|
162
|
+
if (original && !prior) throw new ProviderHubError('Provider does not exist.', 404);
|
|
163
|
+
if (original) this.checkRevision(original, input.expectedRevision);
|
|
164
|
+
if (name !== original) {
|
|
165
|
+
const problem = validateProviderName(name);
|
|
166
|
+
if (problem) throw new ProviderHubError(problem);
|
|
167
|
+
if (readdirSync(this.root).some(value => value.toLowerCase() === name.toLowerCase()))
|
|
168
|
+
throw new ProviderHubError('Provider name or directory already exists.', 409);
|
|
169
|
+
}
|
|
170
|
+
if (!input.entry || typeof input.entry !== 'object') throw new ProviderHubError('Provider configuration is required.');
|
|
171
|
+
if (prior && prior.kind !== input.entry.kind) throw new ProviderHubError('Provider module cannot be changed.');
|
|
172
|
+
const module = this.modules.find(m => m.id === input.entry.kind);
|
|
173
|
+
if (!module) throw new ProviderHubError('Provider module is unavailable.');
|
|
174
|
+
const requested = structuredClone(input.entry);
|
|
175
|
+
if (!requested.spec?.model) throw new ProviderHubError('Model is required.');
|
|
176
|
+
if (input.secretValue !== undefined && (typeof input.secretValue !== 'string' || !input.secretValue || /\s/.test(input.secretValue)))
|
|
177
|
+
throw new ProviderHubError('API Key must be nonempty and contain no whitespace.');
|
|
178
|
+
if (input.secretValue && !requested.secret) requested.secret = 'CORTICO_PROVIDER_API_KEY';
|
|
179
|
+
const entry = validateEntry(module, requested, language);
|
|
180
|
+
const prefix = `providers.${name}.`;
|
|
181
|
+
for (const group of this.groups(name, entry, language)) {
|
|
182
|
+
const values = Object.fromEntries(Object.keys(group.schema.properties).filter(path => path.startsWith(prefix))
|
|
183
|
+
.map(path => [path, getByPath(entry as unknown as Record<string, unknown>, path.slice(prefix.length))])
|
|
184
|
+
.filter(([, value]) => value !== undefined));
|
|
185
|
+
const result = coerceGroupValues(group, values, language);
|
|
186
|
+
if ('error' in result) throw new ProviderHubError(result.error);
|
|
187
|
+
for (const [path, value] of Object.entries(result.values)) setByPath(entry as unknown as Record<string, unknown>, path.slice(prefix.length), value);
|
|
188
|
+
}
|
|
189
|
+
validateEntry(module, entry, language);
|
|
190
|
+
let copiedSecret: Buffer | null = null;
|
|
191
|
+
let credentialSource = original;
|
|
192
|
+
if (!original && input.copyFrom) {
|
|
193
|
+
const source = this.config.providers[input.copyFrom.name];
|
|
194
|
+
if (!source) throw new ProviderHubError('Copy source no longer exists.', 409);
|
|
195
|
+
this.checkRevision(input.copyFrom.name, input.copyFrom.revision);
|
|
196
|
+
if (source.secret === entry.secret) {
|
|
197
|
+
credentialSource = input.copyFrom.name;
|
|
198
|
+
const file = join(this.path(input.copyFrom.name), '.env');
|
|
199
|
+
if (existsSync(file)) copiedSecret = readFileSync(file);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (entry.secret && !input.secretValue && (!credentialSource || this.settings.secretStatus(credentialSource, entry) === 'none'))
|
|
203
|
+
throw new ProviderHubError('API Key is required.');
|
|
204
|
+
const target = this.path(name);
|
|
205
|
+
const source = original ? this.path(original) : null;
|
|
206
|
+
const stage = join(this.root, `.transaction-${randomUUID()}`);
|
|
207
|
+
const refs = original && name !== original ? this.references(original) : [];
|
|
208
|
+
const backups = new Map<string, Buffer | null>();
|
|
209
|
+
for (const file of [...refs, ...(source ? [join(source, 'config.json'), join(source, '.env')] : [])])
|
|
210
|
+
backups.set(file, existsSync(file) ? readFileSync(file) : null);
|
|
211
|
+
let moved = false;
|
|
212
|
+
try {
|
|
213
|
+
if (!source) mkdirSync(stage);
|
|
214
|
+
const dir = source ?? stage;
|
|
215
|
+
updateJsonObject(join(dir, 'config.json'), raw => { for (const key of Object.keys(raw)) delete raw[key]; Object.assign(raw, entry); });
|
|
216
|
+
if (copiedSecret) writeFileSync(join(dir, '.env'), copiedSecret);
|
|
217
|
+
if (input.secretValue) {
|
|
218
|
+
const file = join(dir, '.env');
|
|
219
|
+
const lines = (existsSync(file) ? readTextFile(file) : '').split(/\r?\n/).filter(line => line.split('=')[0]?.trim() !== entry.secret);
|
|
220
|
+
lines.push(`${entry.secret}=${input.secretValue}`);
|
|
221
|
+
writeFileSync(file, lines.filter(Boolean).join('\n') + '\n');
|
|
222
|
+
}
|
|
223
|
+
if (source !== target) { renameSync(dir, target); moved = true; }
|
|
224
|
+
for (const file of refs) updateJsonObject(file, raw => { raw.activeProvider = name; });
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (moved) renameSync(target, source ?? stage);
|
|
227
|
+
for (const [file, contents] of backups) {
|
|
228
|
+
if (contents === null) rmSync(file, { force: true }); else writeFileSync(file, contents);
|
|
229
|
+
}
|
|
230
|
+
throw error;
|
|
231
|
+
} finally { if (existsSync(stage)) rmSync(stage, { recursive: true, force: true }); }
|
|
232
|
+
if (original) this.registry.invalidate(original);
|
|
233
|
+
this.registry.invalidate(name);
|
|
234
|
+
this.refresh();
|
|
235
|
+
return this.detail(name, language);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
activate(name: string, language: Language): void {
|
|
239
|
+
this.locked(() => {
|
|
240
|
+
const entry = this.config.providers[name];
|
|
241
|
+
if (!entry) throw new ProviderHubError('Provider does not exist.', 404);
|
|
242
|
+
const readiness = this.readiness(name, entry, language);
|
|
243
|
+
if (readiness.state !== 'ready') throw new ProviderHubError(readiness.reason ?? readiness.state);
|
|
244
|
+
updateJsonObject(this.file, raw => { raw.activeProvider = name; });
|
|
245
|
+
this.config.activeProvider = name;
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
delete(name: string, revision: string): void {
|
|
249
|
+
this.locked(() => {
|
|
250
|
+
if (!this.config.providers[name]) throw new ProviderHubError('Provider does not exist.', 404);
|
|
251
|
+
this.checkRevision(name, revision);
|
|
252
|
+
const refs = this.references(name);
|
|
253
|
+
if (this.config.activeProvider === name || refs.length)
|
|
254
|
+
throw new ProviderHubError(`Provider is used by: ${refs.map(file => dirname(file).split(/[\\/]/).at(-1)).join(', ') || 'current bot'}`, 409);
|
|
255
|
+
const tomb = join(this.root, `.deleted-${randomUUID()}`);
|
|
256
|
+
renameSync(this.path(name), tomb);
|
|
257
|
+
this.registry.invalidate(name);
|
|
258
|
+
delete this.config.providers[name];
|
|
259
|
+
rmSync(tomb, { recursive: true, force: true });
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
async action(name: string, action: string, language: Language) {
|
|
263
|
+
this.refresh();
|
|
264
|
+
const entry = this.config.providers[name];
|
|
265
|
+
if (!entry) throw new ProviderHubError('Provider does not exist.', 404);
|
|
266
|
+
const source = this.settings.sources().find(source => source.id === `llm:${entry.kind}`);
|
|
267
|
+
if (!source) throw new ProviderHubError('Provider module is unavailable.');
|
|
268
|
+
return source.contribute(language).invoke!('settings', action === 'test' ? 'probe' : 'models', [{ name }]);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -26,14 +26,12 @@ 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 { 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
|
}
|
|
@@ -281,7 +280,7 @@ export class ProviderSettings {
|
|
|
281
280
|
return { label: S.availableLamp, state: 'offline', hint: reason };
|
|
282
281
|
}
|
|
283
282
|
|
|
284
|
-
/**
|
|
283
|
+
/** 所有模块合起来有没有一个可用端点。控制台左栏「模型提供商」那一行点的就是它。 */
|
|
285
284
|
providersLamp(language: Language = 'zh'): ConsoleLamp {
|
|
286
285
|
return this.availableLamp(
|
|
287
286
|
this.modules.flatMap((module) => this.entries(module)),
|
|
@@ -312,16 +311,7 @@ export class ProviderSettings {
|
|
|
312
311
|
kind: 'llm',
|
|
313
312
|
label: module.title,
|
|
314
313
|
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
|
-
],
|
|
314
|
+
lamps: [],
|
|
325
315
|
badges: [{ label: S.instancesBadge, value: String(entries.length) }, ...(extra.badges ?? [])],
|
|
326
316
|
panels: [
|
|
327
317
|
{
|
|
@@ -334,7 +324,7 @@ export class ProviderSettings {
|
|
|
334
324
|
},
|
|
335
325
|
...(extra.panels ?? []),
|
|
336
326
|
],
|
|
337
|
-
config: entries.flatMap(
|
|
327
|
+
config: extra.config ?? entries.flatMap(
|
|
338
328
|
({ name, entry }) => module.config?.(name, entry, language) ?? [],
|
|
339
329
|
),
|
|
340
330
|
invoke: async (panel, method, args) => {
|
|
@@ -358,6 +348,8 @@ export class ProviderSettings {
|
|
|
358
348
|
instances: this.entries(module).map(({ name, entry }) => ({
|
|
359
349
|
name,
|
|
360
350
|
entry,
|
|
351
|
+
config: this.declaredGroups(language).filter((item) => item.name === name)
|
|
352
|
+
.map(({ group }) => ({ group, values: this.values(group.id, language) })),
|
|
361
353
|
secretConfigured: this.secretStatus(name, entry),
|
|
362
354
|
quotes: (entry.spec ? [entry.spec] : []).map((spec) => ({
|
|
363
355
|
model: spec.model,
|
|
@@ -383,7 +375,7 @@ export class ProviderSettings {
|
|
|
383
375
|
const name = body.name;
|
|
384
376
|
if (method === 'create') {
|
|
385
377
|
this.assertNewName(name, language);
|
|
386
|
-
//
|
|
378
|
+
// 报价留空:不覆盖模块价目;模块也没给价目时,这条端点的调用只记 token,不记金额。
|
|
387
379
|
this.save(name, {
|
|
388
380
|
kind: module.id,
|
|
389
381
|
baseUrl: String(body.baseUrl || module.defaultBaseUrl || ''),
|
|
@@ -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
|
+
}
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
* The endpoint comes from `ctx.scope.instance`; edits are written on change.
|
|
4
4
|
*/
|
|
5
5
|
import type { ConsolePanel, ConsolePanelContext } from '../../../web/shared/client-panel.ts';
|
|
6
|
-
import type {
|
|
7
|
-
import
|
|
6
|
+
import type { ConfigValues } from '../../../core/config-schema.ts';
|
|
7
|
+
import { configField } from '../../../web/client/features/config/view.ts';
|
|
8
|
+
import type { RuntimePanelState } from './server.ts';
|
|
8
9
|
import { panel } from '../strings.ts';
|
|
9
10
|
|
|
10
|
-
type Row =
|
|
11
|
+
type Row = RuntimePanelState;
|
|
11
12
|
|
|
12
13
|
const POLL_MS = 2_000;
|
|
13
14
|
|
|
@@ -42,18 +43,35 @@ export const runtimePanel: ConsolePanel = {
|
|
|
42
43
|
await load(true);
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
46
|
+
async function saveConfig(groupId: string, values: ConfigValues): Promise<void> {
|
|
47
|
+
if (busy) return;
|
|
48
|
+
busy = true;
|
|
49
|
+
message.textContent = '';
|
|
50
|
+
try {
|
|
51
|
+
await ctx.setConfig(groupId, values);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
message.textContent = String(error);
|
|
54
|
+
} finally {
|
|
55
|
+
busy = false;
|
|
56
|
+
}
|
|
57
|
+
await load(true);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderConfig(row: Row, index: number, body: HTMLElement): void {
|
|
61
|
+
const { group, values } = row.config[index];
|
|
62
|
+
body.append(ui.section(group.schema.title, group.schema.description));
|
|
63
|
+
for (const [path, property] of Object.entries(group.schema.properties)) {
|
|
64
|
+
const field = configField(ui, property, values[path], () => {
|
|
65
|
+
if (field.read) void saveConfig(group.id, { [path]: field.read() });
|
|
66
|
+
}, ctx.signal);
|
|
67
|
+
field.node.setAttribute('aria-label', property.title);
|
|
68
|
+
// 自备运行时目录时后端由目录里的二进制决定,后端那一格禁用。
|
|
69
|
+
if (path.endsWith('.runtime.backend') && row.own) {
|
|
70
|
+
(field.node as HTMLSelectElement).disabled = true;
|
|
71
|
+
}
|
|
72
|
+
body.append(ui.field(property.title, field.node));
|
|
73
|
+
if (property.description) body.append(ui.msgline(property.description));
|
|
74
|
+
}
|
|
57
75
|
}
|
|
58
76
|
|
|
59
77
|
function renderUnmanaged(row: Row, body: HTMLElement): void {
|
|
@@ -74,42 +92,8 @@ export const runtimePanel: ConsolePanel = {
|
|
|
74
92
|
: install.phase === 'extracting' ? ui.pill(S.extracting, 'plain')
|
|
75
93
|
: ui.pill(S.absent, 'off');
|
|
76
94
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
value: row.release ?? '',
|
|
80
|
-
cls: 'mono',
|
|
81
|
-
onChange: (value) => void act('configure', { release: value }),
|
|
82
|
-
});
|
|
83
|
-
release.setAttribute('aria-label', S.release);
|
|
84
|
-
const backend = ui.select({
|
|
85
|
-
value: row.backend ?? '',
|
|
86
|
-
options: row.backendChoices,
|
|
87
|
-
onChange: (value) => void act('configure', { backend: value }),
|
|
88
|
-
});
|
|
89
|
-
backend.setAttribute('aria-label', S.backend);
|
|
90
|
-
backend.disabled = row.own;
|
|
91
|
-
const dir = ui.input({
|
|
92
|
-
value: row.own ? row.runtimeDir ?? '' : '',
|
|
93
|
-
cls: 'mono',
|
|
94
|
-
onChange: (value) => void act('configure', { runtimeDir: value }),
|
|
95
|
-
});
|
|
96
|
-
dir.setAttribute('aria-label', S.runtimeDir);
|
|
97
|
-
const dirRow = ui.rowbar();
|
|
98
|
-
dirRow.append(dir, ui.button(S.browse, {
|
|
99
|
-
size: 'sm',
|
|
100
|
-
onClick: async () => {
|
|
101
|
-
const picked = await ctx.pickPath({ kind: 'directory', title: S.runtimeDir, currentPath: dir.value });
|
|
102
|
-
if (picked) await act('configure', { runtimeDir: picked });
|
|
103
|
-
},
|
|
104
|
-
}));
|
|
105
|
-
body.append(
|
|
106
|
-
ui.field(S.release, release),
|
|
107
|
-
ui.msgline(S.releaseHint),
|
|
108
|
-
ui.field(S.backend, backend),
|
|
109
|
-
ui.field(S.runtimeDir, dirRow),
|
|
110
|
-
ui.msgline(S.runtimeDirHint),
|
|
111
|
-
ui.kv([{ k: S.installStatus, v: installPill }]),
|
|
112
|
-
);
|
|
95
|
+
renderConfig(row, 0, body);
|
|
96
|
+
body.append(ui.kv([{ k: S.installStatus, v: installPill }]));
|
|
113
97
|
if (row.smartAppControl === 1) body.append(ui.msgline(S.sacWarning, true));
|
|
114
98
|
if (install.phase === 'downloading' || install.phase === 'extracting') {
|
|
115
99
|
const progress = ui.progress({
|
|
@@ -133,28 +117,7 @@ export const runtimePanel: ConsolePanel = {
|
|
|
133
117
|
installBar.append(ui.h('span', 'grow'), ui.button(S.disable, { onClick: () => void act('disable') }));
|
|
134
118
|
body.append(installBar);
|
|
135
119
|
|
|
136
|
-
|
|
137
|
-
const launchRow = ui.rowbar();
|
|
138
|
-
launchRow.append(
|
|
139
|
-
launchNumber(row, 'contextSize', S.contextSize, 1),
|
|
140
|
-
launchNumber(row, 'nGpuLayers', S.nGpuLayers, 0),
|
|
141
|
-
launchNumber(row, 'parallel', S.parallel, 1),
|
|
142
|
-
);
|
|
143
|
-
const extraArgs = ui.input({
|
|
144
|
-
value: row.launch?.extraArgs ?? '',
|
|
145
|
-
cls: 'mono',
|
|
146
|
-
onChange: (value) => void act('configure', { launch: { extraArgs: value } }),
|
|
147
|
-
});
|
|
148
|
-
extraArgs.setAttribute('aria-label', S.extraArgs);
|
|
149
|
-
body.append(
|
|
150
|
-
launchRow,
|
|
151
|
-
ui.field(S.extraArgs, extraArgs),
|
|
152
|
-
ui.msgline(S.extraArgsHint),
|
|
153
|
-
ui.checkbox(S.autoStart, {
|
|
154
|
-
checked: row.autoStart,
|
|
155
|
-
onChange: (checked) => void act('configure', { autoStart: checked }),
|
|
156
|
-
}).el,
|
|
157
|
-
);
|
|
120
|
+
renderConfig(row, 1, body);
|
|
158
121
|
|
|
159
122
|
body.append(ui.section(S.serverSection, S.serverSectionDesc));
|
|
160
123
|
const server = row.server;
|
|
@@ -8,6 +8,9 @@ import type { ProviderConsoleHost } from '../../console/types.ts';
|
|
|
8
8
|
import type { RouterCatalog, RouterModel } from '../catalog.ts';
|
|
9
9
|
import { LAUNCH_DEFAULTS, PINNED_RELEASE, backendChoices, defaultBackend, llamacppOptions, type LaunchOptions } from '../options.ts';
|
|
10
10
|
import type { LlamaRuntime } from '../runtime.ts';
|
|
11
|
+
import type { RuntimeState } from '../runtime.ts';
|
|
12
|
+
import { runtimeConfig } from '../config.ts';
|
|
13
|
+
import { getByPath, type ConfigGroup, type ConfigValues } from '../../../core/config-schema.ts';
|
|
11
14
|
import { text } from '../strings.ts';
|
|
12
15
|
|
|
13
16
|
interface Control {
|
|
@@ -23,6 +26,11 @@ export interface ModelsState {
|
|
|
23
26
|
models: RouterModel[];
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
export type RuntimePanelState = RuntimeState & {
|
|
30
|
+
name: string;
|
|
31
|
+
config: Array<{ group: ConfigGroup; values: ConfigValues }>;
|
|
32
|
+
};
|
|
33
|
+
|
|
26
34
|
/** The launch fields the panel edits, each optional and applied over the stored values. */
|
|
27
35
|
type LaunchPatch = Partial<Record<keyof LaunchOptions, unknown>>;
|
|
28
36
|
|
|
@@ -42,6 +50,7 @@ export function llamacppConsole(host: ProviderConsoleHost): Partial<ConsolePageC
|
|
|
42
50
|
return found.entry;
|
|
43
51
|
};
|
|
44
52
|
return {
|
|
53
|
+
config: [],
|
|
45
54
|
panels: [
|
|
46
55
|
{ id: 'runtime', title: S.runtimePanel, description: S.runtimePanelDescription, slot: 'instance' },
|
|
47
56
|
{ id: 'models', title: S.modelsPanel, description: S.modelsPanelDescription, slot: 'instance' },
|
|
@@ -50,7 +59,17 @@ export function llamacppConsole(host: ProviderConsoleHost): Partial<ConsolePageC
|
|
|
50
59
|
if (panel === 'runtime') {
|
|
51
60
|
const value = body(args);
|
|
52
61
|
const name = value.name as string;
|
|
53
|
-
if (method === 'state')
|
|
62
|
+
if (method === 'state') {
|
|
63
|
+
const entry = entryOf(name);
|
|
64
|
+
const prefix = `providers.${name}.`;
|
|
65
|
+
const config = runtimeConfig(name, entry, host.language).map((group) => ({
|
|
66
|
+
group,
|
|
67
|
+
values: Object.fromEntries(Object.keys(group.schema.properties).map((path) => [
|
|
68
|
+
path, getByPath(entry as unknown as Record<string, unknown>, path.slice(prefix.length)) ?? '',
|
|
69
|
+
])) as ConfigValues,
|
|
70
|
+
}));
|
|
71
|
+
return { name, ...(await control(name).runtime.state(host.language)), config } satisfies RuntimePanelState;
|
|
72
|
+
}
|
|
54
73
|
if (method === 'enable') {
|
|
55
74
|
const entry = entryOf(name);
|
|
56
75
|
const options = llamacppOptions(entry);
|
|
@@ -66,7 +85,7 @@ export function llamacppConsole(host: ProviderConsoleHost): Partial<ConsolePageC
|
|
|
66
85
|
return { ok: true };
|
|
67
86
|
}
|
|
68
87
|
if (method === 'disable') {
|
|
69
|
-
await control(name).runtime.stop(host.language);
|
|
88
|
+
if (!host.editing) await control(name).runtime.stop(host.language);
|
|
70
89
|
const entry = entryOf(name);
|
|
71
90
|
const { runtime: _runtime, launch: _launch, ...rest } = entry.options ?? {};
|
|
72
91
|
host.save(name, { ...entry, options: rest });
|
|
@@ -96,6 +115,7 @@ export function llamacppConsole(host: ProviderConsoleHost): Partial<ConsolePageC
|
|
|
96
115
|
});
|
|
97
116
|
return { ok: true };
|
|
98
117
|
}
|
|
118
|
+
if (host.editing) throw new Error(host.language === 'zh' ? '请先保存配置,再执行运行时操作。' : 'Save configuration before runtime operations.');
|
|
99
119
|
if (method === 'install') {
|
|
100
120
|
await control(name).runtime.install(host.language);
|
|
101
121
|
return { ok: true };
|
|
@@ -120,6 +140,7 @@ export function llamacppConsole(host: ProviderConsoleHost): Partial<ConsolePageC
|
|
|
120
140
|
await catalog.list(true);
|
|
121
141
|
return { ok: true };
|
|
122
142
|
}
|
|
143
|
+
if (host.editing) throw new Error(host.language === 'zh' ? '请先保存配置,再操作模型。' : 'Save configuration before model operations.');
|
|
123
144
|
if (typeof value.model !== 'string' || !value.model.trim()) throw new Error(S.modelIdRequired);
|
|
124
145
|
const model = value.model.trim();
|
|
125
146
|
if (method === 'pull') await catalog.download(model);
|
|
@@ -4,6 +4,7 @@ import type { Language } from '../../core/language.ts';
|
|
|
4
4
|
import { isContextOverflow } from '../transport/errors.ts';
|
|
5
5
|
import { modelsRoot, runtimesRoot } from '../../paths.ts';
|
|
6
6
|
import { RouterCatalog } from './catalog.ts';
|
|
7
|
+
import { runtimeConfig } from './config.ts';
|
|
7
8
|
import { llamacppConsole } from './console/server.ts';
|
|
8
9
|
import { LlamaCppProvider } from './native.ts';
|
|
9
10
|
import { backendChoices, llamacppOptions, normalizeLlamaCpp } from './options.ts';
|
|
@@ -23,12 +24,14 @@ const reasoningTiers = (language: Language): ReasoningTier[] => {
|
|
|
23
24
|
export default {
|
|
24
25
|
id: 'llamacpp',
|
|
25
26
|
title: 'llama.cpp',
|
|
27
|
+
description: 'Connect to or manage local llama.cpp models.',
|
|
26
28
|
defaultBaseUrl: 'http://127.0.0.1:8090/v1',
|
|
27
29
|
baseUrlSuggestions: ['http://127.0.0.1:8090/v1', 'http://127.0.0.1:8080/v1'],
|
|
28
30
|
normalize: normalizeLlamaCpp,
|
|
29
31
|
console: llamacppConsole,
|
|
32
|
+
config: runtimeConfig,
|
|
30
33
|
reasoningTiers: reasoningTiers('zh'),
|
|
31
|
-
localize: (language) => ({ reasoningTiers: reasoningTiers(language) }),
|
|
34
|
+
localize: (language) => ({ description: language === 'zh' ? '连接或托管本地 llama.cpp 模型。' : 'Connect to or manage local llama.cpp models.', reasoningTiers: reasoningTiers(language) }),
|
|
32
35
|
serviceTiers: [],
|
|
33
36
|
validateEntry: (entry, language) => {
|
|
34
37
|
const S = text(language);
|
|
@@ -87,6 +90,7 @@ export default {
|
|
|
87
90
|
apiKey,
|
|
88
91
|
log: host.log,
|
|
89
92
|
media: { enabled: () => entry.multimodal === true, read: host.readBlob },
|
|
93
|
+
keepThinking: host.keepThinking,
|
|
90
94
|
}),
|
|
91
95
|
};
|
|
92
96
|
},
|