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.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/src/boot.ts +11 -0
  3. package/src/core/README.md +8 -0
  4. package/src/core/core.ts +3 -1
  5. package/src/core/generation.ts +66 -9
  6. package/src/core/instance-lock.ts +6 -0
  7. package/src/core/loop.ts +7 -5
  8. package/src/core/secrets.ts +2 -4
  9. package/src/core/types.ts +5 -2
  10. package/src/extensions/dry-mount.ts +16 -9
  11. package/src/extensions/manifest.ts +1 -1
  12. package/src/launcher.ts +8 -14
  13. package/src/protocol/open-responses/context-helpers.ts +3 -1
  14. package/src/providers/README.md +16 -9
  15. package/src/providers/console/config.ts +15 -0
  16. package/src/providers/console/hub.ts +49 -12
  17. package/src/providers/console/settings.ts +20 -8
  18. package/src/providers/console/strings.ts +14 -0
  19. package/src/providers/llamacpp/console/models-panel.ts +87 -4
  20. package/src/providers/llamacpp/console/server.ts +24 -2
  21. package/src/providers/llamacpp/huggingface.ts +93 -0
  22. package/src/providers/llamacpp/strings.ts +42 -0
  23. package/src/providers/name.ts +5 -0
  24. package/src/providers/openai-responses-compat/console/server.ts +9 -1
  25. package/src/providers/openai-responses-compat/native.ts +8 -3
  26. package/src/providers/pricebook.ts +5 -5
  27. package/src/providers/registry.ts +21 -12
  28. package/src/providers/strings.ts +2 -0
  29. package/src/providers/transport/errors.ts +9 -0
  30. package/src/providers/transport/response-http.ts +7 -3
  31. package/src/web/README.md +3 -0
  32. package/src/web/client/console-pages/host.ts +10 -10
  33. package/src/web/client/features/providers/detail.ts +184 -137
  34. package/src/web/client/features/providers/index.ts +48 -42
  35. package/src/web/client/features/providers/strings.ts +22 -18
  36. package/src/web/client/ui/fields.ts +3 -1
  37. package/src/web/public/styles.css +28 -20
  38. package/src/web/server.ts +4 -2
  39. package/src/web/shared/console-protocol.ts +13 -0
  40. package/src/worlds/minecraft/client-launch.ts +11 -2
  41. package/src/worlds/minecraft/client.ts +4 -0
  42. 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 { ConsoleLamp, ConsolePageContribution } from '../../web/shared/console-protocol.ts';
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 async probe(name: string, language: Language) {
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(entry.spec, [record({ type: 'message', role: 'user', content: 'ping' })]),
209
- max_output_tokens: Math.min(entry.spec.maxTokens ?? PROBE_MAX_OUTPUT_TOKENS, PROBE_MAX_OUTPUT_TOKENS),
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 this.registry.bind(name).respond(request, { diagnostic: true, nativeSpec: entry.spec, role: 'probe' });
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
- ...(extra.panels ?? []),
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, and pulling more.
3
- * The endpoint comes from `ctx.scope.instance`.
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
- /** The pull input survives a re-render: the operator types while the poll redraws the card. */
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.h('div');
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
- { id: 'runtime', title: S.runtimePanel, description: S.runtimePanelDescription, slot: 'instance' },
56
- { id: 'models', title: S.modelsPanel, description: S.modelsPanelDescription, slot: 'instance' },
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.',
@@ -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: [{ id: 'reasoning', title: S.reasoningPanel, description: S.reasoningPanelDescription, slot: 'instance' }],
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 and request encrypted reasoning content for local stateless replay;
32
- * how recorded reasoning is replayed follows `reasoningReplay`. Endpoint extraBody fields are merged last.
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
- rules: PriceRule[];
13
- inputBands?: Array<{ from: number; rules: PriceRule[] }>;
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
- /** Preview instances use a detached entry and never enter the live instance cache. */
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 new ProviderRegistry(() => ({ [name]: entry }), this.host, this.modules).resolve(name);
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
- const key = JSON.stringify(transportEntry);
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 stateDir = join(stateRoot, name);
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: secretReader(join(stateDir, '.env')),
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
  }
@@ -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
  };