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
@@ -46,3 +46,12 @@ export function retryDelay(ms: number, signal?: AbortSignal): Promise<void> {
46
46
  signal.addEventListener('abort', onAbort, { once: true });
47
47
  });
48
48
  }
49
+
50
+ /** `Retry-After` 头换算成毫秒:数字按秒,HTTP 日期按距今;缺失或不成形返回 null,过期为 0。 */
51
+ export function parseRetryAfter(header: string | null): number | null {
52
+ if (!header) return null;
53
+ const value = header.trim();
54
+ if (/^\d+$/.test(value)) return Number(value) * 1000;
55
+ const at = Date.parse(value);
56
+ return Number.isNaN(at) ? null : Math.max(0, at - Date.now());
57
+ }
@@ -3,7 +3,7 @@ import type { Request, Response, StreamEvent } from '../../protocol/open-respons
3
3
  import type { ItemOrigin } from '../../protocol/open-responses/context.ts';
4
4
  import { GenerationError, priceUsage, unknownMeters, type GenerateOptions, type Generation, type ProviderAttempt, type PriceSnapshot, type TokenMeters } from '../../core/generation.ts';
5
5
  import { ResponseProtocolError } from '../../protocol/open-responses/stream.ts';
6
- import { LLMError, abortError, retryDelay, reqIdSuffix } from './errors.ts';
6
+ import { LLMError, abortError, parseRetryAfter, retryDelay, reqIdSuffix } from './errors.ts';
7
7
  import type { ResponseAssembly } from './response-assembly.ts';
8
8
 
9
9
  export interface ResponseTransport {
@@ -67,14 +67,17 @@ export async function generate(request: Request, options: GenerateOptions, origi
67
67
  let lastError: unknown;
68
68
  let partial: Response | null = null;
69
69
  let authRetried = false;
70
+ // 上一次尝试的 `Retry-After`;没有头或解析不成形时按固定间隔退避。
71
+ let retryAfterMs: number | null = null;
70
72
  const delays = options.diagnostic ? [] : [1000, 4000, 10000];
71
73
  const fail = (error: unknown): GenerationError => new GenerationError((error instanceof Error ? error.message : String(error)) + reqIdSuffix(attempts.filter(attempt => attempt.purpose !== 'diagnostic').at(-1)?.requestId), attempts,
72
74
  partial, origin, error instanceof LLMError ? error.status : 0, error instanceof LLMError ? error.body : '', { cause: error });
73
75
  for (let ordinal = 0; ordinal <= delays.length; ordinal++) {
74
76
  try {
75
77
  if (options.signal?.aborted) throw abortError(options.signal);
76
- if (ordinal) await retryDelay(delays[ordinal - 1], options.signal);
78
+ if (ordinal) await retryDelay(retryAfterMs ?? delays[ordinal - 1], options.signal);
77
79
  } catch (error) { throw fail(error); }
80
+ retryAfterMs = null;
78
81
  const attempt: ProviderAttempt = {
79
82
  id: crypto.randomUUID(), generationId, ordinal, origin: structuredClone(origin), startedAt: new Date().toISOString(), elapsedMs: 0,
80
83
  requestId: null, responseId: null, outcome: 'failed', status: null, serviceTier: null, requestedServiceTier: typeof transport.body.service_tier === 'string' ? transport.body.service_tier : request.service_tier ?? null, purpose: options.diagnostic ? 'diagnostic' : 'generation', meters: unknownMeters(), charges: [],
@@ -123,6 +126,7 @@ export async function generate(request: Request, options: GenerateOptions, origi
123
126
  attempt.requestId = response.headers.get('x-request-id');
124
127
  if (!response.ok) {
125
128
  const error = new LLMError(`LLM API ${response.status}`, response.status, await response.text());
129
+ retryAfterMs = parseRetryAfter(response.headers.get('retry-after'));
126
130
  if (!options.diagnostic && (response.status === 401 || response.status === 403) && !authRetried && await transport.refresh()) {
127
131
  authRetried = true;
128
132
  lastError = error;
@@ -176,7 +180,7 @@ export async function generate(request: Request, options: GenerateOptions, origi
176
180
  lastError = error;
177
181
  if (!sent || committed || runaway || (!streaming && error instanceof ResponseProtocolError)) break;
178
182
  const status = error instanceof LLMError ? error.status : 0;
179
- if (status !== 0 && status !== 429 && status < 500) break;
183
+ if (status !== 0 && status !== 408 && status !== 429 && status < 500) break;
180
184
  if (!options.diagnostic && observed && Date.now() - started >= 20000 && !await transport.failure({
181
185
  model: request.model ?? '', role: options.role, elapsedMs: Date.now() - started, status,
182
186
  requestId: attempt.requestId, body: error instanceof LLMError ? error.body : '', message: String(error),
package/src/web/README.md CHANGED
@@ -86,4 +86,7 @@ upgrade 断开;登录态是 HttpOnly Cookie 里的无状态签名令牌(见 [con
86
86
  | `bots/*/console/client.ts` | `persona:<name>` |
87
87
 
88
88
  esbuild 输出分包 ESM 和带 hash 的文件名,写入 `asset-manifest.json`;Tailwind 输出 `styles.css`。
89
+ 清单的 `sources` 记下 esbuild 读到的每个仓库内源文件的 sha256,`bin/web-assets.mjs` 据此判断产物是否落后于源码;依赖包与 Tailwind 的输入不在其中。
89
90
  浏览器代码使用 `tsconfig.web.json` 检查:`pnpm typecheck:web`。
91
+
92
+ 供应商卡片区分当前连接、可连接、配置问题与草稿。其他部署的运行实例选用同一连接时显示实例名并隐藏连接操作;未运行部署的选用记录单独标注。页面每五秒刷新列表,保留详情草稿。
@@ -121,20 +121,20 @@ export class ConsolePageHost {
121
121
  this.emitNav();
122
122
  }
123
123
 
124
- /** Mount module panels inside a connection editor while its adapter stages configuration edits. */
125
- async mountConnection(pageId: string, root: HTMLElement, scope: Readonly<Record<string, string>>,
124
+ /** Mount one of a page's panels inside the connection editor; `adapt` stages the panel's configuration edits. */
125
+ async mountConnection(pageId: string, panelId: string, root: HTMLElement, scope: Readonly<Record<string, string>>,
126
126
  adapt: (context: ConsolePanelContext) => ConsolePanelContext): Promise<Disposable> {
127
127
  const lifecycle = new Lifecycle(this.deps.onError);
128
128
  const page = this.find(pageId);
129
- for (const panel of asArray(page?.panels).filter(panel => panel.id !== 'settings')) {
130
- const box = this.deps.doc.createElement('div'); root.append(box);
131
- try {
132
- const impl = panel.builtin ? this.builtinPanel(panel.builtin) : await this.deps.loader.resolvePanel(pageId, panel.id, page?.client);
133
- if (lifecycle.disposed) break;
134
- const result = await impl.mount(adapt(this.panelContext(pageId, panel.id, box, lifecycle, this.generation, scope)));
129
+ const panel = asArray(page?.panels).find(candidate => candidate.id === panelId);
130
+ try {
131
+ if (!panel) throw new Error(S.noSuchPanel(page?.label ?? pageId, panelId));
132
+ const impl = panel.builtin ? this.builtinPanel(panel.builtin) : await this.deps.loader.resolvePanel(pageId, panel.id, page?.client);
133
+ if (!lifecycle.disposed) {
134
+ const result = await impl.mount(adapt(this.panelContext(pageId, panel.id, root, lifecycle, this.generation, scope)));
135
135
  if (result) lifecycle.own(result);
136
- } catch (error) { box.textContent = String(error); }
137
- }
136
+ }
137
+ } catch (error) { root.textContent = String(error); }
138
138
  return lifecycle;
139
139
  }
140
140
 
@@ -6,33 +6,60 @@ import { validateProviderName } from '../../../../providers/name.ts';
6
6
  import { connectionPath, type Detail, type Editing, type Module } from './types.ts';
7
7
  import { LANGUAGE } from '../../core/language.ts';
8
8
  import { pricingEditor } from '../../console-pages/builtins/llm-settings/pricing-panel.ts';
9
+ import type { Disposable } from '../../../shared/client-panel.ts';
9
10
  import { S } from './strings.ts';
10
11
 
11
- export interface DetailController { dispose(): void; dirty(): boolean; leave(): Promise<boolean>; }
12
+ export interface DetailController { dispose(): void; }
12
13
  interface Options {
13
14
  ctx: FeatureContext; root: HTMLElement; modules: Module[]; saved: Detail | null; draft: Editing | null;
14
- changed(editing: Editing, invalid: boolean): void; saveDraft(editing: Editing): void; onSaved(name: string, select?: boolean): Promise<void>; cancelled(): Promise<void>;
15
- discarded(): void; deleted(): Promise<void>; duplicate(editing: Editing): Promise<void>;
15
+ /** Every edit; `dirty` is false when the form again equals the saved connection. */
16
+ changed(editing: Editing, invalid: boolean, dirty: boolean): void;
17
+ onSaved(name: string, select?: boolean): Promise<void>; cancelled(): Promise<void>;
18
+ deleted(): Promise<void>; duplicate(editing: Editing): Promise<void>;
16
19
  }
20
+ type Section = Module['sections'][number];
21
+ type Spec = NonNullable<Detail['entry']['spec']>;
22
+ /** What a save would write; `raw` only carries field text and never reaches the server. */
23
+ const snapshot = (editing: Editing) => JSON.stringify({ name: editing.name, entry: editing.entry, secretValue: editing.secretValue });
24
+ /** Protocol knobs the editor keeps under the protocol block; the rest of a module's scalars belong to its own sections. */
25
+ const PROTOCOL_FIELDS = ['endpointPath', 'extraHeaders', 'extraBody'];
26
+ /**
27
+ * One connection's editor. Sections come from `module.sections` in declared order: a `builtin`
28
+ * names a block drawn here, the rest are the module's own panels, mounted with their edits staged
29
+ * into `editing`.
30
+ */
17
31
  export async function mountDetail(options: Options): Promise<DetailController> {
18
32
  const { ctx, root, saved, modules } = options;
19
33
  const { ui } = ctx;
20
34
  const lifecycle = new Lifecycle(ctx.onError);
21
35
  const opts = { signal: lifecycle.signal };
22
- const editing: Editing = structuredClone(options.draft ?? { original: saved!.name, name: saved!.name, entry: saved!.entry, revision: saved!.revision, secretValue: '', raw: {} });
36
+ const persisted: Editing | null = saved ? { original: saved.name, name: saved.name, entry: saved.entry, revision: saved.revision, secretValue: '', raw: {} } : null;
37
+ const editing: Editing = structuredClone(options.draft ?? persisted!);
23
38
  editing.entry.spec ??= { model: '', thinking: false };
24
- let baseline = JSON.stringify(editing);
25
- const dirty = () => JSON.stringify(editing) !== baseline;
39
+ if (persisted) (persisted.entry.spec ??= { model: '', thinking: false });
40
+ const dirty = () => !persisted || snapshot(editing) !== snapshot(persisted);
26
41
  const report = ui.msgline();
27
42
  const form = ui.h('div');
28
43
  root.append(form, report);
29
44
  const errors = new Map<string, () => boolean>();
30
45
  let rendering = 0;
31
46
  let saving = false;
32
- let panelHandle: { dispose(): void } | null = null;
47
+ const panelHandles: Disposable[] = [];
33
48
  let panelHost: ReturnType<NonNullable<FeatureContext['consolePageHost']>> | null = null;
34
- const change = () => options.changed(editing, !!form.querySelector('[aria-invalid="true"]'));
49
+ /** The model block's way of showing a spec a module panel changed. */
50
+ let syncSpec: (() => void) | null = null;
51
+ /** A saved connection shows the discard button only while the form differs from what is saved. */
52
+ let cancel: HTMLButtonElement | null = null;
53
+ const change = () => {
54
+ if (cancel) cancel.hidden = !!saved && !dirty();
55
+ options.changed(editing, !!form.querySelector('[aria-invalid="true"]'), dirty());
56
+ };
35
57
  const run = (work: () => Promise<unknown>) => { void work().catch(error => { if (!lifecycle.disposed) report.textContent = String(error); }); };
58
+ /** Preview name for an unsaved connection; the server resolves secrets by it. */
59
+ const identity = saved?.name ?? 'draft';
60
+ /** The probe and the model list run on what the form holds, key included, before anything is saved. */
61
+ const draftBody = () => ({ entry: editing.entry, ...(editing.secretValue ? { secretValue: editing.secretValue } : {}) });
62
+ const disposePanels = () => { for (const handle of panelHandles.splice(0)) handle.dispose(); };
36
63
  function field(body: HTMLElement, key: string, label: string, value: string, set: (value: string) => void, validate?: (value: string) => string | null, type = 'text') {
37
64
  const input = ui.input({ value, type: type as 'text' }); input.setAttribute('aria-label', label);
38
65
  const note = ui.h('div', 'field-error');
@@ -57,206 +84,226 @@ export async function mountDetail(options: Options): Promise<DetailController> {
57
84
  if (editing.raw[key] !== undefined) check();
58
85
  input.addEventListener('input', () => { editing.raw[key] = input.value; check(); change(); }, opts);
59
86
  }
60
- function section(title: string, id?: string, open = false) {
61
- const card = id ? ui.foldSheet('connection-' + id, { title, defaultOpen: open }) : ui.sheet({ title });
62
- form.append(card.el); return card.body;
87
+ function block(box: HTMLElement, section: Section, fold = false) {
88
+ const card = fold ? ui.foldSheet('connection-' + section.id, { title: section.title, desc: section.description }) : ui.sheet({ title: section.title, desc: section.description });
89
+ box.append(card.el); return card.body;
63
90
  }
64
- async function render() {
65
- const gen = ++rendering;
66
- panelHandle?.dispose(); panelHandle = null;
67
- errors.clear(); form.replaceChildren();
68
- const basic = section(S.basic);
69
- field(basic, 'name', S.name, editing.name, value => { editing.name = value; }, value => value === saved?.name ? null : validateProviderName(value) ? S.nameHint : null);
70
- basic.append(ui.msgline(S.nameHint));
71
- const selectedModule = modules.find(module => module.id === editing.entry.kind);
72
- if (saved) {
73
- const module = ui.select({ value: editing.entry.kind, options: [{ value: editing.entry.kind, label: selectedModule?.title ?? editing.entry.kind }], disabled: true });
74
- module.setAttribute('aria-label', S.module); module.classList.add('connection-module-readonly');
75
- basic.append(ui.field(S.module, module), ui.msgline(S.fixedModule));
76
- }
77
- else {
78
- const select = ui.select({ value: editing.entry.kind, options: [{ value: '', label: '—' }, ...modules.map(module => ({ value: module.id, label: module.title }))],
79
- onChange: kind => {
80
- const module = modules.find(module => module.id === kind);
81
- editing.entry = { kind, baseUrl: module?.defaultBaseUrl ?? '', spec: { model: '', thinking: module?.reasoningTiers[0]?.thinking ?? true, ...(module?.reasoningTiers[0]?.effort ? { reasoningEffort: module.reasoningTiers[0].effort } : {}) } };
82
- editing.raw = {}; change(); run(render);
83
- } });
84
- select.setAttribute('aria-label', S.module); basic.append(ui.field(S.module + ' *', select));
85
- const required = ui.h('div', 'field-error'); basic.append(required);
86
- errors.set('module', () => { required.textContent = editing.entry.kind ? '' : S.required; select.setAttribute('aria-invalid', String(!editing.entry.kind)); return !!editing.entry.kind; });
87
- }
88
- if (selectedModule) basic.append(ui.h('p', 'field-note', S.moduleNote(selectedModule.description, selectedModule.id)));
89
- const connection = section(S.connection);
90
- field(connection, 'baseUrl', S.url, editing.entry.baseUrl, value => { editing.entry.baseUrl = value; }, value => {
91
+ /** Reads a dotted path under the entry; writes create the objects on the way. */
92
+ const entryPath = (suffix: string) => {
93
+ const parts = suffix.split('.');
94
+ return {
95
+ get: () => parts.reduce<unknown>((value, part) => (value as Record<string, unknown> | undefined)?.[part], editing.entry),
96
+ set: (value: unknown) => {
97
+ let target = editing.entry as unknown as Record<string, unknown>;
98
+ for (const part of parts.slice(0, -1)) target = (target[part] ??= {}) as Record<string, unknown>;
99
+ if (value === undefined) delete target[parts.at(-1)!]; else target[parts.at(-1)!] = value;
100
+ },
101
+ };
102
+ };
103
+ function endpointBlock(box: HTMLElement, section: Section) {
104
+ const body = block(box, section);
105
+ field(body, 'baseUrl', S.url, editing.entry.baseUrl, value => { editing.entry.baseUrl = value; }, value => {
91
106
  try { const url = new URL(value); return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password ? null : S.required; } catch { return S.required; }
92
107
  });
93
- const key = field(connection, 'key', S.key, editing.secretValue, value => { editing.secretValue = value; }, undefined, 'password');
94
- key.placeholder = saved?.secretConfigured !== 'none' && saved ? S.keySet : S.keyEmpty;
108
+ const key = field(body, 'key', S.key, editing.secretValue, value => { editing.secretValue = value; }, undefined, 'password');
109
+ key.placeholder = saved?.secretConfigured !== 'none' && saved ? '••••••••' : S.keyEmpty;
95
110
  const test = ui.button(S.test, { onClick: () => run(async () => {
96
- if (!saved || dirty()) { report.textContent = S.savedFirst; return; }
97
111
  test.disabled = true;
98
112
  try {
99
- const result = await post<{ ok: boolean; status: number | null; elapsedMs: number; model?: string; error?: string; hint?: string }>(connectionPath(saved.name) + '/test', {}, opts);
113
+ const result = await post<{ ok: boolean; status: number | null; elapsedMs: number; model?: string; error?: string; hint?: string }>(connectionPath(identity) + '/test', draftBody(), opts);
100
114
  report.textContent = result.ok ? `${S.testOk} · HTTP ${result.status ?? '—'} · ${(result.elapsedMs / 1000).toFixed(1)}s · ${result.model ?? ''}` : `${S.testFailed}: ${result.hint ?? result.error ?? ''}`;
101
115
  } finally { test.disabled = false; }
102
- }) }); connection.append(test);
103
- if (saved?.readiness.reason) connection.append(ui.msgline(saved.readiness.reason, true));
104
- const model = section(S.modelSection, 'model', true);
105
- const spec = editing.entry.spec ??= { model: '', thinking: false };
106
- const modelInput = field(model, 'model', S.model, spec.model, value => { spec.model = value; }, value => value.trim() ? null : S.required);
107
- const catalog = ui.h('datalist'); catalog.id = 'connection-models-' + Math.random().toString(36).slice(2); modelInput.setAttribute('list', catalog.id); model.append(catalog);
116
+ }) });
117
+ const testRow = ui.rowbar(); testRow.classList.add('connection-test'); testRow.append(test);
118
+ if (saved?.readiness.reason) testRow.append(ui.msgline(saved.readiness.reason, true));
119
+ body.append(testRow);
120
+ }
121
+ function modelBlock(box: HTMLElement, section: Section, module: Module, spec: Spec) {
122
+ const body = block(box, section);
123
+ const modelInput = field(body, 'model', S.model, spec.model, value => { spec.model = value; }, value => value.trim() ? null : S.required);
124
+ const catalog = ui.h('datalist'); catalog.id = 'connection-models-' + Math.random().toString(36).slice(2); modelInput.setAttribute('list', catalog.id); body.append(catalog);
108
125
  let listedModels: Array<{ id: string; contextWindow?: number }> = [];
109
126
  let contextInput: HTMLInputElement | null = null;
127
+ const windowNote = ui.msgline();
128
+ const noteCatalogWindow = () => {
129
+ if (!listedModels.length) { windowNote.textContent = ''; return; }
130
+ windowNote.textContent = listedModels.find(item => item.id === spec.model)?.contextWindow ? '' : S.catalogNoWindow;
131
+ };
110
132
  modelInput.addEventListener('change', () => {
111
133
  const known = listedModels.find(item => item.id === modelInput.value)?.contextWindow;
112
134
  if (known && contextInput) { spec.contextWindow = known; contextInput.value = String(known); delete editing.raw.contextWindow; change(); }
135
+ noteCatalogWindow();
113
136
  }, opts);
114
137
  const fetch = ui.button(S.fetchModels, { onClick: () => run(async () => {
115
- if (!saved || dirty()) { report.textContent = S.savedFirst; return; }
116
138
  fetch.disabled = true;
117
- try { const result = await post<{ models: Array<{ id: string; contextWindow?: number }> }>(connectionPath(saved.name) + '/models', {}, opts);
139
+ try { const result = await post<{ models: Array<{ id: string; contextWindow?: number }> }>(connectionPath(identity) + '/models', draftBody(), opts);
118
140
  catalog.replaceChildren(...result.models.map(item => { const option = ui.h('option'); option.value = item.id; return option; }));
119
141
  listedModels = result.models;
142
+ noteCatalogWindow();
120
143
  } finally { fetch.disabled = false; }
121
- }) }); model.append(fetch);
122
- const tiers = selectedModule?.reasoningTiers ?? [];
144
+ }) }); body.append(fetch);
145
+ const tiers = module.reasoningTiers;
123
146
  if (tiers.length) {
124
147
  const select = ui.select({ value: tiers.find(tier => tier.thinking === spec.thinking && tier.effort === spec.reasoningEffort)?.id ?? '', options: tiers.map(tier => ({ value: tier.id, label: tier.label })), onChange: value => {
125
148
  const tier = tiers.find(tier => tier.id === value)!; spec.thinking = tier.thinking;
126
149
  if (tier.effort) spec.reasoningEffort = tier.effort; else delete spec.reasoningEffort; change();
127
- } }); select.setAttribute('aria-label', S.reasoning); model.append(ui.field(S.reasoning, select));
128
- } else field(model, 'reasoning', S.reasoning, !spec.thinking ? 'none' : spec.reasoningEffort ?? '', value => {
150
+ } }); select.setAttribute('aria-label', S.reasoning); body.append(ui.field(S.reasoning, select));
151
+ } else field(body, 'reasoning', S.reasoning, !spec.thinking ? 'none' : spec.reasoningEffort ?? '', value => {
129
152
  spec.thinking = value !== 'none'; if (value && value !== 'none') spec.reasoningEffort = value; else delete spec.reasoningEffort;
130
153
  });
131
154
  for (const [name, label] of [['temperature', S.temperature], ['maxTokens', S.maxTokens], ['contextWindow', S.context]] as const) {
132
- const input = field(model, name, label, editing.raw[name] ?? String(spec[name] ?? ''), value => {
155
+ const input = field(body, name, label, editing.raw[name] ?? String(spec[name] ?? ''), value => {
133
156
  editing.raw[name] = value; if (!value) delete spec[name]; else spec[name] = Number(value);
134
157
  }, value => !value || Number.isFinite(Number(value)) && (name === 'temperature' ? Number(value) >= 0 && Number(value) <= 2 : Number.isInteger(Number(value)) && Number(value) > 0) ? null : S.invalidNumber, 'number');
135
158
  if (name === 'contextWindow') contextInput = input;
136
159
  }
137
- if (selectedModule?.serviceTiers.length) {
138
- const select = ui.select({ value: editing.entry.serviceTier ?? '', options: [{ value: '', label: '—' }, ...selectedModule.serviceTiers.map(tier => ({ value: tier.id, label: tier.label }))], onChange: value => { editing.entry.serviceTier = value; change(); } });
139
- select.setAttribute('aria-label', S.tier); model.append(ui.field(S.tier, select));
160
+ body.append(windowNote);
161
+ if (module.serviceTiers.length) {
162
+ const select = ui.select({ value: editing.entry.serviceTier ?? '', options: [{ value: '', label: '—' }, ...module.serviceTiers.map(tier => ({ value: tier.id, label: tier.label }))], onChange: value => { editing.entry.serviceTier = value; change(); } });
163
+ select.setAttribute('aria-label', S.tier); body.append(ui.field(S.tier, select));
140
164
  }
141
165
  const images = ui.h('input'); images.type = 'checkbox'; images.checked = editing.entry.multimodal === true; images.setAttribute('aria-label', S.images);
142
- images.addEventListener('change', () => { editing.entry.multimodal = images.checked; change(); }, opts); model.append(ui.field(S.images, images));
143
- const moduleBody = section(S.moduleSection, 'module', true);
144
- const pricing = section(S.pricing, 'pricing');
166
+ images.addEventListener('change', () => { editing.entry.multimodal = images.checked; change(); }, opts); body.append(ui.field(S.images, images));
167
+ syncSpec = () => { modelInput.value = spec.model; errors.get('model')?.(); if (contextInput && spec.contextWindow !== undefined) contextInput.value = String(spec.contextWindow); };
168
+ }
169
+ function pricingBlock(box: HTMLElement, section: Section) {
170
+ const body = block(box, section, true);
145
171
  const prices = pricingEditor(ui, editing.entry.pricing ?? [], saved?.quotes ?? [], value => {
146
172
  editing.entry.pricing = value as Detail['entry']['pricing']; change();
147
173
  }, LANGUAGE, { raw: editing.raw.pricing, onRaw: value => { editing.raw.pricing = value; change(); } });
148
- pricing.append(prices.body);
174
+ body.append(prices.body);
149
175
  errors.set('pricing', prices.validate);
150
- const advanced = section(S.advanced, 'advanced'); advanced.append(ui.msgline(S.advancedHint));
151
- field(advanced, 'secret', S.secret, editing.entry.secret ?? '', value => { if (value) editing.entry.secret = value; else delete editing.entry.secret; });
152
- form.append(ui.h('div', 'connection-shared', S.shared));
176
+ }
177
+ /** Module scalars the editor renders itself: declared fields of a module that ships no sections of its own. */
178
+ function groupBlock(box: HTMLElement, group: ConfigGroup) {
179
+ const scalars = Object.entries(group.schema.properties ?? {}).filter(([path, property]) => property.type !== 'object' && !PROTOCOL_FIELDS.some(name => path.endsWith('.' + name)));
180
+ if (!scalars.length) return;
181
+ const body = block(box, { id: group.id, title: group.schema.title ?? group.id, description: group.schema.description });
182
+ for (const [path, property] of scalars) {
183
+ const at = entryPath(path.slice(`providers.${identity}.`.length));
184
+ const control = configField(ui, property, at.get(), () => { if (control.read) { at.set(control.read()); change(); } }, lifecycle.signal);
185
+ control.node.setAttribute('aria-label', property.title ?? path);
186
+ body.append(ui.field(property.title ?? path, control.node));
187
+ if (property.description) body.append(ui.h('p', 'tdesc', property.description));
188
+ }
189
+ }
190
+ function protocolBlock(box: HTMLElement, section: Section, groups: ConfigGroup[]) {
191
+ const body = block(box, section, true);
192
+ field(body, 'secret', S.secret, editing.entry.secret ?? '', value => { if (value) editing.entry.secret = value; else delete editing.entry.secret; });
193
+ for (const group of groups) for (const [path, property] of Object.entries(group.schema.properties ?? {})) {
194
+ const suffix = path.slice(`providers.${identity}.`.length);
195
+ const at = entryPath(suffix);
196
+ if (property.type === 'object') { jsonField(body, path, property.title ?? suffix.split('.').at(-1)!, at.get(), false, at.set); continue; }
197
+ if (!PROTOCOL_FIELDS.some(name => suffix.endsWith(name))) continue;
198
+ const control = configField(ui, property, at.get(), () => { if (control.read) { at.set(control.read()); change(); } }, lifecycle.signal);
199
+ control.node.setAttribute('aria-label', property.title ?? suffix);
200
+ body.append(ui.field(property.title ?? suffix, control.node));
201
+ if (property.description) body.append(ui.h('p', 'tdesc', property.description));
202
+ }
203
+ }
204
+ async function render() {
205
+ const gen = ++rendering;
206
+ disposePanels(); syncSpec = null;
207
+ errors.clear(); form.replaceChildren();
208
+ const identityCard = ui.sheet({ title: S.basic }); identityCard.el.classList.add('connection-identity'); form.append(identityCard.el);
209
+ const basic = identityCard.body;
210
+ field(basic, 'name', S.name, editing.name, value => { editing.name = value; }, value => value === saved?.name ? null : validateProviderName(value) ? S.nameHint : null)
211
+ .placeholder = S.newName;
212
+ basic.append(ui.msgline(S.nameHint));
213
+ const selectedModule = modules.find(module => module.id === editing.entry.kind);
214
+ if (saved) {
215
+ const module = ui.select({ value: editing.entry.kind, options: [{ value: editing.entry.kind, label: selectedModule?.title ?? editing.entry.kind }], disabled: true });
216
+ module.setAttribute('aria-label', S.module); module.classList.add('connection-module-readonly');
217
+ basic.append(ui.field(S.module, module), ui.msgline(S.fixedModule));
218
+ }
219
+ else {
220
+ const select = ui.select({ value: editing.entry.kind, options: [{ value: '', label: '—' }, ...modules.map(module => ({ value: module.id, label: module.title }))],
221
+ onChange: kind => {
222
+ const module = modules.find(module => module.id === kind);
223
+ editing.entry = { kind, baseUrl: module?.defaultBaseUrl ?? '', spec: { model: '', thinking: module?.reasoningTiers[0]?.thinking ?? true, ...(module?.reasoningTiers[0]?.effort ? { reasoningEffort: module.reasoningTiers[0].effort } : {}) } };
224
+ editing.raw = {}; change(); run(render);
225
+ } });
226
+ select.setAttribute('aria-label', S.module); basic.append(ui.field(S.module + ' *', select));
227
+ const required = ui.h('div', 'field-error'); basic.append(required);
228
+ errors.set('module', () => { required.textContent = editing.entry.kind ? '' : S.required; select.setAttribute('aria-invalid', String(!editing.entry.kind)); return !!editing.entry.kind; });
229
+ }
230
+ const flow = ui.h('div', 'connection-flow'); form.append(flow);
231
+ const spec = editing.entry.spec ??= { model: '', thinking: false };
153
232
  const actions = ui.h('div', 'connection-actions');
154
233
  if (saved) {
155
234
  actions.append(ui.button(S.remove, { variant: 'danger', onClick: () => run(async () => {
156
235
  const current = await get<Detail>(connectionPath(saved.name), opts);
157
236
  if (current.references.length) { await ui.confirm({ title: S.remove, body: S.referenced + current.references.join(', ') }); return; }
158
237
  if (!(await ui.confirm({ title: S.remove, body: S.deleteConfirm, danger: true }))) return;
159
- await post(connectionPath(saved.name) + '/delete', { expectedRevision: saved.revision }, opts); baseline = JSON.stringify(editing); await options.deleted();
160
- }) }), ui.button(S.duplicate, { onClick: () => run(async () => {
161
- if (!(await leave())) return;
162
- await options.duplicate({ original: null, copyFrom: { name: saved.name, revision: saved.revision }, name: (validateProviderName(saved.name) ? 'Connection' : saved.name) + '-Copy', entry: structuredClone(saved.entry), secretValue: '', raw: {} });
163
- }) }));
238
+ await post(connectionPath(saved.name) + '/delete', { expectedRevision: saved.revision }, opts); await options.deleted();
239
+ }) }), ui.button(S.duplicate, { onClick: () => run(() => options.duplicate({ original: null, copyFrom: { name: saved.name, revision: saved.revision }, name: (validateProviderName(saved.name) ? 'Connection' : saved.name) + '-Copy', entry: structuredClone(saved.entry), secretValue: '', raw: {} })) }));
164
240
  }
165
- actions.append(ui.h('span', 'grow'), ui.button(S.cancel, { onClick: () => run(async () => { baseline = JSON.stringify(editing); await options.cancelled(); }) }), ui.button(S.draft, { onClick: () => { try { options.saveDraft(editing); baseline = JSON.stringify(editing); report.textContent = S.drafted; } catch (error) { report.textContent = String(error); } } }), ui.button(S.save, { variant: 'primary', onClick: () => run(() => save()) }));
166
- form.append(actions);
167
- if (!selectedModule) { moduleBody.append(ui.msgline(saved ? S.readiness['module-missing'] : S.chooseModule)); return; }
168
- const identity = saved?.name ?? 'draft';
169
- const groups = await post<ConfigGroup[]>('/api/provider-modules/config', { name: identity, entry: editing.entry }, opts);
241
+ cancel = ui.button(S.cancel, { onClick: () => run(() => options.cancelled()) });
242
+ cancel.hidden = !!saved && !dirty();
243
+ actions.append(ui.h('span', 'grow'), cancel, ui.button(S.save, { variant: 'primary', onClick: () => run(() => save()) }));
244
+ form.append(actions, ui.h('div', 'connection-shared', S.shared), ui.h('div', 'connection-shared', S.draftNote));
245
+ if (!selectedModule) { flow.append(ui.msgline(saved ? S.readiness['module-missing'] : S.chooseModule)); return; }
246
+ const groups = (await post<ConfigGroup[]>('/api/provider-modules/config', { name: identity, entry: editing.entry }, opts)).filter(group => !group.id.endsWith('.connection'));
170
247
  if (gen !== rendering || lifecycle.disposed) return;
171
- if (ctx.consolePageHost) {
172
- panelHost ??= ctx.consolePageHost({ root: moduleBody, route: () => ['providers', saved?.name ?? ''] });
173
- await panelHost.load();
174
- }
175
- const hasPanels = panelHost?.find(`llm:${editing.entry.kind}`)?.panels?.some(panel => panel.id !== 'settings');
176
- for (const group of groups.filter(group => !group.id.endsWith('.connection'))) {
177
- for (const [path, property] of Object.entries(group.schema.properties ?? {})) {
178
- const suffix = path.slice(`providers.${identity}.`.length);
179
- if (['baseUrl', 'secret', 'multimodal'].includes(suffix) || property.type === 'object') continue;
180
- const parts = suffix.split('.');
181
- const getValue = () => parts.reduce<unknown>((value, part) => (value as Record<string, unknown> | undefined)?.[part], editing.entry);
182
- const setValue = (value: unknown) => {
183
- let target = editing.entry as unknown as Record<string, unknown>;
184
- for (const part of parts.slice(0, -1)) target = (target[part] ??= {}) as Record<string, unknown>;
185
- target[parts.at(-1)!] = value;
186
- };
187
- const body = suffix.includes('endpointPath') || suffix.includes('extraHeaders') || suffix.includes('extraBody') ? advanced : moduleBody;
188
- if (body === moduleBody && hasPanels) continue;
189
- const control = configField(ui, property, getValue(), () => { if (control.read) { setValue(control.read()); change(); } }, lifecycle.signal);
190
- control.node.setAttribute('aria-label', property.title ?? suffix);
191
- body.append(ui.field(property.title ?? suffix, control.node));
192
- if (property.description) body.append(ui.h('p', 'tdesc', property.description));
248
+ const ownSections = selectedModule.sections.some(section => !section.builtin);
249
+ const pending: Array<{ section: Section; box: HTMLElement }> = [];
250
+ for (const section of selectedModule.sections) {
251
+ const box = ui.h('div', 'connection-step'); flow.append(box);
252
+ switch (section.builtin) {
253
+ case 'connection-endpoint': endpointBlock(box, section); break;
254
+ case 'connection-model':
255
+ modelBlock(box, section, selectedModule, spec);
256
+ if (!ownSections) for (const group of groups) groupBlock(box, group);
257
+ break;
258
+ case 'connection-pricing': pricingBlock(box, section); break;
259
+ case 'connection-protocol': protocolBlock(box, section, groups); break;
260
+ default: pending.push({ section, box });
193
261
  }
194
262
  }
195
- if (ctx.consolePageHost) {
196
- const slot = ui.h('div'); moduleBody.append(slot);
197
- panelHost ??= ctx.consolePageHost({ root: slot, route: () => ['providers', saved?.name ?? ''] });
198
- await panelHost.load();
199
- if (gen !== rendering || lifecycle.disposed) return;
200
- panelHandle = await panelHost.mountConnection(`llm:${editing.entry.kind}`, slot, { instance: identity }, context => ({
263
+ if (!pending.length || !ctx.consolePageHost) return;
264
+ panelHost ??= ctx.consolePageHost({ root: ui.h('div'), route: () => ['providers', saved?.name ?? ''] });
265
+ await panelHost.load();
266
+ if (gen !== rendering || lifecycle.disposed) return;
267
+ for (const { section, box } of pending) {
268
+ const handle = await panelHost.mountConnection(`llm:${editing.entry.kind}`, section.id, box, { instance: identity }, context => ({
201
269
  ...context,
202
270
  setConfig: async (_id, values) => {
203
271
  for (const [path, value] of Object.entries(values)) {
204
272
  const prefix = `providers.${identity}.`;
205
273
  if (!path.startsWith(prefix)) throw new Error('Foreign connection field');
206
- const parts = path.slice(prefix.length).split('.');
207
- let target = editing.entry as unknown as Record<string, unknown>;
208
- for (const part of parts.slice(0, -1)) target = (target[part] ??= {}) as Record<string, unknown>;
209
- target[parts.at(-1)!] = value;
274
+ entryPath(path.slice(prefix.length)).set(value);
210
275
  }
211
276
  change(); return S.unsaved;
212
277
  },
213
278
  invoke: async <T>(method: string, args?: unknown[]): Promise<T> => {
214
279
  const before = JSON.stringify(editing.entry);
215
280
  const reply = await post<{ result: T; entry: Detail['entry'] }>('/api/provider-modules/preview', { name: identity, entry: editing.entry, panel: context.panelId, method, args }, opts);
216
- if (before === JSON.stringify(editing.entry) && JSON.stringify(reply.entry) !== before) { if (reply.entry.spec) Object.assign(spec, reply.entry.spec); editing.entry = { ...reply.entry, spec }; change(); }
281
+ if (before === JSON.stringify(editing.entry) && JSON.stringify(reply.entry) !== before) {
282
+ if (reply.entry.spec) Object.assign(spec, reply.entry.spec);
283
+ editing.entry = { ...reply.entry, spec }; syncSpec?.(); change();
284
+ }
217
285
  return reply.result;
218
286
  },
219
287
  refresh: async () => {},
220
288
  }));
221
- if (gen !== rendering || lifecycle.disposed) panelHandle.dispose();
222
- }
223
- // Object-valued protocol fields remain JSON editors; their presence is declared by the module schema.
224
- for (const group of groups) for (const [path, property] of Object.entries(group.schema.properties ?? {})) {
225
- if (property.type !== 'object') continue;
226
- const parts = path.slice(`providers.${identity}.`.length).split('.');
227
- const value = parts.reduce<unknown>((object, key) => (object as Record<string, unknown> | undefined)?.[key], editing.entry);
228
- jsonField(advanced, path, property.title ?? parts.at(-1)!, value, false, value => {
229
- let object = editing.entry as unknown as Record<string, unknown>;
230
- for (const key of parts.slice(0, -1)) object = (object[key] ??= {}) as Record<string, unknown>;
231
- if (value === undefined) delete object[parts.at(-1)!]; else object[parts.at(-1)!] = value;
232
- });
289
+ if (gen !== rendering || lifecycle.disposed) { handle.dispose(); return; }
290
+ panelHandles.push(handle);
233
291
  }
234
292
  }
235
293
  async function save(select = true): Promise<boolean> {
236
294
  if (saving) return false;
237
295
  const valid = [...errors.values()].map(check => check()).every(Boolean); change();
238
- if (!valid) { report.textContent = S.readiness.invalid; return false; }
296
+ if (!valid) {
297
+ for (const input of form.querySelectorAll('[aria-invalid="true"]')) { const fold = input.closest('details'); if (fold) fold.open = true; }
298
+ report.textContent = S.readiness.invalid; return false;
299
+ }
239
300
  saving = true;
240
301
  try {
241
302
  const result = await post<Detail>(saved ? connectionPath(saved.name) + '/save' : '/api/providers', { name: editing.name, entry: editing.entry, expectedRevision: editing.revision, copyFrom: editing.copyFrom, ...(editing.secretValue ? { secretValue: editing.secretValue } : {}) }, opts);
242
- baseline = JSON.stringify(editing); await options.onSaved(result.name, select); return true;
303
+ await options.onSaved(result.name, select); return true;
243
304
  } catch (error) { report.textContent = String(error); return false; }
244
305
  finally { saving = false; }
245
306
  }
246
- async function leave(): Promise<boolean> {
247
- if (!dirty()) return true;
248
- return new Promise(resolve => {
249
- const dialog = ui.h('dialog', 'connection-guard');
250
- const buttons = ui.rowbar();
251
- const finish = (answer: boolean) => { dialog.remove(); resolve(answer); };
252
- dialog.append(ui.h('h3', '', S.unsaved), buttons);
253
- buttons.append(ui.button(S.stay, { onClick: () => finish(false) }), ui.button(S.discard, { onClick: () => { baseline = JSON.stringify(editing); options.discarded(); finish(true); } }), ui.button(S.save, { variant: 'primary', onClick: () => { void save(false).then(ok => finish(ok)); } }));
254
- dialog.addEventListener('cancel', event => { event.preventDefault(); finish(false); }, opts);
255
- lifecycle.signal.addEventListener('abort', () => finish(false), { once: true });
256
- root.ownerDocument.body.append(dialog);
257
- if (dialog.showModal) dialog.showModal(); else dialog.setAttribute('open', '');
258
- });
259
- }
260
307
  await render(); change();
261
- return { dispose: () => { lifecycle.dispose(); panelHandle?.dispose(); panelHost?.unmount(); }, dirty, leave };
308
+ return { dispose: () => { lifecycle.dispose(); disposePanels(); panelHost?.unmount(); } };
262
309
  }