@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
@@ -3,6 +3,7 @@ import ReadyResource from 'ready-resource';
3
3
  import { type Agent, type ApproveFn, type AskUserFn, type AttachFn, type CompletionEvent, type FetchAttachmentFn, type Message, type ProgressFn, type RagSearchFn, type ResponseFormat, type ToolDefinition, type ToolExecuteContext } from './lib/capabilities/completion.ts';
4
4
  import { type DetectLanguageContext, type DetectedLanguage } from './lib/capabilities/detect-language.ts';
5
5
  import { type Capability } from './lib/capabilities/invoke.ts';
6
+ import { type ModelSourcePreview, type PreviewModelSourceContext } from './lib/capabilities/preview-model-source.ts';
6
7
  import { type EnhanceMode } from './lib/capabilities/enhance.ts';
7
8
  import { type DownloadArgs, type DownloadEventStream } from './lib/capabilities/download.ts';
8
9
  import { type EmbedContext } from './lib/capabilities/embed.ts';
@@ -29,6 +30,7 @@ export { CAPABILITIES, isCapability, type Capability } from './lib/capabilities/
29
30
  export type { EnhanceMode } from './lib/capabilities/enhance.ts';
30
31
  export { EURONANO_LANGUAGES, NMT_FROM_ENGLISH, NMT_TO_ENGLISH, SLM_LANGUAGE_CODES, SLM_LANGUAGE_NAMES, translationPlanFor, type TranslationPlan } from './lib/models/nmt-catalog.ts';
31
32
  export type { DetectLanguageContext, DetectedLanguage } from './lib/capabilities/detect-language.ts';
33
+ export type { ModelFitVerdict, ModelSourceFile, ModelSourcePreview, ModelSourceRefusal, PreviewModelSourceContext } from './lib/capabilities/preview-model-source.ts';
32
34
  export * from './lib/capabilities/download.ts';
33
35
  export type { EmbedContext } from './lib/capabilities/embed.ts';
34
36
  export type { SpeakContext, SpeakResult, SpeakStreamContext, TranscribeContext } from './lib/capabilities/voice.ts';
@@ -73,6 +75,7 @@ export interface HarnessOptions {
73
75
  gpuStats?: unknown;
74
76
  sdkLoader?: SdkLoader;
75
77
  skillsDir?: string;
78
+ modelsDir?: string;
76
79
  projectionModel?: string | Record<string, string>;
77
80
  imageModel?: string | ImageModelOptions;
78
81
  imageClamps?: ImageClamps;
@@ -141,6 +144,7 @@ export declare class Harness extends ReadyResource {
141
144
  private _voiceLane;
142
145
  private _state;
143
146
  private _skillsDir?;
147
+ private readonly _modelsDir?;
144
148
  private _overlayWatcher;
145
149
  private _supportedSkills;
146
150
  private _projectionModel;
@@ -168,7 +172,7 @@ export declare class Harness extends ReadyResource {
168
172
  private _capability;
169
173
  private _capabilityInflight;
170
174
  private readonly _modelFaults;
171
- constructor({ tools, sdkLoader, skillsDir, projectionModel, imageModel, imageClamps, musicModel, musicClamps, llmDevice, llmGpuLayers, contextSize, kvCacheK, kvCacheV, embedDevice, embedGpuLayers, embedBatchSize, transcriptionDevice, completionModelCap, credentialStore, platform, gpuStats }?: HarnessOptions);
175
+ constructor({ tools, sdkLoader, skillsDir, modelsDir, projectionModel, imageModel, imageClamps, musicModel, musicClamps, llmDevice, llmGpuLayers, contextSize, kvCacheK, kvCacheV, embedDevice, embedGpuLayers, embedBatchSize, transcriptionDevice, completionModelCap, credentialStore, platform, gpuStats }?: HarnessOptions);
172
176
  _open(): Promise<void>;
173
177
  get suspended(): boolean;
174
178
  get skillNames(): string[];
@@ -212,6 +216,7 @@ export declare class Harness extends ReadyResource {
212
216
  supportsImagesFor(model: string): boolean;
213
217
  private _projectionFor;
214
218
  setCustomModels(entries: ReadonlyArray<CustomModelEntry>): void;
219
+ previewModelSource(context: PreviewModelSourceContext): Promise<ModelSourcePreview>;
215
220
  describe(): Promise<HarnessInfo>;
216
221
  diagnostics(): HarnessDiagnostics;
217
222
  private _loadedModels;
package/dist-lib/index.js CHANGED
@@ -8,6 +8,7 @@ import { closePythonWorker } from './lib/tools/exec/adapters/python/worker/clien
8
8
  import { completion, imageSettingsFrom } from './lib/capabilities/completion.js';
9
9
  import { detectLanguage } from './lib/capabilities/detect-language.js';
10
10
  import { invoke } from './lib/capabilities/invoke.js';
11
+ import { previewModelSource } from './lib/capabilities/preview-model-source.js';
11
12
  import { expandImagePrompt } from './lib/capabilities/enhance.js';
12
13
  import { cleanAnswer } from './lib/helpers/clean-answer.js';
13
14
  import { isTranslationModel } from './lib/models/nmt-catalog.js';
@@ -21,7 +22,7 @@ import { createVoiceLane, packSamples, speakOnce, speakStreamOnce, transcribeOnc
21
22
  import { pushQueue } from './lib/queue.js';
22
23
  import { AbortedError, raceAbort } from './lib/helpers/race-abort.js';
23
24
  import { audioGenDownloadMetadata, customProjectionMap, defaultProjectionMap, defaultTtsFor, defaultVoiceModel, diffusionDownloadMetadata, infraModelsFor, modelNameToCapabilityProfile, musicModelSet, setCustomModels } from './lib/models/model-catalog.js';
24
- import { createModelStatusCache, invalidateModelStatusCache, listModels } from './lib/models/model-list.js';
25
+ import { createModelStatusCache, customModelRows, invalidateModelStatusCache, mergeCustomRows, listModels } from './lib/models/model-list.js';
25
26
  import { deleteModelFiles, summarizeDelete } from './lib/models/model-delete.js';
26
27
  import { createEmbedPool, createLoadTracker, createModelPool, createVoicePool, awaitModelCapacity, evictIdle, evictModelWithin, isResident, leaseResidentModel, leaseCount, loadedModels, modelKey, releaseModel, resolveLoadOptions, progressState } from './lib/models/model-pool.js';
27
28
  import { resolveMainGpu } from './lib/models/gpu-selection.js';
@@ -104,6 +105,7 @@ export class Harness extends ReadyResource {
104
105
  _voiceLane = createVoiceLane();
105
106
  _state;
106
107
  _skillsDir;
108
+ _modelsDir;
107
109
  _overlayWatcher = null;
108
110
  _supportedSkills;
109
111
  _projectionModel;
@@ -131,7 +133,7 @@ export class Harness extends ReadyResource {
131
133
  _capability = null;
132
134
  _capabilityInflight = null;
133
135
  _modelFaults = new Map();
134
- constructor({ tools = [], sdkLoader, skillsDir, projectionModel, imageModel, imageClamps, musicModel, musicClamps, llmDevice, llmGpuLayers, contextSize, kvCacheK, kvCacheV, embedDevice, embedGpuLayers, embedBatchSize, transcriptionDevice, completionModelCap, credentialStore, platform, gpuStats } = {}) {
136
+ constructor({ tools = [], sdkLoader, skillsDir, modelsDir, projectionModel, imageModel, imageClamps, musicModel, musicClamps, llmDevice, llmGpuLayers, contextSize, kvCacheK, kvCacheV, embedDevice, embedGpuLayers, embedBatchSize, transcriptionDevice, completionModelCap, credentialStore, platform, gpuStats } = {}) {
135
137
  super();
136
138
  this._sdk = sdkLoader ?? createSdkLoader();
137
139
  this._llmDevice = llmDevice;
@@ -235,6 +237,7 @@ export class Harness extends ReadyResource {
235
237
  this._projectionModel = projectionModel ?? defaultProjectionMap();
236
238
  this._state = HarnessState.RESUME;
237
239
  this._skillsDir = skillsDir;
240
+ this._modelsDir = modelsDir;
238
241
  this._supportedSkills = [];
239
242
  this._allSkills = [];
240
243
  this._credentialStore = credentialStore;
@@ -480,6 +483,21 @@ export class Harness extends ReadyResource {
480
483
  setCustomModels(entries);
481
484
  this._customProjection = customProjectionMap();
482
485
  }
486
+ previewModelSource(context) {
487
+ return previewModelSource(context, {
488
+ assessFit: async ({ sha256Checksum, name, contextTokens }) => {
489
+ const sdk = await this._sdk.load();
490
+ const result = await sdk.assessModelFit({
491
+ models: [{ model: { sha256Checksum, name }, workload: { kind: 'llm', contextTokens } }]
492
+ });
493
+ const model = result.models[0];
494
+ return {
495
+ fit: model?.verdict ?? result.verdict,
496
+ fitReasons: [...(model?.reasons ?? result.reasons)]
497
+ };
498
+ }
499
+ });
500
+ }
483
501
  async describe() {
484
502
  if (!this.opened)
485
503
  await this.ready();
@@ -906,12 +924,19 @@ export class Harness extends ReadyResource {
906
924
  async list(options = {}) {
907
925
  await this.ready();
908
926
  const sdk = (await this._sdk.load());
909
- return listModels(sdk, options, this._modelStatus, (name) => this._projectionFor(name));
927
+ const [rows, custom] = await Promise.all([
928
+ listModels(sdk, options, this._modelStatus, (name) => this._projectionFor(name)),
929
+ customModelRows(options, {
930
+ modelsDir: this._modelsDir,
931
+ loaded: new Set(this._loadedModels().map((entry) => entry.model))
932
+ })
933
+ ]);
934
+ return mergeCustomRows(rows, custom);
910
935
  }
911
936
  async deleteModel(name) {
912
937
  await this.ready();
913
938
  const sdk = (await this._sdk.load());
914
- const result = await deleteModelFiles(sdk, name);
939
+ const result = await deleteModelFiles(sdk, name, { modelsDir: this._modelsDir });
915
940
  invalidateModelStatusCache(this._modelStatus);
916
941
  return summarizeDelete(result);
917
942
  }
@@ -29,7 +29,6 @@ import { NETWORK_TOOLSETS, permittedTools } from '../tools/toolsets.js';
29
29
  import { promptPrefixHash } from './prompt-prefix.js';
30
30
  import { logToolPrompt } from './tool-prompt-log.js';
31
31
  import { DIRECT_CALL_ID, loadedSkillFilesFrom, loadedSkillsFrom, TOOL_CONTINUATION_NUDGE, toolResultTurn, withoutSkillLoadTurns } from './tool-turns.js';
32
- import { projectVisionHistory, visionHistoryCacheKey } from './vision-history.js';
33
32
  export function imageSettingsFrom(agent) {
34
33
  if (!agent)
35
34
  return undefined;
@@ -545,7 +544,7 @@ async function* runRecoveredRoundWith(sdk, args, modelId) {
545
544
  const cacheable = candidate.history === args.history;
546
545
  const params = {
547
546
  modelId,
548
- history: projectVisionHistory(candidate.history),
547
+ history: candidate.history,
549
548
  stream: true,
550
549
  tools: args.tools,
551
550
  generationParams: args.generationParams,
@@ -718,7 +717,6 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
718
717
  const toolDialect = model ? customToolDialect(model) : undefined;
719
718
  let turns = messages;
720
719
  let preloadedSkill = null;
721
- let stubDropped = false;
722
720
  const preload = mention ?? resolveImplicitSkill(lastUserContent(messages), activeSkills);
723
721
  if (preload) {
724
722
  const loader = tools.find((tool) => tool.name === SKILL_TOOL_NAME);
@@ -727,7 +725,6 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
727
725
  preloadedSkill = String(loaded.content);
728
726
  const inlined = preloadLastUser(messages, preload.name, preloadedSkill);
729
727
  turns = withoutSkillLoadTurns(inlined, preload.name);
730
- stubDropped = turns.length < inlined.length;
731
728
  if (!loadedSkills.has(preload.name)) {
732
729
  const preloadArgs = { name: preload.name };
733
730
  const preloadEffect = 'read';
@@ -798,7 +795,7 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
798
795
  }
799
796
  logSelection();
800
797
  let history = annotateLastUserTime(systemContent ? [{ role: 'system', content: systemContent }, ...turns] : turns);
801
- let roundCacheKey = stubDropped ? undefined : visionHistoryCacheKey(cacheKey, history);
798
+ let roundCacheKey = preloadedSkill === null ? cacheKey : undefined;
802
799
  const runUsage = () => usageBreakdown(promptParts, stats.cacheTokens, model);
803
800
  const denied = new Set();
804
801
  const failed = new Map();
@@ -0,0 +1,67 @@
1
+ import type { HarnessPreviewModelSourceResponse } from '../../spec/hyperschema/types.d.ts';
2
+ export type ModelFitVerdict = 'likely-fits' | 'likely-too-large' | 'unknown';
3
+ export type ModelSourceRefusal = 'invalid-source' | 'not-found' | 'private' | 'gated' | 'web-page' | 'unreachable' | 'not-gguf' | 'not-a-model' | 'no-model-files' | 'invalid-projection';
4
+ export type ModelTaskType = 'llm' | 'embeddings' | 'speech-to-text' | 'text-to-speech' | 'image-generation';
5
+ export interface ModelSourceFile {
6
+ src: string;
7
+ label: string;
8
+ sizeBytes: number;
9
+ }
10
+ export interface ModelSourcePreview {
11
+ ok: boolean;
12
+ refusal?: ModelSourceRefusal;
13
+ reason?: string;
14
+ src?: string;
15
+ files?: ModelSourceFile[];
16
+ sizeBytes?: number;
17
+ contextLength?: number;
18
+ architecture?: string;
19
+ sizeLabel?: string;
20
+ taskType?: string;
21
+ license?: string;
22
+ fit?: ModelFitVerdict;
23
+ fitReasons?: string[];
24
+ mmprojSrc?: string;
25
+ }
26
+ export interface PreviewModelSourceContext {
27
+ src: string;
28
+ mmprojSrc?: string;
29
+ }
30
+ export interface PreviewResponse {
31
+ ok: boolean;
32
+ status: number;
33
+ headers: {
34
+ get(name: string): string | null;
35
+ };
36
+ json(): Promise<unknown>;
37
+ arrayBuffer(): Promise<ArrayBuffer>;
38
+ }
39
+ export type PreviewFetch = (url: string, init?: {
40
+ method?: string;
41
+ headers?: Record<string, string>;
42
+ redirect?: 'follow';
43
+ }) => Promise<PreviewResponse>;
44
+ export interface FitQuery {
45
+ sha256Checksum: string;
46
+ name: string;
47
+ contextTokens: number;
48
+ }
49
+ export interface PreviewDeps {
50
+ fetch?: PreviewFetch;
51
+ stat?(path: string): Promise<{
52
+ size: number;
53
+ } | null>;
54
+ assessFit?(query: FitQuery): Promise<{
55
+ fit: ModelFitVerdict;
56
+ fitReasons: string[];
57
+ }>;
58
+ }
59
+ interface HuggingFaceRef {
60
+ repo: string;
61
+ revision: string;
62
+ file?: string;
63
+ }
64
+ export declare function previewModelSource(context: PreviewModelSourceContext, deps?: PreviewDeps): Promise<ModelSourcePreview>;
65
+ export declare function readModelSourcePreview(response: HarnessPreviewModelSourceResponse): ModelSourcePreview;
66
+ export declare function parseHuggingFaceRef(src: string): HuggingFaceRef | null;
67
+ export {};
@@ -0,0 +1,387 @@
1
+ import fs from 'fs';
2
+ import defaultFetch from '#fetch';
3
+ import { defaultContextSize } from '../models/context.js';
4
+ import { isRemoteSource, localSourcePath, parseGgufHeader, readGgufHeaderBytes } from '../models/gguf-header.js';
5
+ const HUGGING_FACE = 'https://huggingface.co';
6
+ const HUGGING_FACE_HOSTS = new Set(['huggingface.co', 'www.huggingface.co', 'hf.co']);
7
+ const NON_MODEL_NAMESPACES = new Set(['api', 'datasets', 'spaces', 'docs', 'models']);
8
+ const SHARD = /^(.+)-(\d{5})-of-(\d{5})\.gguf$/i;
9
+ const PIPELINE_TASKS = {
10
+ 'automatic-speech-recognition': 'speech-to-text',
11
+ 'text-to-speech': 'text-to-speech',
12
+ 'text-to-image': 'image-generation',
13
+ 'feature-extraction': 'embeddings',
14
+ 'sentence-similarity': 'embeddings',
15
+ 'text-generation': 'llm',
16
+ 'image-text-to-text': 'llm'
17
+ };
18
+ const ARCHITECTURE_TASKS = [
19
+ [/^whisper/i, 'speech-to-text'],
20
+ [/^parakeet/i, 'speech-to-text'],
21
+ [/^flux/i, 'image-generation'],
22
+ [/^supertonic/i, 'text-to-speech'],
23
+ [/bert/i, 'embeddings']
24
+ ];
25
+ const NOT_LLM_REASON = 'Fit is estimated only for text-generation models.';
26
+ const NO_CHECKSUM_REASON = 'Fit is estimated only for files the SDK catalog knows by checksum, and this source publishes none.';
27
+ const NO_ESTIMATOR_REASON = 'This harness has no fit estimator.';
28
+ export async function previewModelSource(context, deps = {}) {
29
+ const fetch = deps.fetch ?? defaultFetch;
30
+ const src = context.src.trim();
31
+ if (!src) {
32
+ return refuse('invalid-source', 'Paste a Hugging Face link, a direct link to a .gguf file, or a local path.');
33
+ }
34
+ const weight = await inspectModelFile(src, fetch, deps);
35
+ if ('answer' in weight)
36
+ return weight.answer;
37
+ const problem = modelProblem(weight.header);
38
+ if (problem)
39
+ return refuse(problem[0], problem[1]);
40
+ const taskType = taskTypeFor({
41
+ pipelineTag: weight.file.pipelineTag,
42
+ tags: [...(weight.file.tags ?? []), ...(weight.header.tags ?? [])],
43
+ architecture: weight.header.architecture
44
+ });
45
+ let projectionBytes = 0;
46
+ let projectionSrc;
47
+ const mmprojSrc = context.mmprojSrc?.trim();
48
+ if (mmprojSrc) {
49
+ const projection = await inspectModelFile(mmprojSrc, fetch, deps);
50
+ if ('answer' in projection) {
51
+ return projection.answer.ok
52
+ ? refuse('invalid-projection', 'Link the projection (mmproj) file itself, not its repository.')
53
+ : refuse('invalid-projection', `The projection was refused: ${projection.answer.reason ?? ''}`.trim());
54
+ }
55
+ if (!isProjection(projection.header)) {
56
+ return refuse('invalid-projection', 'The projection is not an mmproj file.');
57
+ }
58
+ projectionBytes = projection.file.sizeBytes;
59
+ projectionSrc = projection.file.src;
60
+ }
61
+ const { fit, fitReasons } = await assess(weight.file, weight.header, taskType, deps);
62
+ return defined({
63
+ ok: true,
64
+ src: weight.file.src,
65
+ sizeBytes: weight.file.sizeBytes + projectionBytes || undefined,
66
+ contextLength: weight.header.contextLength,
67
+ architecture: weight.header.architecture,
68
+ sizeLabel: weight.header.sizeLabel,
69
+ taskType,
70
+ license: weight.file.license ?? weight.header.license,
71
+ fit,
72
+ fitReasons,
73
+ mmprojSrc: projectionSrc
74
+ });
75
+ }
76
+ export function readModelSourcePreview(response) {
77
+ return defined({
78
+ ok: response.ok === true,
79
+ refusal: (response.refusal || undefined),
80
+ reason: response.reason || undefined,
81
+ src: response.src || undefined,
82
+ files: response.files?.length
83
+ ? response.files.map(({ src, label, sizeBytes }) => ({
84
+ src,
85
+ label,
86
+ sizeBytes: sizeBytes ?? 0
87
+ }))
88
+ : undefined,
89
+ sizeBytes: response.sizeBytes || undefined,
90
+ contextLength: response.contextLength || undefined,
91
+ architecture: response.architecture || undefined,
92
+ sizeLabel: response.sizeLabel || undefined,
93
+ taskType: response.taskType || undefined,
94
+ license: response.license || undefined,
95
+ fit: (response.fit || undefined),
96
+ fitReasons: response.fitReasons?.length ? [...response.fitReasons] : undefined,
97
+ mmprojSrc: response.mmprojSrc || undefined
98
+ });
99
+ }
100
+ export function parseHuggingFaceRef(src) {
101
+ let url;
102
+ try {
103
+ url = new URL(src);
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ if (!HUGGING_FACE_HOSTS.has(url.hostname))
109
+ return null;
110
+ const segments = pathSegments(url.pathname);
111
+ if (!segments)
112
+ return null;
113
+ const [org, name, kind, revision, ...rest] = segments;
114
+ if (!org || !name || NON_MODEL_NAMESPACES.has(org))
115
+ return null;
116
+ const repo = `${org}/${name}`;
117
+ if (!kind) {
118
+ const shown = url.searchParams.get('show_file_info');
119
+ return shown ? { repo, revision: 'main', file: shown } : { repo, revision: 'main' };
120
+ }
121
+ if ((kind === 'blob' || kind === 'resolve') && revision && rest.length > 0) {
122
+ return { repo, revision, file: rest.join('/') };
123
+ }
124
+ if (kind === 'tree' && revision)
125
+ return { repo, revision };
126
+ return null;
127
+ }
128
+ async function inspectModelFile(src, fetch, deps) {
129
+ const inspection = await inspect(src, fetch, deps);
130
+ if (inspection.kind === 'answer')
131
+ return { answer: inspection.preview };
132
+ const bytes = await readGgufHeaderBytes(inspection.file.src, {
133
+ fetch: fetch
134
+ });
135
+ if (!bytes) {
136
+ return {
137
+ answer: refuse('unreachable', 'Could not read the start of the file to check it is a model.')
138
+ };
139
+ }
140
+ const header = parseGgufHeader(bytes);
141
+ if (!header) {
142
+ return {
143
+ answer: refuse('not-gguf', 'This is not a GGUF file. Only GGUF models run on this device.')
144
+ };
145
+ }
146
+ return { file: inspection.file, header };
147
+ }
148
+ function inspect(src, fetch, deps) {
149
+ const ref = parseHuggingFaceRef(src);
150
+ if (ref)
151
+ return inspectHuggingFace(ref, fetch);
152
+ if (isRemoteSource(src)) {
153
+ return wellFormedUrl(src)
154
+ ? inspectUrl(src, fetch)
155
+ : Promise.resolve(answer(refuse('invalid-source', 'This link is not a valid URL.')));
156
+ }
157
+ const file = localSourcePath(src);
158
+ if (file === null) {
159
+ return Promise.resolve(answer(refuse('invalid-source', 'Only Hugging Face links, http(s) links and local paths can be added.')));
160
+ }
161
+ return inspectLocal(src, file, deps);
162
+ }
163
+ async function inspectHuggingFace(ref, fetch) {
164
+ let response;
165
+ try {
166
+ response = await fetch(modelInfoUrl(ref));
167
+ }
168
+ catch {
169
+ return answer(refuse('unreachable', 'Could not reach Hugging Face.'));
170
+ }
171
+ if (response.status === 401 || response.status === 403) {
172
+ return answer(refuse('private', `${ref.repo} is private or does not exist on Hugging Face.`));
173
+ }
174
+ if (response.status === 404) {
175
+ return answer(refuse('not-found', `${ref.repo} does not exist on Hugging Face.`));
176
+ }
177
+ if (!response.ok) {
178
+ return answer(refuse('unreachable', `Hugging Face answered HTTP ${response.status}.`));
179
+ }
180
+ const info = (await response.json());
181
+ if (info.private) {
182
+ return answer(refuse('private', `${ref.repo} is private on Hugging Face.`));
183
+ }
184
+ if (info.gated) {
185
+ return answer(refuse('gated', `${ref.repo} is gated: its files need an accepted license and a Hugging Face sign-in, which custom models do not support.`));
186
+ }
187
+ const pipelineTag = info.pipeline_tag;
188
+ const license = licenseOf(info);
189
+ const siblings = info.siblings ?? [];
190
+ const candidates = modelFiles(ref, siblings);
191
+ const wanted = ref.file;
192
+ if (wanted) {
193
+ const sibling = siblings.find((entry) => entry.rfilename === wanted);
194
+ if (!sibling)
195
+ return answer(refuse('not-found', `${ref.repo} has no file ${wanted}.`));
196
+ if (!/\.gguf$/i.test(wanted)) {
197
+ return answer(refuse('not-gguf', `${wanted} is not a GGUF file. Only GGUF models run on this device.`));
198
+ }
199
+ const file = candidates.find((candidate) => candidate.members.includes(wanted)) ?? fileOf(ref, sibling);
200
+ return { kind: 'file', file: { ...file, license, pipelineTag, tags: info.tags } };
201
+ }
202
+ if (candidates.length === 0) {
203
+ return answer(refuse('no-model-files', `${ref.repo} has no GGUF model file. Only GGUF models run on this device.`));
204
+ }
205
+ if (candidates.length === 1) {
206
+ return { kind: 'file', file: { ...candidates[0], license, pipelineTag, tags: info.tags } };
207
+ }
208
+ return answer(defined({
209
+ ok: true,
210
+ files: candidates.map(({ src, label, sizeBytes }) => ({ src, label, sizeBytes })),
211
+ license,
212
+ taskType: taskTypeFor({ pipelineTag, tags: info.tags })
213
+ }));
214
+ }
215
+ async function inspectUrl(src, fetch) {
216
+ let response;
217
+ try {
218
+ response = await fetch(src, { method: 'HEAD', redirect: 'follow' });
219
+ }
220
+ catch {
221
+ return answer(refuse('unreachable', `Could not reach ${new URL(src).host}.`));
222
+ }
223
+ const { status } = response;
224
+ if (status === 401 || status === 403) {
225
+ return answer(refuse('private', `The server refused access (HTTP ${status}). Only public links can be added.`));
226
+ }
227
+ if (status === 404 || status === 410) {
228
+ return answer(refuse('not-found', `Nothing is at this link (HTTP ${status}).`));
229
+ }
230
+ if (status === 405 || status === 501) {
231
+ return { kind: 'file', file: { src, label: basename(src), sizeBytes: 0 } };
232
+ }
233
+ if (!response.ok) {
234
+ return answer(refuse('unreachable', `The server answered HTTP ${status}.`));
235
+ }
236
+ if ((response.headers.get('content-type') ?? '').includes('text/html')) {
237
+ return answer(refuse('web-page', 'This link opens a web page, not a model file. Link to the .gguf file itself.'));
238
+ }
239
+ const sizeBytes = positive(response.headers.get('x-linked-size')) ??
240
+ positive(response.headers.get('content-length')) ??
241
+ 0;
242
+ return { kind: 'file', file: { src, label: basename(src), sizeBytes } };
243
+ }
244
+ async function inspectLocal(src, file, deps) {
245
+ const stat = await (deps.stat ?? statFile)(file);
246
+ if (!stat)
247
+ return answer(refuse('not-found', `There is no file at ${file}.`));
248
+ return { kind: 'file', file: { src, label: basename(file), sizeBytes: stat.size } };
249
+ }
250
+ async function statFile(file) {
251
+ try {
252
+ const stat = await fs.promises.stat(file);
253
+ return stat.isFile() ? { size: stat.size } : null;
254
+ }
255
+ catch {
256
+ return null;
257
+ }
258
+ }
259
+ function modelFiles(ref, siblings) {
260
+ const singles = [];
261
+ const shardSets = new Map();
262
+ for (const sibling of siblings) {
263
+ const name = sibling.rfilename;
264
+ if (!/\.gguf$/i.test(name) || /mmproj/i.test(name))
265
+ continue;
266
+ const shard = SHARD.exec(name);
267
+ if (!shard) {
268
+ singles.push({ ...fileOf(ref, sibling), members: [name] });
269
+ continue;
270
+ }
271
+ const key = `${shard[1]}|${shard[3]}`;
272
+ shardSets.set(key, [...(shardSets.get(key) ?? []), sibling]);
273
+ }
274
+ const sets = [...shardSets.values()].map((parts) => {
275
+ const sorted = [...parts].sort((a, b) => a.rfilename.localeCompare(b.rfilename));
276
+ const first = sorted[0];
277
+ return {
278
+ src: resolveUrl(ref, first.rfilename),
279
+ label: `${basename(first.rfilename.replace(/-\d{5}-of-\d{5}\.gguf$/i, '.gguf'))} (${sorted.length} parts)`,
280
+ sizeBytes: sorted.reduce((total, part) => total + sizeOf(part), 0),
281
+ members: sorted.map((part) => part.rfilename)
282
+ };
283
+ });
284
+ return [...singles, ...sets];
285
+ }
286
+ function fileOf(ref, sibling) {
287
+ return defined({
288
+ src: resolveUrl(ref, sibling.rfilename),
289
+ label: basename(sibling.rfilename),
290
+ sizeBytes: sizeOf(sibling),
291
+ sha256: sibling.lfs?.sha256
292
+ });
293
+ }
294
+ function modelProblem(header) {
295
+ if (isProjection(header)) {
296
+ return [
297
+ 'not-a-model',
298
+ 'This is a vision projection (mmproj), not a model. Add the model file and pass this as its projection.'
299
+ ];
300
+ }
301
+ if (header.type === 'adapter')
302
+ return ['not-a-model', 'This is a LoRA adapter, not a model.'];
303
+ return null;
304
+ }
305
+ function isProjection(header) {
306
+ return header.architecture === 'clip' || header.type === 'mmproj';
307
+ }
308
+ function taskTypeFor({ pipelineTag, tags = [], architecture }) {
309
+ const tagged = [pipelineTag, ...tags].find((tag) => tag !== undefined && Object.hasOwn(PIPELINE_TASKS, tag));
310
+ if (tagged)
311
+ return PIPELINE_TASKS[tagged];
312
+ return ARCHITECTURE_TASKS.find(([pattern]) => pattern.test(architecture ?? ''))?.[1] ?? 'llm';
313
+ }
314
+ async function assess(file, header, taskType, deps) {
315
+ if (taskType !== 'llm')
316
+ return { fit: 'unknown', fitReasons: [NOT_LLM_REASON] };
317
+ if (!file.sha256)
318
+ return { fit: 'unknown', fitReasons: [NO_CHECKSUM_REASON] };
319
+ if (!deps.assessFit)
320
+ return { fit: 'unknown', fitReasons: [NO_ESTIMATOR_REASON] };
321
+ try {
322
+ return await deps.assessFit({
323
+ sha256Checksum: file.sha256,
324
+ name: header.name ?? file.label,
325
+ contextTokens: Math.min(header.contextLength || defaultContextSize(file.src), defaultContextSize(file.src))
326
+ });
327
+ }
328
+ catch (err) {
329
+ return {
330
+ fit: 'unknown',
331
+ fitReasons: [`The fit estimate failed: ${err instanceof Error ? err.message : String(err)}`]
332
+ };
333
+ }
334
+ }
335
+ function modelInfoUrl(ref) {
336
+ const revision = ref.revision === 'main' ? '' : `/revision/${encodeURIComponent(ref.revision)}`;
337
+ return `${HUGGING_FACE}/api/models/${encodePath(ref.repo)}${revision}?blobs=true`;
338
+ }
339
+ function resolveUrl(ref, file) {
340
+ return `${HUGGING_FACE}/${encodePath(ref.repo)}/resolve/${encodeURIComponent(ref.revision)}/${encodePath(file)}`;
341
+ }
342
+ function encodePath(path) {
343
+ return path.split('/').map(encodeURIComponent).join('/');
344
+ }
345
+ function pathSegments(pathname) {
346
+ try {
347
+ return pathname.split('/').filter(Boolean).map(decodeURIComponent);
348
+ }
349
+ catch {
350
+ return null;
351
+ }
352
+ }
353
+ function wellFormedUrl(src) {
354
+ try {
355
+ return pathSegments(new URL(src).pathname) !== null;
356
+ }
357
+ catch {
358
+ return false;
359
+ }
360
+ }
361
+ function licenseOf(info) {
362
+ const license = info.cardData?.license;
363
+ if (Array.isArray(license))
364
+ return license.length ? license.join(', ') : undefined;
365
+ return license || undefined;
366
+ }
367
+ function sizeOf(sibling) {
368
+ return sibling.lfs?.size ?? sibling.size ?? 0;
369
+ }
370
+ function basename(src) {
371
+ const path = src.split(/[?#]/)[0];
372
+ const name = path.slice(path.lastIndexOf('/') + 1);
373
+ return (pathSegments(name) ?? [name])[0] || src;
374
+ }
375
+ function positive(raw) {
376
+ const value = Number(raw);
377
+ return Number.isFinite(value) && value > 0 ? value : undefined;
378
+ }
379
+ function refuse(refusal, reason) {
380
+ return { ok: false, refusal, reason };
381
+ }
382
+ function answer(preview) {
383
+ return { kind: 'answer', preview };
384
+ }
385
+ function defined(value) {
386
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
387
+ }
@@ -1,6 +1,8 @@
1
1
  import EventEmitter from 'events';
2
2
  import { type Transport } from './rpc-session.ts';
3
3
  import { type KvCacheDeletionResult } from './capabilities/kv-cache.ts';
4
+ import type { CustomModelEntry } from './models/model-catalog.ts';
5
+ import { type ModelSourcePreview, type PreviewModelSourceContext } from './capabilities/preview-model-source.ts';
4
6
  import type { CapabilityInfo, CompletionContext, DetectedLanguage, DetectLanguageContext, DownloadContext, EmbedContext, HarnessContract, HarnessDiagnostics, HarnessEvent, HarnessInfo, InvokeContext, ListModelsOptions, ModelInfo, ResolvedSkillCatalogEntry, SpeakContext, SpeakResult, SpeakStreamContext, ToolCatalogEntry, TranscribeContext, WarmModelContext } from './contract-types.ts';
5
7
  import { type EmbedDevice } from './models/embed-config.ts';
6
8
  import type { DeleteModelSummary } from './models/model-delete.ts';
@@ -54,11 +56,8 @@ export declare class RemoteHarness extends EventEmitter implements HarnessContra
54
56
  deleteOverlaySkill(name: string): Promise<{
55
57
  removed: boolean;
56
58
  }>;
57
- setCustomModels(entries: ReadonlyArray<{
58
- name: string;
59
- src: string;
60
- projectionSrc?: string;
61
- }>): Promise<void>;
59
+ setCustomModels(entries: ReadonlyArray<CustomModelEntry>): Promise<void>;
60
+ previewModelSource(context: PreviewModelSourceContext): Promise<ModelSourcePreview>;
62
61
  skills(agentId?: string | null, modelName?: string | null, options?: {
63
62
  readonly refresh?: boolean;
64
63
  readonly skipCredentialProbe?: boolean;
@@ -4,6 +4,7 @@ import { settleOnClose } from './rpc-session.js';
4
4
  import { harnessClient } from '../spec/calls.js';
5
5
  import HRPC from '../spec/hrpc/index.js';
6
6
  import { unsupportedKvCacheDeletion } from './capabilities/kv-cache.js';
7
+ import { readModelSourcePreview } from './capabilities/preview-model-source.js';
7
8
  import { hlog } from './log.js';
8
9
  import { assertEmbedDevice } from './models/embed-config.js';
9
10
  import { errorCode, errorMessage } from './error-fields.js';
@@ -462,6 +463,14 @@ export class RemoteHarness extends EventEmitter {
462
463
  const { client } = await this._ensure();
463
464
  await client.setCustomModels({ models: entries });
464
465
  }
466
+ async previewModelSource(context) {
467
+ const { client } = await this._ensure();
468
+ const response = await client.previewModelSource({
469
+ src: context.src,
470
+ mmprojSrc: context.mmprojSrc
471
+ });
472
+ return readModelSourcePreview(response);
473
+ }
465
474
  async skills(agentId, modelName, options) {
466
475
  const { client } = await this._ensure();
467
476
  const response = await client.skills({