mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
feat(local-model): contract for the install stage (types, stubs, fake runtime)
The types, the module skeletons and the test fake that let the runtime, the install job and the window be built at the same time. Types: - src/types/local-model-install.ts (new): the runtime pin, the install states, steps and error codes with one sentence and one next step each, the progress, the `install` object of GET /api/local-model, the bodies and 409 answers of the install verbs, the state file. `LocalModelStatus` moves here; `install` is optional until the route emits it. - src/types/local-model.ts: the catalogue row's Mac memory figures, the measured speed of a tested entry, the `measured` budget source, `RecommendOptions`, and the row tag, the coding agents and the install total of a candidate. All optional until the catalogue and `recommend()` carry them, so the read-only stage keeps compiling and its tests stay green. Modules with final signatures and bodies that throw: runtime.ts (the compiled pin is final), ollama-client.ts, developer-tools.ts, state-store.ts, install-job.ts (the state, step and transition tables are final), connect.ts, custom-model-hosts-writer.ts, cli-install-runner.ts. Nothing imports them yet, so the server is unchanged. Implemented: paths.ts (every folder of the feature, from dataPath()), and `modelFitsBudget()` in recommend.ts, the judgement the job makes with the measured budget before it downloads a model. `recommend()` takes the options parameter and does not read it yet. Test fake: test/fixtures/fake-ollama.ts, a runtime on a port the system picks, in the shapes measured on a Mac: version, tags, ps, the pull stream (MLX and GGUF lines, an error line, a cut stream, a resumed pull), chat with and without a tool call, the model list, a runtime archive with Range support, and a fake child process that logs the device total. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
66ca92f7cf
commit
771072c36b
17 files changed
+3770
-23
No files matched your search
Vendored
+810
@@ -0,0 +1,810 @@
|
||||
/**
|
||||
* @fileoverview A fake model runtime for the local model tests: an in-process HTTP server on
|
||||
* a port the system picks, plus a fake child process that "is" the runtime, so a test starts
|
||||
* no real runtime, downloads no real model and binds no fixed port.
|
||||
*
|
||||
* Plain `node:http`, like test/fixtures/mock-openai-server.ts. Every request is captured
|
||||
* (`requests`: method, path, headers, parsed JSON body). Every answer has the shape the real
|
||||
* runtime (0.40.15) gave on 2026-11-06; where a shape was not captured, the comment at its
|
||||
* handler says so.
|
||||
*
|
||||
* What it answers:
|
||||
*
|
||||
* | Route | Answer |
|
||||
* |-----------------------------------|---------------------------------------------------------------------|
|
||||
* | `GET /` | `Ollama is running` |
|
||||
* | `GET /api/version` | `{"version":"0.40.15"}` |
|
||||
* | `GET /api/tags` | the model once it is installed (digest, format, runner) |
|
||||
* | `GET /api/ps` | the model once a chat loaded it (`size`, `size_vram`, `context_length`) |
|
||||
* | `POST /api/pull` | NDJSON progress, see `FakePullSwitches` |
|
||||
* | `DELETE /api/delete` | 200, or 404 for a model that is not installed |
|
||||
* | `GET /v1/models` | the model's id, or `data: null` when none is installed (as the real one) |
|
||||
* | `POST /v1/chat/completions` | a plain answer, or a tool call when the request carries `tools` |
|
||||
* | `GET /download/ollama-darwin.tgz` | a small real `.tgz` with Range support, see `FakeArchiveSwitches` |
|
||||
*
|
||||
* The switches (`fake.switches`, changeable between requests):
|
||||
* - `pull.fail`: `error-line` (the real `{"error":"max retries exceeded: ..."}` line),
|
||||
* `cut` (the socket is destroyed mid-stream) or `not-found`; `pull.failTimes` says how many
|
||||
* pulls fail before one succeeds; `pull.resumeFrom` and the fake's own memory of how far the
|
||||
* last pull got make the next pull's first line start there, as a resumed pull does.
|
||||
* - `pull.shape`: `mlx` (one line shape, what a Mac really gets) or `gguf` (one layer after
|
||||
* the other).
|
||||
* - `ps`: `sizeVram` below `size` (not all on the GPU), another `contextLength`, or
|
||||
* `loaded: false`.
|
||||
* - `chat`: `error` makes the chat that loads the model fail.
|
||||
* - `tool`: `call` (default), `none` (an answer without a tool call), `length` (the token
|
||||
* limit ended the answer), `bad-arguments` (a tool call whose arguments are not JSON).
|
||||
* - `archive`: cut the download after N bytes for the first M requests, ignore `Range`, or
|
||||
* answer another status.
|
||||
* - `spawn`: the fake child never listens, or exits at once.
|
||||
*
|
||||
* The fake child (`fake.spawn`, the shape of `RuntimeSupervisorDeps.spawn`): "starting" it
|
||||
* makes the fake listen on the port of `OLLAMA_HOST` from the environment it is given and
|
||||
* writes the runtime's start-up log to its stderr, including the line "inference compute"
|
||||
* with the device total. Killing it closes the listener and emits `exit`. So a supervisor
|
||||
* test reserves a port (`freeLoopbackPort()`), builds the fake with `createFakeOllama()`
|
||||
* (not listening yet) and injects `fake.spawn`.
|
||||
*
|
||||
* Port: none fixed. `startFakeOllama()` listens on 0 and reports `port`.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||
import { createServer as createNetServer, type AddressInfo, type Socket } from 'node:net';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import type { ChildProcess, SpawnOptions } from 'node:child_process';
|
||||
import type { LocalModelRuntimePin } from '../../src/types.js';
|
||||
|
||||
// ---------- Real log lines ----------
|
||||
|
||||
/**
|
||||
* The runtime's line with the device total, VERBATIM from the 16 GB Mac mini (Apple M4),
|
||||
* 2026-11-06. `parseInferenceComputeTotal()` must read 11.8 GiB from it.
|
||||
*/
|
||||
export const REAL_INFERENCE_COMPUTE_LINE_MAC_MINI =
|
||||
'time=2026-11-06T13:21:49.885+01:00 level=INFO source=types.go:32 msg="inference compute" id=0 filter_id=0 library=Metal compute=0.0 name=MTL0 description="Apple M4" libdirs="" driver=0.0 pci_id="" type=iGPU total="11.8 GiB" available="11.8 GiB"';
|
||||
|
||||
/**
|
||||
* The same line as the 48 GB MacBook (Apple M5 Pro) printed it on 2026-11-06. The measurement
|
||||
* notes quote `total="37.4 GiB" available="37.4 GiB"`; the rest of the line is the Mac mini's
|
||||
* with the chip name and a time filled in. `parseInferenceComputeTotal()` must read
|
||||
* 40,157,944,217 bytes from it.
|
||||
*/
|
||||
export const REAL_INFERENCE_COMPUTE_LINE_MACBOOK =
|
||||
'time=2026-11-06T15:04:12.371+01:00 level=INFO source=types.go:32 msg="inference compute" id=0 filter_id=0 library=Metal compute=0.0 name=MTL0 description="Apple M5 Pro" libdirs="" driver=0.0 pci_id="" type=iGPU total="37.4 GiB" available="37.4 GiB"';
|
||||
|
||||
/** 37.4 GiB in bytes, rounded down: what the MacBook line stands for. */
|
||||
export const MACBOOK_MEASURED_TOTAL_BYTES = 40_157_944_217;
|
||||
|
||||
/**
|
||||
* The line that FOLLOWS it and also carries the total, under another key. Verbatim from the
|
||||
* Mac mini. A parser of the budget must not need it and must not be confused by it.
|
||||
*/
|
||||
export const REAL_VRAM_DEFAULT_CONTEXT_LINE_MAC_MINI =
|
||||
'time=2026-11-06T13:21:49.885+01:00 level=INFO source=routes.go:2586 msg="vram-based default context" total_vram="11.8 GiB" default_num_ctx=4096';
|
||||
|
||||
/**
|
||||
* What the runtime writes when it starts, in order, with `total` as the device total (the
|
||||
* text inside the quotes, such as '37.4 GiB'). The shapes are the Mac mini's.
|
||||
*/
|
||||
export function fakeRuntimeLog(total = '37.4 GiB', port = 11435): string[] {
|
||||
return [
|
||||
`time=2026-11-06T15:04:12.210+01:00 level=INFO source=routes.go:2536 msg="Listening on 127.0.0.1:${port} (version 0.40.15)"`,
|
||||
'time=2026-11-06T15:04:12.211+01:00 level=INFO source=runner.go:60 msg="discovering available GPUs..."',
|
||||
`time=2026-11-06T15:04:12.371+01:00 level=INFO source=types.go:32 msg="inference compute" id=0 filter_id=0 library=Metal compute=0.0 name=MTL0 description="Apple M5 Pro" libdirs="" driver=0.0 pci_id="" type=iGPU total="${total}" available="${total}"`,
|
||||
`time=2026-11-06T15:04:12.371+01:00 level=INFO source=routes.go:2586 msg="vram-based default context" total_vram="${total}" default_num_ctx=32768`,
|
||||
];
|
||||
}
|
||||
|
||||
/** The error line of a pull whose download kept failing. Verbatim from the Mac mini. */
|
||||
export const REAL_PULL_ERROR_LINE = '{"error":"max retries exceeded: local error: tls: bad record MAC"}';
|
||||
|
||||
// ---------- Options and switches ----------
|
||||
|
||||
/** The model the fake serves. Default: what the MacBook really got for `qwen3.6:35b-a3b`. */
|
||||
export interface FakeOllamaModel {
|
||||
id: string;
|
||||
/** The pull's total and the size `/api/tags` reports. */
|
||||
bytes: number;
|
||||
/** As `/api/tags` reports it: lowercase hex, no prefix. */
|
||||
digest: string;
|
||||
format: string;
|
||||
runner: string;
|
||||
quantization: string;
|
||||
capabilities: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_FAKE_MODEL: FakeOllamaModel = {
|
||||
id: 'qwen3.6:35b-a3b',
|
||||
bytes: 23_105_798_956,
|
||||
digest: '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba',
|
||||
format: 'safetensors',
|
||||
runner: 'mlx',
|
||||
quantization: 'nvfp4',
|
||||
capabilities: ['completion', 'vision', 'tools', 'thinking'],
|
||||
};
|
||||
|
||||
export interface FakePullSwitches {
|
||||
/**
|
||||
* `mlx`: every progress line is `{"status":"pulling model","digest":"sha256:model",...}`,
|
||||
* what a Mac gets for an MLX build. `gguf`: one layer after the other, each
|
||||
* `{"status":"pulling <12 hex>","digest":"sha256:<64 hex>",...}`, the first lines of a layer
|
||||
* without `completed`, then `verifying sha256 digest`.
|
||||
*/
|
||||
shape: 'mlx' | 'gguf';
|
||||
/** How many progress lines the large layer of one full pull is spread over. Default 20. */
|
||||
progressLines: number;
|
||||
/** Milliseconds between two lines. Default 0. Raise it to abort or cancel mid-stream. */
|
||||
lineDelayMs: number;
|
||||
/**
|
||||
* How a pull goes wrong.
|
||||
* - `none`: it succeeds.
|
||||
* - `error-line`: after `failAfterLines` progress lines, the real error line, then the body ends.
|
||||
* - `cut`: after `failAfterLines` progress lines the socket is destroyed (no `success`, no error).
|
||||
* - `not-found`: `pulling manifest`, then an error line saying the manifest does not exist.
|
||||
*/
|
||||
fail: 'none' | 'error-line' | 'cut' | 'not-found';
|
||||
/** Progress lines sent before the failure. Default 10. */
|
||||
failAfterLines: number;
|
||||
/** How many pulls fail before one succeeds. Default `Infinity`: every pull fails. */
|
||||
failTimes: number;
|
||||
/** Bytes the first pull starts at, as if an earlier run had left partial blobs. Default 0. */
|
||||
resumeFrom: number;
|
||||
}
|
||||
|
||||
export interface FakePsSwitches {
|
||||
/** `size`. Default: the model's bytes plus 100 MB. */
|
||||
size?: number;
|
||||
/** `size_vram`. Default: equal to `size` (all on the GPU). Set it lower for "does not fit". */
|
||||
sizeVram?: number;
|
||||
/** `context_length`. Default: `FakeOllamaOptions.contextLength`. */
|
||||
contextLength?: number;
|
||||
/** False: `/api/ps` stays empty even after a chat. */
|
||||
loaded?: boolean;
|
||||
}
|
||||
|
||||
export interface FakeArchiveSwitches {
|
||||
/** Destroy the socket after this many bytes of the body were sent. */
|
||||
cutAfterBytes?: number;
|
||||
/** For how many requests the cut applies. Default 1. */
|
||||
cutTimes: number;
|
||||
/** True: answer 200 with the whole body even to a `Range` request. */
|
||||
ignoreRange: boolean;
|
||||
/** Answer this status with an empty body instead (404, 503). */
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface FakeSpawnSwitches {
|
||||
/** True: the child starts and writes its log but never listens (the supervisor's `runtime-start-failed`). */
|
||||
neverListens: boolean;
|
||||
/** A number: the child exits with this code right after it was started. */
|
||||
exitsAtOnce?: number;
|
||||
/** The device total the child logs (the text inside the quotes). Default '37.4 GiB'. */
|
||||
total: string;
|
||||
/** False: the "inference compute" line is left out of the log. */
|
||||
logsBudget: boolean;
|
||||
}
|
||||
|
||||
export interface FakeOllamaSwitches {
|
||||
pull: FakePullSwitches;
|
||||
ps: FakePsSwitches;
|
||||
/** `error`: `POST /v1/chat/completions` WITHOUT tools answers 500. */
|
||||
chat: 'ok' | 'error';
|
||||
/**
|
||||
* What `POST /v1/chat/completions` WITH tools answers.
|
||||
* - `call`: one tool call with JSON arguments, `finish_reason: "tool_calls"`.
|
||||
* - `none`: a text answer, `finish_reason: "stop"`.
|
||||
* - `length`: an empty answer with a `reasoning` text, `finish_reason: "length"`.
|
||||
* - `bad-arguments`: a tool call whose `arguments` do not parse.
|
||||
*/
|
||||
tool: 'call' | 'none' | 'length' | 'bad-arguments';
|
||||
archive: FakeArchiveSwitches;
|
||||
spawn: FakeSpawnSwitches;
|
||||
}
|
||||
|
||||
export interface FakeOllamaOptions {
|
||||
/** What `/api/version` answers. Default '0.40.15'. */
|
||||
version?: string;
|
||||
model?: Partial<FakeOllamaModel>;
|
||||
/** True: the model is installed from the start. Default false: it appears after a pull that succeeded. */
|
||||
installed?: boolean;
|
||||
/** The context length the runtime "was started with": what `/api/ps` reports. Default 131072. */
|
||||
contextLength?: number;
|
||||
pull?: Partial<FakePullSwitches>;
|
||||
ps?: FakePsSwitches;
|
||||
chat?: FakeOllamaSwitches['chat'];
|
||||
tool?: FakeOllamaSwitches['tool'];
|
||||
archive?: Partial<FakeArchiveSwitches>;
|
||||
spawn?: Partial<FakeSpawnSwitches>;
|
||||
/** The files of the fake runtime archive, name to content. Default: one executable `ollama` script. */
|
||||
archiveFiles?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CapturedRequest {
|
||||
method: string;
|
||||
path: string;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
/** The parsed JSON body, the raw text when it is not JSON, `undefined` when there is none. */
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
/** The fake child process: enough of `ChildProcess` for a supervisor. */
|
||||
export class FakeRuntimeChild extends EventEmitter {
|
||||
readonly stdout = new PassThrough();
|
||||
readonly stderr = new PassThrough();
|
||||
readonly stdin = null;
|
||||
exitCode: number | null = null;
|
||||
signalCode: NodeJS.Signals | null = null;
|
||||
killed = false;
|
||||
/** Every signal `kill()` was called with. */
|
||||
readonly signals: (NodeJS.Signals | number)[] = [];
|
||||
|
||||
constructor(
|
||||
/** Above every real pid (Linux allows at most 4194304), so a stray `process.kill(pid)` hits nothing. */
|
||||
readonly pid: number,
|
||||
readonly command: string,
|
||||
readonly args: readonly string[],
|
||||
/** The environment the child was started with: what `runtimeEnv()` built. */
|
||||
readonly env: NodeJS.ProcessEnv,
|
||||
private readonly onGone: () => Promise<void>
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/** As `ChildProcess.kill()`: the listener closes and `exit` then `close` are emitted. */
|
||||
kill(signal: NodeJS.Signals | number = 'SIGTERM'): boolean {
|
||||
this.signals.push(signal);
|
||||
if (this.exitCode !== null || this.signalCode !== null) return false;
|
||||
this.killed = true;
|
||||
void this.finish(null, typeof signal === 'string' ? signal : 'SIGTERM');
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The child dies by itself with `code`, as a crash would. */
|
||||
crash(code = 1): void {
|
||||
if (this.exitCode !== null || this.signalCode !== null) return;
|
||||
void this.finish(code, null);
|
||||
}
|
||||
|
||||
private async finish(code: number | null, signal: NodeJS.Signals | null): Promise<void> {
|
||||
this.exitCode = code;
|
||||
this.signalCode = signal;
|
||||
await this.onGone();
|
||||
this.stdout.end();
|
||||
this.stderr.end();
|
||||
this.emit('exit', code, signal);
|
||||
this.emit('close', code, signal);
|
||||
}
|
||||
}
|
||||
|
||||
export interface FakeOllama {
|
||||
/** `http://127.0.0.1:<port>`. Empty while the fake does not listen. */
|
||||
readonly baseUrl: string;
|
||||
/** 0 while the fake does not listen. */
|
||||
readonly port: number;
|
||||
readonly listening: boolean;
|
||||
/** Every request so far, oldest first. */
|
||||
readonly requests: CapturedRequest[];
|
||||
/** Change these between requests. */
|
||||
readonly switches: FakeOllamaSwitches;
|
||||
readonly model: FakeOllamaModel;
|
||||
/** What the fake remembers. */
|
||||
readonly state: {
|
||||
/** The model is in `/api/tags` and `/v1/models`. */
|
||||
installed: boolean;
|
||||
/** A chat loaded the model: it is in `/api/ps`. */
|
||||
loaded: boolean;
|
||||
/** How far the pulls got: what the next pull's first progress line starts at. */
|
||||
completedBytes: number;
|
||||
/** How many `POST /api/pull` arrived. */
|
||||
pulls: number;
|
||||
/** How many `GET` of the archive arrived. */
|
||||
archiveRequests: number;
|
||||
/** Every child `spawn` made, oldest first. */
|
||||
children: FakeRuntimeChild[];
|
||||
};
|
||||
/** The fake runtime archive: a real gzipped tar. */
|
||||
readonly archive: { readonly body: Buffer; readonly size: number; readonly sha256: string };
|
||||
/** A pin that points at this fake's archive (valid while it listens). */
|
||||
pin(): LocalModelRuntimePin;
|
||||
/** Starts listening on 127.0.0.1. Default port 0: the system picks one. */
|
||||
listen(port?: number): Promise<void>;
|
||||
/** Stops listening and drops every open connection. Safe to call twice. */
|
||||
close(): Promise<void>;
|
||||
/**
|
||||
* A stand-in for `child_process.spawn` that "starts the runtime": the fake listens on the
|
||||
* port of `options.env.OLLAMA_HOST` and the child logs the start-up lines. See the file header.
|
||||
*/
|
||||
spawn(command: string, args: readonly string[], options: SpawnOptions): ChildProcess;
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
/** A loopback port nothing listens on right now: bound on 0, read, closed again. */
|
||||
export async function freeLoopbackPort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const probe = createNetServer();
|
||||
probe.once('error', reject);
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const { port } = probe.address() as AddressInfo;
|
||||
probe.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** One file as a tar entry: a 512-byte ustar header, the content, zero padding to 512. */
|
||||
function tarEntry(name: string, content: Buffer, mode: number): Buffer {
|
||||
const header = Buffer.alloc(512);
|
||||
header.write(name, 0, 100, 'utf8');
|
||||
header.write(mode.toString(8).padStart(7, '0') + '\0', 100, 8, 'ascii');
|
||||
header.write('0000000\0', 108, 8, 'ascii');
|
||||
header.write('0000000\0', 116, 8, 'ascii');
|
||||
header.write(content.length.toString(8).padStart(11, '0') + '\0', 124, 12, 'ascii');
|
||||
header.write('00000000000\0', 136, 12, 'ascii');
|
||||
header.write(' ', 148, 8, 'ascii');
|
||||
header.write('0', 156, 1, 'ascii');
|
||||
header.write('ustar\0', 257, 6, 'ascii');
|
||||
header.write('00', 263, 2, 'ascii');
|
||||
let sum = 0;
|
||||
for (const byte of header) sum += byte;
|
||||
header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 8, 'ascii');
|
||||
const padding = Buffer.alloc((512 - (content.length % 512)) % 512);
|
||||
return Buffer.concat([header, content, padding]);
|
||||
}
|
||||
|
||||
/** A gzipped tar of `files`, which `/usr/bin/tar -xzf` unpacks. A file named `ollama` is executable. */
|
||||
export function buildFakeArchive(files: Record<string, string>): Buffer {
|
||||
const entries = Object.entries(files).map(([name, text]) =>
|
||||
tarEntry(name, Buffer.from(text, 'utf8'), name === 'ollama' ? 0o755 : 0o644)
|
||||
);
|
||||
return gzipSync(Buffer.concat([...entries, Buffer.alloc(1024)]));
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<unknown> {
|
||||
return new Promise((resolve) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
if (!raw) return resolve(undefined);
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch {
|
||||
resolve(raw);
|
||||
}
|
||||
});
|
||||
req.on('error', () => resolve(undefined));
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** A value for one tool parameter, by its JSON schema type. */
|
||||
function placeholderFor(schema: unknown): unknown {
|
||||
const type = (schema as { type?: unknown } | undefined)?.type;
|
||||
if (type === 'number' || type === 'integer') return 1;
|
||||
if (type === 'boolean') return true;
|
||||
if (type === 'array') return [];
|
||||
if (type === 'object') return {};
|
||||
return 'README.md';
|
||||
}
|
||||
|
||||
/** The small layers a GGUF pull ends with: the sizes of the real `qwen2.5:0.5b` pull. */
|
||||
const GGUF_SMALL_LAYERS = [68, 1482, 11343, 490];
|
||||
|
||||
// ---------- The fake ----------
|
||||
|
||||
/** A fake that does not listen yet. For a supervisor test, which injects `fake.spawn`. */
|
||||
export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama {
|
||||
const version = options.version ?? '0.40.15';
|
||||
const model: FakeOllamaModel = { ...DEFAULT_FAKE_MODEL, ...options.model };
|
||||
const contextLength = options.contextLength ?? 131072;
|
||||
const switches: FakeOllamaSwitches = {
|
||||
pull: {
|
||||
shape: 'mlx',
|
||||
progressLines: 20,
|
||||
lineDelayMs: 0,
|
||||
fail: 'none',
|
||||
failAfterLines: 10,
|
||||
failTimes: Infinity,
|
||||
resumeFrom: 0,
|
||||
...options.pull,
|
||||
},
|
||||
ps: { ...options.ps },
|
||||
chat: options.chat ?? 'ok',
|
||||
tool: options.tool ?? 'call',
|
||||
archive: { cutTimes: 1, ignoreRange: false, ...options.archive },
|
||||
spawn: { neverListens: false, total: '37.4 GiB', logsBudget: true, ...options.spawn },
|
||||
};
|
||||
const archiveBody = buildFakeArchive(options.archiveFiles ?? { ollama: '#!/bin/sh\necho "fake runtime"\n' });
|
||||
const archive = {
|
||||
body: archiveBody,
|
||||
size: archiveBody.length,
|
||||
sha256: createHash('sha256').update(archiveBody).digest('hex'),
|
||||
};
|
||||
const state: FakeOllama['state'] = {
|
||||
installed: options.installed ?? false,
|
||||
loaded: false,
|
||||
completedBytes: 0,
|
||||
pulls: 0,
|
||||
archiveRequests: 0,
|
||||
children: [],
|
||||
};
|
||||
const requests: CapturedRequest[] = [];
|
||||
const sockets = new Set<Socket>();
|
||||
let server: Server | undefined;
|
||||
let port = 0;
|
||||
let failedPulls = 0;
|
||||
let archiveCuts = 0;
|
||||
let nextPid = 9_000_001;
|
||||
let chatCounter = 100;
|
||||
|
||||
const modelDetails = (): Record<string, unknown> => ({
|
||||
parent_model: '',
|
||||
format: model.format,
|
||||
family: 'qwen35',
|
||||
families: ['qwen35'],
|
||||
parameter_size: '35.9B',
|
||||
quantization_level: model.quantization,
|
||||
});
|
||||
|
||||
async function handlePull(res: ServerResponse, body: unknown): Promise<void> {
|
||||
state.pulls += 1;
|
||||
const asked = (body as { model?: unknown; name?: unknown } | undefined) ?? {};
|
||||
const askedId = typeof asked.model === 'string' ? asked.model : asked.name;
|
||||
const { pull } = switches;
|
||||
res.writeHead(200, { 'content-type': 'application/x-ndjson' });
|
||||
let gone = false;
|
||||
res.on('close', () => {
|
||||
gone = true;
|
||||
});
|
||||
// Each line is handed to the system before the next step, so a cut that follows really
|
||||
// arrives AFTER the lines before it, as on a real connection.
|
||||
const line = async (value: unknown): Promise<boolean> => {
|
||||
if (gone) return false;
|
||||
await new Promise<void>((resolve) => res.write(JSON.stringify(value) + '\n', () => resolve()));
|
||||
if (pull.lineDelayMs > 0) await sleep(pull.lineDelayMs);
|
||||
return !gone;
|
||||
};
|
||||
|
||||
if (!(await line({ status: 'pulling manifest' }))) return;
|
||||
// Not captured on a Mac: the wording of the runtime for a tag the registry does not have.
|
||||
if (pull.fail === 'not-found' || askedId !== model.id) {
|
||||
await line({ error: 'pull model manifest: file does not exist' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const failing = pull.fail !== 'none' && failedPulls < pull.failTimes;
|
||||
const smallTotal = GGUF_SMALL_LAYERS.reduce((sum, size) => sum + size, 0);
|
||||
const largeTotal = pull.shape === 'gguf' ? model.bytes - smallTotal : model.bytes;
|
||||
const largeDigest = pull.shape === 'gguf' ? `sha256:${model.digest}` : 'sha256:model';
|
||||
const largeStatus = pull.shape === 'gguf' ? `pulling ${model.digest.slice(0, 12)}` : 'pulling model';
|
||||
const start = Math.min(Math.max(state.completedBytes, pull.resumeFrom), largeTotal);
|
||||
const lines = Math.max(1, pull.progressLines);
|
||||
|
||||
// A GGUF layer announces itself without `completed` first.
|
||||
if (pull.shape === 'gguf' && start === 0) {
|
||||
if (!(await line({ status: largeStatus, digest: largeDigest, total: largeTotal }))) return;
|
||||
}
|
||||
for (let i = 0; i <= lines; i++) {
|
||||
if (failing && i === pull.failAfterLines) {
|
||||
failedPulls += 1;
|
||||
if (pull.fail === 'error-line') {
|
||||
res.write(REAL_PULL_ERROR_LINE + '\n');
|
||||
res.end();
|
||||
} else {
|
||||
res.socket?.destroy();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const completed = i === lines ? largeTotal : start + Math.floor(((largeTotal - start) * i) / lines);
|
||||
if (!(await line({ status: largeStatus, digest: largeDigest, total: largeTotal, completed }))) return;
|
||||
state.completedBytes = completed;
|
||||
}
|
||||
|
||||
if (pull.shape === 'gguf') {
|
||||
for (const [index, size] of GGUF_SMALL_LAYERS.entries()) {
|
||||
const hex = createHash('sha256').update(`${model.id}:${index}`).digest('hex');
|
||||
const layer = { status: `pulling ${hex.slice(0, 12)}`, digest: `sha256:${hex}`, total: size };
|
||||
if (!(await line(layer))) return;
|
||||
if (!(await line({ ...layer, completed: size }))) return;
|
||||
}
|
||||
if (!(await line({ status: 'verifying sha256 digest' }))) return;
|
||||
}
|
||||
if (!(await line({ status: 'writing manifest' }))) return;
|
||||
state.installed = true;
|
||||
state.completedBytes = model.bytes;
|
||||
await line({ status: 'success' });
|
||||
res.end();
|
||||
}
|
||||
|
||||
function handleChat(res: ServerResponse, body: unknown): void {
|
||||
const request = (body as { model?: unknown; tools?: unknown } | undefined) ?? {};
|
||||
if (!state.installed || request.model !== model.id) {
|
||||
sendJson(res, 404, {
|
||||
error: {
|
||||
message: `model '${String(request.model)}' not found`,
|
||||
type: 'not_found_error',
|
||||
param: null,
|
||||
code: null,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const tools = Array.isArray(request.tools) ? request.tools : [];
|
||||
const envelope = (message: Record<string, unknown>, finishReason: string): Record<string, unknown> => ({
|
||||
id: `chatcmpl-${chatCounter++}`,
|
||||
object: 'chat.completion',
|
||||
created: 1793968038,
|
||||
model: model.id,
|
||||
system_fingerprint: 'fp_ollama',
|
||||
choices: [{ index: 0, message: { role: 'assistant', ...message }, finish_reason: finishReason }],
|
||||
usage: {
|
||||
prompt_tokens: 36,
|
||||
prompt_tokens_details: { cached_tokens: 0 },
|
||||
completion_tokens: 2,
|
||||
total_tokens: 38,
|
||||
},
|
||||
});
|
||||
|
||||
if (tools.length === 0) {
|
||||
if (switches.chat === 'error') {
|
||||
// Not captured: the runtime's 500 for a model that fails to load.
|
||||
sendJson(res, 500, {
|
||||
error: { message: 'llm server error: out of memory', type: 'api_error', param: null, code: null },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (switches.ps.loaded !== false) state.loaded = true;
|
||||
sendJson(res, 200, envelope({ content: 'pong' }, 'stop'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (switches.ps.loaded !== false) state.loaded = true;
|
||||
const fn = (tools[0] as { function?: { name?: unknown; parameters?: { properties?: unknown } } }).function ?? {};
|
||||
const name = typeof fn.name === 'string' ? fn.name : 'tool';
|
||||
const properties = (fn.parameters?.properties ?? {}) as Record<string, unknown>;
|
||||
const args = Object.fromEntries(Object.entries(properties).map(([key, schema]) => [key, placeholderFor(schema)]));
|
||||
const toolCall = (argumentsText: string): Record<string, unknown> => ({
|
||||
content: '',
|
||||
tool_calls: [{ id: 'call_ag8rmzix', index: 0, type: 'function', function: { name, arguments: argumentsText } }],
|
||||
});
|
||||
switch (switches.tool) {
|
||||
case 'call':
|
||||
sendJson(res, 200, envelope(toolCall(JSON.stringify(args)), 'tool_calls'));
|
||||
return;
|
||||
case 'bad-arguments':
|
||||
sendJson(res, 200, envelope(toolCall('{"path": '), 'tool_calls'));
|
||||
return;
|
||||
case 'length':
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
envelope({ content: '', reasoning: 'The user wants me to read a file. I should call the tool' }, 'length')
|
||||
);
|
||||
return;
|
||||
case 'none':
|
||||
sendJson(res, 200, envelope({ content: 'I would read the file, but I cannot.' }, 'stop'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function handleArchive(req: IncomingMessage, res: ServerResponse): void {
|
||||
state.archiveRequests += 1;
|
||||
const { archive: sw } = switches;
|
||||
if (sw.status !== undefined) {
|
||||
res.writeHead(sw.status);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
let from = 0;
|
||||
const range = /^bytes=(\d+)-$/.exec(String(req.headers.range ?? ''));
|
||||
if (range && !sw.ignoreRange) {
|
||||
from = Number(range[1]);
|
||||
if (from >= archive.size) {
|
||||
res.writeHead(416, { 'content-range': `bytes */${archive.size}` });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const body = archive.body.subarray(from);
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/octet-stream',
|
||||
'content-length': String(body.length),
|
||||
'accept-ranges': 'bytes',
|
||||
};
|
||||
if (from > 0) headers['content-range'] = `bytes ${from}-${archive.size - 1}/${archive.size}`;
|
||||
res.writeHead(from > 0 ? 206 : 200, headers);
|
||||
if (req.method === 'HEAD') {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (sw.cutAfterBytes !== undefined && archiveCuts < sw.cutTimes && sw.cutAfterBytes < body.length) {
|
||||
archiveCuts += 1;
|
||||
res.write(body.subarray(0, sw.cutAfterBytes), () => res.socket?.destroy());
|
||||
return;
|
||||
}
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const body = await readBody(req);
|
||||
const path = (req.url ?? '').split('?')[0];
|
||||
const method = req.method ?? 'GET';
|
||||
requests.push({ method, path, headers: req.headers, body });
|
||||
|
||||
if (path === '/' && method === 'GET') {
|
||||
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end('Ollama is running');
|
||||
return;
|
||||
}
|
||||
if (path === '/api/version' && method === 'GET') return sendJson(res, 200, { version });
|
||||
if (path === '/api/tags' && method === 'GET') {
|
||||
const models = state.installed
|
||||
? [
|
||||
{
|
||||
name: model.id,
|
||||
model: model.id,
|
||||
modified_at: '2026-11-06T15:10:02.465346347+01:00',
|
||||
size: model.bytes,
|
||||
digest: model.digest,
|
||||
details: { ...modelDetails(), context_length: 262144, runner: model.runner },
|
||||
capabilities: model.capabilities,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return sendJson(res, 200, { models });
|
||||
}
|
||||
if (path === '/api/ps' && method === 'GET') {
|
||||
const size = switches.ps.size ?? model.bytes + 100_000_000;
|
||||
const models =
|
||||
state.installed && state.loaded && switches.ps.loaded !== false
|
||||
? [
|
||||
{
|
||||
name: model.id,
|
||||
model: model.id,
|
||||
size,
|
||||
digest: model.digest,
|
||||
details: modelDetails(),
|
||||
expires_at: '2026-11-06T15:20:09.488045+01:00',
|
||||
size_vram: switches.ps.sizeVram ?? size,
|
||||
context_length: switches.ps.contextLength ?? contextLength,
|
||||
runner: model.runner,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return sendJson(res, 200, { models });
|
||||
}
|
||||
if (path === '/api/pull' && method === 'POST') return handlePull(res, body);
|
||||
if (path === '/api/delete' && method === 'DELETE') {
|
||||
// Not captured: the answers of a delete (200 with no body, 404 with an error text).
|
||||
const asked = (body as { model?: unknown; name?: unknown } | undefined) ?? {};
|
||||
const askedId = typeof asked.model === 'string' ? asked.model : asked.name;
|
||||
if (!state.installed || askedId !== model.id) {
|
||||
return sendJson(res, 404, { error: `model '${String(askedId)}' not found` });
|
||||
}
|
||||
state.installed = false;
|
||||
state.loaded = false;
|
||||
state.completedBytes = 0;
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (path === '/v1/models' && method === 'GET') {
|
||||
const data = state.installed
|
||||
? [{ id: model.id, object: 'model', created: 1793967749, owned_by: 'library' }]
|
||||
: null;
|
||||
return sendJson(res, 200, { object: 'list', data });
|
||||
}
|
||||
if (path === '/v1/chat/completions' && method === 'POST') return handleChat(res, body);
|
||||
if (path === '/download/ollama-darwin.tgz' && (method === 'GET' || method === 'HEAD')) {
|
||||
return handleArchive(req, res);
|
||||
}
|
||||
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end('404 page not found');
|
||||
}
|
||||
|
||||
async function listen(onPort = 0): Promise<void> {
|
||||
if (server) throw new Error('The fake runtime already listens');
|
||||
const created = createServer((req, res) => {
|
||||
handle(req, res).catch(() => {
|
||||
if (!res.headersSent) res.writeHead(500);
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
created.on('connection', (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on('close', () => sockets.delete(socket));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
created.once('error', reject);
|
||||
created.listen(onPort, '127.0.0.1', () => resolve());
|
||||
});
|
||||
server = created;
|
||||
port = (created.address() as AddressInfo).port;
|
||||
}
|
||||
|
||||
async function close(): Promise<void> {
|
||||
const closing = server;
|
||||
if (!closing) return;
|
||||
server = undefined;
|
||||
port = 0;
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise<void>((resolve) => closing.close(() => resolve()));
|
||||
}
|
||||
|
||||
function spawn(command: string, args: readonly string[], spawnOptions: SpawnOptions): ChildProcess {
|
||||
const env = (spawnOptions.env ?? {}) as NodeJS.ProcessEnv;
|
||||
const child = new FakeRuntimeChild(nextPid++, command, args, env, close);
|
||||
state.children.push(child);
|
||||
const wanted = Number(/:(\d+)$/.exec(env.OLLAMA_HOST ?? '')?.[1] ?? 0);
|
||||
const sw = switches.spawn;
|
||||
void (async () => {
|
||||
// A real child reports nothing before the next turn of the event loop either.
|
||||
await sleep(0);
|
||||
if (sw.exitsAtOnce !== undefined) {
|
||||
child.stderr.write('Error: listen tcp 127.0.0.1: bind: address already in use\n');
|
||||
child.crash(sw.exitsAtOnce);
|
||||
return;
|
||||
}
|
||||
if (!sw.neverListens) {
|
||||
try {
|
||||
await listen(wanted);
|
||||
} catch {
|
||||
child.stderr.write(`Error: listen tcp 127.0.0.1:${wanted}: bind: address already in use\n`);
|
||||
child.crash(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const text of fakeRuntimeLog(sw.total, wanted)) {
|
||||
if (!sw.logsBudget && text.includes('msg="inference compute"')) continue;
|
||||
child.stderr.write(text + '\n');
|
||||
}
|
||||
})();
|
||||
return child as unknown as ChildProcess;
|
||||
}
|
||||
|
||||
return {
|
||||
get baseUrl() {
|
||||
return server ? `http://127.0.0.1:${port}` : '';
|
||||
},
|
||||
get port() {
|
||||
return port;
|
||||
},
|
||||
get listening() {
|
||||
return server !== undefined;
|
||||
},
|
||||
requests,
|
||||
switches,
|
||||
model,
|
||||
state,
|
||||
archive,
|
||||
pin: () => ({
|
||||
id: 'ollama',
|
||||
version,
|
||||
url: `http://127.0.0.1:${port}/download/ollama-darwin.tgz`,
|
||||
bytes: archive.size,
|
||||
sha256: archive.sha256,
|
||||
unpackedBytes: 4096,
|
||||
minMacosMajor: 14,
|
||||
}),
|
||||
listen,
|
||||
close,
|
||||
spawn,
|
||||
};
|
||||
}
|
||||
|
||||
/** A fake that listens on a port the system picked. Close it in `afterEach`. */
|
||||
export async function startFakeOllama(options: FakeOllamaOptions = {}): Promise<FakeOllama> {
|
||||
const fake = createFakeOllama(options);
|
||||
await fake.listen(0);
|
||||
return fake;
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* @fileoverview Tests for test/fixtures/fake-ollama.ts, the fake model runtime every local
|
||||
* model test runs against. A fake that answers in the wrong shape makes every test built on
|
||||
* it prove nothing, so this file pins each answer and each switch once.
|
||||
*
|
||||
* It also shows how the fake is used: `startFakeOllama()` for a client test, and
|
||||
* `createFakeOllama()` plus `fake.spawn` and `freeLoopbackPort()` for a supervisor test.
|
||||
*
|
||||
* Port: none fixed (the fake listens on 0; the spawn test reserves a free port first).
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildFakeArchive,
|
||||
createFakeOllama,
|
||||
DEFAULT_FAKE_MODEL,
|
||||
fakeRuntimeLog,
|
||||
FakeRuntimeChild,
|
||||
freeLoopbackPort,
|
||||
MACBOOK_MEASURED_TOTAL_BYTES,
|
||||
REAL_INFERENCE_COMPUTE_LINE_MAC_MINI,
|
||||
REAL_INFERENCE_COMPUTE_LINE_MACBOOK,
|
||||
REAL_PULL_ERROR_LINE,
|
||||
startFakeOllama,
|
||||
type FakeOllama,
|
||||
} from './fixtures/fake-ollama.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
let fake: FakeOllama | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await fake?.close();
|
||||
fake = undefined;
|
||||
});
|
||||
|
||||
/** Every line of a pull, parsed. `cut` is true when the body ended in an error instead of a clean end. */
|
||||
async function pull(target: FakeOllama, modelId = DEFAULT_FAKE_MODEL.id): Promise<{ lines: any[]; cut: boolean }> {
|
||||
const res = await fetch(`${target.baseUrl}/api/pull`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model: modelId, stream: true }),
|
||||
});
|
||||
let text = '';
|
||||
let cut = false;
|
||||
const decoder = new TextDecoder();
|
||||
try {
|
||||
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
|
||||
text += decoder.decode(chunk, { stream: true });
|
||||
}
|
||||
} catch {
|
||||
cut = true;
|
||||
}
|
||||
const lines = text
|
||||
.split('\n')
|
||||
.filter((line) => line.trim() !== '')
|
||||
.map((line) => JSON.parse(line));
|
||||
return { lines, cut };
|
||||
}
|
||||
|
||||
async function getJson(target: FakeOllama, path: string): Promise<any> {
|
||||
return (await fetch(`${target.baseUrl}${path}`)).json();
|
||||
}
|
||||
|
||||
async function chat(target: FakeOllama, body: Record<string, unknown>): Promise<{ status: number; json: any }> {
|
||||
const res = await fetch(`${target.baseUrl}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model: DEFAULT_FAKE_MODEL.id, messages: [{ role: 'user', content: 'ping' }], ...body }),
|
||||
});
|
||||
return { status: res.status, json: await res.json() };
|
||||
}
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'read_file',
|
||||
description: 'Read a file',
|
||||
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('the fake runtime: listings', () => {
|
||||
it('answers the version and an empty store in the real shapes', async () => {
|
||||
fake = await startFakeOllama();
|
||||
expect(fake.port).toBeGreaterThan(0);
|
||||
expect(await (await fetch(`${fake.baseUrl}/`)).text()).toBe('Ollama is running');
|
||||
expect(await getJson(fake, '/api/version')).toEqual({ version: '0.40.15' });
|
||||
expect(await getJson(fake, '/api/tags')).toEqual({ models: [] });
|
||||
expect(await getJson(fake, '/api/ps')).toEqual({ models: [] });
|
||||
// The real runtime answers `data: null`, not an empty list, when it has no model.
|
||||
expect(await getJson(fake, '/v1/models')).toEqual({ object: 'list', data: null });
|
||||
});
|
||||
|
||||
it('lists an installed model with the digest, format and runner a Mac reports', async () => {
|
||||
fake = await startFakeOllama({ installed: true });
|
||||
const tags = await getJson(fake, '/api/tags');
|
||||
expect(tags.models).toHaveLength(1);
|
||||
expect(tags.models[0]).toMatchObject({
|
||||
name: 'qwen3.6:35b-a3b',
|
||||
size: 23_105_798_956,
|
||||
digest: '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba',
|
||||
details: { format: 'safetensors', quantization_level: 'nvfp4', runner: 'mlx' },
|
||||
capabilities: ['completion', 'vision', 'tools', 'thinking'],
|
||||
});
|
||||
expect((await getJson(fake, '/v1/models')).data.map((m: { id: string }) => m.id)).toEqual(['qwen3.6:35b-a3b']);
|
||||
});
|
||||
|
||||
it('records every request with its parsed body', async () => {
|
||||
fake = await startFakeOllama({ installed: true });
|
||||
await chat(fake, { reasoning_effort: 'none', max_tokens: 16 });
|
||||
expect(fake.requests.at(-1)).toMatchObject({
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
body: { model: 'qwen3.6:35b-a3b', reasoning_effort: 'none', max_tokens: 16 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the fake runtime: pull', () => {
|
||||
it('streams the MLX line shape of a Mac and installs the model', async () => {
|
||||
fake = await startFakeOllama({ pull: { progressLines: 4 } });
|
||||
const { lines, cut } = await pull(fake);
|
||||
expect(cut).toBe(false);
|
||||
expect(lines[0]).toEqual({ status: 'pulling manifest' });
|
||||
expect(lines[1]).toEqual({ status: 'pulling model', digest: 'sha256:model', total: 23_105_798_956, completed: 0 });
|
||||
expect(lines.slice(-3)).toEqual([
|
||||
{ status: 'pulling model', digest: 'sha256:model', total: 23_105_798_956, completed: 23_105_798_956 },
|
||||
{ status: 'writing manifest' },
|
||||
{ status: 'success' },
|
||||
]);
|
||||
const completed = lines.filter((l) => l.status === 'pulling model').map((l) => l.completed);
|
||||
expect(completed).toEqual([...completed].sort((a, b) => a - b));
|
||||
expect(fake.state.installed).toBe(true);
|
||||
expect((await getJson(fake, '/api/tags')).models).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('streams as many progress lines as asked for', async () => {
|
||||
fake = await startFakeOllama({ pull: { progressLines: 5000 } });
|
||||
const { lines } = await pull(fake);
|
||||
expect(lines.filter((l) => l.status === 'pulling model')).toHaveLength(5001);
|
||||
});
|
||||
|
||||
it('streams the GGUF shape: layers that announce themselves, then the digest check', async () => {
|
||||
fake = await startFakeOllama({ pull: { shape: 'gguf', progressLines: 2 } });
|
||||
const { lines } = await pull(fake);
|
||||
const large = `sha256:${DEFAULT_FAKE_MODEL.digest}`;
|
||||
// The first line of a layer has no `completed`.
|
||||
expect(lines[1]).toEqual({ status: 'pulling 0ce5ff81f9ff', digest: large, total: 23_105_798_956 - 13_383 });
|
||||
expect(lines.map((l) => l.status).slice(-3)).toEqual(['verifying sha256 digest', 'writing manifest', 'success']);
|
||||
// The totals of all layers add up to the model.
|
||||
const totals = new Map<string, number>();
|
||||
for (const l of lines) if (l.digest) totals.set(l.digest, l.total);
|
||||
expect([...totals.values()].reduce((a, b) => a + b, 0)).toBe(23_105_798_956);
|
||||
});
|
||||
|
||||
it('ends on the real error line, then resumes where it stopped and succeeds', async () => {
|
||||
fake = await startFakeOllama({ pull: { fail: 'error-line', failAfterLines: 3, failTimes: 1, progressLines: 6 } });
|
||||
const first = await pull(fake);
|
||||
expect(first.cut).toBe(false);
|
||||
expect(JSON.stringify(first.lines.at(-1))).toBe(REAL_PULL_ERROR_LINE);
|
||||
expect(first.lines.some((l) => l.status === 'success')).toBe(false);
|
||||
expect(fake.state.installed).toBe(false);
|
||||
const reached = fake.state.completedBytes;
|
||||
expect(reached).toBeGreaterThan(0);
|
||||
|
||||
const second = await pull(fake);
|
||||
// A resumed pull's first progress line starts at what is already on disk.
|
||||
expect(second.lines[1].completed).toBe(reached);
|
||||
expect(second.lines.at(-1)).toEqual({ status: 'success' });
|
||||
expect(fake.state.pulls).toBe(2);
|
||||
});
|
||||
|
||||
it('cuts the stream without a last line', async () => {
|
||||
fake = await startFakeOllama({ pull: { fail: 'cut', failAfterLines: 2 } });
|
||||
const { lines, cut } = await pull(fake);
|
||||
expect(cut).toBe(true);
|
||||
expect(lines.some((l) => l.status === 'success' || l.error)).toBe(false);
|
||||
expect(fake.state.installed).toBe(false);
|
||||
});
|
||||
|
||||
it('starts a first pull at resumeFrom', async () => {
|
||||
fake = await startFakeOllama({ pull: { resumeFrom: 197_411_434, progressLines: 2 } });
|
||||
const { lines } = await pull(fake);
|
||||
expect(lines[1].completed).toBe(197_411_434);
|
||||
});
|
||||
|
||||
it('says so when the registry has no such model', async () => {
|
||||
fake = await startFakeOllama();
|
||||
const unknown = await pull(fake, 'no-such:1b');
|
||||
expect(unknown.lines.at(-1)).toEqual({ error: 'pull model manifest: file does not exist' });
|
||||
fake.switches.pull.fail = 'not-found';
|
||||
expect((await pull(fake)).lines.at(-1)).toEqual({ error: 'pull model manifest: file does not exist' });
|
||||
});
|
||||
|
||||
it('stops sending when the client aborts, and remembers how far it got', async () => {
|
||||
fake = await startFakeOllama({ pull: { progressLines: 200, lineDelayMs: 5 } });
|
||||
const controller = new AbortController();
|
||||
const res = await fetch(`${fake.baseUrl}/api/pull`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model: DEFAULT_FAKE_MODEL.id, stream: true }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const reader = res.body!.getReader();
|
||||
await reader.read();
|
||||
controller.abort();
|
||||
await reader.read().catch(() => undefined);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const reached = fake.state.completedBytes;
|
||||
expect(fake.state.installed).toBe(false);
|
||||
expect(reached).toBeLessThan(23_105_798_956);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(fake.state.completedBytes).toBe(reached);
|
||||
});
|
||||
|
||||
it('deletes the model', async () => {
|
||||
fake = await startFakeOllama({ installed: true });
|
||||
const del = (): Promise<Response> =>
|
||||
fetch(`${fake!.baseUrl}/api/delete`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ model: DEFAULT_FAKE_MODEL.id }),
|
||||
});
|
||||
expect((await del()).status).toBe(200);
|
||||
expect(fake.state.installed).toBe(false);
|
||||
expect((await del()).status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the fake runtime: chat, the process list and the tool probe', () => {
|
||||
it('answers a plain chat and lists the model as loaded, all on the GPU', async () => {
|
||||
fake = await startFakeOllama({ installed: true });
|
||||
expect((await getJson(fake, '/api/ps')).models).toEqual([]);
|
||||
const { status, json } = await chat(fake, { reasoning_effort: 'none' });
|
||||
expect(status).toBe(200);
|
||||
expect(json.choices[0]).toEqual({
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'pong' },
|
||||
finish_reason: 'stop',
|
||||
});
|
||||
const [loaded] = (await getJson(fake, '/api/ps')).models;
|
||||
expect(loaded).toMatchObject({ name: 'qwen3.6:35b-a3b', context_length: 131072, runner: 'mlx' });
|
||||
expect(loaded.size_vram).toBe(loaded.size);
|
||||
});
|
||||
|
||||
it('can report a model that is partly off the GPU, or loaded at another context', async () => {
|
||||
fake = await startFakeOllama({ installed: true, ps: { size: 30e9, sizeVram: 20e9 } });
|
||||
await chat(fake, {});
|
||||
expect((await getJson(fake, '/api/ps')).models[0]).toMatchObject({ size: 30e9, size_vram: 20e9 });
|
||||
fake.switches.ps = { contextLength: 32768 };
|
||||
expect((await getJson(fake, '/api/ps')).models[0].context_length).toBe(32768);
|
||||
fake.switches.ps = { loaded: false };
|
||||
expect((await getJson(fake, '/api/ps')).models).toEqual([]);
|
||||
});
|
||||
|
||||
it('answers 404 in the real error shape for a model it does not have', async () => {
|
||||
fake = await startFakeOllama();
|
||||
const { status, json } = await chat(fake, {});
|
||||
expect(status).toBe(404);
|
||||
expect(json).toEqual({
|
||||
error: { message: "model 'qwen3.6:35b-a3b' not found", type: 'not_found_error', param: null, code: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('can fail the chat that loads the model', async () => {
|
||||
fake = await startFakeOllama({ installed: true, chat: 'error' });
|
||||
expect((await chat(fake, {})).status).toBe(500);
|
||||
expect((await getJson(fake, '/api/ps')).models).toEqual([]);
|
||||
});
|
||||
|
||||
it('calls the tool it was given, with arguments that parse', async () => {
|
||||
fake = await startFakeOllama({ installed: true });
|
||||
const { json } = await chat(fake, { tools: TOOLS, reasoning_effort: 'none', max_tokens: 1024 });
|
||||
const choice = json.choices[0];
|
||||
expect(choice.finish_reason).toBe('tool_calls');
|
||||
expect(choice.message.tool_calls).toHaveLength(1);
|
||||
expect(choice.message.tool_calls[0]).toMatchObject({ type: 'function', function: { name: 'read_file' } });
|
||||
expect(JSON.parse(choice.message.tool_calls[0].function.arguments)).toEqual({ path: 'README.md' });
|
||||
});
|
||||
|
||||
it('can answer without a tool call, run into the token limit, or send arguments that do not parse', async () => {
|
||||
fake = await startFakeOllama({ installed: true, tool: 'none' });
|
||||
let choice = (await chat(fake, { tools: TOOLS })).json.choices[0];
|
||||
expect(choice.finish_reason).toBe('stop');
|
||||
expect(choice.message.tool_calls).toBeUndefined();
|
||||
|
||||
fake.switches.tool = 'length';
|
||||
choice = (await chat(fake, { tools: TOOLS })).json.choices[0];
|
||||
expect(choice.finish_reason).toBe('length');
|
||||
expect(choice.message.content).toBe('');
|
||||
expect(choice.message.tool_calls).toBeUndefined();
|
||||
|
||||
fake.switches.tool = 'bad-arguments';
|
||||
choice = (await chat(fake, { tools: TOOLS })).json.choices[0];
|
||||
expect(() => JSON.parse(choice.message.tool_calls[0].function.arguments)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the fake runtime: the archive', () => {
|
||||
it('serves an archive whose size and hash are the pin it hands out', async () => {
|
||||
fake = await startFakeOllama();
|
||||
const pin = fake.pin();
|
||||
expect(pin.url).toBe(`${fake.baseUrl}/download/ollama-darwin.tgz`);
|
||||
const res = await fetch(pin.url);
|
||||
const body = Buffer.from(await res.arrayBuffer());
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.length).toBe(pin.bytes);
|
||||
expect(createHash('sha256').update(body).digest('hex')).toBe(pin.sha256);
|
||||
});
|
||||
|
||||
it('continues from a Range request', async () => {
|
||||
fake = await startFakeOllama();
|
||||
const res = await fetch(fake.pin().url, { headers: { range: 'bytes=10-' } });
|
||||
expect(res.status).toBe(206);
|
||||
expect(res.headers.get('content-range')).toBe(`bytes 10-${fake.archive.size - 1}/${fake.archive.size}`);
|
||||
expect(Buffer.from(await res.arrayBuffer()).equals(fake.archive.body.subarray(10))).toBe(true);
|
||||
expect((await fetch(fake.pin().url, { headers: { range: `bytes=${fake.archive.size}-` } })).status).toBe(416);
|
||||
});
|
||||
|
||||
it('cuts the body the first time only, so a Range request can finish it', async () => {
|
||||
fake = await startFakeOllama({ archive: { cutAfterBytes: 20 } });
|
||||
const first = await fetch(fake.pin().url);
|
||||
const got: Buffer[] = [];
|
||||
await (async () => {
|
||||
for await (const chunk of first.body as unknown as AsyncIterable<Uint8Array>) got.push(Buffer.from(chunk));
|
||||
})().catch(() => undefined);
|
||||
const head = Buffer.concat(got);
|
||||
expect(head.length).toBe(20);
|
||||
const rest = await fetch(fake.pin().url, { headers: { range: 'bytes=20-' } });
|
||||
const whole = Buffer.concat([head, Buffer.from(await rest.arrayBuffer())]);
|
||||
expect(createHash('sha256').update(whole).digest('hex')).toBe(fake.archive.sha256);
|
||||
expect(fake.state.archiveRequests).toBe(2);
|
||||
});
|
||||
|
||||
it('can ignore Range, or answer another status', async () => {
|
||||
fake = await startFakeOllama({ archive: { ignoreRange: true } });
|
||||
expect((await fetch(fake.pin().url, { headers: { range: 'bytes=10-' } })).status).toBe(200);
|
||||
fake.switches.archive.status = 503;
|
||||
expect((await fetch(fake.pin().url)).status).toBe(503);
|
||||
});
|
||||
|
||||
it('is a real archive that tar unpacks to an executable', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'fake-ollama-archive-'));
|
||||
try {
|
||||
const archive = join(dir, 'runtime.tgz');
|
||||
await writeFile(archive, buildFakeArchive({ ollama: '#!/bin/sh\necho hello\n', LICENSE: 'text\n' }));
|
||||
await execFileAsync('tar', ['-xzf', archive, '-C', dir]);
|
||||
expect(await readFile(join(dir, 'LICENSE'), 'utf8')).toBe('text\n');
|
||||
expect((await stat(join(dir, 'ollama'))).mode & 0o111).not.toBe(0);
|
||||
expect((await execFileAsync(join(dir, 'ollama'))).stdout).toBe('hello\n');
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the fake runtime: the child process', () => {
|
||||
it('holds the real log lines with the device total', () => {
|
||||
expect(REAL_INFERENCE_COMPUTE_LINE_MAC_MINI).toContain('total="11.8 GiB" available="11.8 GiB"');
|
||||
expect(REAL_INFERENCE_COMPUTE_LINE_MACBOOK).toContain('total="37.4 GiB" available="37.4 GiB"');
|
||||
expect(MACBOOK_MEASURED_TOTAL_BYTES).toBe(Math.floor(37.4 * 1024 ** 3));
|
||||
expect(fakeRuntimeLog('37.4 GiB')[2]).toContain('msg="inference compute"');
|
||||
});
|
||||
|
||||
it('listens on the port of OLLAMA_HOST once spawned, logs the budget, and is gone when killed', async () => {
|
||||
const port = await freeLoopbackPort();
|
||||
fake = createFakeOllama({ installed: true });
|
||||
expect(fake.listening).toBe(false);
|
||||
|
||||
const child = fake.spawn('/somewhere/ollama', ['serve'], {
|
||||
env: { OLLAMA_HOST: `127.0.0.1:${port}`, HOME: '/private/home' },
|
||||
}) as unknown as FakeRuntimeChild;
|
||||
let log = '';
|
||||
child.stderr.on('data', (chunk: Buffer) => (log += chunk.toString('utf8')));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(child.pid).toBeGreaterThan(4_194_304);
|
||||
expect(child.env.HOME).toBe('/private/home');
|
||||
expect(fake.port).toBe(port);
|
||||
expect(await getJson(fake, '/api/version')).toEqual({ version: '0.40.15' });
|
||||
expect(log).toContain('msg="inference compute"');
|
||||
expect(log).toContain('total="37.4 GiB"');
|
||||
|
||||
const exited = new Promise<[number | null, string | null]>((resolve) =>
|
||||
child.once('exit', (code, signal) => resolve([code, signal]))
|
||||
);
|
||||
expect(child.kill('SIGTERM')).toBe(true);
|
||||
expect(await exited).toEqual([null, 'SIGTERM']);
|
||||
expect(fake.listening).toBe(false);
|
||||
await expect(fetch(`http://127.0.0.1:${port}/api/version`)).rejects.toThrow();
|
||||
expect(fake.state.children).toEqual([child]);
|
||||
});
|
||||
|
||||
it('can start without ever listening, exit at once, leave the budget out, or crash later', async () => {
|
||||
const port = await freeLoopbackPort();
|
||||
const env = { OLLAMA_HOST: `127.0.0.1:${port}` };
|
||||
|
||||
fake = createFakeOllama({ spawn: { neverListens: true, logsBudget: false } });
|
||||
const silent = fake.spawn('ollama', ['serve'], { env }) as unknown as FakeRuntimeChild;
|
||||
let log = '';
|
||||
silent.stderr.on('data', (chunk: Buffer) => (log += chunk.toString('utf8')));
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(fake.listening).toBe(false);
|
||||
expect(log).toContain('Listening on');
|
||||
expect(log).not.toContain('inference compute');
|
||||
|
||||
fake.switches.spawn = { neverListens: false, exitsAtOnce: 2, total: '37.4 GiB', logsBudget: true };
|
||||
const dead = fake.spawn('ollama', ['serve'], { env }) as unknown as FakeRuntimeChild;
|
||||
const code = await new Promise((resolve) => dead.once('exit', resolve));
|
||||
expect(code).toBe(2);
|
||||
|
||||
fake.switches.spawn.exitsAtOnce = undefined;
|
||||
const alive = fake.spawn('ollama', ['serve'], { env }) as unknown as FakeRuntimeChild;
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(fake.listening).toBe(true);
|
||||
const crashed = new Promise((resolve) => alive.once('exit', resolve));
|
||||
alive.crash(137);
|
||||
expect(await crashed).toBe(137);
|
||||
expect(fake.listening).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @fileoverview Tests for `modelFitsBudget()` of src/local-model/recommend.ts: the judgement
|
||||
* the install job makes before it downloads a model.
|
||||
*
|
||||
* Pinned: it agrees with what `recommend()` offers (a model that is the pick or listed fits,
|
||||
* a model that is in neither and is not left out for its rank does not), it does not depend
|
||||
* on the ranking, and a model the catalogue does not have never fits.
|
||||
*
|
||||
* Port: N/A (pure functions).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getLocalModelCatalog } from '../src/config/local-model-catalog.js';
|
||||
import { formatGb, memClassGb } from '../src/local-model/format.js';
|
||||
import { modelFitsBudget, recommend } from '../src/local-model/recommend.js';
|
||||
import type { HardwareFacts, HarnessFact } from '../src/types.js';
|
||||
|
||||
/** A Mac with Apple silicon and `gb` GB of memory as sold. */
|
||||
function mac(gb: number, chip = 'Apple M5 Pro'): HardwareFacts {
|
||||
const memBytes = gb * 1024 ** 3;
|
||||
return {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
arm64: true,
|
||||
translated: false,
|
||||
memBytes,
|
||||
memGb: formatGb(memBytes),
|
||||
memClassGb: memClassGb(memBytes),
|
||||
chip,
|
||||
macosMajor: 27,
|
||||
macosVersion: '27.2',
|
||||
wiredLimitMb: 0,
|
||||
source: 'sysctl',
|
||||
};
|
||||
}
|
||||
|
||||
/** Every harness of the catalogue, installed and accepting every model id. */
|
||||
function harnessFacts(): HarnessFact[] {
|
||||
return getLocalModelCatalog().harnesses.map((row) => ({
|
||||
cli: row.cli,
|
||||
label: row.cli,
|
||||
installed: true,
|
||||
installable: false,
|
||||
acceptsModelId: () => true,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('modelFitsBudget', () => {
|
||||
const catalog = getLocalModelCatalog();
|
||||
|
||||
it('agrees with the recommendation on every model and context of the catalogue', () => {
|
||||
for (const gb of [8, 16, 24, 32, 48, 64, 128]) {
|
||||
const hw = mac(gb);
|
||||
const offered = recommend(hw, catalog, harnessFacts());
|
||||
const rows = [offered.pick, ...offered.alternatives].filter((row) => row !== undefined);
|
||||
// Whatever the recommendation offers fits at the context it offers it at.
|
||||
for (const row of rows) {
|
||||
expect(modelFitsBudget(hw, catalog, row.modelId, row.context), `${gb} GB ${row.modelId}`).toBe(true);
|
||||
}
|
||||
// A Mac that gets no recommendation for lack of memory has no model that fits at a
|
||||
// context a harness works at.
|
||||
if (offered.verdict === 'none') {
|
||||
const smallestHarnessContext = Math.min(...catalog.harnesses.filter((h) => !h.gated).map((h) => h.minContext));
|
||||
for (const model of catalog.models) {
|
||||
for (const context of model.contexts.filter((ctx) => ctx >= smallestHarnessContext)) {
|
||||
expect(modelFitsBudget(hw, catalog, model.id, context), `${gb} GB ${model.id}`).toBe(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('does not depend on the ranking: a model that fits and is listed nowhere still fits', () => {
|
||||
// On a 128 GB Mac more models fit than the recommendation lists.
|
||||
const hw = mac(128, 'Apple M5 Max');
|
||||
const offered = recommend(hw, catalog, harnessFacts());
|
||||
const listed = new Set([offered.pick, ...offered.alternatives].map((row) => row?.modelId));
|
||||
const unlisted = catalog.models.filter((model) => !listed.has(model.id));
|
||||
expect(unlisted.length).toBeGreaterThan(0);
|
||||
for (const model of unlisted) {
|
||||
expect(modelFitsBudget(hw, catalog, model.id, model.contexts.at(-1) as number), model.id).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rules out a model that is too large, and one the catalogue does not have', () => {
|
||||
expect(modelFitsBudget(mac(48), catalog, 'qwen3.5:122b-a10b', 131072)).toBe(false);
|
||||
expect(modelFitsBudget(mac(48), catalog, 'no-such:1b', 65536)).toBe(false);
|
||||
expect(modelFitsBudget(mac(48), catalog, 'qwen3.6:35b-a3b', 131072)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @fileoverview Tests for src/local-model/paths.ts: where the local model lives on disk.
|
||||
*
|
||||
* Pinned: every path sits under the data dir (so a second Codeman instance has its own),
|
||||
* everything but the state file sits inside the one folder uninstall owns, a version string
|
||||
* can never add a path segment, and the inside-the-folder check resolves `..` first.
|
||||
*
|
||||
* Port: N/A (pure functions; the data dir is the temp HOME test/setup.ts gives each file).
|
||||
*/
|
||||
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getDataDir } from '../src/config/instance.js';
|
||||
import { isInsideLocalModelRoot, localModelPaths } from '../src/local-model/paths.js';
|
||||
|
||||
describe('localModelPaths', () => {
|
||||
it('builds every path from the data dir', () => {
|
||||
const data = getDataDir();
|
||||
expect(localModelPaths('0.40.15')).toEqual({
|
||||
root: join(data, 'local-model'),
|
||||
runtimeRoot: join(data, 'local-model', 'runtime'),
|
||||
runtimeDir: join(data, 'local-model', 'runtime', 'ollama-0.40.15'),
|
||||
binary: join(data, 'local-model', 'runtime', 'ollama-0.40.15', 'ollama'),
|
||||
archivePartial: join(data, 'local-model', 'runtime', 'ollama-0.40.15.tgz.partial'),
|
||||
unpackDir: join(data, 'local-model', 'runtime', 'ollama-0.40.15.unpack'),
|
||||
modelsDir: join(data, 'local-model', 'models'),
|
||||
homeDir: join(data, 'local-model', 'home'),
|
||||
stateFile: join(data, 'local-model.json'),
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps everything but the state file inside the one folder', () => {
|
||||
const paths = localModelPaths('0.40.15');
|
||||
const { root, stateFile, ...inside } = paths;
|
||||
for (const path of Object.values(inside)) expect(isInsideLocalModelRoot(path, root)).toBe(true);
|
||||
expect(isInsideLocalModelRoot(root, root)).toBe(true);
|
||||
expect(isInsideLocalModelRoot(stateFile, root)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a version that is not digits and dots', () => {
|
||||
for (const version of ['', '0', '../x', '0.40.15/..', '0.40.15-rc1', 'v0.40.15', '0..1', '1.2.3.4.5']) {
|
||||
expect(() => localModelPaths(version)).toThrow(/Not a runtime version/);
|
||||
}
|
||||
expect(() => localModelPaths('1.2')).not.toThrow();
|
||||
expect(() => localModelPaths('1.2.3.4')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInsideLocalModelRoot', () => {
|
||||
const root = '/data/local-model';
|
||||
|
||||
it('accepts the folder and what lies in it', () => {
|
||||
expect(isInsideLocalModelRoot('/data/local-model', root)).toBe(true);
|
||||
expect(isInsideLocalModelRoot('/data/local-model/', root)).toBe(true);
|
||||
expect(isInsideLocalModelRoot('/data/local-model/models/blobs/x', root)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a sibling, a parent and a path that climbs out', () => {
|
||||
expect(isInsideLocalModelRoot('/data/local-model.json', root)).toBe(false);
|
||||
expect(isInsideLocalModelRoot('/data/local-model-2/x', root)).toBe(false);
|
||||
expect(isInsideLocalModelRoot('/data', root)).toBe(false);
|
||||
expect(isInsideLocalModelRoot('/data/local-model/../settings.json', root)).toBe(false);
|
||||
expect(isInsideLocalModelRoot('/data/local-model/models/../../../etc', root)).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults to the folder under the data dir', () => {
|
||||
expect(isInsideLocalModelRoot(join(getDataDir(), 'local-model', 'home'))).toBe(true);
|
||||
expect(isInsideLocalModelRoot(join(getDataDir(), 'settings.json'))).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user