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
package/package.json
CHANGED
package/src/boot.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { existsSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
+
import type { CoreConfig, LLMProviderEntry } from './core/types.ts';
|
|
7
8
|
|
|
8
9
|
export const RESTART_FLAG_FILE = '.restart-request';
|
|
9
10
|
|
|
@@ -62,3 +63,13 @@ export function consoleUrlOf(bound: string | null, port: number): string {
|
|
|
62
63
|
const host = bound === null || listensOnEveryInterface(bound) ? '127.0.0.1' : bound.includes(':') ? `[${bound}]` : bound;
|
|
63
64
|
return `http://${host}:${port}/`;
|
|
64
65
|
}
|
|
66
|
+
|
|
67
|
+
/** 空选择允许进入控制台完成配置;非空名称必须引用已有连接。 */
|
|
68
|
+
export function providerAtBoot(config: Pick<CoreConfig, 'activeProvider' | 'providers'>): LLMProviderEntry | undefined {
|
|
69
|
+
if (!config.activeProvider) return undefined;
|
|
70
|
+
const entry = config.providers[config.activeProvider];
|
|
71
|
+
if (!entry) {
|
|
72
|
+
throw new Error(`activeProvider="${config.activeProvider}" 在 providers 段里不存在(现有: ${Object.keys(config.providers).join(' / ') || '无'})`);
|
|
73
|
+
}
|
|
74
|
+
return entry;
|
|
75
|
+
}
|
package/src/core/README.md
CHANGED
|
@@ -34,6 +34,9 @@ Core 管理 session、事件流与模型调用的生命周期,包括事件投
|
|
|
34
34
|
`sessionDecls`;`spawnFork`、`resolveBlob` / `internBlobs`、`setWorldVisible`、`activeSpec` /
|
|
35
35
|
`activeProviderEntry`、`mountWorld` / `unmountWorld`、`start` / `stop`。
|
|
36
36
|
|
|
37
|
+
`WorldHost.modelFacts` 每次调用按当前端点读取。`activeProvider` 为空或端点没有模型时 `accepts`
|
|
38
|
+
返回 false,`activeProvider` 指向不存在的端点时抛错。
|
|
39
|
+
|
|
37
40
|
Persona 通过 `CoreApi` 访问:`injectInternal` / `injectDeferred` / `injectExternal`、
|
|
38
41
|
`requestContextHandoff`、`spawnFork`、`sessionInfo`、`llm`、`timers`、`deliveryGate`、
|
|
39
42
|
`personaState` / `savePersonaState`、`toolsTagged`、`blob`、`log`。
|
|
@@ -62,6 +65,9 @@ piggyback 只入队,随后续唤醒一起投递。
|
|
|
62
65
|
退回总线,进入下一批。控制台的前缀重载与清空 session 在批次边界执行:正在处理批次时等该批
|
|
63
66
|
结束,空闲时立即;并发请求复用同一事务。
|
|
64
67
|
|
|
68
|
+
一批正文归档后,Core 调用 `Persona.onDelivery` 并等待它返回的 Promise,不设期限。完成前
|
|
69
|
+
`injectInternal` 的内容排在这批的内部行末尾、外部正文之前;完成后的注入进入总线。
|
|
70
|
+
|
|
65
71
|
状态 0、429 或 5xx 的模型调用失败,可保留已记录的输出和工具回执,按 `ResubmitPolicy` 重试。
|
|
66
72
|
默认允许连续重试 2 次、每批最多 4 次,退避为 2 秒、10 秒;上下文超限、抢占、关机或轮数
|
|
67
73
|
达到硬上限时不重试。
|
|
@@ -88,3 +94,5 @@ handler 异常转为失败回执。流式生成时 `EagerDispatch` 可提前执
|
|
|
88
94
|
warn;`onHandoff` 异常记录 error 并使用默认交接策略。其他钩子的异常由调用方处理。
|
|
89
95
|
读取 `console()` 失败时省略该 World 的环境段,继续构建前缀。主循环异常退出时记录 error
|
|
90
96
|
并停止定时器。启动器处理未捕获的进程异常并执行关机流程。
|
|
97
|
+
|
|
98
|
+
`instanceIsRunning(dataDir)` 只读检查实例锁对应进程是否存活,沿用锁接管时的陈旧记录判定,不获取或更改锁。
|
package/src/core/core.ts
CHANGED
|
@@ -468,8 +468,10 @@ export class Core<C extends CoreConfig = CoreConfig> {
|
|
|
468
468
|
return {
|
|
469
469
|
model: () => spec().model,
|
|
470
470
|
accepts: (mime) => {
|
|
471
|
+
if (!this.loaded.config.activeProvider) return false;
|
|
471
472
|
const {entry} = this.activeProviderEntry();
|
|
472
|
-
|
|
473
|
+
if (!entry.spec) return false;
|
|
474
|
+
return providerModule(entry.kind).accepts?.(entry,entry.spec,mime) ?? (entry.multimodal === true && mime.startsWith('image/'));
|
|
473
475
|
},
|
|
474
476
|
contextWindow: () => this.contextWindowOf(spec()),
|
|
475
477
|
};
|
package/src/core/generation.ts
CHANGED
|
@@ -16,13 +16,26 @@ export interface TokenMeters {
|
|
|
16
16
|
}
|
|
17
17
|
export type Meter = Exclude<keyof TokenMeters, 'native' | 'details'> | `detail:${string}`;
|
|
18
18
|
export interface PriceRule { meter: Meter; perMillion: number; unit?: string; }
|
|
19
|
-
export interface
|
|
19
|
+
export interface PriceSchedule { rules: PriceRule[]; inputBands?: Array<{ from: number; rules: PriceRule[] }>; }
|
|
20
|
+
export interface PriceTable extends PriceSchedule { serviceTiers?: Record<string, PriceSchedule>; }
|
|
21
|
+
/**
|
|
22
|
+
* `from` and `to` are `HH:MM` on the clock of `timezone`, half-open; `to` at or before `from` spans midnight.
|
|
23
|
+
* A span belongs to the local date it starts on. `weekdays` (ISO, 1 = Monday to 7 = Sunday) limits the dates
|
|
24
|
+
* that open it; `exceptDates` (`YYYY-MM-DD`) removes single dates.
|
|
25
|
+
*/
|
|
26
|
+
export interface PriceWindow extends PriceTable {
|
|
27
|
+
from: string;
|
|
28
|
+
to: string;
|
|
29
|
+
timezone: string;
|
|
30
|
+
weekdays?: number[];
|
|
31
|
+
exceptDates?: string[];
|
|
32
|
+
}
|
|
33
|
+
export interface PriceSnapshot extends PriceTable {
|
|
20
34
|
id: string;
|
|
21
35
|
currency: string;
|
|
22
36
|
basis: 'marginal' | 'equivalent';
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
serviceTiers?: Record<string, { rules: PriceRule[]; inputBands?: Array<{ from: number; rules: PriceRule[] }> }>;
|
|
37
|
+
/** The first window holding `capturedAt` replaces the table above it; outside every window that table applies. */
|
|
38
|
+
timeWindows?: PriceWindow[];
|
|
26
39
|
source: string;
|
|
27
40
|
capturedAt: string;
|
|
28
41
|
}
|
|
@@ -30,7 +43,7 @@ export interface Charge {
|
|
|
30
43
|
quote: PriceSnapshot;
|
|
31
44
|
amount: number | null;
|
|
32
45
|
knownAmount: number;
|
|
33
|
-
missing: Array<Meter | 'serviceTier'>;
|
|
46
|
+
missing: Array<Meter | 'serviceTier' | 'timeWindow'>;
|
|
34
47
|
lines: Array<{ meter: Meter; unit: string; quantity: number | null; perMillion: number; amount: number | null }>;
|
|
35
48
|
}
|
|
36
49
|
export interface ProviderAttempt {
|
|
@@ -89,13 +102,57 @@ export function standardUsage(meters: TokenMeters): Usage | null {
|
|
|
89
102
|
return { input_tokens: input, output_tokens: output, total_tokens: total,
|
|
90
103
|
input_tokens_details: { cached_tokens: cachedInput }, output_tokens_details: { reasoning_tokens: reasoning } };
|
|
91
104
|
}
|
|
105
|
+
/** Minutes since midnight for an `HH:MM` bound, 24:00 included; null when the text is not one. */
|
|
106
|
+
function clockMinutes(value: string): number | null {
|
|
107
|
+
const match = /^(\d{1,2}):(\d{2})$/.exec(value);
|
|
108
|
+
if (!match) return null;
|
|
109
|
+
const minutes = Number(match[1]) * 60 + Number(match[2]);
|
|
110
|
+
return Number(match[2]) > 59 || minutes > 24 * 60 ? null : minutes;
|
|
111
|
+
}
|
|
112
|
+
/** Whether the text is a real calendar date written `YYYY-MM-DD`. */
|
|
113
|
+
function isCalendarDate(value: string): boolean {
|
|
114
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
115
|
+
if (!match) return false;
|
|
116
|
+
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
117
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
118
|
+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
|
119
|
+
}
|
|
120
|
+
/** Whether the instant falls in the window on the window's own clock and date; null when a field is unreadable. */
|
|
121
|
+
function holdsTime(window: PriceWindow, at: string): boolean | null {
|
|
122
|
+
const instant = new Date(at);
|
|
123
|
+
const [from, to] = [clockMinutes(window.from), clockMinutes(window.to)];
|
|
124
|
+
if (from === null || to === null || Number.isNaN(instant.getTime())) return null;
|
|
125
|
+
if (window.weekdays?.some(day => !Number.isInteger(day) || day < 1 || day > 7)) return null;
|
|
126
|
+
if (window.exceptDates?.some(date => !isCalendarDate(date))) return null;
|
|
127
|
+
let year: number, month: number, day: number, local: number;
|
|
128
|
+
try {
|
|
129
|
+
// Intl throws on a zone name it does not know.
|
|
130
|
+
const parts = new Intl.DateTimeFormat('en-GB', { timeZone: window.timezone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(instant);
|
|
131
|
+
const read = (type: string): number => Number(parts.find(part => part.type === type)!.value);
|
|
132
|
+
[year, month, day] = [read('year'), read('month'), read('day')];
|
|
133
|
+
local = read('hour') * 60 + read('minute');
|
|
134
|
+
} catch { return null; }
|
|
135
|
+
const spansMidnight = to <= from;
|
|
136
|
+
if (spansMidnight ? local < from && local >= to : local < from || local >= to) return false;
|
|
137
|
+
// After midnight the span still belongs to the previous date, the one it started on.
|
|
138
|
+
const start = new Date(Date.UTC(year, month - 1, spansMidnight && local < to ? day - 1 : day));
|
|
139
|
+
if (window.weekdays && !window.weekdays.includes(start.getUTCDay() || 7)) return false;
|
|
140
|
+
return !window.exceptDates?.includes(start.toISOString().slice(0, 10));
|
|
141
|
+
}
|
|
92
142
|
export function priceUsage(meters: TokenMeters, quotes: readonly PriceSnapshot[], serviceTier: string | null = null): Charge[] {
|
|
93
143
|
return quotes.map(quote => {
|
|
94
144
|
const missing: Charge['missing'] = [];
|
|
95
|
-
let
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
145
|
+
let table: PriceTable = quote;
|
|
146
|
+
for (const window of quote.timeWindows ?? []) {
|
|
147
|
+
const holds = holdsTime(window, quote.capturedAt);
|
|
148
|
+
// An unreadable window may be the one holding the request; a later table cannot stand in for it.
|
|
149
|
+
if (holds === null) { missing.push('timeWindow'); break; }
|
|
150
|
+
if (holds) { table = window; break; }
|
|
151
|
+
}
|
|
152
|
+
let schedule: PriceSchedule = table;
|
|
153
|
+
if (table.serviceTiers) {
|
|
154
|
+
if (serviceTier === null || !table.serviceTiers[serviceTier]) missing.push('serviceTier');
|
|
155
|
+
else schedule = table.serviceTiers[serviceTier];
|
|
99
156
|
}
|
|
100
157
|
let rules = schedule.rules;
|
|
101
158
|
if (schedule.inputBands?.length) {
|
|
@@ -72,6 +72,12 @@ function readPayload(file: string): LockPayload | null {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** 只读检查数据目录的锁是否仍对应存活进程。 */
|
|
76
|
+
export function instanceIsRunning(dataDir: string): boolean {
|
|
77
|
+
const payload = readPayload(join(dataDir, INSTANCE_LOCK_FILE));
|
|
78
|
+
return payload !== null && ownerStillRunning(payload);
|
|
79
|
+
}
|
|
80
|
+
|
|
75
81
|
/** 排他创建;仅 EEXIST 返回 false,其他错误抛出。 */
|
|
76
82
|
function createExclusive(file: string, payload: LockPayload): boolean {
|
|
77
83
|
let fd: number;
|
package/src/core/loop.ts
CHANGED
|
@@ -251,7 +251,7 @@ export class MainLoop {
|
|
|
251
251
|
/** 当前正在处理一个事件批;手动交接必须等到该批自然结束,不能重置半轮session。 */
|
|
252
252
|
private processingBatch = false;
|
|
253
253
|
private handoffRequested = false;
|
|
254
|
-
/** onDelivery
|
|
254
|
+
/** onDelivery 执行期间(含其 Promise 完成前),injectInternal 的即时项加入当前批,不经过总线。 */
|
|
255
255
|
private deliveryCollector: EventEnvelope[] | null = null;
|
|
256
256
|
/** 已投递但前面仍有外部缺口的游标;水位只越过连续前缀。 */
|
|
257
257
|
private readonly deliveredCursors = new Set<number>();
|
|
@@ -484,7 +484,7 @@ export class MainLoop {
|
|
|
484
484
|
/**
|
|
485
485
|
* 将一批事件写入主 session。即时事件在前,候选生成内容与延迟渲染内容在后。
|
|
486
486
|
* 候选按 source、origin 和处理函数分组,再按来源项在批次中的顺序生成正文。
|
|
487
|
-
*
|
|
487
|
+
* 正文归档后调用并等待 onDelivery;钩子完成前注入的内部项追加到内部行末尾、外部正文之前。
|
|
488
488
|
* 内部行合成一条 user 消息;外部正文按 eventDelivery 进入合成工具回执或同一条 user 消息。
|
|
489
489
|
*/
|
|
490
490
|
private async deliverBatch(batch: WakeItem[], generation: number): Promise<boolean> {
|
|
@@ -537,11 +537,13 @@ export class MainLoop {
|
|
|
537
537
|
}
|
|
538
538
|
if (!this.active(generation)) return false;
|
|
539
539
|
if (delivered.length > 0) {
|
|
540
|
-
//
|
|
540
|
+
// 捕获钩子完成前的即时注入;钩子完成后恢复总线投递。
|
|
541
541
|
const injected: EventEnvelope[] = [];
|
|
542
542
|
this.deliveryCollector = injected;
|
|
543
543
|
try {
|
|
544
|
-
|
|
544
|
+
// 同步钩子不经过 await,收集范围仍只是钩子本身的执行期。
|
|
545
|
+
const pending = persona.onDelivery?.({ events: [...delivered] });
|
|
546
|
+
if (pending) await pending;
|
|
545
547
|
} catch (e) {
|
|
546
548
|
log.warn('onDelivery钩子异常', { err: e });
|
|
547
549
|
} finally {
|
|
@@ -1663,7 +1665,7 @@ export class MainLoop {
|
|
|
1663
1665
|
return this.stallAt.filter((t) => t >= from).length;
|
|
1664
1666
|
}
|
|
1665
1667
|
|
|
1666
|
-
/** 注入 Persona 提供的内部文本。onDelivery
|
|
1668
|
+
/** 注入 Persona 提供的内部文本。onDelivery 完成前加入当前批,其余时刻进入总线。 */
|
|
1667
1669
|
injectInternal(text: string, kind = 'notice'): void {
|
|
1668
1670
|
if (!this.activeNow()) return;
|
|
1669
1671
|
const item = this.internalItem('persona', kind, text);
|
package/src/core/secrets.ts
CHANGED
|
@@ -3,16 +3,14 @@ import { existsSync } from 'node:fs';
|
|
|
3
3
|
import { readTextFile } from './util.ts';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
6
|
+
* 每次优先读取非空进程环境变量;否则现读文件,缺失返回空串。
|
|
7
7
|
* 文件值读取到首个空白字符,不解析引号。
|
|
8
8
|
*/
|
|
9
9
|
export function secretReader(file: string): (name: string) => string {
|
|
10
|
-
let text: string | null = null;
|
|
11
10
|
return (name: string): string => {
|
|
12
11
|
const fromEnv = process.env[name];
|
|
13
12
|
if (fromEnv) return fromEnv;
|
|
14
|
-
|
|
15
|
-
const m = new RegExp(`^\\s*${name}\\s*=\\s*(\\S+)`, 'm').exec(text);
|
|
13
|
+
const m = new RegExp(`^\\s*${name}\\s*=\\s*(\\S+)`, 'm').exec(existsSync(file) ? readTextFile(file) : '');
|
|
16
14
|
return m ? m[1] : '';
|
|
17
15
|
};
|
|
18
16
|
}
|
package/src/core/types.ts
CHANGED
|
@@ -551,6 +551,7 @@ export interface DeliveryGateApi {
|
|
|
551
551
|
/** 运行时查询当前模型的名称、MIME 支持和上下文窗口;配置热改后读取新值。 */
|
|
552
552
|
export interface ModelFacts {
|
|
553
553
|
model(): string;
|
|
554
|
+
/** 未选端点或端点未选模型时返回 false;activeProvider 指向不存在的端点时抛错。 */
|
|
554
555
|
accepts(mime: string): boolean;
|
|
555
556
|
/** provider 探测值与手动配置取较小者,单位 token;两者均未知时返回 undefined。 */
|
|
556
557
|
contextWindow(): number | undefined;
|
|
@@ -910,9 +911,11 @@ export interface Persona {
|
|
|
910
911
|
onOpening?(ctx: { reason: SessionOpeningReason }): void;
|
|
911
912
|
/**
|
|
912
913
|
* 一批事件已渲染并分配游标、尚未进入上下文时调用;events 按投递序包含内部和外部事件。
|
|
913
|
-
*
|
|
914
|
+
* 返回 Promise 时 Core 等它完成再投递,不设期限:钩子不完成,主循环不前进,钩子自己发起的
|
|
915
|
+
* 外部调用由 Persona 负责超时。完成前 Persona 的所有 injectInternal 都加入当前批,排在已有
|
|
916
|
+
* 内部行之后、外部正文之前。钩子抛错或拒绝时 Core 记录 warn,带着已注入的内容照常投递。
|
|
914
917
|
*/
|
|
915
|
-
onDelivery?(ctx: { events: EventEnvelope[] }): void
|
|
918
|
+
onDelivery?(ctx: { events: EventEnvelope[] }): void | Promise<void>;
|
|
916
919
|
/**
|
|
917
920
|
* 一批事件处理结束时调用;Persona 可查询 sessionInfo 并决定是否请求交接。
|
|
918
921
|
* Core 在超过 hardTokens 时强制交接。
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 在临时部署中检查扩展构造与声明接口,不调用 start()。
|
|
3
3
|
* World 使用默认配置、无密钥,检查 create、tools、envPromptVars、console、工具名冲突与配置路径;
|
|
4
|
-
* provider 使用测试端点 create
|
|
4
|
+
* provider 使用测试端点 create 并检查配置路径,bot 使用测试部署 build。
|
|
5
5
|
* World 构造失败为错误;provider 仅在绑定端点时构造,测试条目不完整导致的失败记为警告。
|
|
6
6
|
* 临时文件位于 scratchDir,调用方负责创建与清理。
|
|
7
7
|
*/
|
|
@@ -40,17 +40,18 @@ const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
|
|
40
40
|
const message = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
43
|
+
* 声明过的每个配置路径都要落在 prefix 指的段内;给了 `defaults` 时还要在里面有对应项。
|
|
44
44
|
* 缺默认值时控制台照样渲染旋钮、也照样写回 config.json,而读到的是代码里另一处的兜底值。
|
|
45
|
+
* provider 的旋钮写进端点条目而不是 `defaults()`,所以只核对段。
|
|
45
46
|
*/
|
|
46
|
-
function configPathProblems(group: ConfigGroup,
|
|
47
|
+
function configPathProblems(group: ConfigGroup, prefix: string, defaults?: Record<string, unknown>): string[] {
|
|
47
48
|
const problems: string[] = [];
|
|
48
49
|
for (const path of Object.keys(group.schema.properties ?? {})) {
|
|
49
50
|
if (!path.startsWith(prefix)) {
|
|
50
51
|
problems.push(`配置组「${group.id}」声明的「${path}」不在 ${prefix} 段里:写回按路径走,值会落到别人的段上。`);
|
|
51
52
|
continue;
|
|
52
53
|
}
|
|
53
|
-
if (getByPath(defaults, path.slice(prefix.length)) === undefined) {
|
|
54
|
+
if (defaults && getByPath(defaults, path.slice(prefix.length)) === undefined) {
|
|
54
55
|
problems.push(`配置组「${group.id}」声明的「${path}」在 defaults() 里没有对应项:旋钮能改、能写回 config.json,读到的仍是代码里的兜底值。`);
|
|
55
56
|
}
|
|
56
57
|
}
|
|
@@ -209,7 +210,7 @@ export async function dryMountWorld(def: WorldDefinition<WorldSection>, opts: Wo
|
|
|
209
210
|
}
|
|
210
211
|
for (const group of decl.config ?? []) {
|
|
211
212
|
if (group.owner !== `world:${def.id}`) warnings.push(`配置组「${group.id}」的 owner 是「${group.owner}」,World 的配置组 owner 应为 world:${def.id}。`);
|
|
212
|
-
failures.push(...configPathProblems(group,
|
|
213
|
+
failures.push(...configPathProblems(group, `worlds.${def.id}.`, defaults));
|
|
213
214
|
}
|
|
214
215
|
const keys = new Set<string>();
|
|
215
216
|
for (const doc of decl.promptDocs ?? []) {
|
|
@@ -233,6 +234,9 @@ export interface ProviderDryMountOptions extends DryMountOptions {
|
|
|
233
234
|
hasConsoleClient?: boolean;
|
|
234
235
|
}
|
|
235
236
|
|
|
237
|
+
/** 假端点的名字;模块声明的配置路径按它组成 `providers.<端点名>.options.`。 */
|
|
238
|
+
const PROBE_ENDPOINT = 'check';
|
|
239
|
+
|
|
236
240
|
export function dryMountProvider(mod: ProviderModule, opts: ProviderDryMountOptions): DryMountReport {
|
|
237
241
|
const report: DryMountReport = { ok: [], warnings: [], failures: [] };
|
|
238
242
|
const { ok, warnings, failures } = report;
|
|
@@ -257,7 +261,7 @@ export function dryMountProvider(mod: ProviderModule, opts: ProviderDryMountOpti
|
|
|
257
261
|
return report;
|
|
258
262
|
}
|
|
259
263
|
|
|
260
|
-
const stateDir = join(opts.scratchDir, 'providers',
|
|
264
|
+
const stateDir = join(opts.scratchDir, 'providers', PROBE_ENDPOINT);
|
|
261
265
|
mkdirSync(stateDir, { recursive: true });
|
|
262
266
|
const resources = new Map<string, unknown>();
|
|
263
267
|
const host = {
|
|
@@ -274,7 +278,7 @@ export function dryMountProvider(mod: ProviderModule, opts: ProviderDryMountOpti
|
|
|
274
278
|
log: nullLogger(),
|
|
275
279
|
};
|
|
276
280
|
try {
|
|
277
|
-
const instance = mod.create(
|
|
281
|
+
const instance = mod.create(PROBE_ENDPOINT, entry, host);
|
|
278
282
|
if (typeof instance?.client?.respond !== 'function') {
|
|
279
283
|
failures.push('create() 返回的实例没有 client.respond():Core 只经它调模型。');
|
|
280
284
|
} else {
|
|
@@ -286,11 +290,14 @@ export function dryMountProvider(mod: ProviderModule, opts: ProviderDryMountOpti
|
|
|
286
290
|
|
|
287
291
|
if (mod.config) {
|
|
288
292
|
try {
|
|
289
|
-
const groups = mod.config(
|
|
293
|
+
const groups = mod.config(PROBE_ENDPOINT, entry, language);
|
|
290
294
|
if (!Array.isArray(groups)) failures.push('config() 没有返回数组。');
|
|
291
295
|
else {
|
|
292
296
|
for (const group of groups) {
|
|
293
297
|
if (group.owner !== `provider:${mod.id}`) warnings.push(`配置组「${group.id}」的 owner 是「${group.owner}」,provider 的配置组 owner 应为 provider:${mod.id}。`);
|
|
298
|
+
// 写回的三道闸都只按 `providers.<端点名>.` 过滤:段外的路径改不动,段内 options 之外的
|
|
299
|
+
// 路径覆盖框架自己编辑的字段(baseUrl、secret、multimodal、spec)。两类都拦在这里。
|
|
300
|
+
failures.push(...configPathProblems(group, `providers.${PROBE_ENDPOINT}.options.`));
|
|
294
301
|
}
|
|
295
302
|
ok.push(`config(): ${groups.length} 个配置组。`);
|
|
296
303
|
}
|
|
@@ -405,7 +412,7 @@ export function dryMountBot(def: BotDefinition<CoreConfig>, opts: BotDryMountOpt
|
|
|
405
412
|
|
|
406
413
|
for (const group of parts.console?.configGroups ?? []) {
|
|
407
414
|
if (group.owner !== 'persona') warnings.push(`配置组「${group.id}」的 owner 是「${group.owner}」,Persona 的配置组 owner 应为 persona。`);
|
|
408
|
-
failures.push(...configPathProblems(group,
|
|
415
|
+
failures.push(...configPathProblems(group, '', config));
|
|
409
416
|
}
|
|
410
417
|
return report;
|
|
411
418
|
}
|
package/src/launcher.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { createBot } from './bot.ts';
|
|
|
13
13
|
import { createDeployment, ensureDeployment, listBots, loadDeployment } from './deploy.ts';
|
|
14
14
|
import { buildListing, type BotDefaults } from './deploy-listing.ts';
|
|
15
15
|
import { secretReader } from './core/secrets.ts';
|
|
16
|
-
import { announceDataDir, consoleUrlOf, consumeBootFlags, listensOnEveryInterface, startsPaused } from './boot.ts';
|
|
16
|
+
import { announceDataDir, consoleUrlOf, consumeBootFlags, listensOnEveryInterface, startsPaused, providerAtBoot } from './boot.ts';
|
|
17
17
|
import { extensionsDir, importBotDefinition, loadExtensions, locateBotPackage, readInstalled, type ActiveBotPackage } from './extensions.ts';
|
|
18
18
|
import { withWorlds } from './world.ts';
|
|
19
19
|
import { BUILTIN_WORLDS } from './worlds/index.ts';
|
|
@@ -179,16 +179,10 @@ async function main(): Promise<void> {
|
|
|
179
179
|
// 重启标志必须在装配与 session 加载之前处理
|
|
180
180
|
consumeBootFlags(loaded.dataDir);
|
|
181
181
|
|
|
182
|
-
const activeProvider = cfg
|
|
183
|
-
|
|
184
|
-
console.error(
|
|
185
|
-
`activeProvider="${cfg.activeProvider}" 在 providers 段里不存在(现有: ${Object.keys(cfg.providers ?? {}).join(' / ') || '无'})`,
|
|
186
|
-
);
|
|
187
|
-
process.exit(1);
|
|
188
|
-
}
|
|
189
|
-
const endpointDir = providerDir(cfg.activeProvider);
|
|
182
|
+
const activeProvider = providerAtBoot(cfg);
|
|
183
|
+
const endpointDir = activeProvider ? providerDir(cfg.activeProvider) : null;
|
|
190
184
|
const missingSecret =
|
|
191
|
-
activeProvider
|
|
185
|
+
activeProvider?.secret && endpointDir && !secretReader(resolve(endpointDir, '.env'))(activeProvider.secret)
|
|
192
186
|
? activeProvider.secret
|
|
193
187
|
: null;
|
|
194
188
|
|
|
@@ -204,9 +198,9 @@ async function main(): Promise<void> {
|
|
|
204
198
|
const consoleUrl = port === null ? null : consoleUrlOf(bot.webApp?.boundAddress ?? null, port);
|
|
205
199
|
if (port !== null) {
|
|
206
200
|
console.log(` 控制台: ${consoleUrl}${listensOnEveryInterface(bot.webApp?.boundAddress ?? null) ? `(监听 ${bot.webApp?.boundAddress},所有网卡)` : ''}`);
|
|
207
|
-
const assetsProblem = webAssetsProblem(fileURLToPath(new URL('
|
|
201
|
+
const assetsProblem = webAssetsProblem(fileURLToPath(new URL('..', import.meta.url)));
|
|
208
202
|
if (assetsProblem) {
|
|
209
|
-
console.log(` ⚠
|
|
203
|
+
console.log(` ⚠ 控制台产物需要重建(${assetsProblem});停止 bot 后运行 pnpm build:web`);
|
|
210
204
|
}
|
|
211
205
|
}
|
|
212
206
|
for (const slot of bot.assembly.slots) {
|
|
@@ -222,8 +216,8 @@ async function main(): Promise<void> {
|
|
|
222
216
|
? ` 扩展: ${ext.name}@${ext.version} · bot 包,本部署未引用`
|
|
223
217
|
: ` 扩展: ${ext.name} 未加载: ${ext.reason}`);
|
|
224
218
|
}
|
|
225
|
-
console.log(` 主模型: ${
|
|
226
|
-
if (missingSecret) {
|
|
219
|
+
console.log(` 主模型: ${activeProvider?.spec?.model || '未配置;请在控制台「模型供应商」页选择连接与模型'}`);
|
|
220
|
+
if (missingSecret && endpointDir) {
|
|
227
221
|
const envFile = resolve(endpointDir, '.env');
|
|
228
222
|
const where = existsSync(envFile) ? `${envFile} 里也没有` : `${envFile} 不存在`;
|
|
229
223
|
console.log(` ⚠ 缺少 ${missingSecret}:进程环境里没有,${where};可在控制台「模型提供商」页修改密钥变量名或补填密钥`);
|
|
@@ -12,7 +12,9 @@ export function withoutPastReasoning(entries: readonly ContextRecord[]): Context
|
|
|
12
12
|
}
|
|
13
13
|
export function responseRequest(spec: ModelSpec, context: readonly ContextRecord[], tools: readonly ToolSchema[] = []): Request {
|
|
14
14
|
return {
|
|
15
|
-
model: spec.model, input: context.map(inputItem),
|
|
15
|
+
model: spec.model, input: context.map(inputItem),
|
|
16
|
+
// 只有协议声明的成员进线;运行时传进来的对象还带着分类标签,它归控制台与工具装配。
|
|
17
|
+
tools: tools.map(({ name, description, parameters }) => ({ type: 'function' as const, name, description, parameters })),
|
|
16
18
|
// effort 词表归端点(见 ModelSpec.reasoningEffort);协议枚举只覆盖 OpenAI 自己的取值。
|
|
17
19
|
reasoning: spec.thinking ? { ...(spec.reasoningEffort ? { effort: spec.reasoningEffort as Schemas['ReasoningEffortEnum'] } : {}) } : { effort: 'none' },
|
|
18
20
|
...(spec.temperature !== undefined ? { temperature: spec.temperature } : {}),
|
package/src/providers/README.md
CHANGED
|
@@ -28,16 +28,17 @@ effort 收任意非空串)、`serviceTiers`、`create(name, entry, host)`。可
|
|
|
28
28
|
(附加配置组与面板)、`prices()`、`estimateTokens()`、`contextOverflow()`。
|
|
29
29
|
|
|
30
30
|
地址、密钥变量名与图像开关不归模块:框架在 `console/config.ts` 里为每个端点声明这一组,
|
|
31
|
-
扩展来的模块照样有。模块自己的 `options.*` 走 `config()`
|
|
31
|
+
扩展来的模块照样有。模块自己的 `options.*` 走 `config()` 的配置组,模块的面板作为端点编辑页的段落
|
|
32
32
|
用控制台的 schema 渲染器画同一份声明、经 `ctx.setConfig` 暂存到连接草稿——内建 llamacpp 的运行时与启动
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
两段走的是这条。段落顺序由 `console()` 的 `panels` 定:编辑页自带的四块(`console/config.ts` 的
|
|
34
|
+
`connectionBlocks`)与模块自己的面板排成一列。`console()` 显式给空 `config` 表示这一页不另开配置页签,
|
|
35
|
+
声明仍参与服务端校验。启停与模型操作这类动作走面板 invoke。
|
|
35
36
|
|
|
36
37
|
`create()` 返回 `ProviderInstance`:`client`(实现 `respond`)、`listModels?`、`control?`、
|
|
37
38
|
`compatibilityKey?`、`start?` / `stop?`、`contextWindow?(model)`。
|
|
38
39
|
|
|
39
40
|
`ProviderHost` 给实例:`stateDir`(`<部署根>/providers/<端点名>/`,归实例独占)、`repoRoot`、
|
|
40
|
-
`resource()`、`currentEntry()`、`secret(name)`(
|
|
41
|
+
`resource()`、`currentEntry()`、`secret(name)`(进程环境优先,否则现读 `stateDir/.env`)、
|
|
41
42
|
`readBlob()`、`keepThinking()`、`log`。
|
|
42
43
|
|
|
43
44
|
## 注册与解析
|
|
@@ -46,10 +47,11 @@ effort 收任意非空串)、`serviceTiers`、`create(name, entry, host)`。可
|
|
|
46
47
|
之前通过 `registerProviderModules()` 注册,id 重复时抛错。
|
|
47
48
|
|
|
48
49
|
`ProviderRegistry.resolve(name)`:按 `entry.kind` 找模块,`normalize`,以去掉 `pricing` 与
|
|
49
|
-
`spec` 的条目 JSON
|
|
50
|
+
`spec` 的条目 JSON 加端点 `.env` 的内容指纹为缓存键(改模型或价格不重建实例,密钥文件变更后
|
|
51
|
+
下一次解析重建),填 `stateDir` 与 `secret`。
|
|
50
52
|
`bind(name)` 在实例外包一层:注入 `quote`(报价快照)与 `origin`(实例、模块、模型、
|
|
51
53
|
`compatibilityDomain` = sha256(kind + baseUrl + `compatibilityKey()`))。Responses 历史推理仅在实例、
|
|
52
|
-
模块、兼容域与模型均匹配时回传。`invalidate()`
|
|
54
|
+
模块、兼容域与模型均匹配时回传。`invalidate()` 清除缓存,控制台保存端点后调用;`.env` 变更经缓存键在下一次解析生效。
|
|
53
55
|
|
|
54
56
|
## 配置形状
|
|
55
57
|
|
|
@@ -64,8 +66,9 @@ Core 侧:`activeProviderEntry()` / `activeSpec()` 每次现读;`contextWindowOf(
|
|
|
64
66
|
## transport
|
|
65
67
|
|
|
66
68
|
`response-http.ts` 处理 HTTP/SSE:一次生成可包含多次请求尝试,重试间隔为 `[1s, 4s, 10s]`;超时
|
|
67
|
-
四档(流式首包 300s、非流式 120s、帧空闲 120s、内容空闲 300s);只对状态 0 / 429 / 5xx
|
|
68
|
-
401 / 403 先 `transport.refresh()`
|
|
69
|
+
四档(流式首包 300s、非流式 120s、帧空闲 120s、内容空闲 300s);只对状态 0 / 408 / 429 / 5xx
|
|
70
|
+
重试,响应带 `Retry-After` 时按它退避,401 / 403 先 `transport.refresh()`
|
|
71
|
+
一次;已提交不可逆增量后不再重试;输出字符超过
|
|
69
72
|
`max_output_tokens × 12` 时终止请求并报告超限;终态只接受 `completed` / `incomplete`,`failed` 抛
|
|
70
73
|
`LLMError`;每次请求尝试记录 `meters` 与 `charges`。
|
|
71
74
|
|
|
@@ -107,4 +110,8 @@ Core 侧:`activeProviderEntry()` / `activeSpec()` 每次现读;`contextWindowOf(
|
|
|
107
110
|
重读共享配置时跟上。
|
|
108
111
|
|
|
109
112
|
`ProviderConsoleHost.editing` 表示这个实例来自还没保存的表单,模块据此拒绝运行时副作用,
|
|
110
|
-
`host.save` 把配置交回浏览器暂存。`ProviderRegistry.
|
|
113
|
+
`host.save` 把配置交回浏览器暂存。`ProviderRegistry.previewRegistry` 按一份条目造注册表,实例不进
|
|
114
|
+
运行实例缓存,浏览器刚输入的密钥作为覆盖值排在进程环境与端点 `.env` 之前;测试与模型列表带
|
|
115
|
+
`{ entry, secretValue }` 时走它。
|
|
116
|
+
|
|
117
|
+
连接列表的 usage 包含其他部署的选用记录及运行锁状态,排除当前部署。运行状态按部署配置的 paths.data(缺省使用 Core 默认值)检查实例锁,不代表供应商正在处理请求,也不建立供应商独占锁。
|
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
import type { ConfigGroup } from '../../core/config-schema.ts';
|
|
2
2
|
import type { LLMProviderEntry } from '../../core/types.ts';
|
|
3
3
|
import type { Language } from '../../core/language.ts';
|
|
4
|
+
import type { ConsolePanelDecl } from '../../web/shared/console-protocol.ts';
|
|
4
5
|
import { text } from './strings.ts';
|
|
5
6
|
|
|
7
|
+
/**
|
|
8
|
+
* The editor's own sections with their default wording. A module lists them among its panels,
|
|
9
|
+
* in the order its workflow wants, and may override title and description.
|
|
10
|
+
*/
|
|
11
|
+
export function connectionBlocks(language: Language): Record<'endpoint' | 'model' | 'pricing' | 'protocol', ConsolePanelDecl> {
|
|
12
|
+
const S = text(language);
|
|
13
|
+
return {
|
|
14
|
+
endpoint: { id: 'endpoint', title: S.endpointBlock, description: S.endpointBlockDescription, builtin: 'connection-endpoint' },
|
|
15
|
+
model: { id: 'model', title: S.modelBlock, description: S.modelBlockDescription, builtin: 'connection-model' },
|
|
16
|
+
pricing: { id: 'pricing', title: S.pricingBlock, builtin: 'connection-pricing' },
|
|
17
|
+
protocol: { id: 'protocol', title: S.protocolBlock, description: S.protocolBlockDescription, builtin: 'connection-protocol' },
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
6
21
|
export function connectionGroup(name: string, entry: LLMProviderEntry, language: Language): ConfigGroup {
|
|
7
22
|
const S = text(language);
|
|
8
23
|
const prefix = `providers.${name}.`;
|
|
@@ -2,18 +2,20 @@ import { coerceGroupValues, getByPath, setByPath } from '../../core/config-schem
|
|
|
2
2
|
/** Connection configuration transactions serialize writers across deployments and roll back all touched files on failure. */
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
-
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
6
6
|
import type { CoreConfig, LLMProviderEntry } from '../../core/types.ts';
|
|
7
7
|
import type { Language } from '../../core/language.ts';
|
|
8
8
|
import { readJsonObject, updateJsonObject } from '../../config-file.ts';
|
|
9
9
|
import { readTextFile } from '../../core/util.ts';
|
|
10
10
|
import type { ProviderModule } from '../base.ts';
|
|
11
11
|
import { validateEntry } from '../configuration.ts';
|
|
12
|
-
import { validateProviderName } from '../name.ts';
|
|
12
|
+
import { validateProviderName, defaultSecretName } from '../name.ts';
|
|
13
13
|
import { providerModules, type ProviderRegistry } from '../registry.ts';
|
|
14
14
|
import type { ProviderSettings } from './settings.ts';
|
|
15
15
|
import { quotePrices } from '../pricebook.ts';
|
|
16
16
|
import { connectionGroup } from './config.ts';
|
|
17
|
+
import { instanceIsRunning } from '../../core/instance-lock.ts';
|
|
18
|
+
import { CORE_DEFAULTS } from '../../core/config.ts';
|
|
17
19
|
|
|
18
20
|
export class ProviderHubError extends Error {
|
|
19
21
|
constructor(message: string, readonly status = 400) { super(message); }
|
|
@@ -63,8 +65,15 @@ export class ProviderHub {
|
|
|
63
65
|
reasoningTiers: module.localize?.(language)?.reasoningTiers ?? module.reasoningTiers,
|
|
64
66
|
effortSuggestions: module.effortSuggestions ?? [], serviceTiers: module.localize?.(language)?.serviceTiers ?? module.serviceTiers,
|
|
65
67
|
temperatureNote: module.localize?.(language)?.temperatureNote ?? module.temperatureNote,
|
|
68
|
+
sections: this.sections(module.id, language),
|
|
66
69
|
}));
|
|
67
70
|
}
|
|
71
|
+
/** The editor's sections for one module, in order: the page's panels minus the endpoint table. */
|
|
72
|
+
private sections(kind: string, language: Language) {
|
|
73
|
+
const source = this.settings.sources().find(source => source.id === `llm:${kind}`);
|
|
74
|
+
return (source?.contribute(language).panels ?? []).filter(panel => panel.id !== 'settings')
|
|
75
|
+
.map(({ id, title, description, builtin }) => ({ id, title, ...(description ? { description } : {}), ...(builtin ? { builtin } : {}) }));
|
|
76
|
+
}
|
|
68
77
|
private readiness(name: string, entry: LLMProviderEntry, language: Language) {
|
|
69
78
|
const module = this.modules.find(m => m.id === entry.kind);
|
|
70
79
|
if (!module) return { state: 'module-missing', reason: language === 'zh' ? '供应商模块不可用。' : 'Provider module is unavailable.' };
|
|
@@ -89,6 +98,11 @@ export class ProviderHub {
|
|
|
89
98
|
id: name, name, module: entry.kind, moduleTitle: this.modules.find(m => m.id === entry.kind)?.title ?? entry.kind,
|
|
90
99
|
model: entry.spec?.model ?? null, baseUrl: entry.baseUrl, active: name === this.config.activeProvider,
|
|
91
100
|
readiness: this.readiness(name, entry, language), revision: this.revision(name),
|
|
101
|
+
usage: this.references(name).filter(file => resolve(file) !== resolve(this.file)).map(file => {
|
|
102
|
+
const config = readJsonObject(file);
|
|
103
|
+
const paths = config.paths as { data?: string } | undefined;
|
|
104
|
+
return { name: basename(dirname(file)), running: instanceIsRunning(resolve(dirname(file), paths?.data ?? CORE_DEFAULTS.paths.data)) };
|
|
105
|
+
}),
|
|
92
106
|
})) };
|
|
93
107
|
}
|
|
94
108
|
detail(name: string, language: Language) {
|
|
@@ -171,11 +185,8 @@ export class ProviderHub {
|
|
|
171
185
|
if (prior && prior.kind !== input.entry.kind) throw new ProviderHubError('Provider module cannot be changed.');
|
|
172
186
|
const module = this.modules.find(m => m.id === input.entry.kind);
|
|
173
187
|
if (!module) throw new ProviderHubError('Provider module is unavailable.');
|
|
174
|
-
const requested = structuredClone(input.entry);
|
|
188
|
+
const requested = withTypedSecret(name, structuredClone(input.entry), input.secretValue);
|
|
175
189
|
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
190
|
const entry = validateEntry(module, requested, language);
|
|
180
191
|
const prefix = `providers.${name}.`;
|
|
181
192
|
for (const group of this.groups(name, entry, language)) {
|
|
@@ -259,12 +270,38 @@ export class ProviderHub {
|
|
|
259
270
|
rmSync(tomb, { recursive: true, force: true });
|
|
260
271
|
});
|
|
261
272
|
}
|
|
262
|
-
|
|
273
|
+
/**
|
|
274
|
+
* Probe (`test`) or list models (`models`). With `draft`, the request goes through an instance
|
|
275
|
+
* built from the browser's entry and typed key; nothing is written, and `name` may not exist yet.
|
|
276
|
+
*/
|
|
277
|
+
async action(name: string, action: 'test' | 'models', language: Language, draft?: { entry: LLMProviderEntry; secretValue?: string }) {
|
|
263
278
|
this.refresh();
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
279
|
+
if (!draft) {
|
|
280
|
+
const entry = this.config.providers[name];
|
|
281
|
+
if (!entry) throw new ProviderHubError('Provider does not exist.', 404);
|
|
282
|
+
const source = this.settings.sources().find(source => source.id === `llm:${entry.kind}`);
|
|
283
|
+
if (!source) throw new ProviderHubError('Provider module is unavailable.');
|
|
284
|
+
return source.contribute(language).invoke!('settings', action === 'test' ? 'probe' : 'models', [{ name }]);
|
|
285
|
+
}
|
|
286
|
+
this.path(name);
|
|
287
|
+
if (!draft.entry || typeof draft.entry !== 'object') throw new ProviderHubError('Provider configuration is required.');
|
|
288
|
+
const module = this.modules.find(m => m.id === draft.entry.kind);
|
|
289
|
+
if (!module) throw new ProviderHubError('Provider module is unavailable.');
|
|
290
|
+
const entry = validateEntry(module, withTypedSecret(name, draft.entry, draft.secretValue), language);
|
|
291
|
+
const registry = this.registry.previewRegistry(name, entry, entry.secret && draft.secretValue ? { [entry.secret]: draft.secretValue } : {});
|
|
292
|
+
if (action === 'models') {
|
|
293
|
+
const instance = registry.resolve(name);
|
|
294
|
+
if (!instance.listModels) throw new ProviderHubError('This module does not list models.');
|
|
295
|
+
return { models: await instance.listModels() };
|
|
296
|
+
}
|
|
297
|
+
if (!entry.spec) throw new ProviderHubError('Model is required.');
|
|
298
|
+
return this.settings.probeClient(registry.bind(name), entry.spec, language);
|
|
269
299
|
}
|
|
270
300
|
}
|
|
301
|
+
|
|
302
|
+
/** A typed key with no variable name declared is stored under the name derived from the endpoint name. */
|
|
303
|
+
function withTypedSecret(name: string, entry: LLMProviderEntry, secretValue: string | undefined): LLMProviderEntry {
|
|
304
|
+
if (secretValue !== undefined && (typeof secretValue !== 'string' || !secretValue || /\s/.test(secretValue)))
|
|
305
|
+
throw new ProviderHubError('API Key must be nonempty and contain no whitespace.');
|
|
306
|
+
return secretValue && !entry.secret ? { ...entry, secret: defaultSecretName(name) } : entry;
|
|
307
|
+
}
|