cortico 0.1.2 → 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/package.json +1 -1
- package/src/boot.ts +11 -0
- package/src/core/README.md +8 -0
- package/src/core/core.ts +3 -1
- package/src/core/generation.ts +66 -9
- package/src/core/instance-lock.ts +6 -0
- package/src/core/loop.ts +7 -5
- package/src/core/secrets.ts +2 -4
- package/src/core/types.ts +5 -2
- package/src/extensions/dry-mount.ts +16 -9
- package/src/extensions/manifest.ts +1 -1
- package/src/launcher.ts +8 -14
- package/src/protocol/open-responses/context-helpers.ts +3 -1
- package/src/providers/README.md +16 -9
- package/src/providers/console/config.ts +15 -0
- package/src/providers/console/hub.ts +49 -12
- package/src/providers/console/settings.ts +20 -8
- package/src/providers/console/strings.ts +14 -0
- package/src/providers/llamacpp/console/models-panel.ts +87 -4
- package/src/providers/llamacpp/console/server.ts +24 -2
- package/src/providers/llamacpp/huggingface.ts +93 -0
- package/src/providers/llamacpp/strings.ts +42 -0
- package/src/providers/name.ts +5 -0
- package/src/providers/openai-responses-compat/console/server.ts +9 -1
- package/src/providers/openai-responses-compat/native.ts +8 -3
- package/src/providers/pricebook.ts +5 -5
- package/src/providers/registry.ts +21 -12
- 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/web/README.md +3 -0
- package/src/web/client/console-pages/host.ts +10 -10
- package/src/web/client/features/providers/detail.ts +184 -137
- package/src/web/client/features/providers/index.ts +48 -42
- package/src/web/client/features/providers/strings.ts +22 -18
- package/src/web/client/ui/fields.ts +3 -1
- package/src/web/public/styles.css +28 -20
- package/src/web/server.ts +4 -2
- package/src/web/shared/console-protocol.ts +13 -0
- package/src/worlds/minecraft/client-launch.ts +11 -2
- package/src/worlds/minecraft/client.ts +4 -0
- package/src/worlds/qq/world.ts +3 -3
|
@@ -15,19 +15,19 @@ 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
29
|
import { PROBE_MAX_OUTPUT_TOKENS, type ProviderConsoleHost } from './types.ts';
|
|
30
|
-
import { connectionGroup } from './config.ts';
|
|
30
|
+
import { connectionBlocks, connectionGroup } from './config.ts';
|
|
31
31
|
|
|
32
32
|
export type SecretStatus = 'env' | 'file' | 'none';
|
|
33
33
|
|
|
@@ -199,18 +199,24 @@ export class ProviderSettings {
|
|
|
199
199
|
return readdirSync(dir).filter((file) => file !== 'config.json');
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
private
|
|
202
|
+
private probe(name: string, language: Language) {
|
|
203
203
|
const S = text(language);
|
|
204
204
|
const entry = this.config.providers[name];
|
|
205
205
|
if (!entry) throw new Error(S.unknownInstance);
|
|
206
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);
|
|
207
213
|
const request = {
|
|
208
|
-
...responseRequest(
|
|
209
|
-
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),
|
|
210
216
|
};
|
|
211
217
|
const started = Date.now();
|
|
212
218
|
try {
|
|
213
|
-
const generation = await
|
|
219
|
+
const generation = await client.respond(request, { diagnostic: true, nativeSpec: spec, role: 'probe' });
|
|
214
220
|
const attempt = generation.attempts.at(-1);
|
|
215
221
|
return {
|
|
216
222
|
ok: true,
|
|
@@ -305,6 +311,12 @@ export class ProviderSettings {
|
|
|
305
311
|
},
|
|
306
312
|
};
|
|
307
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];
|
|
308
320
|
return {
|
|
309
321
|
...extra,
|
|
310
322
|
id: `llm:${module.id}`,
|
|
@@ -322,7 +334,7 @@ export class ProviderSettings {
|
|
|
322
334
|
// 使用控制台内建端点面板,操作由下方 invoke 提供。
|
|
323
335
|
builtin: 'llm-settings',
|
|
324
336
|
},
|
|
325
|
-
...
|
|
337
|
+
...sections,
|
|
326
338
|
],
|
|
327
339
|
config: extra.config ?? entries.flatMap(
|
|
328
340
|
({ name, entry }) => module.config?.(name, entry, language) ?? [],
|
|
@@ -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',
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Model section of one endpoint: what llama-server knows,
|
|
3
|
-
* The endpoint comes from
|
|
2
|
+
* Model section of one endpoint: what llama-server knows, a HuggingFace search that feeds the
|
|
3
|
+
* pull field, and `use` to put a served model into the model section. The endpoint comes from
|
|
4
|
+
* `ctx.scope.instance`.
|
|
4
5
|
*/
|
|
5
6
|
import type { ConsolePanel, ConsolePanelContext } from '../../../web/shared/client-panel.ts';
|
|
7
|
+
import type { HfFile, HfRepo } from '../huggingface.ts';
|
|
6
8
|
import type { ModelsState } from './server.ts';
|
|
7
9
|
import { panel } from '../strings.ts';
|
|
10
|
+
import { HITS_PER_PAGE } from '../../../web/client/features/extensions/index.ts';
|
|
8
11
|
|
|
9
12
|
const POLL_MS = 2_000;
|
|
10
13
|
|
|
@@ -22,8 +25,12 @@ export const modelsPanel: ConsolePanel = {
|
|
|
22
25
|
const card = ui.sheet({ title: S.modelsTitle });
|
|
23
26
|
const message = ui.msgline();
|
|
24
27
|
root.append(card.el, message);
|
|
25
|
-
/**
|
|
28
|
+
/** Typed text and search results survive a re-render: the poll redraws the card around them. */
|
|
26
29
|
let draft = '';
|
|
30
|
+
let query = '';
|
|
31
|
+
let repos: HfRepo[] | null = null;
|
|
32
|
+
let searchNote = '';
|
|
33
|
+
let chosen: { repo: string; files: HfFile[] } | null = null;
|
|
27
34
|
let lastSnapshot = '';
|
|
28
35
|
|
|
29
36
|
async function act(method: string, extra: Record<string, unknown> = {}): Promise<void> {
|
|
@@ -36,6 +43,76 @@ export const modelsPanel: ConsolePanel = {
|
|
|
36
43
|
await load(true);
|
|
37
44
|
}
|
|
38
45
|
|
|
46
|
+
async function search(text: string): Promise<void> {
|
|
47
|
+
const wanted = text.trim();
|
|
48
|
+
if (!wanted) return;
|
|
49
|
+
query = wanted;
|
|
50
|
+
chosen = null;
|
|
51
|
+
try {
|
|
52
|
+
// One page of results, the console's page size for search hits.
|
|
53
|
+
const result = await ctx.invoke<{ repos: HfRepo[] }>('search', [{ name, query: wanted, limit: HITS_PER_PAGE }]);
|
|
54
|
+
repos = result.repos;
|
|
55
|
+
searchNote = repos.length ? '' : S.noRepos;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
repos = [];
|
|
58
|
+
searchNote = S.searchFailed(String(error));
|
|
59
|
+
}
|
|
60
|
+
await load(true);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function showFiles(repo: string): Promise<void> {
|
|
64
|
+
try {
|
|
65
|
+
chosen = { repo, files: (await ctx.invoke<{ files: HfFile[] }>('files', [{ name, repo }])).files };
|
|
66
|
+
} catch (error) {
|
|
67
|
+
message.textContent = String(error);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
await load(true);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderSearch(body: HTMLElement): void {
|
|
74
|
+
const bar = ui.rowbar();
|
|
75
|
+
const input = ui.input({
|
|
76
|
+
placeholder: S.searchPlaceholder,
|
|
77
|
+
value: query,
|
|
78
|
+
onCommit: (value) => void search(value),
|
|
79
|
+
});
|
|
80
|
+
input.setAttribute('aria-label', S.search);
|
|
81
|
+
bar.append(input, ui.button(S.search, { onClick: () => void search(input.value) }));
|
|
82
|
+
body.append(ui.field(S.search, bar));
|
|
83
|
+
if (repos === null) return;
|
|
84
|
+
if (searchNote) body.append(ui.msgline(searchNote, repos.length === 0 && searchNote !== S.noRepos));
|
|
85
|
+
if (repos.length) {
|
|
86
|
+
const table = ui.table({ head: [S.repo, S.downloads, S.likes, S.updated, ''] });
|
|
87
|
+
for (const repo of repos) {
|
|
88
|
+
const open = ui.button(S.showFiles, { size: 'sm', onClick: () => void showFiles(repo.id) });
|
|
89
|
+
table.addRow([
|
|
90
|
+
{ text: repo.id, cls: 'mono' },
|
|
91
|
+
ui.fmt.count(repo.downloads),
|
|
92
|
+
ui.fmt.count(repo.likes),
|
|
93
|
+
repo.updatedAt ? repo.updatedAt.slice(0, 10) : '',
|
|
94
|
+
open,
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
97
|
+
body.append(table.el);
|
|
98
|
+
}
|
|
99
|
+
if (!chosen) return;
|
|
100
|
+
body.append(ui.section(S.filesOf(chosen.repo)));
|
|
101
|
+
const files = ui.table({ head: [S.file, S.size, S.tag, ''] });
|
|
102
|
+
if (chosen.files.length === 0) files.clear(S.noFiles);
|
|
103
|
+
for (const file of chosen.files) {
|
|
104
|
+
const pull = ui.button(S.pull, { size: 'sm', variant: 'primary', onClick: () => void act('pull', { model: file.pull }) });
|
|
105
|
+
pull.title = file.pull;
|
|
106
|
+
files.addRow([
|
|
107
|
+
{ text: file.name, cls: 'mono' },
|
|
108
|
+
file.bytes === null ? '' : bytes(file.bytes),
|
|
109
|
+
{ text: file.tag ?? '', cls: 'mono' },
|
|
110
|
+
pull,
|
|
111
|
+
]);
|
|
112
|
+
}
|
|
113
|
+
body.append(files.el);
|
|
114
|
+
}
|
|
115
|
+
|
|
39
116
|
function render(state: ModelsState, body: HTMLElement): void {
|
|
40
117
|
body.append(ui.kv([
|
|
41
118
|
{ k: S.cacheDir, v: state.cacheDir },
|
|
@@ -79,13 +156,18 @@ export const modelsPanel: ConsolePanel = {
|
|
|
79
156
|
});
|
|
80
157
|
status.append(progress.el);
|
|
81
158
|
}
|
|
82
|
-
const actions = ui.
|
|
159
|
+
const actions = ui.rowbar();
|
|
83
160
|
if (model.status === 'downloading')
|
|
84
161
|
actions.append(ui.button(S.cancel, { size: 'sm', onClick: () => void act('cancel', { model: model.id }) }));
|
|
85
162
|
else if (model.status === 'loaded' || model.status === 'sleeping')
|
|
86
163
|
actions.append(ui.button(S.unload, { size: 'sm', onClick: () => void act('unload', { model: model.id }) }));
|
|
87
164
|
else if (model.status === 'unloaded' || model.status === 'failed')
|
|
88
165
|
actions.append(ui.button(S.load, { size: 'sm', onClick: () => void act('load', { model: model.id }) }));
|
|
166
|
+
if (model.status !== 'downloading') {
|
|
167
|
+
const use = ui.button(S.use, { size: 'sm', onClick: () => void act('use', { model: model.id }) });
|
|
168
|
+
use.title = S.useTitle;
|
|
169
|
+
actions.append(use);
|
|
170
|
+
}
|
|
89
171
|
table.addRow([
|
|
90
172
|
{ text: model.id, cls: 'mono' },
|
|
91
173
|
status,
|
|
@@ -95,6 +177,7 @@ export const modelsPanel: ConsolePanel = {
|
|
|
95
177
|
]);
|
|
96
178
|
}
|
|
97
179
|
body.append(table.el);
|
|
180
|
+
renderSearch(body);
|
|
98
181
|
}
|
|
99
182
|
|
|
100
183
|
async function load(force = false): Promise<void> {
|
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { ConsolePageContribution } from '../../../web/shared/console-protocol.ts';
|
|
7
7
|
import type { ProviderConsoleHost } from '../../console/types.ts';
|
|
8
|
+
import { connectionBlocks } from '../../console/config.ts';
|
|
8
9
|
import type { RouterCatalog, RouterModel } from '../catalog.ts';
|
|
9
10
|
import { LAUNCH_DEFAULTS, PINNED_RELEASE, backendChoices, defaultBackend, llamacppOptions, type LaunchOptions } from '../options.ts';
|
|
10
11
|
import type { LlamaRuntime } from '../runtime.ts';
|
|
11
12
|
import type { RuntimeState } from '../runtime.ts';
|
|
12
13
|
import { runtimeConfig } from '../config.ts';
|
|
14
|
+
import { listGguf, searchGguf } from '../huggingface.ts';
|
|
13
15
|
import { getByPath, type ConfigGroup, type ConfigValues } from '../../../core/config-schema.ts';
|
|
14
16
|
import { text } from '../strings.ts';
|
|
15
17
|
|
|
@@ -49,11 +51,16 @@ export function llamacppConsole(host: ProviderConsoleHost): Partial<ConsolePageC
|
|
|
49
51
|
if (!found) throw new Error(S.instanceNameRequired);
|
|
50
52
|
return found.entry;
|
|
51
53
|
};
|
|
54
|
+
const blocks = connectionBlocks(host.language);
|
|
52
55
|
return {
|
|
53
56
|
config: [],
|
|
54
57
|
panels: [
|
|
55
|
-
{
|
|
56
|
-
{ id: '
|
|
58
|
+
{ ...blocks.endpoint, title: S.endpointPanel, description: S.endpointPanelDescription },
|
|
59
|
+
{ id: 'runtime', title: S.runtimePanel, description: S.runtimePanelDescription },
|
|
60
|
+
{ id: 'models', title: S.modelsPanel, description: S.modelsPanelDescription },
|
|
61
|
+
blocks.model,
|
|
62
|
+
blocks.pricing,
|
|
63
|
+
blocks.protocol,
|
|
57
64
|
],
|
|
58
65
|
invoke: async (panel, method, args) => {
|
|
59
66
|
if (panel === 'runtime') {
|
|
@@ -140,6 +147,21 @@ export function llamacppConsole(host: ProviderConsoleHost): Partial<ConsolePageC
|
|
|
140
147
|
await catalog.list(true);
|
|
141
148
|
return { ok: true };
|
|
142
149
|
}
|
|
150
|
+
if (method === 'search') {
|
|
151
|
+
if (typeof value.query !== 'string' || !value.query.trim()) throw new Error(S.queryRequired);
|
|
152
|
+
if (!Number.isInteger(value.limit) || (value.limit as number) < 1) throw new Error(S.limitRequired);
|
|
153
|
+
return { repos: await searchGguf(value.query.trim(), value.limit as number) };
|
|
154
|
+
}
|
|
155
|
+
if (method === 'files') {
|
|
156
|
+
if (typeof value.repo !== 'string' || !value.repo.trim()) throw new Error(S.repoRequired);
|
|
157
|
+
return { files: await listGguf(value.repo.trim()) };
|
|
158
|
+
}
|
|
159
|
+
if (method === 'use') {
|
|
160
|
+
if (typeof value.model !== 'string' || !value.model.trim()) throw new Error(S.modelIdRequired);
|
|
161
|
+
const entry = entryOf(name);
|
|
162
|
+
host.save(name, { ...entry, spec: { thinking: false, ...entry.spec, model: value.model.trim() } });
|
|
163
|
+
return { ok: true };
|
|
164
|
+
}
|
|
143
165
|
if (host.editing) throw new Error(host.language === 'zh' ? '请先保存配置,再操作模型。' : 'Save configuration before model operations.');
|
|
144
166
|
if (typeof value.model !== 'string' || !value.model.trim()) throw new Error(S.modelIdRequired);
|
|
145
167
|
const model = value.model.trim();
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HuggingFace lookups behind the pull field: repositories tagged `gguf`, and the GGUF files of
|
|
3
|
+
* one repository as `repo:tag` pull targets. The host is the one llama-server itself pulls from:
|
|
4
|
+
* `MODEL_ENDPOINT`, else `HF_ENDPOINT`, else huggingface.co.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface HfRepo {
|
|
8
|
+
id: string;
|
|
9
|
+
downloads: number;
|
|
10
|
+
likes: number;
|
|
11
|
+
/** ISO timestamp of the last commit; null when the listing omits it. */
|
|
12
|
+
updatedAt: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface HfFile {
|
|
16
|
+
name: string;
|
|
17
|
+
/** Sum over shards; null when the listing carries no sizes. */
|
|
18
|
+
bytes: number | null;
|
|
19
|
+
/** Quantization tag read from the file name; null when the name carries none. */
|
|
20
|
+
tag: string | null;
|
|
21
|
+
/** What `POST /models` takes to pull this file: `repo:tag`, or the repository alone. */
|
|
22
|
+
pull: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface HfOptions {
|
|
26
|
+
fetchImpl?: typeof fetch;
|
|
27
|
+
env?: NodeJS.ProcessEnv;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_ENDPOINT = 'https://huggingface.co';
|
|
31
|
+
const SHARD_RE = /-(\d{5})-of-\d{5}\.gguf$/i;
|
|
32
|
+
const QUANT_RE = /^(?:i?q\d[a-z0-9_]*|tq\d[a-z0-9_]*|bf16|f16|f32|f64|mxfp4[a-z0-9_]*)$/i;
|
|
33
|
+
|
|
34
|
+
export function hfEndpoint(env: NodeJS.ProcessEnv = process.env): string {
|
|
35
|
+
return (env.MODEL_ENDPOINT || env.HF_ENDPOINT || DEFAULT_ENDPOINT).replace(/\/+$/, '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function getJson(url: string, fetchImpl: typeof fetch): Promise<unknown> {
|
|
39
|
+
const res = await fetchImpl(url, { headers: { Accept: 'application/json' } });
|
|
40
|
+
if (!res.ok) throw new Error(`GET ${url} ${res.status}`);
|
|
41
|
+
return res.json();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The `limit` most-downloaded matches; without one the listing returns up to a thousand rows. */
|
|
45
|
+
export async function searchGguf(query: string, limit: number, options: HfOptions = {}): Promise<HfRepo[]> {
|
|
46
|
+
const params = new URLSearchParams({ search: query, filter: 'gguf', sort: 'downloads', direction: '-1', limit: String(limit) });
|
|
47
|
+
// The plain listing omits lastModified; `expand` names every field wanted back.
|
|
48
|
+
for (const field of ['downloads', 'likes', 'lastModified']) params.append('expand[]', field);
|
|
49
|
+
const rows = await getJson(`${hfEndpoint(options.env)}/api/models?${params}`, options.fetchImpl ?? fetch);
|
|
50
|
+
if (!Array.isArray(rows)) throw new Error('HuggingFace search returned no list');
|
|
51
|
+
const repos: HfRepo[] = [];
|
|
52
|
+
for (const row of rows as Array<Record<string, unknown>>) {
|
|
53
|
+
const id = typeof row.id === 'string' ? row.id : typeof row.modelId === 'string' ? row.modelId : null;
|
|
54
|
+
if (!id) continue;
|
|
55
|
+
repos.push({
|
|
56
|
+
id,
|
|
57
|
+
downloads: typeof row.downloads === 'number' ? row.downloads : 0,
|
|
58
|
+
likes: typeof row.likes === 'number' ? row.likes : 0,
|
|
59
|
+
updatedAt: typeof row.lastModified === 'string' ? row.lastModified : null,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return repos;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The quantization tag in a GGUF file name: the last `-` or `.` separated token that names one, with an `UD-` prefix kept. */
|
|
66
|
+
export function quantTag(filename: string): string | null {
|
|
67
|
+
const stem = filename.replace(SHARD_RE, '.gguf').replace(/\.gguf$/i, '');
|
|
68
|
+
const tokens = stem.split(/[-.]/);
|
|
69
|
+
for (let i = tokens.length - 1; i >= 0; i--) {
|
|
70
|
+
if (!QUANT_RE.test(tokens[i])) continue;
|
|
71
|
+
return (tokens[i - 1]?.toUpperCase() === 'UD' ? 'UD-' : '') + tokens[i];
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** GGUF files of a repository, shards folded into one entry, projector files (`mmproj-*`) left out. */
|
|
77
|
+
export async function listGguf(repo: string, options: HfOptions = {}): Promise<HfFile[]> {
|
|
78
|
+
const info = await getJson(`${hfEndpoint(options.env)}/api/models/${repo.split('/').map(encodeURIComponent).join('/')}?blobs=true`, options.fetchImpl ?? fetch) as { siblings?: unknown };
|
|
79
|
+
if (!Array.isArray(info.siblings)) throw new Error('HuggingFace repository listing carries no files');
|
|
80
|
+
const files = new Map<string, HfFile>();
|
|
81
|
+
for (const sibling of info.siblings as Array<Record<string, unknown>>) {
|
|
82
|
+
const path = typeof sibling.rfilename === 'string' ? sibling.rfilename : '';
|
|
83
|
+
if (!/\.gguf$/i.test(path) || /(^|\/)mmproj/i.test(path)) continue;
|
|
84
|
+
const name = path.replace(SHARD_RE, '.gguf');
|
|
85
|
+
const lfs = sibling.lfs as { size?: unknown } | undefined;
|
|
86
|
+
const size = typeof sibling.size === 'number' ? sibling.size : typeof lfs?.size === 'number' ? lfs.size : null;
|
|
87
|
+
const entry = files.get(name);
|
|
88
|
+
if (entry) { if (size !== null && entry.bytes !== null) entry.bytes += size; continue; }
|
|
89
|
+
const tag = quantTag(name);
|
|
90
|
+
files.set(name, { name, bytes: size, tag, pull: tag ? `${repo}:${tag}` : repo });
|
|
91
|
+
}
|
|
92
|
+
return [...files.values()];
|
|
93
|
+
}
|
|
@@ -14,6 +14,8 @@ const zh = {
|
|
|
14
14
|
notManaged: '这条端点没有开启托管',
|
|
15
15
|
runtimeNotInstalled: '运行时还没装好',
|
|
16
16
|
runtimeUnsupported: '本机平台没有这个后端的官方构建,填自备运行时目录',
|
|
17
|
+
endpointPanel: '服务地址',
|
|
18
|
+
endpointPanelDescription: 'llama-server 监听的地址;托管的进程按它启动。',
|
|
17
19
|
runtimePanel: '运行时',
|
|
18
20
|
runtimePanelDescription: 'llama.cpp 官方二进制的下载、安装与 llama-server 进程的启停。',
|
|
19
21
|
modelsPanel: '模型',
|
|
@@ -22,6 +24,9 @@ const zh = {
|
|
|
22
24
|
unknownMethod: '未知操作',
|
|
23
25
|
instanceNameRequired: '需要端点名',
|
|
24
26
|
modelIdRequired: '需要模型 id',
|
|
27
|
+
queryRequired: '需要搜索词',
|
|
28
|
+
limitRequired: '需要结果条数',
|
|
29
|
+
repoRequired: '需要仓库名',
|
|
25
30
|
bodyRequired: '需要请求体',
|
|
26
31
|
serverUnreachable: "llama-server 不可达;请检查端点地址与服务状态。",
|
|
27
32
|
externalServer: '端点已有服务在跑(外部启动),不接管',
|
|
@@ -51,6 +56,8 @@ const en: typeof zh = {
|
|
|
51
56
|
notManaged: 'This endpoint is not managed',
|
|
52
57
|
runtimeNotInstalled: 'The runtime is not installed yet',
|
|
53
58
|
runtimeUnsupported: 'No official build for this backend on this platform; set an own runtime directory',
|
|
59
|
+
endpointPanel: 'Server address',
|
|
60
|
+
endpointPanelDescription: 'The address llama-server listens on; a hosted process starts on it.',
|
|
54
61
|
runtimePanel: 'Runtime',
|
|
55
62
|
runtimePanelDescription: 'Download and install the official llama.cpp binaries; start and stop the llama-server process.',
|
|
56
63
|
modelsPanel: 'Models',
|
|
@@ -59,6 +66,9 @@ const en: typeof zh = {
|
|
|
59
66
|
unknownMethod: 'Unknown action',
|
|
60
67
|
instanceNameRequired: 'An endpoint name is required',
|
|
61
68
|
modelIdRequired: 'A model id is required',
|
|
69
|
+
queryRequired: 'A search query is required',
|
|
70
|
+
limitRequired: 'A result count is required',
|
|
71
|
+
repoRequired: 'A repository is required',
|
|
62
72
|
bodyRequired: 'A request body is required',
|
|
63
73
|
serverUnreachable: "llama-server is unreachable; check the endpoint URL and server status.",
|
|
64
74
|
externalServer: 'A server is already running at the endpoint (started externally); not taking over',
|
|
@@ -139,6 +149,22 @@ const panelZh = {
|
|
|
139
149
|
reload: '重扫',
|
|
140
150
|
pull: '拉取',
|
|
141
151
|
pullPlaceholder: 'HuggingFace 仓库,如 ggml-org/Qwen2.5-Coder-7B-Instruct-GGUF',
|
|
152
|
+
use: '选用',
|
|
153
|
+
useTitle: '把这个模型填进「模型与生成」',
|
|
154
|
+
search: '搜索',
|
|
155
|
+
searchPlaceholder: '搜 HuggingFace 上的 GGUF 仓库,如 qwen3',
|
|
156
|
+
searchFailed: (reason: string) => `搜索失败: ${reason}`,
|
|
157
|
+
noRepos: '没有匹配的仓库。',
|
|
158
|
+
repo: '仓库',
|
|
159
|
+
downloads: '下载',
|
|
160
|
+
likes: '点赞',
|
|
161
|
+
updated: '更新',
|
|
162
|
+
showFiles: '看文件',
|
|
163
|
+
filesOf: (repo: string) => `${repo} 里的 GGUF 文件`,
|
|
164
|
+
noFiles: '这个仓库里没有 GGUF 文件。',
|
|
165
|
+
file: '文件',
|
|
166
|
+
size: '大小',
|
|
167
|
+
tag: '标签',
|
|
142
168
|
localDir: '本机 GGUF 目录',
|
|
143
169
|
cacheDir: '缓存目录',
|
|
144
170
|
noModels: '暂无可用的 GGUF 模型:可通过上方输入拉取,或将 .gguf 文件放入本机目录后点击重扫。',
|
|
@@ -213,6 +239,22 @@ const panelEn: typeof panelZh = {
|
|
|
213
239
|
reload: 'Rescan',
|
|
214
240
|
pull: 'Pull',
|
|
215
241
|
pullPlaceholder: 'HuggingFace repo, e.g. ggml-org/Qwen2.5-Coder-7B-Instruct-GGUF',
|
|
242
|
+
use: 'Use',
|
|
243
|
+
useTitle: 'Put this model into the model section',
|
|
244
|
+
search: 'Search',
|
|
245
|
+
searchPlaceholder: 'Search GGUF repositories on HuggingFace, e.g. qwen3',
|
|
246
|
+
searchFailed: (reason: string) => `Search failed: ${reason}`,
|
|
247
|
+
noRepos: 'No matching repositories.',
|
|
248
|
+
repo: 'Repository',
|
|
249
|
+
downloads: 'Downloads',
|
|
250
|
+
likes: 'Likes',
|
|
251
|
+
updated: 'Updated',
|
|
252
|
+
showFiles: 'Files',
|
|
253
|
+
filesOf: (repo: string) => `GGUF files in ${repo}`,
|
|
254
|
+
noFiles: 'This repository has no GGUF files.',
|
|
255
|
+
file: 'File',
|
|
256
|
+
size: 'Size',
|
|
257
|
+
tag: 'Tag',
|
|
216
258
|
localDir: 'Local GGUF directory',
|
|
217
259
|
cacheDir: 'Cache directory',
|
|
218
260
|
noModels: 'No models yet: pull one above, or drop GGUF files into the local directory and rescan.',
|
package/src/providers/name.ts
CHANGED
|
@@ -6,3 +6,8 @@ export function validateProviderName(name: string): string | null {
|
|
|
6
6
|
return 'This name is reserved by Windows.';
|
|
7
7
|
return null;
|
|
8
8
|
}
|
|
9
|
+
|
|
10
|
+
/** 密钥变量名缺省时的默认名:按端点名派生,`-` 折成 `_`,与 validateEntry 的环境变量名格式一致。 */
|
|
11
|
+
export function defaultSecretName(name: string): string {
|
|
12
|
+
return `CORTICO_KEY_${name.toUpperCase().replace(/-/g, '_')}`;
|
|
13
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { ConsolePageContribution } from '../../../web/shared/console-protocol.ts';
|
|
8
8
|
import { PROBE_MAX_OUTPUT_TOKENS, type ProviderConsoleHost } from '../../console/types.ts';
|
|
9
|
+
import { connectionBlocks } from '../../console/config.ts';
|
|
9
10
|
import { getByPath, type ConfigGroup, type ConfigValues } from '../../../core/config-schema.ts';
|
|
10
11
|
import type { LLMProviderEntry, ModelSpec } from '../../../core/types.ts';
|
|
11
12
|
import { GenerationError, type Generation, type ResponseClient } from '../../../core/generation.ts';
|
|
@@ -103,9 +104,16 @@ export function compatConsole(host: ProviderConsoleHost): Partial<ConsolePageCon
|
|
|
103
104
|
if (verdict) host.save(name, { ...entry, options: { ...entry.options, reasoningReplay: verdict } });
|
|
104
105
|
return { verdict, bare, withReasoning };
|
|
105
106
|
};
|
|
107
|
+
const blocks = connectionBlocks(host.language);
|
|
106
108
|
return {
|
|
107
109
|
config: [],
|
|
108
|
-
panels: [
|
|
110
|
+
panels: [
|
|
111
|
+
blocks.endpoint,
|
|
112
|
+
blocks.model,
|
|
113
|
+
{ id: 'reasoning', title: S.reasoningPanel, description: S.reasoningPanelDescription },
|
|
114
|
+
blocks.pricing,
|
|
115
|
+
blocks.protocol,
|
|
116
|
+
],
|
|
109
117
|
invoke: async (panel, method, args) => {
|
|
110
118
|
if (panel !== 'reasoning') throw new Error(S.unknownPanel);
|
|
111
119
|
const name = body(args);
|
|
@@ -28,8 +28,8 @@ export interface ResponsesProviderOptions {
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Send reasoning only when an effort is supplied; otherwise leave it to the endpoint.
|
|
31
|
-
* Requests set store=false
|
|
32
|
-
*
|
|
31
|
+
* Requests set store=false, so replay is local and follows `reasoningReplay`; encrypted replay asks
|
|
32
|
+
* for the signed blocks back through `include`. Endpoint extraBody fields are merged last.
|
|
33
33
|
*/
|
|
34
34
|
export function buildResponsesBody(
|
|
35
35
|
request: Request,
|
|
@@ -41,10 +41,15 @@ export function buildResponsesBody(
|
|
|
41
41
|
const body: Item = {
|
|
42
42
|
...request,
|
|
43
43
|
input: replay.input,
|
|
44
|
-
include: [...new Set([...(request.include ?? []), 'reasoning.encrypted_content'])],
|
|
45
44
|
store: false,
|
|
46
45
|
stream: Boolean(options.onEvent),
|
|
47
46
|
};
|
|
47
|
+
// 加密形态靠 include 把签名推理块取回来,明文形态用不上它;端点不支持 include 时整条请求被拒,
|
|
48
|
+
// 所以只在用得上的形态带。
|
|
49
|
+
const include = new Set<string>(request.include ?? []);
|
|
50
|
+
if (input.reasoningReplay !== 'plaintext') include.add('reasoning.encrypted_content');
|
|
51
|
+
if (include.size) body.include = [...include];
|
|
52
|
+
else delete body.include;
|
|
48
53
|
delete body.previous_response_id;
|
|
49
54
|
if (replay.instructions !== undefined) body.instructions = replay.instructions;
|
|
50
55
|
else delete body.instructions;
|
|
@@ -2,16 +2,15 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import type { Request } from '../protocol/open-responses/index.ts';
|
|
3
3
|
import type { LLMProviderEntry } from '../core/types.ts';
|
|
4
4
|
import type { Language } from '../core/language.ts';
|
|
5
|
-
import type { PriceSnapshot, PriceRule } from '../core/generation.ts';
|
|
5
|
+
import type { PriceSnapshot, PriceRule, PriceTable, PriceWindow } from '../core/generation.ts';
|
|
6
6
|
import { text } from './strings.ts';
|
|
7
7
|
|
|
8
|
-
export interface PriceDefinition {
|
|
8
|
+
export interface PriceDefinition extends PriceTable {
|
|
9
9
|
models: string[];
|
|
10
10
|
currency: string;
|
|
11
11
|
basis: 'marginal' | 'equivalent';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
serviceTiers?: Record<string, { rules: PriceRule[]; inputBands?: Array<{ from: number; rules: PriceRule[] }> }>;
|
|
12
|
+
/** A module declares its own time-of-day windows; `validatePrices` does not take them from an endpoint. */
|
|
13
|
+
timeWindows?: PriceWindow[];
|
|
15
14
|
source: string;
|
|
16
15
|
}
|
|
17
16
|
export interface QuoteTime { startedAt: string; requestedServiceTier: string | null; }
|
|
@@ -60,6 +59,7 @@ export function validatePrices(value: unknown, language: Language = 'zh'): Price
|
|
|
60
59
|
if (typeof raw.currency !== 'string' || !raw.currency.trim()) throw new Error(S.currencyRequired);
|
|
61
60
|
if (raw.basis !== 'marginal' && raw.basis !== 'equivalent') throw new Error(S.basisValue);
|
|
62
61
|
if (typeof raw.source !== 'string' || !raw.source.trim()) throw new Error(S.sourceRequired);
|
|
62
|
+
if (raw.timeWindows !== undefined) throw new Error(S.timeWindowsUnsupported);
|
|
63
63
|
const tiers: PriceDefinition['serviceTiers'] = {};
|
|
64
64
|
if (raw.serviceTiers !== undefined) {
|
|
65
65
|
if (!raw.serviceTiers || typeof raw.serviceTiers !== 'object' || Array.isArray(raw.serviceTiers)) throw new Error(S.tiersObject);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { URL, fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
4
|
import type { LLMProviderEntry } from '../core/types.ts';
|
|
@@ -56,6 +56,8 @@ export class ProviderRegistry {
|
|
|
56
56
|
private readonly entries: () => Record<string, LLMProviderEntry>,
|
|
57
57
|
private readonly host: ProviderHostBase,
|
|
58
58
|
private readonly modules: readonly ProviderModule[] = providerModules,
|
|
59
|
+
/** Secrets read before the process environment and the endpoint `.env`; preview registries carry the typed, unsaved key here. */
|
|
60
|
+
private readonly secretOverrides: Readonly<Record<string, string>> = {},
|
|
59
61
|
) {}
|
|
60
62
|
|
|
61
63
|
private module(kind: string): ProviderModule {
|
|
@@ -64,9 +66,16 @@ export class ProviderRegistry {
|
|
|
64
66
|
return module;
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* A registry over one detached entry. Its instances never enter the live cache; `secrets`
|
|
71
|
+
* are the values the console holds but has not written to the endpoint's `.env`.
|
|
72
|
+
*/
|
|
73
|
+
previewRegistry(name: string, entry: LLMProviderEntry, secrets: Readonly<Record<string, string>> = {}): ProviderRegistry {
|
|
74
|
+
return new ProviderRegistry(() => ({ [name]: entry }), this.host, this.modules, secrets);
|
|
75
|
+
}
|
|
76
|
+
|
|
68
77
|
preview(name: string, entry: LLMProviderEntry): ProviderInstance {
|
|
69
|
-
return
|
|
78
|
+
return this.previewRegistry(name, entry).resolve(name);
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
resolve(name: string): ProviderInstance {
|
|
@@ -75,16 +84,20 @@ export class ProviderRegistry {
|
|
|
75
84
|
const module = this.module(raw.kind);
|
|
76
85
|
const entry = module.normalize?.(structuredClone(raw)) ?? structuredClone(raw);
|
|
77
86
|
const { pricing: _pricing, spec: _spec, ...transportEntry } = entry;
|
|
78
|
-
|
|
87
|
+
// 密钥在实例创建时定格,`.env` 的内容指纹进缓存键:文件变更后下一次解析重建实例。
|
|
88
|
+
const stateDir = join(this.host.stateRoot, name);
|
|
89
|
+
const envFile = join(stateDir, '.env');
|
|
90
|
+
const fingerprint = existsSync(envFile) ? createHash('sha256').update(readFileSync(envFile)).digest('hex') : '';
|
|
91
|
+
const key = JSON.stringify([transportEntry, fingerprint]);
|
|
79
92
|
const previous = this.instances.get(name);
|
|
80
93
|
if (previous?.key === key) return previous.value;
|
|
81
94
|
// 按端点名分岔的两样在这里填:模块自己的目录,与只读那个目录的密钥链。
|
|
82
|
-
const { stateRoot, ...base } = this.host;
|
|
83
|
-
const
|
|
95
|
+
const { stateRoot: _stateRoot, ...base } = this.host;
|
|
96
|
+
const stored = secretReader(envFile);
|
|
84
97
|
const value = module.create(name, entry, {
|
|
85
98
|
...base,
|
|
86
99
|
stateDir,
|
|
87
|
-
secret:
|
|
100
|
+
secret: (key) => this.secretOverrides[key] ?? stored(key),
|
|
88
101
|
currentEntry: () =>
|
|
89
102
|
module.normalize?.(structuredClone(this.entries()[name])) ?? this.entries()[name],
|
|
90
103
|
resource: <T>(resource: string, create: () => T): T => {
|
|
@@ -124,11 +137,7 @@ export class ProviderRegistry {
|
|
|
124
137
|
return client;
|
|
125
138
|
}
|
|
126
139
|
|
|
127
|
-
/**
|
|
128
|
-
* Drop the cached instance so the next resolve rebuilds it; `host.resource` objects
|
|
129
|
-
* survive. Secrets are read once per instance, so a key written to the endpoint's `.env`
|
|
130
|
-
* takes effect only through this.
|
|
131
|
-
*/
|
|
140
|
+
/** Drop the cached instance so the next resolve rebuilds it; `host.resource` objects survive. */
|
|
132
141
|
invalidate(name: string): void {
|
|
133
142
|
this.instances.delete(name);
|
|
134
143
|
}
|
package/src/providers/strings.ts
CHANGED
|
@@ -31,6 +31,7 @@ const zh = {
|
|
|
31
31
|
tiersObject: '服务档报价必须是对象',
|
|
32
32
|
tierNameRequired: '服务档名不能为空',
|
|
33
33
|
tierRules: '服务档报价需要规则对象',
|
|
34
|
+
timeWindowsUnsupported: '分时段价目由 provider 扩展在模块价目里声明,端点价目不接受 timeWindows',
|
|
34
35
|
noModel: '未选模型',
|
|
35
36
|
noSecret: (name: string) => `缺少密钥 ${name}`,
|
|
36
37
|
};
|
|
@@ -65,6 +66,7 @@ const en: typeof zh = {
|
|
|
65
66
|
tiersObject: 'Service tier pricing must be an object',
|
|
66
67
|
tierNameRequired: 'Service tier name cannot be empty',
|
|
67
68
|
tierRules: 'Service tier pricing needs a rules object',
|
|
69
|
+
timeWindowsUnsupported: 'Time-of-day pricing is declared by a provider extension in its module quote; endpoint pricing does not accept timeWindows',
|
|
68
70
|
noModel: 'No model selected',
|
|
69
71
|
noSecret: (name: string) => `Missing secret ${name}`,
|
|
70
72
|
};
|