mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
fix(local-model): claim the connection first, keep an outcome the disk refused, say why a start failed
The connect step records the connection in the state file BEFORE it writes the endpoint record and the settings flag (the flag's earlier value read by inspectLocalEndpoint()), so a state write that fails or a server killed between the two never leaves a record of ours that the state file does not know; a record under the reserved id that says exactly what ours would say is ours. changePort() writes the state first and puts the old port back when the endpoint move is refused. A job whose end could not be written (a full disk) is kept in memory: the view shows it, Resume and a new install start from it, the next verb writes it, and Cancel never answers a 500. A first install on a full disk is a 507 with the sentence. "installing" in the file while this process runs no job always reads as paused or that failure. An installed model whose runtime does not start says so (runtime-start-failed with Start as the next step, and the launch check says it too); the stored runtime version is started while its folder is unpacked. Stop, Remove and the boot of anything that is not a running installation end a runtime an earlier server left behind before any file is deleted. Stop and "Use another port" answer 409 sessions-open while a live session uses the model. A pull that failed because the runtime is gone is runtime-stopped, not a network problem. After "does not fit" only a model that needs less memory is offered. A replaced job's partial blobs are deleted. Every 400 carries its sentence as data.message, and the six inline sentences are labels with zh-CN entries. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
6319fffce1
commit
fa978008d2
13 files changed
+1647
-184
No files matched your search
@@ -15,6 +15,15 @@
|
||||
* Rules:
|
||||
* - The id is reserved. A record with it that this feature did not write is never
|
||||
* overwritten: the connect step fails with `endpoint-id-taken`.
|
||||
* - The connection is CLAIMED before it is written: the install job records `connection` in
|
||||
* the state file first (`inspectLocalEndpoint()` tells it what is there and what the flag
|
||||
* is), then calls `connectLocalModel()` with `ours: true`. A state write that fails, or a
|
||||
* server that is killed between the two, therefore never leaves a record of ours that the
|
||||
* state file does not know: the worst case is a claim with nothing behind it, which a
|
||||
* resumed job fills in and Remove undoes.
|
||||
* - Second net: a record with the reserved id that already says exactly what ours would say
|
||||
* (`isSameLocalEndpoint()`) is ours whatever `ours` says. Nobody else can have made it: the
|
||||
* endpoint routes refuse the reserved id.
|
||||
* - Both writes are idempotent, so a resumed job writes them again.
|
||||
* - Undoing removes our record, and switches the flag off again only when this feature
|
||||
* switched it on AND no other endpoint is saved.
|
||||
@@ -27,7 +36,10 @@
|
||||
|
||||
import { mutateCustomModelHosts } from '../custom-model-hosts-writer.js';
|
||||
import type { CustomModelHost } from '../custom-model-hosts.js';
|
||||
import { getDataDir } from '../config/instance.js';
|
||||
import { readCustomModelHosts } from '../custom-model-hosts.js';
|
||||
import { getErrorMessage, type LocalModelErrorCode } from '../types.js';
|
||||
import { readJsonConfig, SETTINGS_PATH } from '../web/route-helpers.js';
|
||||
import { CustomModelHostSchema } from '../web/schemas.js';
|
||||
import { mutateSettingsFile } from '../web/settings-writer.js';
|
||||
import { readLocalModelState } from './state-store.js';
|
||||
@@ -63,10 +75,58 @@ export function buildLocalEndpointRecord(input: LocalEndpointInput): CustomModel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a saved record says exactly what our record for `input` says: the same address, the
|
||||
* one model, its context, no key. What discovery may have added to it (context lengths, a
|
||||
* time stamp) does not count. Pure.
|
||||
*/
|
||||
export function isSameLocalEndpoint(host: CustomModelHost, input: LocalEndpointInput): boolean {
|
||||
const ours = buildLocalEndpointRecord(input);
|
||||
return (
|
||||
host.id === ours.id &&
|
||||
host.label === ours.label &&
|
||||
host.baseUrl === ours.baseUrl &&
|
||||
host.authStyle === ours.authStyle &&
|
||||
host.apiKey === undefined &&
|
||||
Array.isArray(host.models) &&
|
||||
host.models.length === 1 &&
|
||||
host.models[0] === input.modelId &&
|
||||
host.defaultModelId === ours.defaultModelId &&
|
||||
host.defaultContextLength === ours.defaultContextLength
|
||||
);
|
||||
}
|
||||
|
||||
/** What the connect step finds before it claims the connection. */
|
||||
export interface LocalEndpointInspection {
|
||||
/**
|
||||
* The saved record with the reserved id: there is none, it says what ours would say, or it
|
||||
* says something else (somebody else's: `endpoint-id-taken`).
|
||||
*/
|
||||
record: 'absent' | 'same' | 'other';
|
||||
/** `customModelEndpointsEnabled` right now: what the claim keeps as the value before this feature. */
|
||||
flagEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks at the endpoint store and the settings flag WITHOUT writing either. The install job
|
||||
* calls it before it claims the connection in the state file. Rejects when the endpoint store
|
||||
* cannot be read; a settings file that cannot be read counts as "the flag is off".
|
||||
*/
|
||||
export async function inspectLocalEndpoint(input: LocalEndpointInput): Promise<LocalEndpointInspection> {
|
||||
const hosts = await readCustomModelHosts(getDataDir());
|
||||
const existing = hosts.find((host) => host.id === LOCAL_MODEL_ENDPOINT_ID);
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
|
||||
return {
|
||||
record: !existing ? 'absent' : isSameLocalEndpoint(existing, input) ? 'same' : 'other',
|
||||
flagEnabled: settings[ENDPOINTS_FLAG] === true,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConnectLocalModelInput extends LocalEndpointInput {
|
||||
/**
|
||||
* True when the state file already records a connection of ours: an existing record with
|
||||
* our id is then ours and is replaced. False: such a record is somebody else's.
|
||||
* our id is then ours and is replaced. False: such a record is somebody else's, unless it
|
||||
* says exactly what ours would say (`isSameLocalEndpoint()`).
|
||||
*/
|
||||
ours: boolean;
|
||||
/**
|
||||
@@ -106,7 +166,7 @@ export async function connectLocalModel(input: ConnectLocalModelInput): Promise<
|
||||
outcome = await mutateCustomModelHosts<'added' | 'replaced' | 'taken'>((hosts) => {
|
||||
const index = hosts.findIndex((host) => host.id === record.id);
|
||||
if (index === -1) return { hosts: [...hosts, record], result: 'added' };
|
||||
if (!input.ours) return { result: 'taken' };
|
||||
if (!input.ours && !isSameLocalEndpoint(hosts[index], input)) return { result: 'taken' };
|
||||
const next = [...hosts];
|
||||
next[index] = record;
|
||||
return { hosts: next, result: 'replaced' };
|
||||
@@ -158,13 +218,17 @@ async function removeLocalEndpoint(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Moves our record to another port (the answer to "Use another port" once connected). A missing record is not an error. */
|
||||
/**
|
||||
* Moves our record to another port (the answer to "Use another port" once connected). A
|
||||
* missing record is not an error, and a record that is already there is not written again.
|
||||
*/
|
||||
export async function moveLocalEndpoint(port: number): Promise<void> {
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
await mutateCustomModelHosts<void>((hosts) => {
|
||||
const index = hosts.findIndex((host) => host.id === LOCAL_MODEL_ENDPOINT_ID);
|
||||
if (index === -1) return { result: undefined };
|
||||
if (index === -1 || hosts[index].baseUrl === baseUrl) return { result: undefined };
|
||||
const next = [...hosts];
|
||||
next[index] = { ...next[index], baseUrl: `http://127.0.0.1:${port}` };
|
||||
next[index] = { ...next[index], baseUrl };
|
||||
return { hosts: next, result: undefined };
|
||||
});
|
||||
}
|
||||
|
||||
+501
-120
File diff suppressed because it is too large.
Load diff
@@ -31,6 +31,7 @@
|
||||
*/
|
||||
|
||||
import type { LocalModelErrorCode } from '../types.js';
|
||||
import { formatGb } from './format.js';
|
||||
|
||||
/** How often a pull that ended on an error line or a cut stream is started again. */
|
||||
export const PULL_RETRIES = 5;
|
||||
@@ -683,7 +684,8 @@ export function judgeLoaded(
|
||||
return {
|
||||
ok: false,
|
||||
code: 'does-not-fit',
|
||||
detail: `${model.sizeVram} of ${model.size} bytes are on the GPU`,
|
||||
// A size a person reads: decimal GB, like every other size of this feature.
|
||||
detail: `${formatGb(model.sizeVram)} of ${formatGb(model.size)} GB of the model are on the GPU`,
|
||||
};
|
||||
}
|
||||
if (model.contextLength !== context) {
|
||||
|
||||
@@ -27,14 +27,19 @@
|
||||
* | | | `already-installed`, `size-changed` |
|
||||
* | `POST /api/local-model/cancel` | none | 409 `wrong-state` (no job is running) |
|
||||
* | `POST /api/local-model/resume` | `LocalModelResumeRequest` | 409 `job-running`, `wrong-state` (not paused or |
|
||||
* | | | failed) |
|
||||
* | `POST /api/local-model/runtime` | `LocalModelRuntimeRequest` | 409 `wrong-state` (not installed), `job-running` |
|
||||
* | | | failed), `sessions-open` (another port while live |
|
||||
* | | | sessions use the model) |
|
||||
* | `POST /api/local-model/runtime` | `LocalModelRuntimeRequest` | 409 `wrong-state` (not installed), `job-running`, |
|
||||
* | | | `sessions-open` (stop, or another port, while live |
|
||||
* | | | sessions use the model) |
|
||||
* | `DELETE /api/local-model` | `LocalModelRemoveRequest` | 409 `job-running`, `sessions-open` |
|
||||
*
|
||||
* A 409 is the error envelope plus `data: LocalModelConflict`:
|
||||
* `{ success: false, error, errorCode: 'CONFLICT', data: { reason, message, ... } }`. A 409
|
||||
* WITHOUT `data` is a write of `settings.json` or of the endpoint store that was refused
|
||||
* because the file does not parse; `error` names the fix.
|
||||
* because the file does not parse; `error` names the fix. A 400 the service names carries its
|
||||
* sentence the same way: `{ success: false, error, errorCode: 'INVALID_INPUT', data: {
|
||||
* message } }` (`LocalModelRefusal`), so the window can print it in its own language.
|
||||
*
|
||||
* The rules of src/types/local-model.ts hold here too:
|
||||
* 1. A size a person reads travels twice: as bytes and as decimal GB text from `formatGb()`
|
||||
@@ -118,8 +123,10 @@ export const LOCAL_MODEL_INSTALL_STEPS: readonly LocalModelInstallStep[] = [
|
||||
* | `runtime-download-failed` | the archive download still fails after its retries |
|
||||
* | `runtime-damaged` | the archive's sha256 is not the pinned one (the file was deleted) |
|
||||
* | `runtime-unpack-failed` | `/usr/bin/tar` failed |
|
||||
* | `runtime-start-failed` | the runtime was started and never answered `/api/version` |
|
||||
* | `runtime-stopped` | installed: the runtime crashed and was restarted 3 times, then given up |
|
||||
* | `runtime-start-failed` | the runtime was started and never answered `/api/version`. Installed: |
|
||||
* | | the last start failed (the detail says why), and Start tries again |
|
||||
* | `runtime-stopped` | the runtime crashed and was restarted 3 times, then given up: on an |
|
||||
* | | installed model, or during a job's download |
|
||||
* | `runtime-not-running` | installed: the runtime is not running (the person stopped it). Only a |
|
||||
* | | refused session start says it; the view shows no error for a stopped runtime |
|
||||
* | `port-in-use` | another program listens on the stored port, or no port of the span is free |
|
||||
@@ -170,6 +177,11 @@ export type LocalModelErrorCode =
|
||||
* Without a `nextModel` the window offers the chooser and Remove.
|
||||
* - `start`: "Start". `POST /api/local-model/runtime` with `action: 'start'`.
|
||||
* - `remove`: "Remove". `DELETE /api/local-model`.
|
||||
*
|
||||
* `LOCAL_MODEL_ERROR_NEXT` is the default of a code. The view adapts it to the state the
|
||||
* error is shown in, so the button is always one the server accepts there: a code whose
|
||||
* default is `start` says `retry` on a failed job (there is no Start verb for a job), and
|
||||
* `runtime-start-failed` says `start` on an installed model (there is nothing to resume).
|
||||
*/
|
||||
export type LocalModelNextStep = 'retry' | 'other-port' | 'next-model' | 'start' | 'remove';
|
||||
|
||||
@@ -212,11 +224,18 @@ export type LocalModelDeveloperTools =
|
||||
* | `errContextNotApplied` | `model`; `actual`: the context the runtime reports; `wanted`: the chosen one |
|
||||
* | `errHarnessFailed` | `cli`: the coding agent's label |
|
||||
* | `errEndpointIdTaken` | `id`: the endpoint id this feature uses |
|
||||
* | `refusedAgentNotInstallable` | `cli`: the coding agent's label |
|
||||
* | `refusedOtherModel` | `model`: the label of the model that is installed |
|
||||
* | `conflictSizeChanged` | `size`: the server's own total as decimal GB |
|
||||
* | `conflictSessionsOpen` | `sessions`: the session names, joined with ', ' |
|
||||
*
|
||||
* `developerToolsMissing` is not sent: the view carries `developerTools` and the window prints
|
||||
* this sentence when it is `missing`. It is here so that its wording has one source.
|
||||
*
|
||||
* `endpointManaged` and `endpointIdReserved` are what the endpoint routes
|
||||
* (src/web/routes/custom-model-routes.ts) answer for the record this feature manages and for
|
||||
* its reserved id. They have no slot, so the App Settings toast that prints them translates
|
||||
* them as it translates every other whole sentence.
|
||||
*/
|
||||
export const LOCAL_MODEL_INSTALL_LABELS = {
|
||||
errRuntimeDownloadFailed:
|
||||
@@ -246,6 +265,13 @@ export const LOCAL_MODEL_INSTALL_LABELS = {
|
||||
conflictSizeChanged: 'The download is {size} GB now. Check the sizes and confirm again.',
|
||||
conflictSessionsOpen: 'Sessions are still using this model: {sessions}. Close them first.',
|
||||
conflictWrongState: 'That is not possible in the current state.',
|
||||
refusedModelNotOffered: 'This model at this context is not offered for this Mac right now.',
|
||||
refusedAgentNotOffered: 'This coding agent is not offered for this model.',
|
||||
refusedAgentNotInstallable: '{cli} is not installed and cannot be installed from here.',
|
||||
refusedOtherModel: 'Only {model} is installed on this Mac. Remove it and install another model first.',
|
||||
endpointManaged:
|
||||
'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.',
|
||||
endpointIdReserved: 'This endpoint id is reserved for the model Codeman runs on this Mac. Pick another id.',
|
||||
developerToolsMissing:
|
||||
'Coding agents use git and Python. This Mac has no developer tools yet, so macOS will offer to install them the first time an agent needs one.',
|
||||
} as const;
|
||||
@@ -336,11 +362,12 @@ export interface LocalModelInstallError {
|
||||
code: LocalModelErrorCode;
|
||||
/** `LOCAL_MODEL_ERROR_LABELS[code]` with its slots filled. */
|
||||
message: LocalModelInstallMessage;
|
||||
/** `LOCAL_MODEL_ERROR_NEXT[code]`. */
|
||||
/** `LOCAL_MODEL_ERROR_NEXT[code]`, adapted to the state the error is shown in (see `LocalModelNextStep`). */
|
||||
next: LocalModelNextStep;
|
||||
/**
|
||||
* For `next-model`: the best model that still fits and is not the one that failed. Absent
|
||||
* when there is none.
|
||||
* For `next-model`: the model to try instead. After `does-not-fit` it is one that needs LESS
|
||||
* memory than the one that failed; after the other codes the best other model that fits.
|
||||
* Absent when there is none.
|
||||
*/
|
||||
nextModel?: { modelId: string; modelLabel: string };
|
||||
/**
|
||||
@@ -484,6 +511,11 @@ export interface LocalModelRemoveRequest {
|
||||
removeModel?: boolean;
|
||||
}
|
||||
|
||||
/** The `data` of a 400 the service names: its sentence, for the window to print in its own language. */
|
||||
export interface LocalModelRefusal {
|
||||
message: LocalModelInstallMessage;
|
||||
}
|
||||
|
||||
/** What every verb answers on success: the view after the verb. */
|
||||
export type LocalModelVerbAnswer = LocalModelInstallView;
|
||||
|
||||
@@ -493,7 +525,9 @@ export type LocalModelVerbAnswer = LocalModelInstallView;
|
||||
* - `already-installed`: install while a model is installed. Remove it first.
|
||||
* - `size-changed`: `acknowledgedBytes` is not the server's total; `expectedBytes` and
|
||||
* `expectedGb` carry that total, so the window can show it and ask again.
|
||||
* - `sessions-open`: remove while live sessions use the endpoint; `sessions` names them.
|
||||
* - `sessions-open`: remove, stop or a change of port while live sessions use the endpoint;
|
||||
* `sessions` names them. Their coding agents were given the runtime's address when they
|
||||
* started, so they would go on calling a port nothing of ours holds.
|
||||
* - `wrong-state`: the verb makes no sense in the current state (see the route table above).
|
||||
*/
|
||||
export type LocalModelConflictReason =
|
||||
@@ -567,11 +601,17 @@ export interface LocalModelStateFile {
|
||||
cli?: { id: string; label: string };
|
||||
/** The runtime, once its port was chosen. */
|
||||
runtime?: {
|
||||
/** The pinned version that was unpacked. */
|
||||
/**
|
||||
* The runtime version this installation runs. A later build with another pin keeps
|
||||
* starting this version for as long as its folder is unpacked.
|
||||
*/
|
||||
version: string;
|
||||
/** The loopback port, chosen once by binding. */
|
||||
port: number;
|
||||
/** The pid of the child Codeman last started. Used at boot to tell our own orphan from another program. */
|
||||
/**
|
||||
* The pid of the child Codeman last started, stored the moment it is spawned. Used to
|
||||
* tell our own runtime from another program: at boot, and before the folder is deleted.
|
||||
*/
|
||||
pid?: number;
|
||||
/** False after Stop: the runtime is not started at boot. */
|
||||
enabled: boolean;
|
||||
@@ -582,7 +622,11 @@ export interface LocalModelStateFile {
|
||||
progress?: { completedBytes: number; totalBytes: number };
|
||||
/** What the runtime reported when the job checked the loaded model. */
|
||||
loaded?: { context: number; sizeBytes: number; sizeVramBytes: number };
|
||||
/** Present once the `connect` step wrote: what uninstall must undo. */
|
||||
/**
|
||||
* Present once the `connect` step CLAIMED the connection, which it does before its two
|
||||
* writes: what uninstall must undo. A claim with nothing behind it (the step was interrupted
|
||||
* right after it) is harmless: a resumed job writes both, and uninstall removes nothing.
|
||||
*/
|
||||
connection?: {
|
||||
/** The id of the endpoint record this feature wrote. */
|
||||
endpointId: string;
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, LOCAL_MODEL_INSTALL_LABELS, type ApiResponse } from '../../types.js';
|
||||
import { isAdmin, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
@@ -933,10 +933,7 @@ function refusedHostsWrite(err: unknown, reply: { code: (n: number) => unknown }
|
||||
|
||||
/** The answer to an edit or a delete of the record the local model install manages. */
|
||||
function managedEndpointRefusal(): ApiResponse<never> {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.'
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, LOCAL_MODEL_INSTALL_LABELS.endpointManaged);
|
||||
}
|
||||
|
||||
export function registerCustomModelRoutes(app: FastifyInstance): void {
|
||||
@@ -956,10 +953,7 @@ export function registerCustomModelRoutes(app: FastifyInstance): void {
|
||||
const badDefault = invalidDefaultModel(host);
|
||||
if (badDefault) return badDefault;
|
||||
if (isLocalModelEndpointId(host.id)) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`The endpoint id "${host.id}" is reserved for the model Codeman runs on this Mac. Pick another id.`
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, LOCAL_MODEL_INSTALL_LABELS.endpointIdReserved);
|
||||
}
|
||||
try {
|
||||
return await mutateCustomModelHosts<ApiResponse<{ host: RedactedHost }>>((hosts) => {
|
||||
|
||||
@@ -40,11 +40,14 @@
|
||||
* - A verb answers the view AFTER the verb. A refusal the service names is a 400 (the request
|
||||
* is not a row the server offers) or a 409 whose `data` is the `LocalModelConflict`
|
||||
* (`reason`, the sentence, and for `size-changed` the server's own total, for
|
||||
* `sessions-open` the session names). A write of the settings or of the endpoint store
|
||||
* that was refused (a file that does not parse) is a 409 without `data`, with the fix.
|
||||
* `sessions-open` the session names). A 400 the service names carries its sentence as
|
||||
* `data.message` too, so the window prints it in its own language. A write of the settings
|
||||
* or of the endpoint store that was refused (a file that does not parse) is a 409 without
|
||||
* `data`, with the fix.
|
||||
* - The verbs do the work in the install service; this file only gates, parses and maps.
|
||||
* `DELETE` is the one that looks at the live sessions: it hands their names to the service,
|
||||
* which refuses while one of them still uses the model.
|
||||
* `DELETE`, `runtime` and `resume` look at the live sessions: they hand their names to the
|
||||
* service, which refuses a removal, a stop and a change of port while one of them still
|
||||
* uses the model.
|
||||
* - Every dependency is injected (`LocalModelRouteDeps`), so a route test spawns nothing and
|
||||
* does not need a Mac: it passes its own hardware, harness facts and service.
|
||||
*/
|
||||
@@ -61,6 +64,7 @@ import {
|
||||
type LocalModelCatalog,
|
||||
type LocalModelConflict,
|
||||
type LocalModelInstallView,
|
||||
type LocalModelRefusal,
|
||||
type LocalModelStatus,
|
||||
type Recommendation,
|
||||
type RecommendOptions,
|
||||
@@ -152,8 +156,20 @@ export interface LocalModelConflictResponse {
|
||||
data: LocalModelConflict;
|
||||
}
|
||||
|
||||
/** What a verb answers: the view after it, an error envelope, or the 409 that carries the conflict. */
|
||||
export type LocalModelVerbResponse = ApiResponse<LocalModelInstallView> | LocalModelConflictResponse;
|
||||
/** The 400 (or 507) of a verb the service refused with a sentence: the error envelope plus that sentence as `data`. */
|
||||
export interface LocalModelRefusalResponse {
|
||||
success: false;
|
||||
/** The sentence in English, its slots filled. */
|
||||
error: string;
|
||||
errorCode: ApiErrorCode;
|
||||
data: LocalModelRefusal;
|
||||
}
|
||||
|
||||
/** What a verb answers: the view after it, an error envelope, or a refusal that carries its sentence. */
|
||||
export type LocalModelVerbResponse =
|
||||
| ApiResponse<LocalModelInstallView>
|
||||
| LocalModelConflictResponse
|
||||
| LocalModelRefusalResponse;
|
||||
|
||||
/** 403 for a non-admin in multi-user mode, before anything else runs. `null`: go on. */
|
||||
function adminOnly(req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null {
|
||||
@@ -170,7 +186,14 @@ function actorOf(req: FastifyRequest): LocalModelActor {
|
||||
function respond(result: LocalModelVerbResult, reply: FastifyReply): LocalModelVerbResponse {
|
||||
if (result.ok) return { success: true, data: result.view };
|
||||
reply.code(result.status);
|
||||
if (result.status === 400) return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.message);
|
||||
if (result.status !== 409) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatInstallMessage(result.message),
|
||||
errorCode: result.status === 400 ? ApiErrorCode.INVALID_INPUT : ApiErrorCode.OPERATION_FAILED,
|
||||
data: { message: result.message },
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: formatInstallMessage(result.conflict.message),
|
||||
@@ -258,14 +281,18 @@ export function registerLocalModelRoutes(
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const body = parseBody(LocalModelResumeSchema, req.body ?? {});
|
||||
return runVerb(reply, () => resolved.service().resume(body, actorOf(req)));
|
||||
const sessionNames = sessionNamesUsingEndpoint(ctx.sessions.values());
|
||||
return runVerb(reply, () => resolved.service().resume(body, actorOf(req), sessionNames));
|
||||
});
|
||||
|
||||
app.post('/api/local-model/runtime', async (req, reply): Promise<LocalModelVerbResponse> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const body = parseBody(LocalModelRuntimeSchema, req.body);
|
||||
return runVerb(reply, () => resolved.service().runtime(body));
|
||||
// Asked at the moment of the request, like the DELETE: a stop or a change of port leaves
|
||||
// a live session calling an address nothing of ours holds.
|
||||
const sessionNames = sessionNamesUsingEndpoint(ctx.sessions.values());
|
||||
return runVerb(reply, () => resolved.service().runtime(body, sessionNames));
|
||||
});
|
||||
|
||||
app.delete('/api/local-model', async (req, reply): Promise<LocalModelVerbResponse> => {
|
||||
|
||||
Reference in new issue
Block a user