@qvac/harness 0.3.2 → 0.4.0

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 (36) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/README.md +134 -0
  3. package/dist/harness.bundle +304 -175
  4. package/dist-lib/index.d.ts +6 -1
  5. package/dist-lib/index.js +29 -4
  6. package/dist-lib/lib/capabilities/completion.js +2 -5
  7. package/dist-lib/lib/capabilities/preview-model-source.d.ts +67 -0
  8. package/dist-lib/lib/capabilities/preview-model-source.js +387 -0
  9. package/dist-lib/lib/connect.d.ts +4 -5
  10. package/dist-lib/lib/connect.js +9 -0
  11. package/dist-lib/lib/contract-types.d.ts +4 -5
  12. package/dist-lib/lib/models/custom-model-cache.d.ts +4 -0
  13. package/dist-lib/lib/models/custom-model-cache.js +42 -0
  14. package/dist-lib/lib/models/gguf-header.d.ts +30 -0
  15. package/dist-lib/lib/models/gguf-header.js +208 -0
  16. package/dist-lib/lib/models/model-catalog.d.ts +6 -1
  17. package/dist-lib/lib/models/model-catalog.js +7 -2
  18. package/dist-lib/lib/models/model-delete.d.ts +4 -1
  19. package/dist-lib/lib/models/model-delete.js +19 -14
  20. package/dist-lib/lib/models/model-list.d.ts +6 -0
  21. package/dist-lib/lib/models/model-list.js +23 -1
  22. package/dist-lib/lib/models/model-source.js +1 -1
  23. package/dist-lib/lib/serve.js +21 -5
  24. package/dist-lib/schema/harness.js +42 -1
  25. package/dist-lib/spec/bind.js +3 -0
  26. package/dist-lib/spec/calls.js +1 -0
  27. package/dist-lib/spec/hrpc/hrpc.json +13 -0
  28. package/dist-lib/spec/hrpc/index.js +15 -3
  29. package/dist-lib/spec/hrpc/messages.js +173 -5
  30. package/dist-lib/spec/hyperschema/index.js +173 -5
  31. package/dist-lib/spec/hyperschema/schema.json +136 -1
  32. package/dist-lib/spec/hyperschema/types.d.ts +34 -1
  33. package/package.json +6 -7
  34. package/worker/harness-sidecar.mjs +1 -0
  35. package/dist-lib/lib/capabilities/vision-history.d.ts +0 -3
  36. package/dist-lib/lib/capabilities/vision-history.js +0 -21
@@ -1,9 +1,11 @@
1
1
  import type { HarnessCapabilityProfile } from '../spec/hyperschema/types.d.ts';
2
2
  import type { ApproveFn, AskUserFn, AttachFn, FetchAttachmentFn, ProgressFn, RagSearchFn, ResourceDescriptor, ResponseFormat } from './capabilities/completion.ts';
3
3
  import type { KvCacheDeletionResult } from './capabilities/kv-cache.ts';
4
+ import type { ModelSourcePreview, PreviewModelSourceContext } from './capabilities/preview-model-source.ts';
4
5
  import type { MemoryBudget } from './hardware/memory-budget.ts';
5
6
  import type { EmbedDevice } from './models/embed-config.ts';
6
7
  import type { LlmGenerationParams } from './models/llm-presets.ts';
8
+ import type { CustomModelEntry } from './models/model-catalog.ts';
7
9
  import type { DeleteModelSummary } from './models/model-delete.ts';
8
10
  import type { ListModelsOptions, ModelInfo } from './models/model-list.ts';
9
11
  import type { ToolCatalogEntry } from './tools/catalog.ts';
@@ -219,11 +221,8 @@ export interface HarnessContract {
219
221
  name: string;
220
222
  addon: string;
221
223
  }>>;
222
- setCustomModels?(entries: ReadonlyArray<{
223
- name: string;
224
- src: string;
225
- projectionSrc?: string;
226
- }>): void | Promise<void>;
224
+ setCustomModels?(entries: ReadonlyArray<CustomModelEntry>): void | Promise<void>;
225
+ previewModelSource?(context: PreviewModelSourceContext): Promise<ModelSourcePreview>;
227
226
  capability?(): Promise<CapabilityInfo>;
228
227
  diagnostics?(): HarnessDiagnostics;
229
228
  }
@@ -0,0 +1,4 @@
1
+ import type { CustomModelEntry } from './model-catalog.ts';
2
+ export declare function customFileCachePath(src: string, modelsDir: string | undefined): string | null;
3
+ export declare function downloadedCustomFiles(entry: CustomModelEntry, modelsDir: string | undefined): string[];
4
+ export declare function isCustomModelCached(entry: CustomModelEntry, modelsDir: string | undefined): Promise<boolean>;
@@ -0,0 +1,42 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { isRemoteSource, localSourcePath } from './gguf-header.js';
5
+ const SHARD = /-\d{5}-of-\d{5}\.\w+$/;
6
+ const ARCHIVE = /\.(tar|tgz|zip)(\.\w+)?$/i;
7
+ export function customFileCachePath(src, modelsDir) {
8
+ if (!isRemoteSource(src))
9
+ return localSourcePath(src);
10
+ const filename = src.split('/').pop()?.split('?')[0] || 'model.gguf';
11
+ if (!modelsDir || SHARD.test(filename) || ARCHIVE.test(filename))
12
+ return null;
13
+ const hash = crypto.createHash('sha256').update(src, 'utf8').digest('hex').slice(0, 16);
14
+ return path.join(modelsDir, `${hash}_${filename}`);
15
+ }
16
+ export function downloadedCustomFiles(entry, modelsDir) {
17
+ const srcs = entry.projectionSrc ? [entry.src, entry.projectionSrc] : [entry.src];
18
+ return srcs
19
+ .filter(isRemoteSource)
20
+ .map((src) => customFileCachePath(src, modelsDir))
21
+ .filter((file) => file !== null);
22
+ }
23
+ export async function isCustomModelCached(entry, modelsDir) {
24
+ const srcs = entry.projectionSrc ? [entry.src, entry.projectionSrc] : [entry.src];
25
+ const sizes = await Promise.all(srcs.map((src) => onDiskSize(customFileCachePath(src, modelsDir))));
26
+ const present = sizes.filter((size) => size !== null);
27
+ if (present.length < sizes.length)
28
+ return false;
29
+ const total = present.reduce((sum, size) => sum + size, 0);
30
+ return entry.expectedSize ? total === entry.expectedSize : true;
31
+ }
32
+ async function onDiskSize(file) {
33
+ if (file === null)
34
+ return null;
35
+ try {
36
+ const stat = await fs.promises.stat(file);
37
+ return stat.isFile() ? stat.size : null;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
@@ -0,0 +1,30 @@
1
+ import { type AbortSignal } from 'bare-abort-controller';
2
+ export interface GgufHeader {
3
+ readonly architecture?: string;
4
+ readonly name?: string;
5
+ readonly type?: string;
6
+ readonly license?: string;
7
+ readonly contextLength?: number;
8
+ readonly sizeLabel?: string;
9
+ readonly tags?: ReadonlyArray<string>;
10
+ }
11
+ export type GgufRangeFetch = (url: string, init: {
12
+ headers: Record<string, string>;
13
+ signal?: AbortSignal;
14
+ }) => Promise<{
15
+ ok: boolean;
16
+ status: number;
17
+ headers: {
18
+ get(name: string): string | null;
19
+ };
20
+ arrayBuffer(): Promise<ArrayBuffer>;
21
+ }>;
22
+ export declare const GGUF_HEADER_BYTES = 1048576;
23
+ export declare const GGUF_HEADER_TIMEOUT_MS = 30000;
24
+ export declare function isRemoteSource(src: string): boolean;
25
+ export declare function localSourcePath(src: string): string | null;
26
+ export declare function readGgufHeaderBytes(src: string, opts?: {
27
+ fetch?: GgufRangeFetch;
28
+ maxBytes?: number;
29
+ }): Promise<Uint8Array | null>;
30
+ export declare function parseGgufHeader(bytes: Uint8Array): GgufHeader | null;
@@ -0,0 +1,208 @@
1
+ import b4a from 'b4a';
2
+ import { AbortController } from 'bare-abort-controller';
3
+ import fs from 'fs';
4
+ import os from 'os';
5
+ import path from 'path';
6
+ import { fileURLToPath } from 'url';
7
+ import defaultFetch from '#fetch';
8
+ export const GGUF_HEADER_BYTES = 1_048_576;
9
+ export const GGUF_HEADER_TIMEOUT_MS = 30_000;
10
+ const UINT8 = 0;
11
+ const INT8 = 1;
12
+ const UINT16 = 2;
13
+ const INT16 = 3;
14
+ const UINT32 = 4;
15
+ const INT32 = 5;
16
+ const FLOAT32 = 6;
17
+ const BOOL = 7;
18
+ const STRING = 8;
19
+ const ARRAY = 9;
20
+ const UINT64 = 10;
21
+ const INT64 = 11;
22
+ const FLOAT64 = 12;
23
+ export function isRemoteSource(src) {
24
+ return /^https?:\/\//i.test(src);
25
+ }
26
+ export function localSourcePath(src) {
27
+ if (/^file:/i.test(src))
28
+ return filePath(src);
29
+ if (/^[a-z][a-z0-9+.-]+:/i.test(src))
30
+ return null;
31
+ if (src === '~')
32
+ return os.homedir();
33
+ if (src.startsWith('~/'))
34
+ return path.join(os.homedir(), src.slice(2));
35
+ return src;
36
+ }
37
+ function filePath(src) {
38
+ try {
39
+ return fileURLToPath(src);
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ export function readGgufHeaderBytes(src, opts = {}) {
46
+ const maxBytes = opts.maxBytes ?? GGUF_HEADER_BYTES;
47
+ return isRemoteSource(src)
48
+ ? fetchHead(src, maxBytes, opts.fetch ?? defaultFetch)
49
+ : readLocalHead(src, maxBytes);
50
+ }
51
+ async function fetchHead(src, maxBytes, fetch) {
52
+ const controller = new AbortController();
53
+ const timeout = setTimeout(() => controller.abort(undefined), GGUF_HEADER_TIMEOUT_MS);
54
+ try {
55
+ const response = await fetch(src, {
56
+ headers: { Range: `bytes=0-${maxBytes - 1}` },
57
+ signal: controller.signal
58
+ });
59
+ if (!response.ok)
60
+ return null;
61
+ const length = response.headers.get('content-length');
62
+ if (length === null || !(Number(length) <= maxBytes))
63
+ return null;
64
+ return new Uint8Array(await response.arrayBuffer()).subarray(0, maxBytes);
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ finally {
70
+ clearTimeout(timeout);
71
+ }
72
+ }
73
+ async function readLocalHead(src, maxBytes) {
74
+ const file = localSourcePath(src);
75
+ if (file === null)
76
+ return null;
77
+ let handle;
78
+ try {
79
+ handle = await fs.promises.open(file, 'r');
80
+ const buffer = b4a.allocUnsafe(maxBytes);
81
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
82
+ return buffer.subarray(0, bytesRead);
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ finally {
88
+ await handle?.close();
89
+ }
90
+ }
91
+ export function parseGgufHeader(bytes) {
92
+ if (bytes.byteLength < 24)
93
+ return null;
94
+ if (b4a.toString(bytes.subarray(0, 4), 'latin1') !== 'GGUF')
95
+ return null;
96
+ const c = {
97
+ view: new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength),
98
+ bytes,
99
+ offset: 16
100
+ };
101
+ if (c.view.getUint32(4, true) < 2)
102
+ return null;
103
+ const count = readU64(c);
104
+ const header = {};
105
+ const contextLengths = new Map();
106
+ for (let i = 0; i < count; i += 1) {
107
+ const key = readString(c);
108
+ if (key === null || !has(c, 4))
109
+ break;
110
+ const value = readValue(c, readU32(c), key === 'general.tags');
111
+ if (!value.ok)
112
+ break;
113
+ if (key === 'general.architecture')
114
+ header.architecture = value.str;
115
+ else if (key === 'general.name')
116
+ header.name = value.str;
117
+ else if (key === 'general.type')
118
+ header.type = value.str;
119
+ else if (key === 'general.license')
120
+ header.license = value.str;
121
+ else if (key === 'general.size_label')
122
+ header.sizeLabel = value.str;
123
+ else if (key === 'general.tags')
124
+ header.tags = value.strs;
125
+ else if (key.endsWith('.context_length'))
126
+ contextLengths.set(key, value.num);
127
+ }
128
+ header.contextLength =
129
+ contextLengths.get(`${header.architecture}.context_length`) ??
130
+ contextLengths.values().next().value;
131
+ return header;
132
+ }
133
+ function readValue(c, type, collect = false) {
134
+ switch (type) {
135
+ case UINT8:
136
+ case BOOL:
137
+ return fixed(c, 1, () => c.view.getUint8(c.offset));
138
+ case INT8:
139
+ return fixed(c, 1, () => c.view.getInt8(c.offset));
140
+ case UINT16:
141
+ return fixed(c, 2, () => c.view.getUint16(c.offset, true));
142
+ case INT16:
143
+ return fixed(c, 2, () => c.view.getInt16(c.offset, true));
144
+ case UINT32:
145
+ return fixed(c, 4, () => c.view.getUint32(c.offset, true));
146
+ case INT32:
147
+ return fixed(c, 4, () => c.view.getInt32(c.offset, true));
148
+ case FLOAT32:
149
+ return fixed(c, 4, () => c.view.getFloat32(c.offset, true));
150
+ case FLOAT64:
151
+ return fixed(c, 8, () => c.view.getFloat64(c.offset, true));
152
+ case UINT64:
153
+ case INT64:
154
+ return has(c, 8) ? { ok: true, num: readU64(c) } : { ok: false };
155
+ case STRING: {
156
+ const str = readString(c);
157
+ return str === null ? { ok: false } : { ok: true, str };
158
+ }
159
+ case ARRAY: {
160
+ if (!has(c, 12))
161
+ return { ok: false };
162
+ const elementType = readU32(c);
163
+ const length = readU64(c);
164
+ const strs = new Array();
165
+ for (let i = 0; i < length; i += 1) {
166
+ const element = readValue(c, elementType);
167
+ if (!element.ok)
168
+ return { ok: false };
169
+ if (collect && element.str !== undefined)
170
+ strs.push(element.str);
171
+ }
172
+ return collect ? { ok: true, strs } : { ok: true };
173
+ }
174
+ default:
175
+ return { ok: false };
176
+ }
177
+ }
178
+ function fixed(c, width, read) {
179
+ if (!has(c, width))
180
+ return { ok: false };
181
+ const num = read();
182
+ c.offset += width;
183
+ return { ok: true, num };
184
+ }
185
+ function has(c, n) {
186
+ return n >= 0 && c.offset + n <= c.bytes.byteLength;
187
+ }
188
+ function readU32(c) {
189
+ const value = c.view.getUint32(c.offset, true);
190
+ c.offset += 4;
191
+ return value;
192
+ }
193
+ function readU64(c) {
194
+ const lo = c.view.getUint32(c.offset, true);
195
+ const hi = c.view.getUint32(c.offset + 4, true);
196
+ c.offset += 8;
197
+ return hi * 2 ** 32 + lo;
198
+ }
199
+ function readString(c) {
200
+ if (!has(c, 8))
201
+ return null;
202
+ const length = readU64(c);
203
+ if (!has(c, length))
204
+ return null;
205
+ const str = b4a.toString(c.bytes.subarray(c.offset, c.offset + length), 'utf8');
206
+ c.offset += length;
207
+ return str;
208
+ }
@@ -105,13 +105,18 @@ export interface CuratedListable {
105
105
  }
106
106
  export declare function curatedListables(): CuratedListable[];
107
107
  export declare function curatedModelAsset(name: string, addon: ModelRegistryEntry['addon']): SdkModelAsset | undefined;
108
+ export declare const CUSTOM_TASK_TYPES: readonly ['llm'];
109
+ export type CustomTaskType = (typeof CUSTOM_TASK_TYPES)[number];
108
110
  export interface CustomModelEntry {
109
111
  readonly name: string;
110
112
  readonly src: string;
111
113
  readonly projectionSrc?: string;
114
+ readonly taskType?: CustomTaskType;
115
+ readonly expectedSize?: number;
112
116
  }
113
117
  export declare function setCustomModels(entries: ReadonlyArray<CustomModelEntry>): void;
114
- export declare function customModelSrc(name: string): string | undefined;
118
+ export declare function customModels(): ReadonlyArray<CustomModelEntry>;
119
+ export declare function customModelSrc(name: string, addon: ModelRegistryEntry['addon']): string | undefined;
115
120
  export declare function customToolDialect(name: string): ToolDialect | undefined;
116
121
  export declare function customProjectionMap(): Record<string, string>;
117
122
  export declare function companionRegistryPaths(): Map<string, string[]>;
@@ -311,12 +311,17 @@ export function curatedListables() {
311
311
  export function curatedModelAsset(name, addon) {
312
312
  return curatedListables().find((entry) => entry.asset.addon === addon && (entry.name === name || entry.asset.name === name))?.asset;
313
313
  }
314
+ export const CUSTOM_TASK_TYPES = ['llm'];
314
315
  let CUSTOM_MODELS = new Map();
315
316
  export function setCustomModels(entries) {
316
317
  CUSTOM_MODELS = new Map(entries.map((entry) => [entry.name, entry]));
317
318
  }
318
- export function customModelSrc(name) {
319
- return CUSTOM_MODELS.get(name)?.src;
319
+ export function customModels() {
320
+ return [...CUSTOM_MODELS.values()];
321
+ }
322
+ export function customModelSrc(name, addon) {
323
+ const entry = CUSTOM_MODELS.get(name);
324
+ return entry && (entry.taskType ?? 'llm') === addon ? entry.src : undefined;
320
325
  }
321
326
  export function customToolDialect(name) {
322
327
  const entry = CUSTOM_MODELS.get(name);
@@ -15,4 +15,7 @@ export interface DeleteModelSummary {
15
15
  readonly freedBytes: number;
16
16
  }
17
17
  export declare function summarizeDelete(result: DeleteModelResult): DeleteModelSummary;
18
- export declare function deleteModelFiles(sdk: ModelDeleteSdk, name: string): Promise<DeleteModelResult>;
18
+ export interface DeleteModelOptions {
19
+ readonly modelsDir?: string;
20
+ }
21
+ export declare function deleteModelFiles(sdk: ModelDeleteSdk, name: string, { modelsDir }?: DeleteModelOptions): Promise<DeleteModelResult>;
@@ -1,27 +1,32 @@
1
1
  import fs from 'fs';
2
- import { curatedListables, musicListables } from './model-catalog.js';
2
+ import { downloadedCustomFiles } from './custom-model-cache.js';
3
+ import { curatedListables, customModels, musicListables } from './model-catalog.js';
3
4
  export function summarizeDelete(result) {
4
5
  return { name: result.name, removed: result.removed.length, freedBytes: result.freedBytes };
5
6
  }
6
- export async function deleteModelFiles(sdk, name) {
7
- const seen = new Set();
7
+ export async function deleteModelFiles(sdk, name, { modelsDir } = {}) {
8
+ const custom = customModels().find((entry) => entry.name === name);
9
+ const paths = custom ? downloadedCustomFiles(custom, modelsDir) : await catalogFiles(sdk, name);
8
10
  const removed = [];
11
+ for (const filePath of new Set(paths)) {
12
+ const bytes = await onDiskSize(filePath);
13
+ if (bytes === null)
14
+ continue;
15
+ await fs.promises.rm(filePath, { force: true });
16
+ removed.push({ path: filePath, bytes });
17
+ }
18
+ return { name, removed, freedBytes: removed.reduce((sum, file) => sum + file.bytes, 0) };
19
+ }
20
+ async function catalogFiles(sdk, name) {
21
+ const paths = [];
9
22
  for (const catalogName of namesToPurge(name)) {
10
23
  const info = await sdk.getModelInfo({ name: catalogName }).catch(() => null);
11
24
  if (!info)
12
25
  continue;
13
- for (const file of info.cacheFiles) {
14
- if (seen.has(file.path))
15
- continue;
16
- seen.add(file.path);
17
- const bytes = await onDiskSize(file.path);
18
- if (bytes === null)
19
- continue;
20
- await fs.promises.rm(file.path, { force: true });
21
- removed.push({ path: file.path, bytes });
22
- }
26
+ for (const file of info.cacheFiles)
27
+ paths.push(file.path);
23
28
  }
24
- return { name, removed, freedBytes: removed.reduce((sum, file) => sum + file.bytes, 0) };
29
+ return paths;
25
30
  }
26
31
  async function onDiskSize(filePath) {
27
32
  try {
@@ -39,4 +39,10 @@ export declare const MODEL_STATUS_TTL_MS = 30000;
39
39
  export declare function createModelStatusCache(): ModelStatusCache;
40
40
  export declare function invalidateModelStatusCache(cache: ModelStatusCache): void;
41
41
  export declare function listModels(sdk: ModelListSdk, { type, cachedOnly, fullRegistry }?: ListModelsOptions, cache?: ModelStatusCache, projectionFor?: (name: string) => string | undefined): Promise<ModelInfo[]>;
42
+ export interface CustomModelRowsContext {
43
+ readonly modelsDir?: string;
44
+ readonly loaded: ReadonlySet<string>;
45
+ }
46
+ export declare function customModelRows({ type, cachedOnly }: ListModelsOptions, { modelsDir, loaded }: CustomModelRowsContext): Promise<ModelInfo[]>;
47
+ export declare function mergeCustomRows(catalog: ReadonlyArray<ModelInfo>, custom: ReadonlyArray<ModelInfo>): ModelInfo[];
42
48
  export {};
@@ -1,4 +1,5 @@
1
- import { companionRegistryPaths, curatedListables, musicListables } from './model-catalog.js';
1
+ import { isCustomModelCached } from './custom-model-cache.js';
2
+ import { companionRegistryPaths, curatedListables, customModels, musicListables } from './model-catalog.js';
2
3
  export const MODEL_STATUS_TTL_MS = 30_000;
3
4
  export function createModelStatusCache() {
4
5
  return { entries: new Map(), epoch: 0 };
@@ -121,3 +122,24 @@ export async function listModels(sdk, { type, cachedOnly, fullRegistry } = {}, c
121
122
  });
122
123
  return cachedOnly ? rows.filter((row) => row.isCached) : rows;
123
124
  }
125
+ export async function customModelRows({ type, cachedOnly }, { modelsDir, loaded }) {
126
+ const entries = customModels().filter((entry) => !type || (entry.taskType ?? 'llm') === type);
127
+ const rows = await Promise.all(entries.map(async (entry) => {
128
+ const addon = entry.taskType ?? 'llm';
129
+ return {
130
+ name: entry.name,
131
+ type: addon,
132
+ registryPath: entry.src,
133
+ registrySource: 'custom',
134
+ isCached: await isCustomModelCached(entry, modelsDir),
135
+ isLoaded: loaded.has(entry.name),
136
+ supportsImages: addon === 'llm' && Boolean(entry.projectionSrc),
137
+ expectedSize: entry.expectedSize ?? 0
138
+ };
139
+ }));
140
+ return cachedOnly ? rows.filter((row) => row.isCached) : rows;
141
+ }
142
+ export function mergeCustomRows(catalog, custom) {
143
+ const shadowed = new Set(custom.map((row) => row.name));
144
+ return [...catalog.filter((row) => !shadowed.has(row.name)), ...custom];
145
+ }
@@ -2,7 +2,7 @@ import { audioGenModelByName, curatedModelAsset, customModelSrc } from './model-
2
2
  export async function resolveModelSrc(sdk, model, addon = 'llm') {
3
3
  if (/^(\/|~|\.|file:|https?:|registry:|pear:|hyper:)/.test(model))
4
4
  return model;
5
- const custom = customModelSrc(model);
5
+ const custom = customModelSrc(model, addon);
6
6
  if (custom)
7
7
  return custom;
8
8
  if (addon === 'audiogen') {
@@ -5,6 +5,7 @@ import { pipeline, Readable } from 'streamx';
5
5
  import { bindApi } from '../spec/bind.js';
6
6
  import HRPC from '../spec/hrpc/index.js';
7
7
  import { isCapability } from './capabilities/invoke.js';
8
+ import { CUSTOM_TASK_TYPES } from './models/model-catalog.js';
8
9
  import { unsupportedKvCacheDeletion } from './capabilities/kv-cache.js';
9
10
  import { errorMessage } from './error-fields.js';
10
11
  import { hlog } from './log.js';
@@ -65,13 +66,28 @@ export function serveHarness(stream, harness) {
65
66
  removed: (await harness.deleteOverlaySkill?.(name))?.removed ?? false
66
67
  }),
67
68
  setCustomModels: async ({ models }) => {
68
- await harness.setCustomModels?.((models ?? []).map(({ name, src, projectionSrc }) => ({
69
- name,
70
- src,
71
- projectionSrc: projectionSrc ?? undefined
72
- })));
69
+ await harness.setCustomModels?.((models ?? []).flatMap(({ name, src, projectionSrc, taskType, expectedSize }) => {
70
+ const known = CUSTOM_TASK_TYPES.find((task) => task === taskType);
71
+ if (taskType && !known)
72
+ return [];
73
+ return [
74
+ {
75
+ name,
76
+ src,
77
+ projectionSrc: projectionSrc ?? undefined,
78
+ taskType: known,
79
+ expectedSize: expectedSize || undefined
80
+ }
81
+ ];
82
+ }));
73
83
  return { ok: true };
74
84
  },
85
+ previewModelSource: async ({ src, mmprojSrc }) => {
86
+ if (!harness.previewModelSource) {
87
+ throw new Error('harness does not support previewModelSource');
88
+ }
89
+ return harness.previewModelSource({ src: src ?? '', mmprojSrc: mmprojSrc ?? undefined });
90
+ },
75
91
  describe: async () => (await harness.describe?.()) ?? {
76
92
  toolNames: [],
77
93
  skills: [],
@@ -564,7 +564,9 @@ export function registerHarnessTypes(ns) {
564
564
  fields: [
565
565
  { name: 'name', type: 'string', required: true },
566
566
  { name: 'src', type: 'string', required: true },
567
- { name: 'projectionSrc', type: 'string' }
567
+ { name: 'projectionSrc', type: 'string' },
568
+ { name: 'taskType', type: 'string' },
569
+ { name: 'expectedSize', type: 'uint' }
568
570
  ]
569
571
  });
570
572
  ns.register({
@@ -678,6 +680,40 @@ export function registerHarnessTypes(ns) {
678
680
  { name: 'verify', type: '@harness/skill-oauth-verify' }
679
681
  ]
680
682
  });
683
+ ns.register({
684
+ name: 'preview-model-source-request',
685
+ fields: [
686
+ { name: 'src', type: 'string', required: true },
687
+ { name: 'mmprojSrc', type: 'string' }
688
+ ]
689
+ });
690
+ ns.register({
691
+ name: 'model-source-file',
692
+ fields: [
693
+ { name: 'src', type: 'string', required: true },
694
+ { name: 'label', type: 'string', required: true },
695
+ { name: 'sizeBytes', type: 'uint' }
696
+ ]
697
+ });
698
+ ns.register({
699
+ name: 'preview-model-source-response',
700
+ fields: [
701
+ { name: 'ok', type: 'bool', required: true },
702
+ { name: 'refusal', type: 'string' },
703
+ { name: 'reason', type: 'string' },
704
+ { name: 'src', type: 'string' },
705
+ { name: 'files', type: '@harness/model-source-file', array: true },
706
+ { name: 'sizeBytes', type: 'uint' },
707
+ { name: 'contextLength', type: 'uint' },
708
+ { name: 'architecture', type: 'string' },
709
+ { name: 'sizeLabel', type: 'string' },
710
+ { name: 'taskType', type: 'string' },
711
+ { name: 'license', type: 'string' },
712
+ { name: 'fit', type: 'string' },
713
+ { name: 'fitReasons', type: 'string', array: true },
714
+ { name: 'mmprojSrc', type: 'string' }
715
+ ]
716
+ });
681
717
  }
682
718
  export function registerHarnessApi(api) {
683
719
  api.register({
@@ -825,4 +861,9 @@ export function registerHarnessApi(api) {
825
861
  request: { name: '@harness/detect-language-request', stream: false },
826
862
  response: { name: '@harness/detect-language-response', stream: false }
827
863
  });
864
+ api.register({
865
+ name: 'preview-model-source',
866
+ request: { name: '@harness/preview-model-source-request', stream: false },
867
+ response: { name: '@harness/preview-model-source-response', stream: false }
868
+ });
828
869
  }
@@ -127,4 +127,7 @@ export function bindApi(rpc, api) {
127
127
  if (api.detectLanguage) {
128
128
  rpc.onDetectLanguage((req) => api.detectLanguage(req));
129
129
  }
130
+ if (api.previewModelSource) {
131
+ rpc.onPreviewModelSource((req) => api.previewModelSource(req));
132
+ }
130
133
  }
@@ -39,5 +39,6 @@ export function harnessClient(rpc) {
39
39
  deleteOverlaySkill: (input) => rpc.deleteOverlaySkill(input),
40
40
  deleteKvCache: (input) => rpc.deleteKvCache(input),
41
41
  detectLanguage: (input) => rpc.detectLanguage(input),
42
+ previewModelSource: (input) => rpc.previewModelSource(input),
42
43
  };
43
44
  }
@@ -377,6 +377,19 @@
377
377
  "stream": false
378
378
  },
379
379
  "version": 1
380
+ },
381
+ {
382
+ "id": 29,
383
+ "name": "@harness/preview-model-source",
384
+ "request": {
385
+ "name": "@harness/preview-model-source-request",
386
+ "stream": false
387
+ },
388
+ "response": {
389
+ "name": "@harness/preview-model-source-response",
390
+ "stream": false
391
+ },
392
+ "version": 1
380
393
  }
381
394
  ]
382
395
  }
@@ -63,7 +63,9 @@ const methods = new Map([
63
63
  ['@harness/delete-kv-cache', 27],
64
64
  [27, '@harness/delete-kv-cache'],
65
65
  ['@harness/detect-language', 28],
66
- [28, '@harness/detect-language']
66
+ [28, '@harness/detect-language'],
67
+ ['@harness/preview-model-source', 29],
68
+ [29, '@harness/preview-model-source']
67
69
  ])
68
70
 
69
71
  class HRPC {
@@ -99,7 +101,8 @@ class HRPC {
99
101
  ['@harness/copy-skill-for-edit', getEncoding('@harness/skill-name-request')],
100
102
  ['@harness/delete-overlay-skill', getEncoding('@harness/skill-name-request')],
101
103
  ['@harness/delete-kv-cache', getEncoding('@harness/kv-cache-delete-request')],
102
- ['@harness/detect-language', getEncoding('@harness/detect-language-request')]
104
+ ['@harness/detect-language', getEncoding('@harness/detect-language-request')],
105
+ ['@harness/preview-model-source', getEncoding('@harness/preview-model-source-request')]
103
106
  ])
104
107
  this._responseEncodings = new Map([
105
108
  ['@harness/completion', getEncoding('@harness/completion-event')],
@@ -130,7 +133,8 @@ class HRPC {
130
133
  ['@harness/copy-skill-for-edit', getEncoding('@harness/copy-skill-response')],
131
134
  ['@harness/delete-overlay-skill', getEncoding('@harness/delete-skill-response')],
132
135
  ['@harness/delete-kv-cache', getEncoding('@harness/kv-cache-deletion-response')],
133
- ['@harness/detect-language', getEncoding('@harness/detect-language-response')]
136
+ ['@harness/detect-language', getEncoding('@harness/detect-language-response')],
137
+ ['@harness/preview-model-source', getEncoding('@harness/preview-model-source-response')]
134
138
  ])
135
139
  this._rpc = new RPC(stream, async (req) => {
136
140
  const command = methods.get(req.command)
@@ -344,6 +348,10 @@ class HRPC {
344
348
  return this._call('@harness/detect-language', args)
345
349
  }
346
350
 
351
+ async previewModelSource(args) {
352
+ return this._call('@harness/preview-model-source', args)
353
+ }
354
+
347
355
  onCompletion(responseFn) {
348
356
  this._handlers['@harness/completion'] = responseFn
349
357
  }
@@ -460,6 +468,10 @@ class HRPC {
460
468
  this._handlers['@harness/detect-language'] = responseFn
461
469
  }
462
470
 
471
+ onPreviewModelSource(responseFn) {
472
+ this._handlers['@harness/preview-model-source'] = responseFn
473
+ }
474
+
463
475
  _requestIsStream(command) {
464
476
  return [
465
477
  '@harness/completion',