@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.
- package/CHANGELOG.md +56 -1
- package/README.md +134 -0
- package/dist/harness.bundle +304 -175
- package/dist-lib/index.d.ts +6 -1
- package/dist-lib/index.js +29 -4
- package/dist-lib/lib/capabilities/completion.js +2 -5
- package/dist-lib/lib/capabilities/preview-model-source.d.ts +67 -0
- package/dist-lib/lib/capabilities/preview-model-source.js +387 -0
- package/dist-lib/lib/connect.d.ts +4 -5
- package/dist-lib/lib/connect.js +9 -0
- package/dist-lib/lib/contract-types.d.ts +4 -5
- package/dist-lib/lib/models/custom-model-cache.d.ts +4 -0
- package/dist-lib/lib/models/custom-model-cache.js +42 -0
- package/dist-lib/lib/models/gguf-header.d.ts +30 -0
- package/dist-lib/lib/models/gguf-header.js +208 -0
- package/dist-lib/lib/models/model-catalog.d.ts +6 -1
- package/dist-lib/lib/models/model-catalog.js +7 -2
- package/dist-lib/lib/models/model-delete.d.ts +4 -1
- package/dist-lib/lib/models/model-delete.js +19 -14
- package/dist-lib/lib/models/model-list.d.ts +6 -0
- package/dist-lib/lib/models/model-list.js +23 -1
- package/dist-lib/lib/models/model-source.js +1 -1
- package/dist-lib/lib/serve.js +21 -5
- package/dist-lib/schema/harness.js +42 -1
- package/dist-lib/spec/bind.js +3 -0
- package/dist-lib/spec/calls.js +1 -0
- package/dist-lib/spec/hrpc/hrpc.json +13 -0
- package/dist-lib/spec/hrpc/index.js +15 -3
- package/dist-lib/spec/hrpc/messages.js +173 -5
- package/dist-lib/spec/hyperschema/index.js +173 -5
- package/dist-lib/spec/hyperschema/schema.json +136 -1
- package/dist-lib/spec/hyperschema/types.d.ts +34 -1
- package/package.json +6 -7
- package/worker/harness-sidecar.mjs +1 -0
- package/dist-lib/lib/capabilities/vision-history.d.ts +0 -3
- package/dist-lib/lib/capabilities/vision-history.js +0 -21
package/dist-lib/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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;
|
package/dist-lib/lib/connect.js
CHANGED
|
@@ -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({
|