From f785a850f0740a6659d9a541e09d02f6cffcfcff Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Mon, 6 Jul 2026 08:40:06 -0700 Subject: [PATCH 01/39] feat(models): add OpenAI-compatible /v1/* REST gateway (#631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers three REST resources on the REST port when `modelsGateway: {}` is present in config: POST /v1/chat/completions — streaming and non-streaming chat POST /v1/embeddings — embedding endpoint GET /v1/models — enumerate registered backends Key design points: - OpenAI SDK sends `Accept: application/json` for ALL requests (incl. streaming). Harper's REST layer only dispatches `Accept: text/event-stream` as CONNECT; everything else is dispatched as the HTTP method. So `stream: true` chat requests land in `post()`, not `connect()`. The resource returns `{ body: Readable }` which REST.ts bypasses serialisation on (REST.ts:165-193). - Fixes a latent bug in `transformIterable` (contentTypes.ts): the transform function was called on the terminal `{ done: true }` step of async generators, crashing when `serialize()` received `undefined`. Guard added: skip transform on any `done: true` step. - Pure shape-mapper layer in `v1/translation.ts`; all functions are side-effect-free and fully unit-tested without a running server. - OpenAI error envelope (`{ error: { message, type, code, param } }`) built in `v1/errors.ts`; resources catch errors themselves to avoid REST.ts serialising them as RFC 9457 Problem Details. - `tool_choice: 'auto'` maps to `toolMode: 'return'`; full in-process orchestration is #612 (out of scope for this PR). - `openai` added as devDependency (^6.45.0) for future integration tests. - `listBackends(kind)` added to backendRegistry.ts for GET /v1/models. Co-Authored-By: Claude Sonnet 4.6 --- components/componentLoader.ts | 2 + package.json | 1 + resources/models/backendRegistry.ts | 10 + resources/models/v1/chatCompletions.ts | 68 +++++ resources/models/v1/embeddings.ts | 38 +++ resources/models/v1/errors.ts | 67 +++++ resources/models/v1/index.ts | 35 +++ resources/models/v1/models.ts | 49 ++++ resources/models/v1/translation.ts | 239 +++++++++++++++ server/serverHelpers/contentTypes.ts | 14 +- .../v1/chatCompletions.sse-http.test.js | 126 ++++++++ unitTests/resources/models/v1/errors.test.js | 85 ++++++ .../resources/models/v1/translation.test.js | 277 ++++++++++++++++++ 13 files changed, 1006 insertions(+), 5 deletions(-) create mode 100644 resources/models/v1/chatCompletions.ts create mode 100644 resources/models/v1/embeddings.ts create mode 100644 resources/models/v1/errors.ts create mode 100644 resources/models/v1/index.ts create mode 100644 resources/models/v1/models.ts create mode 100644 resources/models/v1/translation.ts create mode 100644 unitTests/resources/models/v1/chatCompletions.sse-http.test.js create mode 100644 unitTests/resources/models/v1/errors.test.js create mode 100644 unitTests/resources/models/v1/translation.test.js diff --git a/components/componentLoader.ts b/components/componentLoader.ts index c41c78e2e0..86f1094a0a 100644 --- a/components/componentLoader.ts +++ b/components/componentLoader.ts @@ -20,6 +20,7 @@ import * as graphqlQueryHandler from '../server/graphqlQuerying.ts'; import * as roles from '../resources/roles.ts'; import * as jsHandler from '../resources/jsResource.ts'; import * as login from '../resources/login.ts'; +import * as modelsGateway from '../resources/models/v1/index.ts'; import * as REST from '../server/REST.ts'; import * as staticFiles from '../server/static.ts'; import * as loadEnv from '../resources/loadEnv.ts'; @@ -106,6 +107,7 @@ export const TRUSTED_RESOURCE_PLUGINS: any = { return require('../server/fastifyRoutes'); }, login, + modelsGateway, static: staticFiles, customFunctions: {}, http: httpComponent, diff --git a/package.json b/package.json index 47ab4a5047..a3d83ce318 100644 --- a/package.json +++ b/package.json @@ -143,6 +143,7 @@ "chai-as-promised": "^8.0.2", "chai-integer": "^0.1.0", "eventsource": "^4.0.0", + "openai": "^6.45.0", "globals": "^17.0.0", "intercept-stdout": "0.1.2", "mkcert": "^3.2.0", diff --git a/resources/models/backendRegistry.ts b/resources/models/backendRegistry.ts index 547061be74..3a350a2dc9 100644 --- a/resources/models/backendRegistry.ts +++ b/resources/models/backendRegistry.ts @@ -39,6 +39,16 @@ export function getBackend(kind: ModelKind, logicalName: string): ModelBackend | return (kind === 'embedding' ? embedding : generative).get(logicalName); } +/** + * Enumerate all registrations for `kind` as `{logicalName, backend}` pairs. Used by + * `GET /v1/models` (#631) to advertise selectable model names; `logicalName` is what a + * caller passes as `opts.model`, not the backend's own `.name`. + */ +export function listBackends(kind: ModelKind): Array<{ logicalName: string; backend: ModelBackend }> { + const map = kind === 'embedding' ? embedding : generative; + return [...map.entries()].map(([logicalName, backend]) => ({ logicalName, backend })); +} + /** * Resolve the embedding backend mapped to `logicalName` (default: `'default'`). * Throws `ModelBackendNotFoundError` if no backend is mapped. diff --git a/resources/models/v1/chatCompletions.ts b/resources/models/v1/chatCompletions.ts new file mode 100644 index 0000000000..dd31ab13c0 --- /dev/null +++ b/resources/models/v1/chatCompletions.ts @@ -0,0 +1,68 @@ +/** + * `POST /v1/chat/completions` — OpenAI-compatible chat endpoint (#631). + * + * SSE serving-path note: the OpenAI SDK sends `Accept: application/json` for + * ALL requests including streaming ones (`client.ts:1160` in the SDK source). + * Harper's REST layer dispatches `Accept: text/event-stream` as CONNECT, and + * everything else as the HTTP method. So `stream: true` from an OpenAI SDK + * client reaches this `post()` handler, NOT `connect()`. We detect the `stream` + * flag in the body and return `{ body: Readable }` which REST.ts bypasses + * serialisation on (REST.ts:165-193) — exactly like any SSE resource response, + * but initiated from `post()` rather than `connect()`. + */ + +import type { Readable } from 'node:stream'; +import { contentTypes } from '../../../server/serverHelpers/contentTypes.ts'; +import { Resource } from '../../Resource.ts'; +import { models } from '../Models.ts'; +import { openaiStream } from '../openaiStream.ts'; +import { toOpenAIError, badRequest } from './errors.ts'; +import { translateMessages, translateTools, toGenerateInput, toGenerateOpts, toChatCompletion } from './translation.ts'; +import type { OAIChatRequest } from './translation.ts'; + +type SseHandler = { serializeStream: (iterable: AsyncIterable) => Readable }; +const sseHandler = contentTypes.get('text/event-stream') as SseHandler; + +// @ts-ignore — Resource base class is not typed for static dispatch; pattern mirrors login.ts +export class V1ChatCompletions extends Resource { + static async post(_target: unknown, body: unknown, _request: unknown) { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return badRequest('Request body must be a JSON object'); + } + const req = body as OAIChatRequest; + + if (!Array.isArray(req.messages) || req.messages.length === 0) { + return badRequest("'messages' must be a non-empty array"); + } + + const model = typeof req.model === 'string' ? req.model : 'default'; + const messages = translateMessages(req.messages); + const tools = req.tools?.length ? translateTools(req.tools) : undefined; + const input = toGenerateInput(messages, tools); + const opts = toGenerateOpts(req); + + try { + if (req.stream) { + const tokenStream = models.generateStream(input, opts); + // serializeStream wraps the async iterable in a Node Readable so REST.ts + // can return it without re-serialising. The `body` presence on the return + // value skips REST.ts's own serialize() call (REST.ts:165-193). + const body = sseHandler.serializeStream(openaiStream(tokenStream, { model })); + return { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + }, + body, + }; + } + + const result = await models.generate(input, opts); + return toChatCompletion(result, model); + } catch (err) { + return toOpenAIError(err); + } + } +} diff --git a/resources/models/v1/embeddings.ts b/resources/models/v1/embeddings.ts new file mode 100644 index 0000000000..56d92cd774 --- /dev/null +++ b/resources/models/v1/embeddings.ts @@ -0,0 +1,38 @@ +/** + * `POST /v1/embeddings` — OpenAI-compatible embedding endpoint (#631). + * + * Maps OpenAI's `{ model, input }` request body to `scope.models.embed()` and + * returns `{ object: 'list', data: [...], model, usage }` per the OpenAI wire spec. + */ + +import { Resource } from '../../Resource.ts'; +import { models } from '../Models.ts'; +import { toOpenAIError, badRequest } from './errors.ts'; +import { toEmbedOpts, toEmbedResponse } from './translation.ts'; + +// @ts-ignore — Resource base class is not typed for static dispatch; pattern mirrors login.ts +export class V1Embeddings extends Resource { + static async post(_target: unknown, body: Record, _request: unknown) { + if (!body || typeof body !== 'object') return badRequest('Request body must be a JSON object'); + const raw = body as Record; + + const input = raw.input; + if (input === undefined || input === null) return badRequest("'input' is required"); + if (typeof input !== 'string' && !Array.isArray(input)) { + return badRequest("'input' must be a string or array of strings"); + } + if (Array.isArray(input) && !input.every((v) => typeof v === 'string')) { + return badRequest("'input' array elements must be strings"); + } + + const model = typeof raw.model === 'string' ? raw.model : 'default'; + const opts = toEmbedOpts(raw as any); + + try { + const vecs = await models.embed(input as string | string[], opts); + return toEmbedResponse(vecs, model); + } catch (err) { + return toOpenAIError(err); + } + } +} diff --git a/resources/models/v1/errors.ts b/resources/models/v1/errors.ts new file mode 100644 index 0000000000..765688b63b --- /dev/null +++ b/resources/models/v1/errors.ts @@ -0,0 +1,67 @@ +/** + * OpenAI error envelope helpers for the `/v1/*` gateway (#631). + * + * Harper's REST layer serialises uncaught errors as RFC 9457 Problem Details. + * Resources that need the OpenAI `{ error: { message, type, code, param } }` + * shape must catch errors themselves and call `toOpenAIError()` / `badRequest()`. + */ + +import { ModelBackendNotFoundError } from '../backendRegistry.ts'; + +type OpenAIErrorType = 'invalid_request_error' | 'server_error' | 'authentication_error' | 'api_error'; + +export interface OpenAIErrorBody { + message: string; + type: OpenAIErrorType; + code: string | null; + param: string | null; +} + +/** HTTP response payload from a gateway error; resource methods return this directly. */ +export interface OpenAIErrorResponse { + status: number; + headers: { 'Content-Type': 'application/json' }; + data: { error: OpenAIErrorBody }; +} + +/** + * Map any thrown value to an OpenAI error envelope. Uses `statusCode` when + * present (Harper's `ClientError` / `ServerError` convention). Falls back to + * `500 server_error`. `ModelBackendNotFoundError` maps to `404 model_not_found`. + */ +export function toOpenAIError(err: unknown): OpenAIErrorResponse { + const message = err instanceof Error ? err.message : 'Internal server error'; + let status = 500; + let type: OpenAIErrorType = 'server_error'; + let code: string | null = null; + + if (err instanceof ModelBackendNotFoundError) { + status = 404; + type = 'invalid_request_error'; + code = 'model_not_found'; + } else if (err instanceof Error && typeof (err as any).statusCode === 'number') { + status = (err as any).statusCode; + if (status === 401 || status === 403) { + type = 'authentication_error'; + } else if (status < 500) { + type = 'invalid_request_error'; + } else { + type = 'server_error'; + } + } + + return { + status, + headers: { 'Content-Type': 'application/json' }, + data: { error: { message, type, code, param: null } }, + }; +} + +/** Convenience for early request-body validation failures. */ +export function badRequest(message: string): OpenAIErrorResponse { + return { + status: 400, + headers: { 'Content-Type': 'application/json' }, + data: { error: { message, type: 'invalid_request_error', code: null, param: null } }, + }; +} diff --git a/resources/models/v1/index.ts b/resources/models/v1/index.ts new file mode 100644 index 0000000000..a5f92824bc --- /dev/null +++ b/resources/models/v1/index.ts @@ -0,0 +1,35 @@ +/** + * `/v1/*` OpenAI-compatible gateway (#631). + * + * Registers three REST resources on the REST port: + * POST /v1/embeddings → V1Embeddings + * POST /v1/chat/completions → V1ChatCompletions + * GET /v1/models → V1Models + * + * Activated by adding `modelsGateway: {}` (or any truthy value) to + * `harperdb-config.yaml`. Example: + * + * ```yaml + * modelsGateway: {} + * models: + * generative: + * default: + * backend: ollama + * model: llama3.2 + * ``` + * + * The gateway intentionally does NOT add authentication — Harper's REST layer + * applies auth before dispatching to any resource. Deploy behind a network + * boundary or configure Harper's auth as appropriate. + */ + +import type { Scope } from '../../../components/Scope.ts'; +import { V1Embeddings } from './embeddings.ts'; +import { V1ChatCompletions } from './chatCompletions.ts'; +import { V1Models } from './models.ts'; + +export function handleApplication(scope: Scope): void { + scope.resources.set('v1/models', V1Models); + scope.resources.set('v1/embeddings', V1Embeddings); + scope.resources.set('v1/chat/completions', V1ChatCompletions); +} diff --git a/resources/models/v1/models.ts b/resources/models/v1/models.ts new file mode 100644 index 0000000000..854eb48dbe --- /dev/null +++ b/resources/models/v1/models.ts @@ -0,0 +1,49 @@ +/** + * `GET /v1/models` — OpenAI-compatible model list endpoint (#631). + * + * Enumerates all registered embedding and generative backends from the + * process-wide `backendRegistry`. The response mirrors the OpenAI shape: + * `{ object: 'list', data: [{ id, object: 'model', created, owned_by }] }`. + * + * `logicalName` (not `backend.name`) is the `id` — it's what callers pass as + * `model` in subsequent requests. + */ + +import { Resource } from '../../Resource.ts'; +import { listBackends } from '../backendRegistry.ts'; + +export interface OAIModelEntry { + id: string; + object: 'model'; + created: number; + owned_by: string; +} + +export interface OAIModelList { + object: 'list'; + data: OAIModelEntry[]; +} + +// @ts-ignore — Resource base class is not typed for static dispatch; pattern mirrors login.ts +export class V1Models extends Resource { + static get(_target: unknown, _request: unknown): OAIModelList { + const created = Math.floor(Date.now() / 1000); + const generative = listBackends('generative').map( + ({ logicalName }): OAIModelEntry => ({ + id: logicalName, + object: 'model', + created, + owned_by: 'harper', + }) + ); + const embedding = listBackends('embedding').map( + ({ logicalName }): OAIModelEntry => ({ + id: logicalName, + object: 'model', + created, + owned_by: 'harper', + }) + ); + return { object: 'list', data: [...generative, ...embedding] }; + } +} diff --git a/resources/models/v1/translation.ts b/resources/models/v1/translation.ts new file mode 100644 index 0000000000..2ca30858ad --- /dev/null +++ b/resources/models/v1/translation.ts @@ -0,0 +1,239 @@ +/** + * OpenAI ↔ Harper internal shape mappers for the `/v1/*` gateway (#631). + * + * All functions are pure (no I/O, no side-effects) so they can be unit-tested + * in isolation without a running Harper instance. + */ + +import { randomUUID } from 'node:crypto'; +import type { + EmbedOpts, + GenerateInput, + GenerateOpts, + GenerateResult, + Message, + ToolCall, + ToolDef, + TokenUsage, +} from '../types.ts'; + +// --------------------------------------------------------------------------- +// OpenAI wire shapes — inlined to avoid a runtime dependency on the SDK +// --------------------------------------------------------------------------- + +export interface OAIMessageIn { + role: string; + /** May be null for assistant messages that only contain tool_calls. */ + content: string | null; + tool_calls?: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + }>; + /** Present on role === 'tool' messages. */ + tool_call_id?: string; +} + +export interface OAIToolIn { + type: 'function'; + function: { + name: string; + description?: string; + parameters?: object; + }; +} + +export interface OAIChatRequest { + model?: string; + messages: OAIMessageIn[]; + tools?: OAIToolIn[]; + /** Subset recognised: 'none' | 'auto' | 'required' | {type:'function', function:{name}}. */ + tool_choice?: unknown; + temperature?: number; + /** OpenAI v1 field; superseded by max_completion_tokens in v1+. */ + max_tokens?: number; + /** Preferred alias; takes precedence over max_tokens when both present. */ + max_completion_tokens?: number; + response_format?: { type: string; json_schema?: unknown }; + stream?: boolean; +} + +// --------------------------------------------------------------------------- +// OpenAI request → Harper internal +// --------------------------------------------------------------------------- + +/** + * Map an OpenAI `messages` array to Harper `Message[]`. + * Normalises `tool_calls[].function.arguments` from JSON strings to parsed + * objects (Harper's internal contract); maps `tool_call_id` to `toolCallId`. + */ +export function translateMessages(oaiMessages: OAIMessageIn[]): Message[] { + return oaiMessages.map((m): Message => { + const base: Message = { + role: m.role as Message['role'], + content: m.content ?? '', + }; + if (m.tool_calls?.length) { + base.toolCalls = m.tool_calls.map((tc): ToolCall => { + let args: object; + try { + args = JSON.parse(tc.function.arguments); + } catch { + // Preserve unparseable argument strings under a sentinel key rather + // than dropping them — backend can decide what to do. + args = { _raw: tc.function.arguments }; + } + return { id: tc.id, name: tc.function.name, arguments: args }; + }); + } + if (m.tool_call_id) base.toolCallId = m.tool_call_id; + return base; + }); +} + +/** Map OpenAI `tools[]` to Harper `ToolDef[]`. */ +export function translateTools(oaiTools: OAIToolIn[]): ToolDef[] { + return oaiTools.map( + (t): ToolDef => ({ + name: t.function.name, + description: t.function.description ?? '', + parameters: t.function.parameters ?? {}, + }) + ); +} + +/** + * Build Harper `GenerateInput` from translated messages and tool definitions. + * Uses the object form `{ messages, tools }` when tools are present, so that + * tool definitions travel alongside messages per Harper's type contract. + */ +export function toGenerateInput(messages: Message[], tools: ToolDef[] | undefined): GenerateInput { + if (tools?.length) return { messages, tools }; + return messages; +} + +/** + * Map an OpenAI chat-completion request body to `GenerateOpts`. + * + * `tool_choice: 'auto' | 'required'` both map to `toolMode: 'return'` — + * full in-process tool-call orchestration is tracked in #612 (out of scope + * for #631). The caller still receives `finish_reason: 'tool_calls'` and + * may invoke tools itself. + */ +export function toGenerateOpts(body: OAIChatRequest): GenerateOpts { + const opts: GenerateOpts = { toolMode: 'return' }; + if (typeof body.model === 'string') opts.model = body.model; + if (typeof body.temperature === 'number') opts.temperature = body.temperature; + const maxTokens = body.max_completion_tokens ?? body.max_tokens; + if (typeof maxTokens === 'number') opts.maxTokens = maxTokens; + if (body.response_format) { + const rf = body.response_format; + if (rf.type === 'json_object') { + opts.responseFormat = 'json'; + } else if (rf.type === 'json_schema' && rf.json_schema) { + opts.responseFormat = { schema: rf.json_schema as object }; + } else { + opts.responseFormat = 'text'; + } + } + return opts; +} + +/** Map an OpenAI embeddings request body to `EmbedOpts`. */ +export function toEmbedOpts(body: { model?: string }): EmbedOpts { + const opts: EmbedOpts = {}; + if (typeof body.model === 'string') opts.model = body.model; + return opts; +} + +// --------------------------------------------------------------------------- +// Harper internal → OpenAI response shapes +// --------------------------------------------------------------------------- + +interface OAIToolCallOut { + id: string; + type: 'function'; + function: { name: string; arguments: string }; +} + +interface OAIAssistantMessage { + role: 'assistant'; + /** null when the message contains only tool_calls. */ + content: string | null; + tool_calls?: OAIToolCallOut[]; +} + +export interface OAIChatCompletion { + id: string; + object: 'chat.completion'; + created: number; + model: string; + choices: Array<{ + index: number; + message: OAIAssistantMessage; + finish_reason: string; + }>; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + +function toOAIToolCalls(toolCalls: ToolCall[]): OAIToolCallOut[] { + return toolCalls.map((tc) => ({ + id: tc.id, + type: 'function', + function: { name: tc.name, arguments: JSON.stringify(tc.arguments) }, + })); +} + +/** Map a Harper `GenerateResult` to an OpenAI `chat.completion` response object. */ +export function toChatCompletion(result: GenerateResult, model: string, id?: string): OAIChatCompletion { + const completionId = id ?? `chatcmpl-${randomUUID().replaceAll('-', '')}`; + const hasTools = !!result.toolCalls?.length; + const message: OAIAssistantMessage = { + role: 'assistant', + // OpenAI sets content to null when there are tool calls and no text content. + content: result.content || (hasTools ? null : ''), + }; + if (hasTools) message.tool_calls = toOAIToolCalls(result.toolCalls!); + const usage = result.usage ?? {}; + return { + id: completionId, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, message, finish_reason: result.finishReason }], + usage: { + prompt_tokens: usage.promptTokens ?? 0, + completion_tokens: usage.completionTokens ?? 0, + total_tokens: (usage.promptTokens ?? 0) + (usage.completionTokens ?? 0), + }, + }; +} + +export interface OAIEmbedResponse { + object: 'list'; + data: Array<{ embedding: number[]; index: number; object: 'embedding' }>; + model: string; + usage: { prompt_tokens: number; total_tokens: number }; +} + +/** Map `Float32Array[]` from `models.embed()` to an OpenAI embeddings response. */ +export function toEmbedResponse(vecs: Float32Array[], model: string, usage?: TokenUsage): OAIEmbedResponse { + return { + object: 'list', + data: vecs.map((vec, index) => ({ + embedding: Array.from(vec), + index, + object: 'embedding', + })), + model, + usage: { + prompt_tokens: usage?.promptTokens ?? 0, + // OpenAI uses `embeddingTokens` aliased here; fall back to promptTokens. + total_tokens: usage?.embeddingTokens ?? usage?.promptTokens ?? 0, + }, + }; +} diff --git a/server/serverHelpers/contentTypes.ts b/server/serverHelpers/contentTypes.ts index d5fcf7f874..bbb37e42cb 100644 --- a/server/serverHelpers/contentTypes.ts +++ b/server/serverHelpers/contentTypes.ts @@ -623,14 +623,18 @@ function transformIterable(iterable, transform) { next() { const step = iterator.next(); if (step.then) { - return step.then((step) => ({ - value: transform(step.value), - done: step.done, - })); + // Async iterator: skip transform on terminal step (done: true) so + // serialize() is never called with undefined. Generator return values + // are not iterable items and must not be serialised (#631). + return step.then((step) => { + if (step.done) return step; + return { value: transform(step.value), done: false }; + }); } + if (step.done) return step; return { value: transform(step.value), - done: step.done, + done: false, }; }, return(value) { diff --git a/unitTests/resources/models/v1/chatCompletions.sse-http.test.js b/unitTests/resources/models/v1/chatCompletions.sse-http.test.js new file mode 100644 index 0000000000..66691d97e0 --- /dev/null +++ b/unitTests/resources/models/v1/chatCompletions.sse-http.test.js @@ -0,0 +1,126 @@ +'use strict'; + +/** + * HTTP SSE integration test for `POST /v1/chat/completions` with `stream: true` (#631). + * + * Drives the gateway's streaming path end-to-end: + * openaiStream() → transformIterable (fixed) → serializeStream → HTTP → EventSource + * + * This is the critical path that was BLOCKED before the transformIterable bug fix: + * `serializeStream()` called `transform(undefined)` on the terminal async generator step, + * which crashed inside `serialize()` at `message.acknowledge()`. The fix guards `done: true` + * steps and skips the transform. + * + * We do NOT spin up a full Harper instance here — that's the integration test's job. + * Instead we serve a single HTTP response with the same pipeline the gateway uses, + * verifying that a real SSE client parses the framed output correctly. + */ + +const assert = require('node:assert'); +const http = require('node:http'); +const { openaiStream } = require('#src/resources/models/openaiStream'); +const { contentTypes } = require('#src/server/serverHelpers/contentTypes'); +const { TestBackend } = require('#src/resources/models/TestBackend'); + +const sseHandler = contentTypes.get('text/event-stream'); + +/** + * Serve one request using the same pipeline as V1ChatCompletions.post() for + * `stream: true`: `serializeStream(openaiStream(generateStream(...), opts))`. + * This is the `{ body: Readable }` response path. + */ +function serveOnce(tokens, opts) { + const server = http.createServer((_req, res) => { + const body = sseHandler.serializeStream(openaiStream(tokens, opts)); + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + body.pipe(res); + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve({ server, url: `http://127.0.0.1:${server.address().port}/` })); + }); +} + +/** Collect parsed OpenAI chunks from SSE until the [DONE] sentinel. */ +function collectSSE(EventSource, url) { + return new Promise((resolve, reject) => { + const es = new EventSource(url); + const events = []; + const finish = (fn, arg) => { + clearTimeout(timer); + es.close(); + fn(arg); + }; + const timer = setTimeout(() => finish(reject, new Error('SSE timed out before [DONE]')), 5000); + es.addEventListener('message', (event) => { + if (event.data === '[DONE]') return finish(resolve, events); + try { + events.push(JSON.parse(event.data)); + } catch (err) { + finish(reject, err); + } + }); + es.addEventListener('error', () => finish(reject, new Error('EventSource errored before [DONE]'))); + }); +} + +describe('V1ChatCompletions streaming via serializeStream (transformIterable fix)', () => { + let EventSource; + + before(async () => { + ({ EventSource } = await import('eventsource')); + }); + + it('streams TestBackend output through serializeStream without crashing on terminal step', async () => { + const backend = new TestBackend(); + const { server, url } = await serveOnce(backend.generateStream('hello world', {}), { + model: 'test', + id: 'chatcmpl-sse-test', + }); + try { + const events = await collectSSE(EventSource, url); + + assert.ok(events.length >= 2, 'expected multiple chunk events'); + for (const ev of events) { + assert.equal(ev.object, 'chat.completion.chunk'); + assert.equal(ev.id, 'chatcmpl-sse-test'); + assert.equal(ev.model, 'test'); + } + + // Content reassembles to TestBackend's deterministic prefix + const content = events.map((e) => e.choices[0].delta.content ?? '').join(''); + assert.ok(content.startsWith('[TestBackend stream]:'), `unexpected content: ${content}`); + + // Terminal chunk: empty delta + finish_reason 'stop' + const terminal = events[events.length - 1]; + assert.ok(terminal.choices[0].finish_reason !== null, 'terminal chunk must carry finish_reason'); + } finally { + server.closeAllConnections?.(); + server.close(); + } + }); + + it('correctly serialises the [DONE] sentinel as the last SSE event', async () => { + // A minimal one-token stream that immediately finishes — verifies the + // terminal-step guard doesn't eat the [DONE] sentinel. + async function* singleChunk() { + yield { deltaContent: 'hi' }; + yield { finishReason: 'stop' }; + } + const { server, url } = await serveOnce(singleChunk(), { model: 'm', id: 'id1' }); + try { + const events = await collectSSE(EventSource, url); + // [DONE] is consumed by collectSSE; events contains all OpenAI chunks + assert.ok(events.length >= 1); + // The last chunk must carry a finish_reason, not [DONE] + const last = events[events.length - 1]; + assert.equal(last.choices[0].finish_reason, 'stop'); + } finally { + server.closeAllConnections?.(); + server.close(); + } + }); +}); diff --git a/unitTests/resources/models/v1/errors.test.js b/unitTests/resources/models/v1/errors.test.js new file mode 100644 index 0000000000..c3d12d30a2 --- /dev/null +++ b/unitTests/resources/models/v1/errors.test.js @@ -0,0 +1,85 @@ +'use strict'; + +/** + * Unit tests for `resources/models/v1/errors.ts` (#631). + * + * Verifies OpenAI error envelope construction without I/O. + */ + +const assert = require('node:assert'); +const { toOpenAIError, badRequest } = require('#src/resources/models/v1/errors'); +const { ModelBackendNotFoundError } = require('#src/resources/models/backendRegistry'); + +function makeClientError(message, statusCode) { + const err = new Error(message); + err.statusCode = statusCode; + return err; +} + +describe('toOpenAIError', () => { + it('maps ModelBackendNotFoundError to 404 model_not_found', () => { + const err = new ModelBackendNotFoundError('generative', 'missing-model'); + const resp = toOpenAIError(err); + assert.equal(resp.status, 404); + assert.equal(resp.data.error.type, 'invalid_request_error'); + assert.equal(resp.data.error.code, 'model_not_found'); + assert.ok(resp.data.error.message.includes('missing-model')); + }); + + it('maps 400 statusCode errors to invalid_request_error', () => { + const resp = toOpenAIError(makeClientError('bad input', 400)); + assert.equal(resp.status, 400); + assert.equal(resp.data.error.type, 'invalid_request_error'); + assert.equal(resp.data.error.code, null); + }); + + it('maps 401 statusCode to authentication_error', () => { + const resp = toOpenAIError(makeClientError('unauthorized', 401)); + assert.equal(resp.status, 401); + assert.equal(resp.data.error.type, 'authentication_error'); + }); + + it('maps 403 statusCode to authentication_error', () => { + const resp = toOpenAIError(makeClientError('forbidden', 403)); + assert.equal(resp.status, 403); + assert.equal(resp.data.error.type, 'authentication_error'); + }); + + it('maps 500 statusCode to server_error', () => { + const resp = toOpenAIError(makeClientError('boom', 500)); + assert.equal(resp.status, 500); + assert.equal(resp.data.error.type, 'server_error'); + }); + + it('defaults to 500 server_error for unknown errors', () => { + const resp = toOpenAIError(new Error('surprise')); + assert.equal(resp.status, 500); + assert.equal(resp.data.error.type, 'server_error'); + }); + + it('uses a fallback message for non-Error throws', () => { + const resp = toOpenAIError('string error'); + assert.equal(resp.data.error.message, 'Internal server error'); + }); + + it('always sets param to null', () => { + const resp = toOpenAIError(new Error('x')); + assert.equal(resp.data.error.param, null); + }); + + it('sets Content-Type application/json header', () => { + const resp = toOpenAIError(new Error('x')); + assert.equal(resp.headers['Content-Type'], 'application/json'); + }); +}); + +describe('badRequest', () => { + it('returns 400 invalid_request_error with the supplied message', () => { + const resp = badRequest('field missing'); + assert.equal(resp.status, 400); + assert.equal(resp.data.error.type, 'invalid_request_error'); + assert.equal(resp.data.error.message, 'field missing'); + assert.equal(resp.data.error.code, null); + assert.equal(resp.data.error.param, null); + }); +}); diff --git a/unitTests/resources/models/v1/translation.test.js b/unitTests/resources/models/v1/translation.test.js new file mode 100644 index 0000000000..c27371cc15 --- /dev/null +++ b/unitTests/resources/models/v1/translation.test.js @@ -0,0 +1,277 @@ +'use strict'; + +/** + * Pure-mapper unit tests for `resources/models/v1/translation.ts` (#631). + * + * No I/O, no Harper server — just input→output assertions on the shape translators. + */ + +const assert = require('node:assert'); +const { + translateMessages, + translateTools, + toGenerateInput, + toGenerateOpts, + toEmbedOpts, + toChatCompletion, + toEmbedResponse, +} = require('#src/resources/models/v1/translation'); + +// --------------------------------------------------------------------------- +// translateMessages +// --------------------------------------------------------------------------- + +describe('translateMessages', () => { + it('maps a simple user message', () => { + const result = translateMessages([{ role: 'user', content: 'hi' }]); + assert.equal(result.length, 1); + assert.equal(result[0].role, 'user'); + assert.equal(result[0].content, 'hi'); + }); + + it('maps null content to empty string', () => { + const result = translateMessages([{ role: 'assistant', content: null }]); + assert.equal(result[0].content, ''); + }); + + it('parses tool_calls arguments from JSON string to object', () => { + const result = translateMessages([ + { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call_abc', + type: 'function', + function: { name: 'get_weather', arguments: '{"city":"NYC"}' }, + }, + ], + }, + ]); + assert.ok(Array.isArray(result[0].toolCalls)); + assert.equal(result[0].toolCalls[0].id, 'call_abc'); + assert.equal(result[0].toolCalls[0].name, 'get_weather'); + assert.deepEqual(result[0].toolCalls[0].arguments, { city: 'NYC' }); + }); + + it('keeps unparseable arguments under _raw sentinel', () => { + const result = translateMessages([ + { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'c1', + type: 'function', + function: { name: 'fn', arguments: 'not-json' }, + }, + ], + }, + ]); + assert.deepEqual(result[0].toolCalls[0].arguments, { _raw: 'not-json' }); + }); + + it('maps tool_call_id to toolCallId on tool role messages', () => { + const result = translateMessages([{ role: 'tool', content: '42', tool_call_id: 'call_1' }]); + assert.equal(result[0].toolCallId, 'call_1'); + }); +}); + +// --------------------------------------------------------------------------- +// translateTools +// --------------------------------------------------------------------------- + +describe('translateTools', () => { + it('maps OpenAI tool definitions to ToolDef', () => { + const tools = translateTools([ + { + type: 'function', + function: { + name: 'search', + description: 'Search the web', + parameters: { type: 'object', properties: { query: { type: 'string' } } }, + }, + }, + ]); + assert.equal(tools.length, 1); + assert.equal(tools[0].name, 'search'); + assert.equal(tools[0].description, 'Search the web'); + assert.deepEqual(tools[0].parameters, { type: 'object', properties: { query: { type: 'string' } } }); + }); + + it('uses empty string for missing description and empty object for missing parameters', () => { + const tools = translateTools([{ type: 'function', function: { name: 'noop' } }]); + assert.equal(tools[0].description, ''); + assert.deepEqual(tools[0].parameters, {}); + }); +}); + +// --------------------------------------------------------------------------- +// toGenerateInput +// --------------------------------------------------------------------------- + +describe('toGenerateInput', () => { + const msgs = [{ role: 'user', content: 'hello' }]; + + it('returns Message[] when no tools', () => { + const input = toGenerateInput(msgs, undefined); + assert.ok(Array.isArray(input)); + }); + + it('returns object form { messages, tools } when tools present', () => { + const tools = [{ name: 'x', description: '', parameters: {} }]; + const input = toGenerateInput(msgs, tools); + assert.ok(!Array.isArray(input)); + assert.deepEqual(input.messages, msgs); + assert.deepEqual(input.tools, tools); + }); + + it('returns Message[] when tools array is empty', () => { + const input = toGenerateInput(msgs, []); + assert.ok(Array.isArray(input)); + }); +}); + +// --------------------------------------------------------------------------- +// toGenerateOpts +// --------------------------------------------------------------------------- + +describe('toGenerateOpts', () => { + it('maps model, temperature, max_tokens', () => { + const opts = toGenerateOpts({ model: 'my-model', temperature: 0.5, max_tokens: 100, messages: [] }); + assert.equal(opts.model, 'my-model'); + assert.equal(opts.temperature, 0.5); + assert.equal(opts.maxTokens, 100); + }); + + it('prefers max_completion_tokens over max_tokens', () => { + const opts = toGenerateOpts({ max_tokens: 100, max_completion_tokens: 200, messages: [] }); + assert.equal(opts.maxTokens, 200); + }); + + it('maps response_format json_object to json', () => { + const opts = toGenerateOpts({ response_format: { type: 'json_object' }, messages: [] }); + assert.equal(opts.responseFormat, 'json'); + }); + + it('maps response_format json_schema to { schema }', () => { + const schema = { type: 'object', properties: {} }; + const opts = toGenerateOpts({ + response_format: { type: 'json_schema', json_schema: schema }, + messages: [], + }); + assert.deepEqual(opts.responseFormat, { schema }); + }); + + it('maps response_format text to text', () => { + const opts = toGenerateOpts({ response_format: { type: 'text' }, messages: [] }); + assert.equal(opts.responseFormat, 'text'); + }); + + it('always sets toolMode to return', () => { + const opts = toGenerateOpts({ messages: [] }); + assert.equal(opts.toolMode, 'return'); + }); +}); + +// --------------------------------------------------------------------------- +// toEmbedOpts +// --------------------------------------------------------------------------- + +describe('toEmbedOpts', () => { + it('maps model field', () => { + assert.equal(toEmbedOpts({ model: 'embed-v1' }).model, 'embed-v1'); + }); + + it('returns empty opts when model absent', () => { + assert.deepEqual(toEmbedOpts({}), {}); + }); +}); + +// --------------------------------------------------------------------------- +// toChatCompletion +// --------------------------------------------------------------------------- + +describe('toChatCompletion', () => { + const baseResult = { + content: 'Hello!', + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 5 }, + }; + + it('builds a valid chat.completion object', () => { + const resp = toChatCompletion(baseResult, 'gpt-test', 'chatcmpl-fixed'); + assert.equal(resp.id, 'chatcmpl-fixed'); + assert.equal(resp.object, 'chat.completion'); + assert.equal(resp.model, 'gpt-test'); + assert.equal(resp.choices.length, 1); + assert.equal(resp.choices[0].finish_reason, 'stop'); + assert.equal(resp.choices[0].message.role, 'assistant'); + assert.equal(resp.choices[0].message.content, 'Hello!'); + assert.equal(resp.usage.prompt_tokens, 10); + assert.equal(resp.usage.completion_tokens, 5); + assert.equal(resp.usage.total_tokens, 15); + }); + + it('generates an id when none provided', () => { + const resp = toChatCompletion(baseResult, 'm'); + assert.ok(resp.id.startsWith('chatcmpl-')); + }); + + it('sets content to null and includes tool_calls when toolCalls present', () => { + const resp = toChatCompletion( + { + content: '', + finishReason: 'tool_calls', + toolCalls: [{ id: 'c1', name: 'search', arguments: { q: 'hi' } }], + }, + 'm', + 'id1' + ); + assert.equal(resp.choices[0].message.content, null); + assert.ok(Array.isArray(resp.choices[0].message.tool_calls)); + const tc = resp.choices[0].message.tool_calls[0]; + assert.equal(tc.id, 'c1'); + assert.equal(tc.type, 'function'); + assert.equal(tc.function.name, 'search'); + assert.deepEqual(JSON.parse(tc.function.arguments), { q: 'hi' }); + }); + + it('uses zeros when usage absent', () => { + const resp = toChatCompletion({ content: 'hi', finishReason: 'stop' }, 'm', 'id2'); + assert.equal(resp.usage.prompt_tokens, 0); + assert.equal(resp.usage.completion_tokens, 0); + assert.equal(resp.usage.total_tokens, 0); + }); +}); + +// --------------------------------------------------------------------------- +// toEmbedResponse +// --------------------------------------------------------------------------- + +describe('toEmbedResponse', () => { + it('converts Float32Array vectors to number arrays', () => { + const vec = new Float32Array([0.1, -0.5, 0.9]); + const resp = toEmbedResponse([vec], 'embed-v1', { embeddingTokens: 3 }); + assert.equal(resp.object, 'list'); + assert.equal(resp.model, 'embed-v1'); + assert.equal(resp.data.length, 1); + assert.equal(resp.data[0].index, 0); + assert.equal(resp.data[0].object, 'embedding'); + assert.ok(Array.isArray(resp.data[0].embedding)); + assert.equal(resp.data[0].embedding.length, 3); + assert.equal(resp.usage.total_tokens, 3); + }); + + it('assigns sequential indices to multiple vectors', () => { + const resp = toEmbedResponse([new Float32Array(2), new Float32Array(2)], 'm'); + assert.equal(resp.data[0].index, 0); + assert.equal(resp.data[1].index, 1); + }); + + it('uses zero usage when absent', () => { + const resp = toEmbedResponse([new Float32Array(1)], 'm'); + assert.equal(resp.usage.prompt_tokens, 0); + assert.equal(resp.usage.total_tokens, 0); + }); +}); From b4c664736b98e2496ee2ff8cdfaa50d213d60f50 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Mon, 6 Jul 2026 08:45:10 -0700 Subject: [PATCH 02/39] test(models): integration test for /v1/* gateway with real OpenAI SDK (#631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an integration test that starts a real Harper instance with: - `modelsGateway: {}` in config - A deterministic echo backend registered via the registerFromModule path (CJS fixture at integrationTests/server/fixtures/v1-gateway-test-backend.cjs) Tests all three endpoints: - GET /v1/models — model list shape - POST /v1/embeddings — single and batched input; 400 error shape - POST /v1/chat/completions — non-streaming shape; 400 error shape; streaming via the real OpenAI Node.js SDK (validates full SSE framing) The streaming test specifically exercises the SSE serving-path: the OpenAI SDK sends Accept: application/json for all requests, so stream: true lands in post() not connect(). The resource returns { body: Readable } which REST.ts bypasses serialisation on, and the SDK successfully parses the [DONE]-terminated SSE stream. Co-Authored-By: Claude Sonnet 4.6 --- .../fixtures/v1-gateway-test-backend.cjs | 66 ++++++ integrationTests/server/v1-gateway.test.ts | 208 ++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 integrationTests/server/fixtures/v1-gateway-test-backend.cjs create mode 100644 integrationTests/server/v1-gateway.test.ts diff --git a/integrationTests/server/fixtures/v1-gateway-test-backend.cjs b/integrationTests/server/fixtures/v1-gateway-test-backend.cjs new file mode 100644 index 0000000000..628f00ed33 --- /dev/null +++ b/integrationTests/server/fixtures/v1-gateway-test-backend.cjs @@ -0,0 +1,66 @@ +'use strict'; + +/** + * Minimal test backend for the /v1/* gateway integration test (#631). + * + * Implements a simple echo backend inline — no imports from the Harper dist + * directory. Loaded by `bootstrapModels()` via a `backend: ` + * entry in the test config. Uses `global.models.registerBackend()` (which is + * already populated when this register function is invoked). + */ + +function textFromInput(input) { + if (typeof input === 'string') return input; + const messages = Array.isArray(input) ? input : input.messages; + return messages.map((m) => m.content || '').join(' '); +} + +const echoGenerative = { + name: 'integration-test-echo', + capabilities: () => ({ embed: false, generate: true, stream: true, tools: false, adapters: false }), + async generate(input) { + const text = textFromInput(input); + const content = `[echo]: ${text}`; + return { + status: 'completed', + output: { content, finishReason: 'stop' }, + usage: { promptTokens: text.length, completionTokens: content.length }, + }; + }, + async *generateStream(input) { + const text = textFromInput(input); + const words = `[echo stream]: ${text}`.split(' '); + for (const word of words) { + yield { deltaContent: word + ' ' }; + } + yield { finishReason: 'stop' }; + }, +}; + +const echoEmbedding = { + name: 'integration-test-echo-embed', + capabilities: () => ({ embed: true, generate: false, stream: false, tools: false, adapters: false }), + async embed(input) { + const inputs = Array.isArray(input) ? input : [input]; + return { + status: 'completed', + output: inputs.map(() => new Float32Array([0.1, 0.2, 0.3, 0.4])), + usage: { embeddingTokens: inputs.length }, + }; + }, +}; + +/** + * Called by `registerFromModule` in bootstrap.ts. `global.models` is + * available at call time (Models singleton is set before bootstrapModels runs). + * @param {{ logicalName: string, kind: 'embedding' | 'generative' }} args + */ +exports.register = function ({ logicalName, kind }) { + const models = global.models; + if (!models) throw new Error('global.models is not set — bootstrap order violation'); + if (kind === 'generative') { + models.registerBackend('generative', logicalName, echoGenerative); + } else if (kind === 'embedding') { + models.registerBackend('embedding', logicalName, echoEmbedding); + } +}; diff --git a/integrationTests/server/v1-gateway.test.ts b/integrationTests/server/v1-gateway.test.ts new file mode 100644 index 0000000000..d3f2b45613 --- /dev/null +++ b/integrationTests/server/v1-gateway.test.ts @@ -0,0 +1,208 @@ +/** + * Integration test for the OpenAI-compatible `/v1/*` REST gateway (#631). + * + * Starts a real Harper instance with `modelsGateway: {}` configured and a + * deterministic echo backend registered via the `registerFromModule` path. + * Exercises all three endpoints: + * GET /v1/models — list registered backends + * POST /v1/embeddings — embed a string + * POST /v1/chat/completions — non-streaming and streaming chat + * + * The streaming test uses the real OpenAI Node.js SDK to confirm that the + * SSE framing (openaiStream → serializeStream → HTTP) is parseable by an + * unmodified OpenAI client. See the SSE serving-path note in chatCompletions.ts + * for why `stream: true` routes through `post()` rather than `connect()`. + */ +import { suite, test, before, after } from 'node:test'; +import assert from 'node:assert'; +import { resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { startHarper, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const ECHO_BACKEND_PATH = resolvePath(__dirname, 'fixtures/v1-gateway-test-backend.cjs'); + +function restUrl(ctx: ContextWithHarper, path: string): string { + return `${ctx.harper.httpURL}${path}`; +} + +function authHeader(ctx: ContextWithHarper): string { + return `Basic ${Buffer.from(`${ctx.harper.admin.username}:${ctx.harper.admin.password}`).toString('base64')}`; +} + +/** Fetch helper that always injects Basic auth. */ +async function harperFetch(ctx: ContextWithHarper, url: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + headers.set('Authorization', authHeader(ctx)); + return fetch(url, { ...init, headers }); +} + +suite('OpenAI /v1/* gateway (modelsGateway)', (ctx: ContextWithHarper) => { + before(async () => { + await startHarper(ctx, { + config: { + modelsGateway: {}, + models: { + generative: { + default: { backend: ECHO_BACKEND_PATH }, + }, + embedding: { + default: { backend: ECHO_BACKEND_PATH }, + }, + }, + }, + env: {}, + }); + }); + + after(async () => { + await teardownHarper(ctx); + }); + + // ----------------------------------------------------------------------- + // GET /v1/models + // ----------------------------------------------------------------------- + + test('GET /v1/models returns the registered backends in OpenAI model list shape', async () => { + const res = await harperFetch(ctx, restUrl(ctx, '/v1/models')); + assert.equal(res.status, 200, `expected 200, got ${res.status}`); + const body = (await res.json()) as { object: string; data: Array<{ id: string; object: string }> }; + assert.equal(body.object, 'list'); + assert.ok(Array.isArray(body.data), 'expected data array'); + const ids = body.data.map((m) => m.id); + assert.ok(ids.includes('default'), `expected 'default' backend in model ids, got: ${ids.join(', ')}`); + for (const entry of body.data) { + assert.equal(entry.object, 'model'); + } + }); + + // ----------------------------------------------------------------------- + // POST /v1/embeddings + // ----------------------------------------------------------------------- + + test('POST /v1/embeddings returns embedding vectors in OpenAI list shape', async () => { + const res = await harperFetch(ctx, restUrl(ctx, '/v1/embeddings'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ model: 'default', input: 'hello world' }), + }); + assert.equal(res.status, 200, `expected 200, got ${res.status}: ${await res.text()}`); + const body = (await res.json()) as { + object: string; + data: Array<{ embedding: number[]; index: number; object: string }>; + }; + assert.equal(body.object, 'list'); + assert.equal(body.data.length, 1); + assert.equal(body.data[0].object, 'embedding'); + assert.equal(body.data[0].index, 0); + assert.ok(Array.isArray(body.data[0].embedding)); + assert.ok(body.data[0].embedding.length > 0); + }); + + test('POST /v1/embeddings supports batched input', async () => { + const res = await harperFetch(ctx, restUrl(ctx, '/v1/embeddings'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ model: 'default', input: ['foo', 'bar', 'baz'] }), + }); + assert.equal(res.status, 200); + const body = (await res.json()) as { data: Array<{ index: number }> }; + assert.equal(body.data.length, 3); + // Indices must be in order + assert.equal(body.data[0].index, 0); + assert.equal(body.data[1].index, 1); + assert.equal(body.data[2].index, 2); + }); + + test('POST /v1/embeddings returns 400 with OpenAI error shape when input is missing', async () => { + const res = await harperFetch(ctx, restUrl(ctx, '/v1/embeddings'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ model: 'default' }), + }); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { type: string; message: string } }; + assert.equal(body.error.type, 'invalid_request_error'); + assert.ok(body.error.message.length > 0); + }); + + // ----------------------------------------------------------------------- + // POST /v1/chat/completions — non-streaming + // ----------------------------------------------------------------------- + + test('POST /v1/chat/completions returns a chat.completion object (non-streaming)', async () => { + const res = await harperFetch(ctx, restUrl(ctx, '/v1/chat/completions'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ + model: 'default', + messages: [{ role: 'user', content: 'hello' }], + }), + }); + assert.equal(res.status, 200, `expected 200, got ${res.status}: ${await res.text()}`); + const body = (await res.json()) as { + id: string; + object: string; + choices: Array<{ message: { role: string; content: string }; finish_reason: string }>; + usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; + }; + assert.equal(body.object, 'chat.completion'); + assert.ok(typeof body.id === 'string' && body.id.startsWith('chatcmpl-')); + assert.equal(body.choices.length, 1); + assert.equal(body.choices[0].message.role, 'assistant'); + assert.ok(typeof body.choices[0].message.content === 'string'); + assert.ok(body.choices[0].message.content.includes('[echo]'), `unexpected: ${body.choices[0].message.content}`); + assert.equal(body.choices[0].finish_reason, 'stop'); + assert.ok(typeof body.usage.prompt_tokens === 'number'); + assert.ok(typeof body.usage.completion_tokens === 'number'); + }); + + test('POST /v1/chat/completions returns 400 with OpenAI error shape when messages missing', async () => { + const res = await harperFetch(ctx, restUrl(ctx, '/v1/chat/completions'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ model: 'default' }), + }); + assert.equal(res.status, 400); + const body = (await res.json()) as { error: { type: string } }; + assert.equal(body.error.type, 'invalid_request_error'); + }); + + // ----------------------------------------------------------------------- + // POST /v1/chat/completions — streaming via real OpenAI SDK + // ----------------------------------------------------------------------- + + test('streaming chat completions are parseable by the real OpenAI SDK (stream: true)', async () => { + // The OpenAI SDK sends Accept: application/json even for streaming requests, + // so the stream: true request lands in post() via Harper's REST layer. + // This test validates the full SSE framing path end-to-end. + // + // AUTHENTICATION_AUTHORIZELOCAL=true (set by the test harness) means all + // requests from loopback addresses bypass auth, so the SDK's `apiKey` is + // not validated — any non-empty string works. + const { OpenAI } = (await import('openai')) as { OpenAI: new (opts: object) => any }; + const client = new OpenAI({ + apiKey: 'test-key', + baseURL: `${ctx.harper.httpURL}/v1`, + }); + + const chunks: string[] = []; + const stream = client.chat.completions.stream({ + model: 'default', + messages: [{ role: 'user', content: 'tell me something' }], + }); + + for await (const chunk of stream) { + const delta = chunk.choices[0]?.delta?.content; + if (delta) chunks.push(delta); + } + + const content = chunks.join(''); + assert.ok(content.length > 0, 'expected non-empty streamed content'); + assert.ok(content.includes('[echo stream]'), `unexpected content: ${content}`); + + const completion = await stream.finalChatCompletion(); + assert.equal(completion.object, 'chat.completion'); + assert.equal(completion.choices[0].finish_reason, 'stop'); + }); +}); From 82b44433049d250b35b52a4de44d20c10e6d9d86 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Mon, 6 Jul 2026 10:24:35 -0700 Subject: [PATCH 03/39] fix(deps): regenerate package-lock.json with openai resolved npm ci in CI was failing with "Missing: openai@6.45.0 from lock file" because package-lock.json was not updated when openai was added to devDependencies. Ran `npm install openai --package-lock-only`. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 92 +++++++++++++++++++++++++---------------------- package.json | 2 +- 2 files changed, 51 insertions(+), 43 deletions(-) diff --git a/package-lock.json b/package-lock.json index 78907bb074..e204e5f1bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -119,6 +119,7 @@ "mkcert": "^3.2.0", "mocha": "^11.7.5", "mqtt": "^5.15.1", + "openai": "^6.45.0", "oxlint": "^1.31.0", "prettier": "~3.8.0", "rewire": "^9.0.1", @@ -2676,9 +2677,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2695,9 +2693,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2714,9 +2709,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2733,9 +2725,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3446,11 +3435,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { "version": "3.0.4", @@ -3459,11 +3450,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { "version": "3.0.4", @@ -3472,11 +3465,13 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { "version": "3.0.4", @@ -3485,11 +3480,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { "version": "3.0.4", @@ -3498,11 +3495,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { "version": "3.0.4", @@ -3511,11 +3510,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@noble/hashes": { "version": "1.8.0", @@ -3684,9 +3685,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3704,9 +3702,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3724,9 +3719,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3744,9 +3736,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3764,9 +3753,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3784,9 +3770,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3804,9 +3787,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3824,9 +3804,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -11980,6 +11957,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "6.45.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz", + "integrity": "sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/optionator": { "version": "0.9.4", "dev": true, diff --git a/package.json b/package.json index a3d83ce318..f133fd4053 100644 --- a/package.json +++ b/package.json @@ -143,12 +143,12 @@ "chai-as-promised": "^8.0.2", "chai-integer": "^0.1.0", "eventsource": "^4.0.0", - "openai": "^6.45.0", "globals": "^17.0.0", "intercept-stdout": "0.1.2", "mkcert": "^3.2.0", "mocha": "^11.7.5", "mqtt": "^5.15.1", + "openai": "^6.45.0", "oxlint": "^1.31.0", "prettier": "~3.8.0", "rewire": "^9.0.1", From 0eda02741bd8e71ea42f94f576493531a8ffe30f Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Mon, 6 Jul 2026 10:33:18 -0700 Subject: [PATCH 04/39] fix(models): resolve gateway 404s and address bot review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use `modelsGateway: { enabled: true }` in the integration test; an empty object `{}` is silently dropped by `flattenObject()` in `harperConfigEnvVars.ts` (no leaf paths → nothing set via HARPER_SET_CONFIG) so `componentLoader` never sees the key and skips loading the gateway - Add `|| Array.isArray(body)` guard to `V1Embeddings.post` (Gemini: arrays pass `typeof x !== 'object'` unchanged since `typeof [] === 'object'`) - Guard against pre-serialised string arguments in `toOAIToolCalls` (Gemini: passing a JSON string through `JSON.stringify` would double-encode it) - Rename inner `body` → `readable` in `V1ChatCompletions.post` streaming path (Claude bot: shadowed the outer `body` parameter at line 28) - AbortSignal thread left as informational: signal is already propagated via `resolveCallContext` / `contextStorage.getStore()?.signal` in `Models.ts` Co-Authored-By: Claude Sonnet 4.6 --- integrationTests/server/v1-gateway.test.ts | 5 ++++- resources/models/v1/chatCompletions.ts | 4 ++-- resources/models/v1/embeddings.ts | 3 ++- resources/models/v1/translation.ts | 7 ++++++- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/integrationTests/server/v1-gateway.test.ts b/integrationTests/server/v1-gateway.test.ts index d3f2b45613..fc252205d3 100644 --- a/integrationTests/server/v1-gateway.test.ts +++ b/integrationTests/server/v1-gateway.test.ts @@ -41,7 +41,10 @@ suite('OpenAI /v1/* gateway (modelsGateway)', (ctx: ContextWithHarper) => { before(async () => { await startHarper(ctx, { config: { - modelsGateway: {}, + // modelsGateway: {} would be silently dropped by flattenObject() in + // harperConfigEnvVars.ts because an empty plain object has no leaf paths + // to flatten. Use a non-empty sentinel so the key survives the env-var path. + modelsGateway: { enabled: true }, models: { generative: { default: { backend: ECHO_BACKEND_PATH }, diff --git a/resources/models/v1/chatCompletions.ts b/resources/models/v1/chatCompletions.ts index dd31ab13c0..db4dba7763 100644 --- a/resources/models/v1/chatCompletions.ts +++ b/resources/models/v1/chatCompletions.ts @@ -47,7 +47,7 @@ export class V1ChatCompletions extends Resource { // serializeStream wraps the async iterable in a Node Readable so REST.ts // can return it without re-serialising. The `body` presence on the return // value skips REST.ts's own serialize() call (REST.ts:165-193). - const body = sseHandler.serializeStream(openaiStream(tokenStream, { model })); + const readable = sseHandler.serializeStream(openaiStream(tokenStream, { model })); return { status: 200, headers: { @@ -55,7 +55,7 @@ export class V1ChatCompletions extends Resource { 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no', }, - body, + body: readable, }; } diff --git a/resources/models/v1/embeddings.ts b/resources/models/v1/embeddings.ts index 56d92cd774..dc36f1241f 100644 --- a/resources/models/v1/embeddings.ts +++ b/resources/models/v1/embeddings.ts @@ -13,7 +13,8 @@ import { toEmbedOpts, toEmbedResponse } from './translation.ts'; // @ts-ignore — Resource base class is not typed for static dispatch; pattern mirrors login.ts export class V1Embeddings extends Resource { static async post(_target: unknown, body: Record, _request: unknown) { - if (!body || typeof body !== 'object') return badRequest('Request body must be a JSON object'); + if (!body || typeof body !== 'object' || Array.isArray(body)) + return badRequest('Request body must be a JSON object'); const raw = body as Record; const input = raw.input; diff --git a/resources/models/v1/translation.ts b/resources/models/v1/translation.ts index 2ca30858ad..852347f9dd 100644 --- a/resources/models/v1/translation.ts +++ b/resources/models/v1/translation.ts @@ -184,7 +184,12 @@ function toOAIToolCalls(toolCalls: ToolCall[]): OAIToolCallOut[] { return toolCalls.map((tc) => ({ id: tc.id, type: 'function', - function: { name: tc.name, arguments: JSON.stringify(tc.arguments) }, + // Guard against backends that return arguments as a pre-serialised JSON string; + // passing a string through JSON.stringify would double-encode it. + function: { + name: tc.name, + arguments: typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments), + }, })); } From 5690be592211aafbb60b36e1d47af794e403d257 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Mon, 6 Jul 2026 11:51:07 -0700 Subject: [PATCH 05/39] fix(models): await the /v1/* request body before reading fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server/REST.ts builds `request.data` via the streaming JSON deserializer and hands it to resource methods unawaited, so the `body` argument V1ChatCompletions.post/V1Embeddings.post received was a Promise, not the parsed object. Every real JSON POST to the gateway was reading undefined fields off a Promise and returning 400 — the integration test's 200 assertions were masked by the (separate) 404 gating bug and had never actually exercised this path. Await the body before reading any field, including `stream`. Adjudicated cross-model review finding on #1616. Co-Authored-By: Claude Sonnet 4.6 --- resources/models/v1/chatCompletions.ts | 4 ++ resources/models/v1/embeddings.ts | 4 ++ .../models/v1/chatCompletions.test.js | 47 +++++++++++++++++++ .../resources/models/v1/embeddings.test.js | 38 +++++++++++++++ 4 files changed, 93 insertions(+) create mode 100644 unitTests/resources/models/v1/chatCompletions.test.js create mode 100644 unitTests/resources/models/v1/embeddings.test.js diff --git a/resources/models/v1/chatCompletions.ts b/resources/models/v1/chatCompletions.ts index db4dba7763..a429efa233 100644 --- a/resources/models/v1/chatCompletions.ts +++ b/resources/models/v1/chatCompletions.ts @@ -26,6 +26,10 @@ const sseHandler = contentTypes.get('text/event-stream') as SseHandler; // @ts-ignore — Resource base class is not typed for static dispatch; pattern mirrors login.ts export class V1ChatCompletions extends Resource { static async post(_target: unknown, body: unknown, _request: unknown) { + // REST.ts passes `request.data` directly, which is the (unawaited) streaming + // JSON deserializer's Promise — awaiting here is a no-op for callers (e.g. + // unit tests) that already pass a plain object. + body = await body; if (!body || typeof body !== 'object' || Array.isArray(body)) { return badRequest('Request body must be a JSON object'); } diff --git a/resources/models/v1/embeddings.ts b/resources/models/v1/embeddings.ts index dc36f1241f..9b45b5edde 100644 --- a/resources/models/v1/embeddings.ts +++ b/resources/models/v1/embeddings.ts @@ -13,6 +13,10 @@ import { toEmbedOpts, toEmbedResponse } from './translation.ts'; // @ts-ignore — Resource base class is not typed for static dispatch; pattern mirrors login.ts export class V1Embeddings extends Resource { static async post(_target: unknown, body: Record, _request: unknown) { + // REST.ts passes `request.data` directly, which is the (unawaited) streaming + // JSON deserializer's Promise — awaiting here is a no-op for callers (e.g. + // unit tests) that already pass a plain object. + body = await body; if (!body || typeof body !== 'object' || Array.isArray(body)) return badRequest('Request body must be a JSON object'); const raw = body as Record; diff --git a/unitTests/resources/models/v1/chatCompletions.test.js b/unitTests/resources/models/v1/chatCompletions.test.js new file mode 100644 index 0000000000..fe9c4056bc --- /dev/null +++ b/unitTests/resources/models/v1/chatCompletions.test.js @@ -0,0 +1,47 @@ +'use strict'; + +/** + * Unit tests for `resources/models/v1/chatCompletions.ts` (#631). + * + * REST.ts hands the handler `request.data`, which is an unawaited Promise for + * JSON bodies (server/REST.ts's streaming deserializer) — the handler must + * `await` it before reading any field, including `stream`. + */ + +const assert = require('node:assert'); +require('#src/resources/databases'); +const { setGenerative, clearRegistry } = require('#src/resources/models/backendRegistry'); +const { TestBackend } = require('#src/resources/models/TestBackend'); +const { V1ChatCompletions } = require('#src/resources/models/v1/chatCompletions'); + +describe('V1ChatCompletions.post', () => { + beforeEach(() => { + setGenerative('default', new TestBackend()); + }); + + afterEach(() => { + clearRegistry(); + }); + + it('returns a chat completion for a plain-object body (unit-test caller shape)', async () => { + const body = { messages: [{ role: 'user', content: 'hi' }] }; + const result = await V1ChatCompletions.post(undefined, body, {}); + assert.equal(result.object, 'chat.completion'); + assert.ok(result.choices[0].message.content.includes('hi')); + }); + + it('awaits a Promise-wrapped body, matching REST.ts passing request.data unawaited', async () => { + const body = Promise.resolve({ messages: [{ role: 'user', content: 'hello' }] }); + const result = await V1ChatCompletions.post(undefined, body, {}); + assert.equal(result.object, 'chat.completion'); + assert.ok(result.choices[0].message.content.includes('hello')); + }); + + it('reads the stream flag from a Promise-wrapped body', async () => { + const body = Promise.resolve({ messages: [{ role: 'user', content: 'hi' }], stream: true }); + const result = await V1ChatCompletions.post(undefined, body, {}); + assert.equal(result.status, 200); + assert.equal(result.headers['Content-Type'], 'text/event-stream'); + assert.ok(result.body, 'expected a Readable body for the streaming path'); + }); +}); diff --git a/unitTests/resources/models/v1/embeddings.test.js b/unitTests/resources/models/v1/embeddings.test.js new file mode 100644 index 0000000000..df4ff4ffab --- /dev/null +++ b/unitTests/resources/models/v1/embeddings.test.js @@ -0,0 +1,38 @@ +'use strict'; + +/** + * Unit tests for `resources/models/v1/embeddings.ts` (#631). + * + * REST.ts hands the handler `request.data`, which is an unawaited Promise for + * JSON bodies — the handler must `await` it before reading any field. + */ + +const assert = require('node:assert'); +require('#src/resources/databases'); +const { setEmbedding, clearRegistry } = require('#src/resources/models/backendRegistry'); +const { TestBackend } = require('#src/resources/models/TestBackend'); +const { V1Embeddings } = require('#src/resources/models/v1/embeddings'); + +describe('V1Embeddings.post', () => { + beforeEach(() => { + setEmbedding('default', new TestBackend()); + }); + + afterEach(() => { + clearRegistry(); + }); + + it('returns embeddings for a plain-object body (unit-test caller shape)', async () => { + const body = { input: 'hello world' }; + const result = await V1Embeddings.post(undefined, body, {}); + assert.equal(result.object, 'list'); + assert.equal(result.data.length, 1); + }); + + it('awaits a Promise-wrapped body, matching REST.ts passing request.data unawaited', async () => { + const body = Promise.resolve({ input: ['a', 'b'] }); + const result = await V1Embeddings.post(undefined, body, {}); + assert.equal(result.object, 'list'); + assert.equal(result.data.length, 2); + }); +}); From 469c5e1387ff0dc4e205b9cd1ffbfb6b775994d7 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Mon, 6 Jul 2026 16:01:26 -0700 Subject: [PATCH 06/39] fix(models): require super_user auth on all /v1/* gateway handlers Static method overrides on V1Models, V1ChatCompletions, and V1Embeddings bypass Resource's transactional() wrapper and therefore the default allowRead/allowCreate super_user gate. Add authorizeV1Request() in errors.ts that returns a well-formed OpenAI error envelope (401 for anonymous, 403 for non-super_user, null to proceed). Call it at the top of each handler before touching the body. Unit tests cover: 401 anon, 403 non-super_user, 200 super_user, and that auth is checked before body validation (prevents body deserialization on unauthorized requests). Co-Authored-By: Claude Sonnet 4.6 --- resources/models/v1/chatCompletions.ts | 7 ++- resources/models/v1/embeddings.ts | 7 ++- resources/models/v1/errors.ts | 54 ++++++++++++++++++- resources/models/v1/models.ts | 6 ++- .../models/v1/chatCompletions.test.js | 38 ++++++++++--- .../resources/models/v1/embeddings.test.js | 34 ++++++++++-- unitTests/resources/models/v1/errors.test.js | 21 +++++++- unitTests/resources/models/v1/models.test.js | 45 ++++++++++++++++ 8 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 unitTests/resources/models/v1/models.test.js diff --git a/resources/models/v1/chatCompletions.ts b/resources/models/v1/chatCompletions.ts index a429efa233..b3aa258981 100644 --- a/resources/models/v1/chatCompletions.ts +++ b/resources/models/v1/chatCompletions.ts @@ -16,7 +16,7 @@ import { contentTypes } from '../../../server/serverHelpers/contentTypes.ts'; import { Resource } from '../../Resource.ts'; import { models } from '../Models.ts'; import { openaiStream } from '../openaiStream.ts'; -import { toOpenAIError, badRequest } from './errors.ts'; +import { toOpenAIError, badRequest, authorizeV1Request } from './errors.ts'; import { translateMessages, translateTools, toGenerateInput, toGenerateOpts, toChatCompletion } from './translation.ts'; import type { OAIChatRequest } from './translation.ts'; @@ -25,7 +25,10 @@ const sseHandler = contentTypes.get('text/event-stream') as SseHandler; // @ts-ignore — Resource base class is not typed for static dispatch; pattern mirrors login.ts export class V1ChatCompletions extends Resource { - static async post(_target: unknown, body: unknown, _request: unknown) { + static async post(_target: unknown, body: unknown, request: unknown) { + const authError = authorizeV1Request(request as any); + if (authError) return authError; + // REST.ts passes `request.data` directly, which is the (unawaited) streaming // JSON deserializer's Promise — awaiting here is a no-op for callers (e.g. // unit tests) that already pass a plain object. diff --git a/resources/models/v1/embeddings.ts b/resources/models/v1/embeddings.ts index 9b45b5edde..0cea1cf993 100644 --- a/resources/models/v1/embeddings.ts +++ b/resources/models/v1/embeddings.ts @@ -7,12 +7,15 @@ import { Resource } from '../../Resource.ts'; import { models } from '../Models.ts'; -import { toOpenAIError, badRequest } from './errors.ts'; +import { toOpenAIError, badRequest, authorizeV1Request } from './errors.ts'; import { toEmbedOpts, toEmbedResponse } from './translation.ts'; // @ts-ignore — Resource base class is not typed for static dispatch; pattern mirrors login.ts export class V1Embeddings extends Resource { - static async post(_target: unknown, body: Record, _request: unknown) { + static async post(_target: unknown, body: Record, request: unknown) { + const authError = authorizeV1Request(request as any); + if (authError) return authError; + // REST.ts passes `request.data` directly, which is the (unawaited) streaming // JSON deserializer's Promise — awaiting here is a no-op for callers (e.g. // unit tests) that already pass a plain object. diff --git a/resources/models/v1/errors.ts b/resources/models/v1/errors.ts index 765688b63b..0a29dcbd87 100644 --- a/resources/models/v1/errors.ts +++ b/resources/models/v1/errors.ts @@ -8,7 +8,12 @@ import { ModelBackendNotFoundError } from '../backendRegistry.ts'; -type OpenAIErrorType = 'invalid_request_error' | 'server_error' | 'authentication_error' | 'api_error'; +type OpenAIErrorType = + | 'invalid_request_error' + | 'server_error' + | 'authentication_error' + | 'permission_error' + | 'api_error'; export interface OpenAIErrorBody { message: string; @@ -65,3 +70,50 @@ export function badRequest(message: string): OpenAIErrorResponse { data: { error: { message, type: 'invalid_request_error', code: null, param: null } }, }; } + +/** + * Gate for the `/v1/*` handlers, since overriding the static `get`/`post` methods + * bypasses Resource's `transactional()` wrapper and its default `allowRead`/`allowCreate` + * checks (Resource.ts:685-733, 426-435) never run for these endpoints. + * + * Mirrors Resource's default gate (super_user-only) rather than introducing a new + * permission — see PR discussion for whether a dedicated `/v1/*` permission should + * replace this later. + * + * Returns an OpenAI-shape error response when access should be denied, or `null` + * when the request may proceed. + */ +export function authorizeV1Request(request: { + user?: { role?: { permission?: { super_user?: boolean } } }; +}): OpenAIErrorResponse | null { + const user = request?.user; + if (!user) { + return { + status: 401, + headers: { 'Content-Type': 'application/json' }, + data: { + error: { + message: 'You must provide valid credentials to access this endpoint.', + type: 'authentication_error' as const, + code: null, + param: null, + }, + }, + }; + } + if (!user.role?.permission?.super_user) { + return { + status: 403, + headers: { 'Content-Type': 'application/json' }, + data: { + error: { + message: 'You do not have permission to access this endpoint.', + type: 'permission_error' as const, + code: null, + param: null, + }, + }, + }; + } + return null; +} diff --git a/resources/models/v1/models.ts b/resources/models/v1/models.ts index 854eb48dbe..bb39dbc09f 100644 --- a/resources/models/v1/models.ts +++ b/resources/models/v1/models.ts @@ -11,6 +11,7 @@ import { Resource } from '../../Resource.ts'; import { listBackends } from '../backendRegistry.ts'; +import { authorizeV1Request, type OpenAIErrorResponse } from './errors.ts'; export interface OAIModelEntry { id: string; @@ -26,7 +27,10 @@ export interface OAIModelList { // @ts-ignore — Resource base class is not typed for static dispatch; pattern mirrors login.ts export class V1Models extends Resource { - static get(_target: unknown, _request: unknown): OAIModelList { + static get(_target: unknown, request: unknown): OAIModelList | OpenAIErrorResponse { + const authError = authorizeV1Request(request as any); + if (authError) return authError; + const created = Math.floor(Date.now() / 1000); const generative = listBackends('generative').map( ({ logicalName }): OAIModelEntry => ({ diff --git a/unitTests/resources/models/v1/chatCompletions.test.js b/unitTests/resources/models/v1/chatCompletions.test.js index fe9c4056bc..99c8b45f8c 100644 --- a/unitTests/resources/models/v1/chatCompletions.test.js +++ b/unitTests/resources/models/v1/chatCompletions.test.js @@ -3,9 +3,13 @@ /** * Unit tests for `resources/models/v1/chatCompletions.ts` (#631). * - * REST.ts hands the handler `request.data`, which is an unawaited Promise for - * JSON bodies (server/REST.ts's streaming deserializer) — the handler must - * `await` it before reading any field, including `stream`. + * Covers two adjudicated blockers on PR #1616: + * - REST.ts hands the handler `request.data`, which is an unawaited Promise for + * JSON bodies (server/REST.ts's streaming deserializer) — the handler must + * `await` it before reading any field, including `stream`. + * - Anonymous / non-super_user requests must be rejected with an OpenAI-shape + * 401 / 403 envelope, mirroring Resource's default `allowRead`/`allowCreate` + * gate that static overrides bypass. */ const assert = require('node:assert'); @@ -14,6 +18,9 @@ const { setGenerative, clearRegistry } = require('#src/resources/models/backendR const { TestBackend } = require('#src/resources/models/TestBackend'); const { V1ChatCompletions } = require('#src/resources/models/v1/chatCompletions'); +const SUPER_USER = { role: { permission: { super_user: true } } }; +const NON_SUPER_USER = { role: { permission: { super_user: false } } }; + describe('V1ChatCompletions.post', () => { beforeEach(() => { setGenerative('default', new TestBackend()); @@ -25,23 +32,42 @@ describe('V1ChatCompletions.post', () => { it('returns a chat completion for a plain-object body (unit-test caller shape)', async () => { const body = { messages: [{ role: 'user', content: 'hi' }] }; - const result = await V1ChatCompletions.post(undefined, body, {}); + const result = await V1ChatCompletions.post(undefined, body, { user: SUPER_USER }); assert.equal(result.object, 'chat.completion'); assert.ok(result.choices[0].message.content.includes('hi')); }); it('awaits a Promise-wrapped body, matching REST.ts passing request.data unawaited', async () => { const body = Promise.resolve({ messages: [{ role: 'user', content: 'hello' }] }); - const result = await V1ChatCompletions.post(undefined, body, {}); + const result = await V1ChatCompletions.post(undefined, body, { user: SUPER_USER }); assert.equal(result.object, 'chat.completion'); assert.ok(result.choices[0].message.content.includes('hello')); }); it('reads the stream flag from a Promise-wrapped body', async () => { const body = Promise.resolve({ messages: [{ role: 'user', content: 'hi' }], stream: true }); - const result = await V1ChatCompletions.post(undefined, body, {}); + const result = await V1ChatCompletions.post(undefined, body, { user: SUPER_USER }); assert.equal(result.status, 200); assert.equal(result.headers['Content-Type'], 'text/event-stream'); assert.ok(result.body, 'expected a Readable body for the streaming path'); }); + + it('rejects an anonymous request with a 401 OpenAI-shape envelope', async () => { + const body = { messages: [{ role: 'user', content: 'hi' }] }; + const result = await V1ChatCompletions.post(undefined, body, {}); + assert.equal(result.status, 401); + assert.equal(result.data.error.type, 'authentication_error'); + }); + + it('rejects a non-super_user request with a 403 OpenAI-shape envelope', async () => { + const body = { messages: [{ role: 'user', content: 'hi' }] }; + const result = await V1ChatCompletions.post(undefined, body, { user: NON_SUPER_USER }); + assert.equal(result.status, 403); + assert.equal(result.data.error.type, 'permission_error'); + }); + + it('checks authorization before touching the body, even for a malformed body', async () => { + const result = await V1ChatCompletions.post(undefined, 'not an object', {}); + assert.equal(result.status, 401, 'auth must be checked ahead of body validation'); + }); }); diff --git a/unitTests/resources/models/v1/embeddings.test.js b/unitTests/resources/models/v1/embeddings.test.js index df4ff4ffab..5c91876954 100644 --- a/unitTests/resources/models/v1/embeddings.test.js +++ b/unitTests/resources/models/v1/embeddings.test.js @@ -3,8 +3,12 @@ /** * Unit tests for `resources/models/v1/embeddings.ts` (#631). * - * REST.ts hands the handler `request.data`, which is an unawaited Promise for - * JSON bodies — the handler must `await` it before reading any field. + * Covers two adjudicated blockers on PR #1616: + * - REST.ts hands the handler `request.data`, which is an unawaited Promise for + * JSON bodies — the handler must `await` it before reading any field. + * - Anonymous / non-super_user requests must be rejected with an OpenAI-shape + * 401 / 403 envelope, mirroring Resource's default `allowRead`/`allowCreate` + * gate that static overrides bypass. */ const assert = require('node:assert'); @@ -13,6 +17,9 @@ const { setEmbedding, clearRegistry } = require('#src/resources/models/backendRe const { TestBackend } = require('#src/resources/models/TestBackend'); const { V1Embeddings } = require('#src/resources/models/v1/embeddings'); +const SUPER_USER = { role: { permission: { super_user: true } } }; +const NON_SUPER_USER = { role: { permission: { super_user: false } } }; + describe('V1Embeddings.post', () => { beforeEach(() => { setEmbedding('default', new TestBackend()); @@ -24,15 +31,34 @@ describe('V1Embeddings.post', () => { it('returns embeddings for a plain-object body (unit-test caller shape)', async () => { const body = { input: 'hello world' }; - const result = await V1Embeddings.post(undefined, body, {}); + const result = await V1Embeddings.post(undefined, body, { user: SUPER_USER }); assert.equal(result.object, 'list'); assert.equal(result.data.length, 1); }); it('awaits a Promise-wrapped body, matching REST.ts passing request.data unawaited', async () => { const body = Promise.resolve({ input: ['a', 'b'] }); - const result = await V1Embeddings.post(undefined, body, {}); + const result = await V1Embeddings.post(undefined, body, { user: SUPER_USER }); assert.equal(result.object, 'list'); assert.equal(result.data.length, 2); }); + + it('rejects an anonymous request with a 401 OpenAI-shape envelope', async () => { + const body = { input: 'hello' }; + const result = await V1Embeddings.post(undefined, body, {}); + assert.equal(result.status, 401); + assert.equal(result.data.error.type, 'authentication_error'); + }); + + it('rejects a non-super_user request with a 403 OpenAI-shape envelope', async () => { + const body = { input: 'hello' }; + const result = await V1Embeddings.post(undefined, body, { user: NON_SUPER_USER }); + assert.equal(result.status, 403); + assert.equal(result.data.error.type, 'permission_error'); + }); + + it('checks authorization before touching the body, even for a malformed body', async () => { + const result = await V1Embeddings.post(undefined, 'not an object', {}); + assert.equal(result.status, 401, 'auth must be checked ahead of body validation'); + }); }); diff --git a/unitTests/resources/models/v1/errors.test.js b/unitTests/resources/models/v1/errors.test.js index c3d12d30a2..51dc10fc55 100644 --- a/unitTests/resources/models/v1/errors.test.js +++ b/unitTests/resources/models/v1/errors.test.js @@ -7,7 +7,7 @@ */ const assert = require('node:assert'); -const { toOpenAIError, badRequest } = require('#src/resources/models/v1/errors'); +const { toOpenAIError, badRequest, authorizeV1Request } = require('#src/resources/models/v1/errors'); const { ModelBackendNotFoundError } = require('#src/resources/models/backendRegistry'); function makeClientError(message, statusCode) { @@ -83,3 +83,22 @@ describe('badRequest', () => { assert.equal(resp.data.error.param, null); }); }); + +describe('authorizeV1Request', () => { + it('returns a 401 authentication_error envelope when request.user is absent', () => { + const resp = authorizeV1Request({}); + assert.equal(resp.status, 401); + assert.equal(resp.data.error.type, 'authentication_error'); + }); + + it('returns a 403 permission_error envelope for a non-super_user', () => { + const resp = authorizeV1Request({ user: { role: { permission: { super_user: false } } } }); + assert.equal(resp.status, 403); + assert.equal(resp.data.error.type, 'permission_error'); + }); + + it('returns null (allow) for a super_user', () => { + const resp = authorizeV1Request({ user: { role: { permission: { super_user: true } } } }); + assert.equal(resp, null); + }); +}); diff --git a/unitTests/resources/models/v1/models.test.js b/unitTests/resources/models/v1/models.test.js new file mode 100644 index 0000000000..604499974c --- /dev/null +++ b/unitTests/resources/models/v1/models.test.js @@ -0,0 +1,45 @@ +'use strict'; + +/** + * Unit tests for `resources/models/v1/models.ts` (#631). + * + * Covers the adjudicated auth blocker: anonymous / non-super_user requests must + * be rejected with an OpenAI-shape 401 / 403 envelope, mirroring Resource's + * default `allowRead` gate that the static `get()` override bypasses. + */ + +const assert = require('node:assert'); +require('#src/resources/databases'); +const { setGenerative, setEmbedding, clearRegistry } = require('#src/resources/models/backendRegistry'); +const { TestBackend } = require('#src/resources/models/TestBackend'); +const { V1Models } = require('#src/resources/models/v1/models'); + +const SUPER_USER = { role: { permission: { super_user: true } } }; +const NON_SUPER_USER = { role: { permission: { super_user: false } } }; + +describe('V1Models.get', () => { + afterEach(() => { + clearRegistry(); + }); + + it('lists registered generative and embedding backends for a super_user', () => { + setGenerative('default', new TestBackend()); + setEmbedding('default', new TestBackend()); + const result = V1Models.get(undefined, { user: SUPER_USER }); + assert.equal(result.object, 'list'); + assert.equal(result.data.length, 2); + assert.ok(result.data.every((m) => m.object === 'model')); + }); + + it('rejects an anonymous request with a 401 OpenAI-shape envelope', () => { + const result = V1Models.get(undefined, {}); + assert.equal(result.status, 401); + assert.equal(result.data.error.type, 'authentication_error'); + }); + + it('rejects a non-super_user request with a 403 OpenAI-shape envelope', () => { + const result = V1Models.get(undefined, { user: NON_SUPER_USER }); + assert.equal(result.status, 403); + assert.equal(result.data.error.type, 'permission_error'); + }); +}); From 89f2e119d2b378c006934c9e91fe11df42d5ed3f Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Mon, 6 Jul 2026 16:26:52 -0700 Subject: [PATCH 07/39] fix(models): guarantee modelsGateway config key; default-OFF with enabled flag Previously the gateway only loaded if the user added a `modelsGateway:` key to harperdb-config.yaml (presence-gating). On CI and fast-startup environments the HARPER_SET_CONFIG env var raced component loading, causing intermittent 404s. Add `modelsGateway: { enabled: false }` to defaultConfig.yaml so the key is always present in the resolved config and componentLoader always sees it. The `handleApplication` entry point now checks `scope.options.get(['enabled'])` and returns immediately when false (same pattern as the agent component). Opt in with `modelsGateway: { enabled: true }`. The integration test already passes this explicitly; its new header comment notes the dependency on #1618 (env-config ordering) for reliable CI behaviour. Co-Authored-By: Claude Sonnet 4.6 --- integrationTests/server/v1-gateway.test.ts | 15 +++++++++++---- resources/models/v1/index.ts | 16 ++++++++++------ static/defaultConfig.yaml | 2 ++ 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/integrationTests/server/v1-gateway.test.ts b/integrationTests/server/v1-gateway.test.ts index fc252205d3..917c0bd57b 100644 --- a/integrationTests/server/v1-gateway.test.ts +++ b/integrationTests/server/v1-gateway.test.ts @@ -1,7 +1,7 @@ /** * Integration test for the OpenAI-compatible `/v1/*` REST gateway (#631). * - * Starts a real Harper instance with `modelsGateway: {}` configured and a + * Starts a real Harper instance with `modelsGateway: { enabled: true }` and a * deterministic echo backend registered via the `registerFromModule` path. * Exercises all three endpoints: * GET /v1/models — list registered backends @@ -12,6 +12,12 @@ * SSE framing (openaiStream → serializeStream → HTTP) is parseable by an * unmodified OpenAI client. See the SSE serving-path note in chatCompletions.ts * for why `stream: true` routes through `post()` rather than `connect()`. + * + * NOTE: `modelsGateway: { enabled: true }` is passed explicitly because the + * gateway is off by default (`enabled: false` in defaultConfig.yaml). The test + * harness plumbs this via HARPER_SET_CONFIG. On CI (Linux, fast startup), the + * env-var config can race component loading; that race is tracked in #1618 and + * the fix there will make this test reliable without needing further changes here. */ import { suite, test, before, after } from 'node:test'; import assert from 'node:assert'; @@ -41,9 +47,10 @@ suite('OpenAI /v1/* gateway (modelsGateway)', (ctx: ContextWithHarper) => { before(async () => { await startHarper(ctx, { config: { - // modelsGateway: {} would be silently dropped by flattenObject() in - // harperConfigEnvVars.ts because an empty plain object has no leaf paths - // to flatten. Use a non-empty sentinel so the key survives the env-var path. + // Gateway is off by default (enabled: false in defaultConfig.yaml). Pass + // enabled: true explicitly to activate it for these tests. A plain empty + // object would be silently dropped by flattenObject() in harperConfigEnvVars.ts + // because it has no leaf paths to flatten. modelsGateway: { enabled: true }, models: { generative: { diff --git a/resources/models/v1/index.ts b/resources/models/v1/index.ts index a5f92824bc..3f75a57bde 100644 --- a/resources/models/v1/index.ts +++ b/resources/models/v1/index.ts @@ -6,11 +6,15 @@ * POST /v1/chat/completions → V1ChatCompletions * GET /v1/models → V1Models * - * Activated by adding `modelsGateway: {}` (or any truthy value) to - * `harperdb-config.yaml`. Example: + * Off by default. Opt in by setting `enabled: true` in the `modelsGateway` + * block of `harperdb-config.yaml`. Opt out explicitly with `enabled: false`. + * This mirrors the `agent` component's enabled-flag pattern. + * + * Example (opt in): * * ```yaml - * modelsGateway: {} + * modelsGateway: + * enabled: true * models: * generative: * default: @@ -18,9 +22,8 @@ * model: llama3.2 * ``` * - * The gateway intentionally does NOT add authentication — Harper's REST layer - * applies auth before dispatching to any resource. Deploy behind a network - * boundary or configure Harper's auth as appropriate. + * All three endpoints require `super_user` permission. Anonymous or + * insufficient-privilege requests receive a well-formed OpenAI error envelope. */ import type { Scope } from '../../../components/Scope.ts'; @@ -29,6 +32,7 @@ import { V1ChatCompletions } from './chatCompletions.ts'; import { V1Models } from './models.ts'; export function handleApplication(scope: Scope): void { + if (!scope.options.get(['enabled'])) return; scope.resources.set('v1/models', V1Models); scope.resources.set('v1/embeddings', V1Embeddings); scope.resources.set('v1/chat/completions', V1ChatCompletions); diff --git a/static/defaultConfig.yaml b/static/defaultConfig.yaml index ccd7274b85..41407e278f 100644 --- a/static/defaultConfig.yaml +++ b/static/defaultConfig.yaml @@ -34,6 +34,8 @@ applications: componentsRoot: null localStudio: enabled: true +modelsGateway: + enabled: false logging: auditAuthEvents: logFailed: false From 2597cc23666657a03626fe50ba9e84d9a219ddd7 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Wed, 8 Jul 2026 15:01:03 -0700 Subject: [PATCH 08/39] style: format v1 gateway files with prettier 3.9.3 (lockfile version) CI's Format Check runs the lockfile prettier (3.9.3); the files were formatted with 3.8.3 which disagrees on these three. Co-Authored-By: Claude Fable 5 --- resources/models/v1/errors.ts | 6 +----- resources/models/v1/models.ts | 28 ++++++++++++---------------- resources/models/v1/translation.ts | 12 +++++------- 3 files changed, 18 insertions(+), 28 deletions(-) diff --git a/resources/models/v1/errors.ts b/resources/models/v1/errors.ts index 0a29dcbd87..bae2231cf2 100644 --- a/resources/models/v1/errors.ts +++ b/resources/models/v1/errors.ts @@ -9,11 +9,7 @@ import { ModelBackendNotFoundError } from '../backendRegistry.ts'; type OpenAIErrorType = - | 'invalid_request_error' - | 'server_error' - | 'authentication_error' - | 'permission_error' - | 'api_error'; + 'invalid_request_error' | 'server_error' | 'authentication_error' | 'permission_error' | 'api_error'; export interface OpenAIErrorBody { message: string; diff --git a/resources/models/v1/models.ts b/resources/models/v1/models.ts index bb39dbc09f..bc83864058 100644 --- a/resources/models/v1/models.ts +++ b/resources/models/v1/models.ts @@ -32,22 +32,18 @@ export class V1Models extends Resource { if (authError) return authError; const created = Math.floor(Date.now() / 1000); - const generative = listBackends('generative').map( - ({ logicalName }): OAIModelEntry => ({ - id: logicalName, - object: 'model', - created, - owned_by: 'harper', - }) - ); - const embedding = listBackends('embedding').map( - ({ logicalName }): OAIModelEntry => ({ - id: logicalName, - object: 'model', - created, - owned_by: 'harper', - }) - ); + const generative = listBackends('generative').map(({ logicalName }): OAIModelEntry => ({ + id: logicalName, + object: 'model', + created, + owned_by: 'harper', + })); + const embedding = listBackends('embedding').map(({ logicalName }): OAIModelEntry => ({ + id: logicalName, + object: 'model', + created, + owned_by: 'harper', + })); return { object: 'list', data: [...generative, ...embedding] }; } } diff --git a/resources/models/v1/translation.ts b/resources/models/v1/translation.ts index 852347f9dd..42ffe9a02d 100644 --- a/resources/models/v1/translation.ts +++ b/resources/models/v1/translation.ts @@ -93,13 +93,11 @@ export function translateMessages(oaiMessages: OAIMessageIn[]): Message[] { /** Map OpenAI `tools[]` to Harper `ToolDef[]`. */ export function translateTools(oaiTools: OAIToolIn[]): ToolDef[] { - return oaiTools.map( - (t): ToolDef => ({ - name: t.function.name, - description: t.function.description ?? '', - parameters: t.function.parameters ?? {}, - }) - ); + return oaiTools.map((t): ToolDef => ({ + name: t.function.name, + description: t.function.description ?? '', + parameters: t.function.parameters ?? {}, + })); } /** From 58bb6e8679dfe3456e0849fb8df7f41ce74f21b2 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Wed, 8 Jul 2026 15:06:19 -0700 Subject: [PATCH 09/39] =?UTF-8?q?debug:=20TEMP=20instrumentation=20to=20pi?= =?UTF-8?q?n=20CI-only=20modelsGateway=20404=20(#1616)=20=E2=80=94=20REVER?= =?UTF-8?q?T=20BEFORE=20MERGE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logs per-thread: componentLoader's resolved modelsGateway config + env-var presence, handleApplication's enabled value, and (test-side) the child's harper-config.yaml block + get_configuration.modelsGateway. Co-Authored-By: Claude Fable 5 --- components/componentLoader.ts | 6 +++++ integrationTests/server/v1-gateway.test.ts | 26 +++++++++++++++++++++- resources/models/v1/index.ts | 2 ++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/components/componentLoader.ts b/components/componentLoader.ts index 86f1094a0a..49d2de9329 100644 --- a/components/componentLoader.ts +++ b/components/componentLoader.ts @@ -391,6 +391,12 @@ export async function loadComponent( compName = componentName; const componentConfig = config[componentName]; + // TEMP #1616-debug (revert before merge): pin why modelsGateway 404s in CI only + if (componentName === 'modelsGateway') { + harperLogger.error( + `[1616-debug] componentLoader modelsGateway config=${JSON.stringify(componentConfig)} isRoot=${isRoot} HARPER_SET_CONFIG=${process.env.HARPER_SET_CONFIG ? 'set' : 'UNSET'}` + ); + } if (!componentConfig) continue; // Initialize loading status for all components (applications and extensions) diff --git a/integrationTests/server/v1-gateway.test.ts b/integrationTests/server/v1-gateway.test.ts index 917c0bd57b..2c1f0f846e 100644 --- a/integrationTests/server/v1-gateway.test.ts +++ b/integrationTests/server/v1-gateway.test.ts @@ -21,7 +21,8 @@ */ import { suite, test, before, after } from 'node:test'; import assert from 'node:assert'; -import { resolve as resolvePath } from 'node:path'; +import { resolve as resolvePath, join } from 'node:path'; +import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { startHarper, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing'; @@ -63,6 +64,29 @@ suite('OpenAI /v1/* gateway (modelsGateway)', (ctx: ContextWithHarper) => { }, env: {}, }); + + // TEMP #1616-debug (revert before merge): dump what the child actually resolved. + try { + const cfgPath = join(ctx.harper.dataRootDir, 'harper-config.yaml'); + const cfgText = await readFile(cfgPath, 'utf8'); + const mgLines = cfgText + .split('\n') + .filter((l, i, a) => /modelsGateway/.test(l) || (i > 0 && /modelsGateway/.test(a[i - 1]))); + console.error(`[1616-debug] harper-config.yaml modelsGateway block: ${JSON.stringify(mgLines)}`); + } catch (err) { + console.error(`[1616-debug] failed reading child config file: ${(err as Error).message}`); + } + try { + const res = await fetch(ctx.harper.operationsAPIURL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': authHeader(ctx) }, + body: JSON.stringify({ operation: 'get_configuration' }), + }); + const cfg = (await res.json()) as Record; + console.error(`[1616-debug] get_configuration.modelsGateway=${JSON.stringify(cfg.modelsGateway)}`); + } catch (err) { + console.error(`[1616-debug] get_configuration failed: ${(err as Error).message}`); + } }); after(async () => { diff --git a/resources/models/v1/index.ts b/resources/models/v1/index.ts index 3f75a57bde..609f970ad9 100644 --- a/resources/models/v1/index.ts +++ b/resources/models/v1/index.ts @@ -32,6 +32,8 @@ import { V1ChatCompletions } from './chatCompletions.ts'; import { V1Models } from './models.ts'; export function handleApplication(scope: Scope): void { + // TEMP #1616-debug (revert before merge) + console.error(`[1616-debug] modelsGateway handleApplication enabled=${JSON.stringify(scope.options.get(['enabled']))}`); if (!scope.options.get(['enabled'])) return; scope.resources.set('v1/models', V1Models); scope.resources.set('v1/embeddings', V1Embeddings); From 6a0deeb535a06497649fb9d5f131d377a0cd445e Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Wed, 8 Jul 2026 15:12:07 -0700 Subject: [PATCH 10/39] fix(models): keep /v1 5xx error messages generic; log the real error Internal error strings (backend stack details, paths) don't belong in the wire response. 4xx messages are client-actionable and pass through unchanged. Adjudicated cross-model review suggestion on #1616. Co-Authored-By: Claude Fable 5 --- resources/models/v1/errors.ts | 13 ++++++++++++- unitTests/resources/models/v1/errors.test.js | 15 +++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/resources/models/v1/errors.ts b/resources/models/v1/errors.ts index bae2231cf2..6f6aabb9a6 100644 --- a/resources/models/v1/errors.ts +++ b/resources/models/v1/errors.ts @@ -7,6 +7,7 @@ */ import { ModelBackendNotFoundError } from '../backendRegistry.ts'; +import harperLogger from '../../../utility/logging/harper_logger.ts'; type OpenAIErrorType = 'invalid_request_error' | 'server_error' | 'authentication_error' | 'permission_error' | 'api_error'; @@ -31,7 +32,6 @@ export interface OpenAIErrorResponse { * `500 server_error`. `ModelBackendNotFoundError` maps to `404 model_not_found`. */ export function toOpenAIError(err: unknown): OpenAIErrorResponse { - const message = err instanceof Error ? err.message : 'Internal server error'; let status = 500; let type: OpenAIErrorType = 'server_error'; let code: string | null = null; @@ -51,6 +51,17 @@ export function toOpenAIError(err: unknown): OpenAIErrorResponse { } } + // 5xx messages stay generic: internal error strings (backend stack details, file + // paths) don't belong in a wire response. The real error goes to the log. 4xx + // messages are client-actionable and pass through. + let message: string; + if (status >= 500) { + harperLogger.error('v1 gateway error', err); + message = 'Internal server error'; + } else { + message = err instanceof Error ? err.message : 'Bad request'; + } + return { status, headers: { 'Content-Type': 'application/json' }, diff --git a/unitTests/resources/models/v1/errors.test.js b/unitTests/resources/models/v1/errors.test.js index 51dc10fc55..a752b38228 100644 --- a/unitTests/resources/models/v1/errors.test.js +++ b/unitTests/resources/models/v1/errors.test.js @@ -45,16 +45,23 @@ describe('toOpenAIError', () => { assert.equal(resp.data.error.type, 'authentication_error'); }); - it('maps 500 statusCode to server_error', () => { - const resp = toOpenAIError(makeClientError('boom', 500)); + it('maps 500 statusCode to server_error with a generic message (no internal detail leak)', () => { + const resp = toOpenAIError(makeClientError('boom: /internal/path secrets', 500)); assert.equal(resp.status, 500); assert.equal(resp.data.error.type, 'server_error'); + assert.equal(resp.data.error.message, 'Internal server error'); }); - it('defaults to 500 server_error for unknown errors', () => { - const resp = toOpenAIError(new Error('surprise')); + it('defaults to 500 server_error with a generic message for unknown errors', () => { + const resp = toOpenAIError(new Error('surprise with stack details')); assert.equal(resp.status, 500); assert.equal(resp.data.error.type, 'server_error'); + assert.equal(resp.data.error.message, 'Internal server error'); + }); + + it('passes 4xx messages through (client-actionable)', () => { + const resp = toOpenAIError(makeClientError('bad input: missing field', 400)); + assert.equal(resp.data.error.message, 'bad input: missing field'); }); it('uses a fallback message for non-Error throws', () => { From 1d7dc8e45eaef0852a0b92c0fe9b1fb247b5a56f Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Wed, 8 Jul 2026 15:13:57 -0700 Subject: [PATCH 11/39] =?UTF-8?q?Revert=20"debug:=20TEMP=20instrumentation?= =?UTF-8?q?=20to=20pin=20CI-only=20modelsGateway=20404=20(#1616)=20?= =?UTF-8?q?=E2=80=94=20REVERT=20BEFORE=20MERGE"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 58bb6e8679dfe3456e0849fb8df7f41ce74f21b2. --- components/componentLoader.ts | 6 ----- integrationTests/server/v1-gateway.test.ts | 26 +--------------------- resources/models/v1/index.ts | 2 -- 3 files changed, 1 insertion(+), 33 deletions(-) diff --git a/components/componentLoader.ts b/components/componentLoader.ts index 49d2de9329..86f1094a0a 100644 --- a/components/componentLoader.ts +++ b/components/componentLoader.ts @@ -391,12 +391,6 @@ export async function loadComponent( compName = componentName; const componentConfig = config[componentName]; - // TEMP #1616-debug (revert before merge): pin why modelsGateway 404s in CI only - if (componentName === 'modelsGateway') { - harperLogger.error( - `[1616-debug] componentLoader modelsGateway config=${JSON.stringify(componentConfig)} isRoot=${isRoot} HARPER_SET_CONFIG=${process.env.HARPER_SET_CONFIG ? 'set' : 'UNSET'}` - ); - } if (!componentConfig) continue; // Initialize loading status for all components (applications and extensions) diff --git a/integrationTests/server/v1-gateway.test.ts b/integrationTests/server/v1-gateway.test.ts index 2c1f0f846e..917c0bd57b 100644 --- a/integrationTests/server/v1-gateway.test.ts +++ b/integrationTests/server/v1-gateway.test.ts @@ -21,8 +21,7 @@ */ import { suite, test, before, after } from 'node:test'; import assert from 'node:assert'; -import { resolve as resolvePath, join } from 'node:path'; -import { readFile } from 'node:fs/promises'; +import { resolve as resolvePath } from 'node:path'; import { fileURLToPath } from 'node:url'; import { startHarper, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing'; @@ -64,29 +63,6 @@ suite('OpenAI /v1/* gateway (modelsGateway)', (ctx: ContextWithHarper) => { }, env: {}, }); - - // TEMP #1616-debug (revert before merge): dump what the child actually resolved. - try { - const cfgPath = join(ctx.harper.dataRootDir, 'harper-config.yaml'); - const cfgText = await readFile(cfgPath, 'utf8'); - const mgLines = cfgText - .split('\n') - .filter((l, i, a) => /modelsGateway/.test(l) || (i > 0 && /modelsGateway/.test(a[i - 1]))); - console.error(`[1616-debug] harper-config.yaml modelsGateway block: ${JSON.stringify(mgLines)}`); - } catch (err) { - console.error(`[1616-debug] failed reading child config file: ${(err as Error).message}`); - } - try { - const res = await fetch(ctx.harper.operationsAPIURL, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': authHeader(ctx) }, - body: JSON.stringify({ operation: 'get_configuration' }), - }); - const cfg = (await res.json()) as Record; - console.error(`[1616-debug] get_configuration.modelsGateway=${JSON.stringify(cfg.modelsGateway)}`); - } catch (err) { - console.error(`[1616-debug] get_configuration failed: ${(err as Error).message}`); - } }); after(async () => { diff --git a/resources/models/v1/index.ts b/resources/models/v1/index.ts index 609f970ad9..3f75a57bde 100644 --- a/resources/models/v1/index.ts +++ b/resources/models/v1/index.ts @@ -32,8 +32,6 @@ import { V1ChatCompletions } from './chatCompletions.ts'; import { V1Models } from './models.ts'; export function handleApplication(scope: Scope): void { - // TEMP #1616-debug (revert before merge) - console.error(`[1616-debug] modelsGateway handleApplication enabled=${JSON.stringify(scope.options.get(['enabled']))}`); if (!scope.options.get(['enabled'])) return; scope.resources.set('v1/models', V1Models); scope.resources.set('v1/embeddings', V1Embeddings); From 2f000ea134baadfa008ee5dd941a49f4c4001868 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Wed, 8 Jul 2026 15:25:47 -0700 Subject: [PATCH 12/39] =?UTF-8?q?debug:=20TEMP=20round-2=20instrumentation?= =?UTF-8?q?=20for=20CI-only=20modelsGateway=20404=20(#1616)=20=E2=80=94=20?= =?UTF-8?q?REVERT=20BEFORE=20MERGE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probes the install/config path this time: createConfigFile post-env-apply + post-validate, updateConfigValue entry + pre-write, per-thread loadRootComponents config view, plus the round-1 loader/handler probes. Test-side dump intentionally NOT restored (round 1's before() awaits delayed the first request and masked the race — that run went green). Co-Authored-By: Claude Fable 5 --- components/componentLoader.ts | 6 ++++++ config/configUtils.ts | 16 ++++++++++++++++ resources/models/v1/index.ts | 2 ++ server/loadRootComponents.js | 9 +++++++++ 4 files changed, 33 insertions(+) diff --git a/components/componentLoader.ts b/components/componentLoader.ts index 86f1094a0a..49d2de9329 100644 --- a/components/componentLoader.ts +++ b/components/componentLoader.ts @@ -391,6 +391,12 @@ export async function loadComponent( compName = componentName; const componentConfig = config[componentName]; + // TEMP #1616-debug (revert before merge): pin why modelsGateway 404s in CI only + if (componentName === 'modelsGateway') { + harperLogger.error( + `[1616-debug] componentLoader modelsGateway config=${JSON.stringify(componentConfig)} isRoot=${isRoot} HARPER_SET_CONFIG=${process.env.HARPER_SET_CONFIG ? 'set' : 'UNSET'}` + ); + } if (!componentConfig) continue; // Initialize loading status for all components (applications and extensions) diff --git a/config/configUtils.ts b/config/configUtils.ts index ca6894aea9..41688fc2a5 100644 --- a/config/configUtils.ts +++ b/config/configUtils.ts @@ -161,9 +161,17 @@ export function createConfigFile(args, skipFsValidation = false) { // Must be called AFTER rootPath is set in configDoc // Mutates configDoc in place applyRuntimeEnvVarConfig(configDoc, null, { isInstall: true }); + // TEMP #1616-debug (revert before merge) + console.error( + `[1616-debug] createConfigFile post-env-apply modelsGateway=${JSON.stringify(configDoc.getIn(['modelsGateway']))} HARPER_SET_CONFIG=${process.env.HARPER_SET_CONFIG ? 'set' : 'UNSET'}` + ); // Validates config doc and if required sets default values for some parameters. validateConfig(configDoc, skipFsValidation); + // TEMP #1616-debug (revert before merge) + console.error( + `[1616-debug] createConfigFile post-validate modelsGateway=${JSON.stringify(configDoc.getIn(['modelsGateway']))}` + ); const configObj = configDoc.toJSON(); flatConfigObj = flattenConfig(configObj); @@ -547,6 +555,10 @@ export function updateConfigValue( update_config_obj = false, skipParamMap = false ) { + // TEMP #1616-debug (revert before merge) + console.error( + `[1616-debug] updateConfigValue entry param=${JSON.stringify(param)} hasParsedArgs=${!!parsedArgs} update_config_obj=${update_config_obj} inMem-modelsGateway=${JSON.stringify((flatConfigObj as any)?.modelsGateway_enabled)}` + ); if (flatConfigObj === undefined) { initConfig(); } @@ -692,6 +704,10 @@ export function updateConfigValue( HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR ); } + // TEMP #1616-debug (revert before merge): does this rewrite drop env-applied keys? + console.error( + `[1616-debug] updateConfigValue pre-write modelsGateway=${JSON.stringify(configDoc.getIn(['modelsGateway']))} update_config_obj=${update_config_obj}` + ); atomicWriteFile(configFileLocation, String(configDoc)); if (update_config_obj) { flatConfigObj = flattenConfig(configDoc.toJSON()); diff --git a/resources/models/v1/index.ts b/resources/models/v1/index.ts index 3f75a57bde..609f970ad9 100644 --- a/resources/models/v1/index.ts +++ b/resources/models/v1/index.ts @@ -32,6 +32,8 @@ import { V1ChatCompletions } from './chatCompletions.ts'; import { V1Models } from './models.ts'; export function handleApplication(scope: Scope): void { + // TEMP #1616-debug (revert before merge) + console.error(`[1616-debug] modelsGateway handleApplication enabled=${JSON.stringify(scope.options.get(['enabled']))}`); if (!scope.options.get(['enabled'])) return; scope.resources.set('v1/models', V1Models); scope.resources.set('v1/embeddings', V1Embeddings); diff --git a/server/loadRootComponents.js b/server/loadRootComponents.js index ce3a79b8cd..43481bef03 100644 --- a/server/loadRootComponents.js +++ b/server/loadRootComponents.js @@ -13,6 +13,15 @@ let loadedComponents = new Map(); * @returns {Promise} */ async function loadRootComponents(isWorkerThread = false) { + // TEMP #1616-debug (revert before merge): per-thread view of the resolved config at load time + try { + const cfg = configUtils.getConfigObj(); + console.error( + `[1616-debug] loadRootComponents entry isWorker=${isWorkerThread} modelsGateway=${JSON.stringify(cfg?.modelsGateway)}` + ); + } catch (e) { + console.error(`[1616-debug] loadRootComponents getConfigObj failed: ${e.message}`); + } try { if (isMainThread && !process.env.HARPER_SAFE_MODE) await installApplications(); } catch (error) { From a5f2f410cf7aa65fc8eb589b30c7dabe37b54920 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Wed, 8 Jul 2026 15:46:18 -0700 Subject: [PATCH 13/39] =?UTF-8?q?Revert=20"debug:=20TEMP=20round-2=20instr?= =?UTF-8?q?umentation=20for=20CI-only=20modelsGateway=20404=20(#1616)=20?= =?UTF-8?q?=E2=80=94=20REVERT=20BEFORE=20MERGE"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 2f000ea134baadfa008ee5dd941a49f4c4001868. --- components/componentLoader.ts | 6 ------ config/configUtils.ts | 16 ---------------- resources/models/v1/index.ts | 2 -- server/loadRootComponents.js | 9 --------- 4 files changed, 33 deletions(-) diff --git a/components/componentLoader.ts b/components/componentLoader.ts index 49d2de9329..86f1094a0a 100644 --- a/components/componentLoader.ts +++ b/components/componentLoader.ts @@ -391,12 +391,6 @@ export async function loadComponent( compName = componentName; const componentConfig = config[componentName]; - // TEMP #1616-debug (revert before merge): pin why modelsGateway 404s in CI only - if (componentName === 'modelsGateway') { - harperLogger.error( - `[1616-debug] componentLoader modelsGateway config=${JSON.stringify(componentConfig)} isRoot=${isRoot} HARPER_SET_CONFIG=${process.env.HARPER_SET_CONFIG ? 'set' : 'UNSET'}` - ); - } if (!componentConfig) continue; // Initialize loading status for all components (applications and extensions) diff --git a/config/configUtils.ts b/config/configUtils.ts index 41688fc2a5..ca6894aea9 100644 --- a/config/configUtils.ts +++ b/config/configUtils.ts @@ -161,17 +161,9 @@ export function createConfigFile(args, skipFsValidation = false) { // Must be called AFTER rootPath is set in configDoc // Mutates configDoc in place applyRuntimeEnvVarConfig(configDoc, null, { isInstall: true }); - // TEMP #1616-debug (revert before merge) - console.error( - `[1616-debug] createConfigFile post-env-apply modelsGateway=${JSON.stringify(configDoc.getIn(['modelsGateway']))} HARPER_SET_CONFIG=${process.env.HARPER_SET_CONFIG ? 'set' : 'UNSET'}` - ); // Validates config doc and if required sets default values for some parameters. validateConfig(configDoc, skipFsValidation); - // TEMP #1616-debug (revert before merge) - console.error( - `[1616-debug] createConfigFile post-validate modelsGateway=${JSON.stringify(configDoc.getIn(['modelsGateway']))}` - ); const configObj = configDoc.toJSON(); flatConfigObj = flattenConfig(configObj); @@ -555,10 +547,6 @@ export function updateConfigValue( update_config_obj = false, skipParamMap = false ) { - // TEMP #1616-debug (revert before merge) - console.error( - `[1616-debug] updateConfigValue entry param=${JSON.stringify(param)} hasParsedArgs=${!!parsedArgs} update_config_obj=${update_config_obj} inMem-modelsGateway=${JSON.stringify((flatConfigObj as any)?.modelsGateway_enabled)}` - ); if (flatConfigObj === undefined) { initConfig(); } @@ -704,10 +692,6 @@ export function updateConfigValue( HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR ); } - // TEMP #1616-debug (revert before merge): does this rewrite drop env-applied keys? - console.error( - `[1616-debug] updateConfigValue pre-write modelsGateway=${JSON.stringify(configDoc.getIn(['modelsGateway']))} update_config_obj=${update_config_obj}` - ); atomicWriteFile(configFileLocation, String(configDoc)); if (update_config_obj) { flatConfigObj = flattenConfig(configDoc.toJSON()); diff --git a/resources/models/v1/index.ts b/resources/models/v1/index.ts index 609f970ad9..3f75a57bde 100644 --- a/resources/models/v1/index.ts +++ b/resources/models/v1/index.ts @@ -32,8 +32,6 @@ import { V1ChatCompletions } from './chatCompletions.ts'; import { V1Models } from './models.ts'; export function handleApplication(scope: Scope): void { - // TEMP #1616-debug (revert before merge) - console.error(`[1616-debug] modelsGateway handleApplication enabled=${JSON.stringify(scope.options.get(['enabled']))}`); if (!scope.options.get(['enabled'])) return; scope.resources.set('v1/models', V1Models); scope.resources.set('v1/embeddings', V1Embeddings); diff --git a/server/loadRootComponents.js b/server/loadRootComponents.js index 43481bef03..ce3a79b8cd 100644 --- a/server/loadRootComponents.js +++ b/server/loadRootComponents.js @@ -13,15 +13,6 @@ let loadedComponents = new Map(); * @returns {Promise} */ async function loadRootComponents(isWorkerThread = false) { - // TEMP #1616-debug (revert before merge): per-thread view of the resolved config at load time - try { - const cfg = configUtils.getConfigObj(); - console.error( - `[1616-debug] loadRootComponents entry isWorker=${isWorkerThread} modelsGateway=${JSON.stringify(cfg?.modelsGateway)}` - ); - } catch (e) { - console.error(`[1616-debug] loadRootComponents getConfigObj failed: ${e.message}`); - } try { if (isMainThread && !process.env.HARPER_SAFE_MODE) await installApplications(); } catch (error) { From 0cdd0f3df4df23a170d46461ae7a1b2623491d9c Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Wed, 8 Jul 2026 16:45:11 -0700 Subject: [PATCH 14/39] fix(models): map /v1 403 errors to permission_error per OpenAI semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 401 = bad/missing credentials (authentication_error); 403 = valid credentials lacking permission (permission_error) — matching authorizeV1Request's own envelope. Addresses claude-bot review feedback on #1616. Co-Authored-By: Claude Fable 5 --- resources/models/v1/errors.ts | 6 +++++- unitTests/resources/models/v1/errors.test.js | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/resources/models/v1/errors.ts b/resources/models/v1/errors.ts index 6f6aabb9a6..e03262c201 100644 --- a/resources/models/v1/errors.ts +++ b/resources/models/v1/errors.ts @@ -42,8 +42,12 @@ export function toOpenAIError(err: unknown): OpenAIErrorResponse { code = 'model_not_found'; } else if (err instanceof Error && typeof (err as any).statusCode === 'number') { status = (err as any).statusCode; - if (status === 401 || status === 403) { + if (status === 401) { type = 'authentication_error'; + } else if (status === 403) { + // OpenAI semantics: 401 = bad/missing credentials, 403 = valid credentials + // lacking permission (matches authorizeV1Request's own 403 envelope). + type = 'permission_error'; } else if (status < 500) { type = 'invalid_request_error'; } else { diff --git a/unitTests/resources/models/v1/errors.test.js b/unitTests/resources/models/v1/errors.test.js index a752b38228..f536a43293 100644 --- a/unitTests/resources/models/v1/errors.test.js +++ b/unitTests/resources/models/v1/errors.test.js @@ -39,10 +39,10 @@ describe('toOpenAIError', () => { assert.equal(resp.data.error.type, 'authentication_error'); }); - it('maps 403 statusCode to authentication_error', () => { + it('maps 403 statusCode to permission_error (valid credentials, insufficient permission)', () => { const resp = toOpenAIError(makeClientError('forbidden', 403)); assert.equal(resp.status, 403); - assert.equal(resp.data.error.type, 'authentication_error'); + assert.equal(resp.data.error.type, 'permission_error'); }); it('maps 500 statusCode to server_error with a generic message (no internal detail leak)', () => { From 05fe16d23cfc1143f9cffc9d5b21f2dee874d368 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Sat, 11 Jul 2026 17:06:35 -0700 Subject: [PATCH 15/39] docs: add openai devDependency entry to dependencies.md Per the dependency policy: the openai SDK was added as a devDependency for the /v1 gateway's unmodified-SDK acceptance test (#631) without the accompanying dependencies.md justification. Dev-only (lighter-bar per the doc's own note): ~10MB unpacked, zero transitive deps, single dynamic import in one integration test, never in the published install tree. Co-Authored-By: Claude Opus 4.8 (1M context) --- dependencies.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/dependencies.md b/dependencies.md index c044d84f3a..d7d700875b 100644 --- a/dependencies.md +++ b/dependencies.md @@ -184,3 +184,13 @@ Generally, dependencies are added by simply adding them to the dependencies list - Binary compilation: No. - Can be deferred: The require happens only when `server/serverHelpers/multipartParser.ts` is imported, which is loaded by `registerContentHandlers` at operations-server boot. Realistically always loaded. - Eventual removal: Could be replaced by writing our own streaming multipart parser (a few hundred lines plus tests for edge cases) if maintenance ever lapses, or by Node.js's `request.formData()` once that API supports streaming file parts without buffering (currently it doesn't on the standard Node http server interface used by Fastify). + +## openai (devDependency) + +- Need for usage: End-to-end acceptance test for the OpenAI-compatible `/v1/*` gateway (#631/#510). The acceptance criterion is that an **unmodified** OpenAI SDK client completes chat and embedding calls against Harper — which cannot be demonstrated without the actual SDK. Imported dynamically in a single integration test (`integrationTests/server/v1-gateway.test.ts`); never loaded in production and never in the published package's install tree (devDependencies only). +- Size/memory cost: ~10 MB unpacked, **zero transitive dependencies** (v6 is self-contained). Cost is contributor `npm install` and CI only. +- Security: High-profile, actively maintained (OpenAI). Dev-only, so supply-chain exposure is limited to development/CI environments, not production. +- Environment interaction: None in production (not loaded). In the test it constructs a client pointed at the local Harper instance; no global mutation. +- Overlap: The wire-shape coverage (raw `fetch` + the `eventsource` client over Harper's real SSE serializer) already exists and is independent; the SDK test exists specifically to prove _unmodified-client_ compatibility, which hand-rolled assertions cannot. +- Binary compilation: No. +- Eventual removal: Delete the one dynamic import and the devDependency line; the wire-shape tests remain. If the gateway's compatibility target ever changes, the SDK pin changes with it. From 5858cc2208c4341de9b37f7cd515d09306b4f240 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Sun, 12 Jul 2026 08:29:59 -0700 Subject: [PATCH 16/39] Revert "docs: add openai devDependency entry to dependencies.md" This reverts commit 05fe16d23cfc1143f9cffc9d5b21f2dee874d368. --- dependencies.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/dependencies.md b/dependencies.md index d7d700875b..c044d84f3a 100644 --- a/dependencies.md +++ b/dependencies.md @@ -184,13 +184,3 @@ Generally, dependencies are added by simply adding them to the dependencies list - Binary compilation: No. - Can be deferred: The require happens only when `server/serverHelpers/multipartParser.ts` is imported, which is loaded by `registerContentHandlers` at operations-server boot. Realistically always loaded. - Eventual removal: Could be replaced by writing our own streaming multipart parser (a few hundred lines plus tests for edge cases) if maintenance ever lapses, or by Node.js's `request.formData()` once that API supports streaming file parts without buffering (currently it doesn't on the standard Node http server interface used by Fastify). - -## openai (devDependency) - -- Need for usage: End-to-end acceptance test for the OpenAI-compatible `/v1/*` gateway (#631/#510). The acceptance criterion is that an **unmodified** OpenAI SDK client completes chat and embedding calls against Harper — which cannot be demonstrated without the actual SDK. Imported dynamically in a single integration test (`integrationTests/server/v1-gateway.test.ts`); never loaded in production and never in the published package's install tree (devDependencies only). -- Size/memory cost: ~10 MB unpacked, **zero transitive dependencies** (v6 is self-contained). Cost is contributor `npm install` and CI only. -- Security: High-profile, actively maintained (OpenAI). Dev-only, so supply-chain exposure is limited to development/CI environments, not production. -- Environment interaction: None in production (not loaded). In the test it constructs a client pointed at the local Harper instance; no global mutation. -- Overlap: The wire-shape coverage (raw `fetch` + the `eventsource` client over Harper's real SSE serializer) already exists and is independent; the SDK test exists specifically to prove _unmodified-client_ compatibility, which hand-rolled assertions cannot. -- Binary compilation: No. -- Eventual removal: Delete the one dynamic import and the devDependency line; the wire-shape tests remain. If the gateway's compatibility target ever changes, the SDK pin changes with it. From 69a9807c088e7575adf7832c63bd615ad2d013c9 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Fri, 17 Jul 2026 17:07:17 -0700 Subject: [PATCH 17/39] fix(models): activate REST serving from the /v1 gateway; dedupe model ids The gateway registered its v1/* resources into the global registry, but REST's middleware chain only activates when some component config contains a rest/REST key. On a bare instance (no deployed apps) nothing provides one, so the resources were registered but unservable and every /v1/* request fell through to unhandled -> 404 (the "CI-only" integration failure; it was never a config race, and reproduces anywhere Harper runs with no apps). - Split the started-guarded chain registration out of REST.handleApplication into an exported REST.ensureStarted(scope); handleApplication delegates to it. ensureStarted does not adopt the caller's config section as REST's http options, so the gateway (or any core plugin registering REST-served resources) can activate serving without clobbering rest config semantics. - modelsGateway.handleApplication calls ensureStarted after registering its resources. - /v1/models: dedupe logical names registered for both generative and embedding (previously `default` appeared twice); OpenAI model ids are unique. - Refresh the stale race-theory NOTE in the integration test; the suite now also covers the bare-instance activation path. - package-lock: regenerated after the main merge (naive git lockfile merge). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PgCUUCjEqChDgquKJhaNZr --- integrationTests/server/v1-gateway.test.ts | 7 +- package-lock.json | 780 ++----------------- resources/models/v1/index.ts | 5 + resources/models/v1/models.ts | 25 +- server/REST.ts | 10 + unitTests/resources/models/v1/models.test.js | 17 +- 6 files changed, 121 insertions(+), 723 deletions(-) diff --git a/integrationTests/server/v1-gateway.test.ts b/integrationTests/server/v1-gateway.test.ts index 917c0bd57b..feb6d772e6 100644 --- a/integrationTests/server/v1-gateway.test.ts +++ b/integrationTests/server/v1-gateway.test.ts @@ -15,9 +15,10 @@ * * NOTE: `modelsGateway: { enabled: true }` is passed explicitly because the * gateway is off by default (`enabled: false` in defaultConfig.yaml). The test - * harness plumbs this via HARPER_SET_CONFIG. On CI (Linux, fast startup), the - * env-var config can race component loading; that race is tracked in #1618 and - * the fix there will make this test reliable without needing further changes here. + * harness plumbs this via HARPER_SET_CONFIG. This suite runs against a bare + * instance (no deployed apps), so no component config contains a `rest` key — + * the gateway itself must activate REST serving (REST.ensureStarted) for these + * endpoints to be reachable. That activation is part of what this suite covers. */ import { suite, test, before, after } from 'node:test'; import assert from 'node:assert'; diff --git a/package-lock.json b/package-lock.json index 76827ad5fb..f5e583c162 100644 --- a/package-lock.json +++ b/package-lock.json @@ -192,6 +192,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1080.0.tgz", "integrity": "sha512-erKuxbwhYLKs0ZviBeTDvXxC0E4AtugHXLbt5kFk8u9/rQMglh/QJNK5f9KuLjlMrbFSJgkBmjNSY5O03YoK4A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-sdk/checksums": "^3.1000.13", "@aws-sdk/core": "^3.974.28", @@ -517,52 +518,8 @@ "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", "license": "MIT", "optional": true, - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, "engines": { "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver.js" } }, "node_modules/@babel/generator": { @@ -595,7 +552,6 @@ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", @@ -613,7 +569,6 @@ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "yallist": "^3.0.2" } @@ -624,7 +579,6 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "optional": true, - "peer": true, "bin": { "semver": "bin/semver.js" } @@ -642,7 +596,6 @@ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" @@ -657,7 +610,6 @@ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", @@ -676,7 +628,6 @@ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=6.9.0" } @@ -701,7 +652,6 @@ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=6.9.0" } @@ -712,7 +662,6 @@ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" @@ -740,7 +689,6 @@ "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -754,7 +702,6 @@ "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -768,7 +715,6 @@ "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.12.13" }, @@ -782,7 +728,6 @@ "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -799,7 +744,6 @@ "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, @@ -816,7 +760,6 @@ "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -830,7 +773,6 @@ "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -844,7 +786,6 @@ "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -858,7 +799,6 @@ "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -872,7 +812,6 @@ "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -886,7 +825,6 @@ "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -900,7 +838,6 @@ "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -914,7 +851,6 @@ "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -928,7 +864,6 @@ "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -945,7 +880,6 @@ "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -1094,7 +1028,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "node-gyp-build": "<4.0", "pprof-format": "^2.2.1", @@ -2132,6 +2065,7 @@ "version": "8.44.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.0", "@typescript-eslint/types": "8.44.0", @@ -2746,7 +2680,6 @@ "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", "license": "ISC", "optional": true, - "peer": true, "engines": { "node": ">=12" } @@ -2757,7 +2690,6 @@ "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", @@ -2775,7 +2707,6 @@ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "sprintf-js": "~1.0.2" } @@ -2786,7 +2717,6 @@ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=6" } @@ -2797,7 +2727,6 @@ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -2812,7 +2741,6 @@ "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -2827,7 +2755,6 @@ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "p-locate": "^4.1.0" }, @@ -2841,7 +2768,6 @@ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "p-try": "^2.0.0" }, @@ -2858,7 +2784,6 @@ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "p-limit": "^2.2.0" }, @@ -2872,7 +2797,6 @@ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -2883,7 +2807,6 @@ "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -2894,7 +2817,6 @@ "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@jest/types": "^29.6.3" }, @@ -2908,7 +2830,6 @@ "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", @@ -2925,7 +2846,6 @@ "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@jest/types": "^29.6.3", "@sinonjs/fake-timers": "^10.0.2", @@ -2944,7 +2864,6 @@ "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", "license": "BSD-3-Clause", "optional": true, - "peer": true, "dependencies": { "@sinonjs/commons": "^3.0.0" } @@ -2955,7 +2874,6 @@ "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@sinclair/typebox": "^0.27.8" }, @@ -2969,7 +2887,6 @@ "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/core": "^7.11.6", "@jest/types": "^29.6.3", @@ -2997,7 +2914,6 @@ "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", @@ -3024,7 +2940,6 @@ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" @@ -3043,7 +2958,6 @@ "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -3249,8 +3163,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { "version": "3.0.4", @@ -3264,8 +3177,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { "version": "3.0.4", @@ -3279,8 +3191,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { "version": "3.0.4", @@ -3294,8 +3205,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { "version": "3.0.4", @@ -3309,8 +3219,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { "version": "3.0.4", @@ -3324,8 +3233,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@noble/hashes": { "version": "1.8.0", @@ -3735,7 +3643,6 @@ "integrity": "sha512-B1SRwpntaAcckiatxbjzylvNK562Ayza05gdJCjDQHTiDafa1OABmyB5LHt7qWDOpNkaluD+w11vHF7pBmTpzQ==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 20.19.4" } @@ -3746,7 +3653,6 @@ "integrity": "sha512-ezXTN70ygVm9l2m0i+pAlct0RntoV4afftWMGUIeAWLgaca9qItQ54uOt32I/9dBJvzBibT33luIR/pBG0dQvg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.25.3", @@ -3768,8 +3674,7 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@react-native/codegen/node_modules/brace-expansion": { "version": "1.1.14", @@ -3777,7 +3682,6 @@ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3789,7 +3693,6 @@ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -3806,7 +3709,6 @@ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -3828,7 +3730,6 @@ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -3842,7 +3743,6 @@ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -3861,7 +3761,6 @@ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -3881,7 +3780,6 @@ "integrity": "sha512-H/eMdtOy9nEeX7YVeEG1N2vyCoifw3dr9OV8++xfUElNYV7LtSmJ6AqxZUUfxGJRDFPQvaU/8enmJlM/l11VxQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@react-native/dev-middleware": "0.82.1", "debug": "^4.4.0", @@ -3913,7 +3811,6 @@ "integrity": "sha512-a2O6M7/OZ2V9rdavOHyCQ+10z54JX8+B+apYKCQ6a9zoEChGTxUMG2YzzJ8zZJVvYf1ByWSNxv9Se0dca1hO9A==", "license": "BSD-3-Clause", "optional": true, - "peer": true, "engines": { "node": ">= 20.19.4" } @@ -3924,7 +3821,6 @@ "integrity": "sha512-fdRHAeqqPT93bSrxfX+JHPpCXHApfDUdrXMXhoxlPgSzgXQXJDykIViKhtpu0M6slX6xU/+duq+AtP/qWJRpBw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "cross-spawn": "^7.0.6", "fb-dotslash": "0.5.8" @@ -3939,7 +3835,6 @@ "integrity": "sha512-wuOIzms/Qg5raBV6Ctf2LmgzEOCqdP3p1AYN4zdhMT110c39TVMbunpBaJxm0Kbt2HQ762MQViF9naxk7SBo4w==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.82.1", @@ -3964,7 +3859,6 @@ "integrity": "sha512-PNIUUyLI5YpkJZj60YBzX1o0ByQ4ovvfmq9N/Kig/PAYbVlGyz4R6G0SEWrD0O9acc0sT2+IdMBVLFv8FSi0Nw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "async-limiter": "~1.0.0" } @@ -3975,7 +3869,6 @@ "integrity": "sha512-KkF/2T1NSn6EJ5ALNT/gx0MHlrntFHv8YdooH9OOGl9HQn5NM0ZmQSr86o5utJsGc7ME3R6p3SaQuzlsFDrn8Q==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 20.19.4" } @@ -3986,7 +3879,6 @@ "integrity": "sha512-tf70X7pUodslOBdLN37J57JmDPB/yiZcNDzS2m+4bbQzo8fhx3eG9QEBv5n4fmzqfGAgSB4BWRHgDMXmmlDSVA==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 20.19.4" } @@ -3996,8 +3888,7 @@ "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.82.1.tgz", "integrity": "sha512-CCfTR1uX+Z7zJTdt3DNX9LUXr2zWXsNOyLbwupW2wmRzrxlHRYfmLgTABzRL/cKhh0Ubuwn15o72MQChvCRaHw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@react-native/virtualized-lists": { "version": "0.82.1", @@ -4005,7 +3896,6 @@ "integrity": "sha512-f5zpJg9gzh7JtCbsIwV+4kP3eI0QBuA93JGmwFRd4onQ3DnCjV2J5pYqdWtM95sjSKK1dyik59Gj01lLeKqs1Q==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" @@ -4044,8 +3934,7 @@ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@sinonjs/commons": { "version": "3.0.1", @@ -4090,7 +3979,6 @@ "integrity": "sha512-GW2yqqOTzdz3K6z0XpPO1EjLzOw0kclmAcLeW6cBt0DYM7ZNLRKanpzXxaSXkePpo4ZYMWhddE4WpSWG8e/QaQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" @@ -4144,7 +4032,6 @@ "version": "4.2.2", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.6.2" }, @@ -4158,7 +4045,6 @@ "integrity": "sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/core": "^3.23.17", "@smithy/middleware-serde": "^4.2.20", @@ -4179,7 +4065,6 @@ "integrity": "sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/core": "^3.23.17", "@smithy/protocol-http": "^5.3.14", @@ -4196,7 +4081,6 @@ "integrity": "sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" @@ -4211,7 +4095,6 @@ "integrity": "sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", @@ -4242,7 +4125,6 @@ "integrity": "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" @@ -4257,7 +4139,6 @@ "integrity": "sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" @@ -4272,7 +4153,6 @@ "integrity": "sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" @@ -4287,7 +4167,6 @@ "integrity": "sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" @@ -4316,7 +4195,6 @@ "integrity": "sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/core": "^3.23.17", "@smithy/middleware-endpoint": "^4.4.32", @@ -4348,7 +4226,6 @@ "integrity": "sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/querystring-parser": "^4.2.14", "@smithy/types": "^4.14.1", @@ -4362,7 +4239,6 @@ "version": "4.3.2", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", @@ -4376,7 +4252,6 @@ "version": "4.2.2", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" @@ -4391,7 +4266,6 @@ "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.6.2" }, @@ -4405,7 +4279,6 @@ "integrity": "sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" @@ -4420,7 +4293,6 @@ "integrity": "sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/fetch-http-handler": "^5.3.17", "@smithy/node-http-handler": "^4.6.1", @@ -4439,7 +4311,6 @@ "version": "4.2.2", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" @@ -4681,7 +4552,6 @@ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", @@ -4696,7 +4566,6 @@ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/types": "^7.0.0" } @@ -4707,7 +4576,6 @@ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" @@ -4719,7 +4587,6 @@ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/types": "^7.28.2" } @@ -4763,7 +4630,6 @@ "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@types/node": "*" } @@ -4788,8 +4654,7 @@ "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.3", @@ -4797,7 +4662,6 @@ "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@types/istanbul-lib-coverage": "*" } @@ -4808,7 +4672,6 @@ "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@types/istanbul-lib-report": "*" } @@ -4861,6 +4724,7 @@ "node_modules/@types/node": { "version": "25.4.0", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -4899,8 +4763,7 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@types/tar-fs": { "version": "2.0.4", @@ -4935,7 +4798,6 @@ "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@types/yargs-parser": "*" } @@ -4945,8 +4807,7 @@ "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.63.0", @@ -4991,6 +4852,7 @@ "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", @@ -5225,6 +5087,7 @@ "version": "8.16.0", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5246,7 +5109,6 @@ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 14" } @@ -5332,8 +5194,7 @@ "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/ansi-escapes": { "version": "4.3.2", @@ -5374,7 +5235,6 @@ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -5418,7 +5278,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5430,7 +5289,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5463,8 +5321,7 @@ "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/asynckit": { "version": "0.4.0", @@ -5554,7 +5411,6 @@ "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", @@ -5577,7 +5433,6 @@ "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", "license": "BSD-3-Clause", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", @@ -5595,7 +5450,6 @@ "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/template": "^7.3.3", "@babel/types": "^7.3.3", @@ -5612,7 +5466,6 @@ "integrity": "sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "hermes-parser": "0.32.0" } @@ -5623,7 +5476,6 @@ "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", @@ -5651,7 +5503,6 @@ "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" @@ -5775,7 +5626,6 @@ "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", "license": "Apache-2.0", "optional": true, - "peer": true, "bin": { "baseline-browser-mapping": "dist/cli.cjs" }, @@ -5927,48 +5777,12 @@ "pako": "~0.2.0" } }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", "license": "Apache-2.0", "optional": true, - "peer": true, "dependencies": { "node-int64": "^0.4.0" } @@ -5989,32 +5803,6 @@ "version": "1.1.2", "license": "MIT" }, - "node_modules/bufferutil": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", - "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/bufferutil/node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "optional": true, - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -6122,8 +5910,7 @@ } ], "license": "CC-BY-4.0", - "optional": true, - "peer": true + "optional": true }, "node_modules/cbor-extract": { "version": "2.2.2", @@ -6156,6 +5943,7 @@ "version": "6.2.2", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -6224,7 +6012,6 @@ "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", "license": "Apache-2.0", "optional": true, - "peer": true, "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", @@ -6244,7 +6031,6 @@ "integrity": "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==", "license": "Apache-2.0", "optional": true, - "peer": true, "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", @@ -6266,7 +6052,6 @@ ], "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -6346,7 +6131,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "is-regexp": "^1.0.0", "is-supported-regexp-flag": "^1.0.0" @@ -6393,7 +6177,6 @@ "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=18" } @@ -6462,7 +6245,6 @@ "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", @@ -6479,7 +6261,6 @@ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -6489,8 +6270,7 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/content-disposition": { "version": "1.0.1", @@ -6518,8 +6298,7 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cookie": { "version": "1.1.1", @@ -6735,7 +6514,6 @@ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" @@ -6843,8 +6621,7 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", "license": "ISC", - "optional": true, - "peer": true + "optional": true }, "node_modules/emoji-regex": { "version": "8.0.0", @@ -6870,7 +6647,6 @@ "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "stackframe": "^1.3.4" } @@ -6985,6 +6761,7 @@ "version": "9.39.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7043,6 +6820,7 @@ "version": "10.1.8", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7179,7 +6957,6 @@ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "license": "BSD-2-Clause", "optional": true, - "peer": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -7282,7 +7059,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "clone-regexp": "^1.0.0" }, @@ -7295,8 +7071,7 @@ "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "license": "Apache-2.0", - "optional": true, - "peer": true + "optional": true }, "node_modules/express": { "version": "5.2.1", @@ -7735,7 +7510,6 @@ "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", "license": "(MIT OR Apache-2.0)", "optional": true, - "peer": true, "bin": { "dotslash": "bin/dotslash" }, @@ -7749,7 +7523,6 @@ "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", "license": "Apache-2.0", "optional": true, - "peer": true, "dependencies": { "bser": "2.1.1" } @@ -7806,7 +7579,6 @@ "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", @@ -7826,7 +7598,6 @@ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -7837,7 +7608,6 @@ "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 0.8" } @@ -7847,8 +7617,7 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/finalhandler/node_modules/on-finished": { "version": "2.3.0", @@ -7856,7 +7625,6 @@ "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "ee-first": "1.1.1" }, @@ -7870,7 +7638,6 @@ "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 0.6" } @@ -7934,8 +7701,7 @@ "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/follow-redirects": { "version": "1.16.0", @@ -8051,8 +7817,7 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "license": "ISC", - "optional": true, - "peer": true + "optional": true }, "node_modules/fsevents": { "version": "2.3.3", @@ -8088,7 +7853,6 @@ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=6.9.0" } @@ -8156,7 +7920,6 @@ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=8.0.0" } @@ -8266,6 +8029,7 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", "license": "MIT", + "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -8331,7 +8095,6 @@ "integrity": "sha512-RRXMLbbdymiZsHOeg5b+DShzsMvVvkgsG9690BBCc7tzIpDb0CT7EgWEQo+rwCICr35EwZoLjtfwF6mMiCOenA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/client-s3": "^3.1012.0", "@aws-sdk/lib-storage": "3.964.0", @@ -8434,7 +8197,6 @@ "integrity": "sha512-ro6B04Q5TjPgIKdSWGJ+tj2ordVF1IfZJERwGpYkrwhboNEoXBXuzpfnh2LYBPvMmFJQ+8UXSFw1jkLLgxM+ig==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/abort-controller": "^4.2.7", "@smithy/middleware-endpoint": "^4.4.1", @@ -8457,7 +8219,6 @@ "integrity": "sha512-gipd/g0USN8ncvRMdoaru8PxYNUSEJp//+XbLf+3VNDQ6gcSsTcYqyNa3f+oEKIyV0clpOkxzautkN7hVPsn/g==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@harperfast/extended-iterable": "1.0.3", "msgpackr": "1.11.9", @@ -8490,7 +8251,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -8508,7 +8268,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -8526,7 +8285,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -8544,7 +8302,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -8562,7 +8319,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -8580,7 +8336,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -8598,7 +8353,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -8616,7 +8370,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -8633,8 +8386,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/harper/node_modules/@lmdb/lmdb-darwin-x64": { "version": "3.5.3", @@ -8648,8 +8400,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/harper/node_modules/@lmdb/lmdb-linux-arm": { "version": "3.5.3", @@ -8663,8 +8414,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/harper/node_modules/@lmdb/lmdb-linux-arm64": { "version": "3.5.3", @@ -8678,8 +8428,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/harper/node_modules/@lmdb/lmdb-linux-x64": { "version": "3.5.3", @@ -8693,8 +8442,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/harper/node_modules/@lmdb/lmdb-win32-arm64": { "version": "3.5.3", @@ -8708,8 +8456,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/harper/node_modules/@lmdb/lmdb-win32-x64": { "version": "3.5.3", @@ -8723,8 +8470,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/harper/node_modules/alasql": { "version": "4.6.6", @@ -8732,7 +8478,6 @@ "integrity": "sha512-kuRnDciFgtWSR2tpgraFwE6Q19PmeY9d2O2JzXdpEd38xoBrbp3qKDiGhzBvKfrxpuimwn+6w94FXE9NT3hJsg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cross-fetch": "4.1.0", "yargs": "16" @@ -8750,7 +8495,6 @@ "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", @@ -8766,7 +8510,6 @@ "integrity": "sha512-/xBKk3i6uqLyOPdeF+06WUkMEFtUgybr9PtOzJfACNiJqvZ8Ek3qwPW73IwkkDiXmLDWm7bAxRdkrYP6fXs/uw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "asn1js": "^3.0.5", "pkijs": "^3.2.4" @@ -8781,7 +8524,6 @@ "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -8797,7 +8539,6 @@ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -8811,7 +8552,6 @@ "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", @@ -8827,7 +8567,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@harperfast/extended-iterable": "^1.0.3", "msgpackr": "^1.11.2", @@ -8855,7 +8594,6 @@ "integrity": "sha512-FkoAAyyA6HM8wL882EcEyFZ9s7hVADSwG9xrVx3dxxNQAtgADTrJoEWivID82Iv1zWDsv/OtbrrcZAzGzOMdNw==", "dev": true, "license": "MIT", - "peer": true, "optionalDependencies": { "msgpackr-extract": "^3.0.2" } @@ -8866,7 +8604,6 @@ "integrity": "sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "iconv-lite": "^0.6.3", "sax": "^1.2.4" @@ -8883,8 +8620,7 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/harper/node_modules/node-gyp-build-optional-packages": { "version": "5.2.2", @@ -8892,7 +8628,6 @@ "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "detect-libc": "^2.0.1" }, @@ -8907,8 +8642,7 @@ "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/harper/node_modules/pino": { "version": "8.16.0", @@ -8916,7 +8650,6 @@ "integrity": "sha512-UUmvQ/7KTZt/vHjhRrnyS7h+J7qPBQnpG80V56xmIC+o9IqYmQOw/UIny9S9zYDfRBR0ClouCr464EkBMIT7Fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", @@ -8940,7 +8673,6 @@ "integrity": "sha512-lsleG3/2a/JIWUtf9Q5gUNErBqwIu1tUKTT3dUzaf5DySw9ra1wcqKjJjLX1VTY64Wk1eEOYsVGSaGfCK85ekA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readable-stream": "^4.0.0", "split2": "^4.0.0" @@ -8952,7 +8684,6 @@ "integrity": "sha512-WX0la7n7CbnguuaIQoT4Fc0IJckPDOUldzOwlZ0nwpOcySS+Six/tXBdc0RX17J5o1To0SAr3xDJjDLsOfDFQA==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@noble/hashes": "^1.4.0", "asn1js": "^3.0.5", @@ -8970,8 +8701,7 @@ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-2.3.2.tgz", "integrity": "sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/harper/node_modules/readable-stream": { "version": "4.7.0", @@ -8979,7 +8709,6 @@ "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", @@ -9011,7 +8740,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" @@ -9023,7 +8751,6 @@ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -9037,7 +8764,6 @@ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">= 10.x" } @@ -9048,7 +8774,6 @@ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -9063,7 +8788,6 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", - "peer": true, "bin": { "uuid": "dist/esm/bin/uuid" } @@ -9074,7 +8798,6 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -9097,7 +8820,6 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -9165,7 +8887,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "parse-columns": "git+https://github.com/int0h/parse-columns.git" } @@ -9190,16 +8911,14 @@ "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-0.0.0.tgz", "integrity": "sha512-boVFutx6ME/Km2mB6vvsQcdnazEYYI/jV1pomx1wcFUG/EVqTkr5CU0CW9bKipOA/8Hyu3NYwW3THg2Q1kNCfA==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/hermes-estree": { "version": "0.32.0", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/hermes-parser": { "version": "0.32.0", @@ -9207,7 +8926,6 @@ "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "hermes-estree": "0.32.0" } @@ -9218,6 +8936,7 @@ "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -9246,7 +8965,6 @@ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -9308,7 +9026,6 @@ "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "queue": "6.0.2" }, @@ -9349,7 +9066,6 @@ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -9446,7 +9162,6 @@ "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "loose-envify": "^1.0.0" } @@ -9506,7 +9221,6 @@ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", "license": "MIT", "optional": true, - "peer": true, "bin": { "is-docker": "cli.js" }, @@ -9531,7 +9245,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" }, @@ -9626,7 +9339,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9638,7 +9350,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9659,7 +9370,6 @@ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "is-docker": "^2.0.0" }, @@ -9685,7 +9395,6 @@ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "license": "BSD-3-Clause", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -9696,7 +9405,6 @@ "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", "license": "BSD-3-Clause", "optional": true, - "peer": true, "dependencies": { "@babel/core": "^7.12.3", "@babel/parser": "^7.14.7", @@ -9714,7 +9422,6 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "optional": true, - "peer": true, "bin": { "semver": "bin/semver.js" } @@ -9743,7 +9450,6 @@ "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", @@ -9762,7 +9468,6 @@ "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } @@ -9773,7 +9478,6 @@ "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@jest/types": "^29.6.3", "@types/graceful-fs": "^4.1.3", @@ -9800,7 +9504,6 @@ "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.12.13", "@jest/types": "^29.6.3", @@ -9822,7 +9525,6 @@ "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -9838,7 +9540,6 @@ "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } @@ -9849,7 +9550,6 @@ "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -9868,7 +9568,6 @@ "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", @@ -9887,7 +9586,6 @@ "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", @@ -9904,7 +9602,6 @@ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -9967,8 +9664,7 @@ "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==", "license": "0BSD", - "optional": true, - "peer": true + "optional": true }, "node_modules/jsesc": { "version": "2.5.2", @@ -10032,7 +9728,6 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "license": "MIT", "optional": true, - "peer": true, "bin": { "json5": "lib/cli.js" }, @@ -10116,7 +9811,6 @@ "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=6" } @@ -10172,7 +9866,6 @@ "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", "license": "Apache-2.0", "optional": true, - "peer": true, "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" @@ -10184,7 +9877,6 @@ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -10194,8 +9886,7 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/lmdb": { "version": "3.5.6", @@ -10344,8 +10035,7 @@ "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/lodash.toarray": { "version": "3.0.2", @@ -10377,7 +10067,6 @@ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -10398,7 +10087,6 @@ "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", "license": "BSD-3-Clause", "optional": true, - "peer": true, "dependencies": { "tmpl": "1.0.5" } @@ -10408,8 +10096,7 @@ "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", "license": "Apache-2.0", - "optional": true, - "peer": true + "optional": true }, "node_modules/math-intrinsics": { "version": "1.1.0", @@ -10454,8 +10141,7 @@ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/merge-descriptors": { "version": "2.0.0", @@ -10475,8 +10161,7 @@ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/merge2": { "version": "1.4.1", @@ -10499,7 +10184,6 @@ "integrity": "sha512-SPaPEyvTsTmd0LpT7RaZciQyDw2i/JB7+iY9L5VfBo72+psescFxBqpI1TL9dnL+pmnfkU+l/J1mEEGLeF65EQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/core": "^7.25.2", @@ -10554,7 +10238,6 @@ "integrity": "sha512-sBqBkt6kNut/88bv+Ucvm4yqdPetbvAEsHzi3MAgJEifOSYYzX5Z5Kgw3TFOrwf/mHJTOBG2ONlaMHoyfP15TA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", @@ -10571,8 +10254,7 @@ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/metro-babel-transformer/node_modules/hermes-parser": { "version": "0.35.0", @@ -10580,7 +10262,6 @@ "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "hermes-estree": "0.35.0" } @@ -10591,7 +10272,6 @@ "integrity": "sha512-E9SRePXQ1Zvlj79VcOk57q7VC7rMHMFQ+jhmPHBiq+dJ0bJB5BL87lWZF6oh5X76Cci5tpDuQNaDwwuSCToEeg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", @@ -10608,7 +10288,6 @@ "integrity": "sha512-W1c2Nmx8MiJTJt+eWhMO08z9VKi3kZOaz99IYGdqeqDgY9j+yZjXl62rUav4Di0heZfh4/n2s722PqRL1OODeg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6" }, @@ -10622,7 +10301,6 @@ "integrity": "sha512-83mjWFbFOt2GeJ6pFIum5mSnc1uTsZJAtD8o4ej0s4NVsYsA7fB+pHvTfHhFrpeMONaobu2riKavkPei05Er/Q==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", @@ -10643,7 +10321,6 @@ "integrity": "sha512-6yn3w1wnltT6RQl7p7YES2l95ArC+mWrOssEiH8p5/DDrJS65/szf9LsC9JrBv8c5DdvSY3V3f0GRYg0Ox7hCg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", @@ -10659,7 +10336,6 @@ "integrity": "sha512-+j0F1m+FQYVAQ6syf+mwhIPV5GoFQrkInX8bppuc50IzNsZbMrp8R5H/Sx/K2daQ3YEa9F/XwkeZT8gzJfgeCw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", @@ -10681,7 +10357,6 @@ "integrity": "sha512-MfJar2IS4tBRuLb9svwb0Gu5l9BsH+pcRm8eGcEi/wy8MzZinfinh5dFLt2nWkocnulIgtGB5NkFDdbXqMXKhQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" @@ -10696,7 +10371,6 @@ "integrity": "sha512-WSJIENlMcoSsuz66IfBHOkgfp3KJt2UW2TnEHPf1b8pIG2eEXNOVmo2+03A0H17WY2XGXWgxL0CG7FAopqgB1A==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6" }, @@ -10710,7 +10384,6 @@ "integrity": "sha512-9GKkJURaB2iyYoEExKnedzAHzxmKtSi+k0tsZUvMoU27tBZJElchYt7JH/Ai/XzYAI9lCAaV7u5HZSI8J5Z+wQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" @@ -10725,7 +10398,6 @@ "integrity": "sha512-JgA1h7oc1a1jydBe1GhVFsUoMYo3wLPk7oRA32rjlDsq+sP2JLt9x2p2lWbNSxTm/u8NV4VRid3hvEJgcX8tKw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", @@ -10747,7 +10419,6 @@ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10758,7 +10429,6 @@ "integrity": "sha512-g4suyxw20WOHWI680c+Kq4wC/NF+Hx5pRH9afrMp+sMTxqLeKcPR1Xf4wMhsjlbvx7LbIREdke6q928jEjvJWw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", @@ -10780,7 +10450,6 @@ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10791,7 +10460,6 @@ "integrity": "sha512-Ss0FpBiZDjX2kwhukMDl5sNdYK8T/06IPqxNE4H6PTlRlfs9q11cef13c/xESY/Pm4VCkp1yJUZO3kXzvMxQFA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", @@ -10810,7 +10478,6 @@ "integrity": "sha512-UegCo7ygB2fT64mRK2nbAjQVJ1zSwIIHy8d96jJv2nKZFDaViYBiughEdu5HM/Ceq0WN3LZrZk3zhl9aoiLYFw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", @@ -10835,8 +10502,7 @@ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/metro/node_modules/cliui": { "version": "8.0.1", @@ -10844,7 +10510,6 @@ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -10859,8 +10524,7 @@ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/metro/node_modules/hermes-parser": { "version": "0.35.0", @@ -10868,7 +10532,6 @@ "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "hermes-estree": "0.35.0" } @@ -10879,7 +10542,6 @@ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "mime-db": "^1.54.0" }, @@ -10897,7 +10559,6 @@ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10908,7 +10569,6 @@ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -10927,7 +10587,6 @@ "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=8.3.0" }, @@ -10950,7 +10609,6 @@ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -11459,7 +11117,6 @@ "version": "3.9.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", @@ -11484,8 +11141,7 @@ "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/node-releases": { "version": "2.0.46", @@ -11493,7 +11149,6 @@ "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=18" } @@ -11513,7 +11168,6 @@ "version": "0.2.7", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10" }, @@ -11540,7 +11194,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10" } @@ -11558,7 +11211,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10" } @@ -11576,7 +11228,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -11594,7 +11245,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -11612,7 +11262,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -11630,7 +11279,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -11648,7 +11296,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -11665,8 +11312,7 @@ "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/num-sort": { "version": "1.0.0", @@ -11675,7 +11321,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "number-is-nan": "^1.0.0" }, @@ -11699,7 +11344,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11710,7 +11354,6 @@ "integrity": "sha512-9M5kpuOLyTPogMtZiQUIxdAZxl7Dxs6tVBbJErSumsqGMuhVSoUbkfeZ3XNPpLpwBBtqY5QDUzGwggLHX3slQg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6" }, @@ -11803,7 +11446,6 @@ "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" @@ -12096,7 +11738,6 @@ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=6" } @@ -12133,7 +11774,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "escape-string-regexp": "^1.0.3", "execall": "^1.0.0", @@ -12151,7 +11791,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.8.0" } @@ -12220,7 +11859,6 @@ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12441,8 +12079,7 @@ "node_modules/pprof-format": { "version": "2.2.1", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/prelude-ls": { "version": "1.2.1", @@ -12458,6 +12095,7 @@ "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12485,7 +12123,6 @@ "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", @@ -12501,7 +12138,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=10" }, @@ -12540,7 +12176,6 @@ "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "asap": "~2.0.6" } @@ -12664,7 +12299,6 @@ "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "inherits": "~2.0.3" } @@ -12733,24 +12367,12 @@ "quickselect": "^2.0.0" } }, - "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react-devtools-core": { "version": "6.1.5", "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -12762,7 +12384,6 @@ "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=8.3.0" }, @@ -12784,68 +12405,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/react-native": { - "version": "0.82.1", - "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.82.1.tgz", - "integrity": "sha512-tFAqcU7Z4g49xf/KnyCEzI4nRTu1Opcx05Ov2helr8ZTg1z7AJR/3sr2rZ+AAVlAs2IXk+B0WOxXGmdD3+4czA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jest/create-cache-key-function": "^29.7.0", - "@react-native/assets-registry": "0.82.1", - "@react-native/codegen": "0.82.1", - "@react-native/community-cli-plugin": "0.82.1", - "@react-native/gradle-plugin": "0.82.1", - "@react-native/js-polyfills": "0.82.1", - "@react-native/normalize-colors": "0.82.1", - "@react-native/virtualized-lists": "0.82.1", - "abort-controller": "^3.0.0", - "anser": "^1.4.9", - "ansi-regex": "^5.0.0", - "babel-jest": "^29.7.0", - "babel-plugin-syntax-hermes-parser": "0.32.0", - "base64-js": "^1.5.1", - "commander": "^12.0.0", - "flow-enums-runtime": "^0.0.6", - "glob": "^7.1.1", - "hermes-compiler": "0.0.0", - "invariant": "^2.2.4", - "jest-environment-node": "^29.7.0", - "memoize-one": "^5.0.0", - "metro-runtime": "^0.83.1", - "metro-source-map": "^0.83.1", - "nullthrows": "^1.1.1", - "pretty-format": "^29.7.0", - "promise": "^8.3.0", - "react-devtools-core": "^6.1.5", - "react-refresh": "^0.14.0", - "regenerator-runtime": "^0.13.2", - "scheduler": "0.26.0", - "semver": "^7.1.3", - "stacktrace-parser": "^0.1.10", - "whatwg-fetch": "^3.0.0", - "ws": "^6.2.3", - "yargs": "^17.6.2" - }, - "bin": { - "react-native": "cli.js" - }, - "engines": { - "node": ">= 20.19.4" - }, - "peerDependencies": { - "@types/react": "^19.1.1", - "react": "^19.1.1" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } + "optional": true }, "node_modules/react-native-fs": { "version": "2.20.0", @@ -12867,136 +12427,12 @@ } } }, - "node_modules/react-native/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/react-native/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/react-native/node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/react-native/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/react-native/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/react-native/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/react-native/node_modules/ws": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.4.tgz", - "integrity": "sha512-PNIUUyLI5YpkJZj60YBzX1o0ByQ4ovvfmq9N/Kig/PAYbVlGyz4R6G0SEWrD0O9acc0sT2+IdMBVLFv8FSi0Nw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "async-limiter": "~1.0.0" - } - }, - "node_modules/react-native/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13058,8 +12494,7 @@ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", @@ -13086,7 +12521,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "is-finite": "^1.0.0" }, @@ -13173,7 +12607,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -13189,8 +12622,7 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/rimraf/node_modules/brace-expansion": { "version": "1.1.14", @@ -13198,7 +12630,6 @@ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -13211,7 +12642,6 @@ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -13233,7 +12663,6 @@ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -13358,8 +12787,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/secure-json-parse": { "version": "4.1.0", @@ -13445,7 +12873,6 @@ "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13464,7 +12891,6 @@ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", @@ -13481,7 +12907,6 @@ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -13491,8 +12916,7 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/serve-static/node_modules/fresh": { "version": "0.5.2", @@ -13500,7 +12924,6 @@ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 0.6" } @@ -13511,7 +12934,6 @@ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "license": "MIT", "optional": true, - "peer": true, "bin": { "mime": "cli.js" }, @@ -13525,7 +12947,6 @@ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -13613,7 +13034,6 @@ "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 0.4" }, @@ -13742,7 +13162,6 @@ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -13784,7 +13203,6 @@ "version": "0.7.6", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">= 12" } @@ -13795,7 +13213,6 @@ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -13807,7 +13224,6 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "license": "BSD-3-Clause", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13826,7 +13242,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "array-uniq": "^1.0.2", "arrify": "^1.0.0", @@ -13850,8 +13265,7 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause", - "optional": true, - "peer": true + "optional": true }, "node_modules/stack-trace": { "version": "0.0.10", @@ -13866,7 +13280,6 @@ "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "escape-string-regexp": "^2.0.0" }, @@ -13880,7 +13293,6 @@ "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -13890,8 +13302,7 @@ "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/stacktrace-parser": { "version": "0.1.11", @@ -13899,7 +13310,6 @@ "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "type-fest": "^0.7.1" }, @@ -13913,7 +13323,6 @@ "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", "license": "(MIT OR CC0-1.0)", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -14201,7 +13610,6 @@ "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -14220,8 +13628,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/test-exclude": { "version": "6.0.0", @@ -14229,7 +13636,6 @@ "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", @@ -14244,8 +13650,7 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/test-exclude/node_modules/brace-expansion": { "version": "1.1.14", @@ -14253,7 +13658,6 @@ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -14266,7 +13670,6 @@ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -14288,7 +13691,6 @@ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -14315,8 +13717,7 @@ "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/through": { "version": "2.3.8", @@ -14375,6 +13776,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14387,8 +13789,7 @@ "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", "license": "BSD-3-Clause", - "optional": true, - "peer": true + "optional": true }, "node_modules/to-regex-range": { "version": "5.0.1", @@ -14547,6 +13948,7 @@ "version": "5.9.3", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14637,7 +14039,6 @@ ], "license": "MIT", "optional": true, - "peer": true, "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" @@ -14657,32 +14058,6 @@ "punycode": "^2.1.0" } }, - "node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/utf-8-validate/node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "optional": true, - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/utf8": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/utf8/-/utf8-3.0.0.tgz", @@ -14740,8 +14115,7 @@ "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/walker": { "version": "1.0.8", @@ -14749,7 +14123,6 @@ "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", "license": "Apache-2.0", "optional": true, - "peer": true, "dependencies": { "makeerror": "1.0.12" } @@ -14774,8 +14147,7 @@ "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/whatwg-url": { "version": "5.0.0", @@ -14928,7 +14300,6 @@ "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" @@ -14942,8 +14313,7 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC", - "optional": true, - "peer": true + "optional": true }, "node_modules/ws": { "version": "8.21.0", @@ -14985,8 +14355,7 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC", - "optional": true, - "peer": true + "optional": true }, "node_modules/yaml": { "version": "2.9.0", @@ -15065,6 +14434,7 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/resources/models/v1/index.ts b/resources/models/v1/index.ts index 3f75a57bde..765a208ece 100644 --- a/resources/models/v1/index.ts +++ b/resources/models/v1/index.ts @@ -27,6 +27,7 @@ */ import type { Scope } from '../../../components/Scope.ts'; +import { ensureStarted as ensureRestServing } from '../../../server/REST.ts'; import { V1Embeddings } from './embeddings.ts'; import { V1ChatCompletions } from './chatCompletions.ts'; import { V1Models } from './models.ts'; @@ -36,4 +37,8 @@ export function handleApplication(scope: Scope): void { scope.resources.set('v1/models', V1Models); scope.resources.set('v1/embeddings', V1Embeddings); scope.resources.set('v1/chat/completions', V1ChatCompletions); + // REST's middleware chain only activates when some component config contains a + // `rest`/`REST` key; on a bare instance (no apps) nothing provides one, so the + // resources above would be registered but unservable (#631). + ensureRestServing(scope); } diff --git a/resources/models/v1/models.ts b/resources/models/v1/models.ts index bc83864058..572b6056a6 100644 --- a/resources/models/v1/models.ts +++ b/resources/models/v1/models.ts @@ -32,18 +32,17 @@ export class V1Models extends Resource { if (authError) return authError; const created = Math.floor(Date.now() / 1000); - const generative = listBackends('generative').map(({ logicalName }): OAIModelEntry => ({ - id: logicalName, - object: 'model', - created, - owned_by: 'harper', - })); - const embedding = listBackends('embedding').map(({ logicalName }): OAIModelEntry => ({ - id: logicalName, - object: 'model', - created, - owned_by: 'harper', - })); - return { object: 'list', data: [...generative, ...embedding] }; + // OpenAI model ids are unique; a logical name registered for both generative and + // embedding (e.g. `default` in each section) is one model id to callers. + const ids = new Set(); + const data: OAIModelEntry[] = []; + for (const kind of ['generative', 'embedding'] as const) { + for (const { logicalName } of listBackends(kind)) { + if (ids.has(logicalName)) continue; + ids.add(logicalName); + data.push({ id: logicalName, object: 'model', created, owned_by: 'harper' }); + } + } + return { object: 'list', data }; } } diff --git a/server/REST.ts b/server/REST.ts index eaa4d3c703..c09e1f8251 100644 --- a/server/REST.ts +++ b/server/REST.ts @@ -335,6 +335,16 @@ export function handleApplication(scope: import('../components/Scope.ts').Scope) // If they really want to enable expensive record count estimates (Request.prototype as any).includeExpensiveRecordCountEstimates = true; } + ensureStarted(scope); +} + +/** + * Idempotently register REST's HTTP/WS handlers on the middleware chain. Split from + * `handleApplication` so core plugins that register REST-served resources (e.g. the /v1 + * models gateway) can activate serving on instances where no component config contains a + * `rest`/`REST` key — without adopting their own config section as REST's http options. + */ +export function ensureStarted(scope: import('../components/Scope.ts').Scope) { resources = scope.resources; if (started) return; started = true; diff --git a/unitTests/resources/models/v1/models.test.js b/unitTests/resources/models/v1/models.test.js index 604499974c..395aae03ee 100644 --- a/unitTests/resources/models/v1/models.test.js +++ b/unitTests/resources/models/v1/models.test.js @@ -24,13 +24,26 @@ describe('V1Models.get', () => { it('lists registered generative and embedding backends for a super_user', () => { setGenerative('default', new TestBackend()); - setEmbedding('default', new TestBackend()); + setEmbedding('embed-small', new TestBackend()); const result = V1Models.get(undefined, { user: SUPER_USER }); assert.equal(result.object, 'list'); - assert.equal(result.data.length, 2); + assert.deepEqual( + result.data.map((m) => m.id), + ['default', 'embed-small'] + ); assert.ok(result.data.every((m) => m.object === 'model')); }); + it('dedupes a logical name registered for both generative and embedding', () => { + setGenerative('default', new TestBackend()); + setEmbedding('default', new TestBackend()); + const result = V1Models.get(undefined, { user: SUPER_USER }); + assert.deepEqual( + result.data.map((m) => m.id), + ['default'] + ); + }); + it('rejects an anonymous request with a 401 OpenAI-shape envelope', () => { const result = V1Models.get(undefined, {}); assert.equal(result.status, 401); From 1c5cc320c16755d412a132abe337c2530ab2b4f6 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Fri, 17 Jul 2026 17:24:39 -0700 Subject: [PATCH 18/39] test(models): fix v1-gateway self-sabotaging assertions; use a real token for the SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The 200-assertions interpolated `await res.text()` into the assert message, which evaluates unconditionally — consuming the body on SUCCESS and making the following res.json() throw "Body is unusable". Read the text once and JSON.parse it. (CI showed the endpoints themselves returning 200: the batched-input test, without the template, passed.) - The streaming test assumed AUTHENTICATION_AUTHORIZELOCAL lets any apiKey through; it only covers credential-less requests — a present-but-invalid Bearer is 401 "invalid token" (verified against a live instance). Mint an operation token via create_authentication_tokens and use it as the SDK apiKey, which is also the documented production flow. - Revert package-lock.json to the merge result (897e98747): the locally regenerated lockfile dropped platform-conditional optionals (bufferutil, utf-8-validate) and drifted react-native pins, so CI `npm ci` rejected it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PgCUUCjEqChDgquKJhaNZr --- integrationTests/server/v1-gateway.test.ts | 32 +- package-lock.json | 780 +++++++++++++++++++-- 2 files changed, 729 insertions(+), 83 deletions(-) diff --git a/integrationTests/server/v1-gateway.test.ts b/integrationTests/server/v1-gateway.test.ts index feb6d772e6..2a6fef12b6 100644 --- a/integrationTests/server/v1-gateway.test.ts +++ b/integrationTests/server/v1-gateway.test.ts @@ -97,8 +97,9 @@ suite('OpenAI /v1/* gateway (modelsGateway)', (ctx: ContextWithHarper) => { headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ model: 'default', input: 'hello world' }), }); - assert.equal(res.status, 200, `expected 200, got ${res.status}: ${await res.text()}`); - const body = (await res.json()) as { + const text = await res.text(); + assert.equal(res.status, 200, `expected 200, got ${res.status}: ${text}`); + const body = JSON.parse(text) as { object: string; data: Array<{ embedding: number[]; index: number; object: string }>; }; @@ -150,8 +151,9 @@ suite('OpenAI /v1/* gateway (modelsGateway)', (ctx: ContextWithHarper) => { messages: [{ role: 'user', content: 'hello' }], }), }); - assert.equal(res.status, 200, `expected 200, got ${res.status}: ${await res.text()}`); - const body = (await res.json()) as { + const text = await res.text(); + assert.equal(res.status, 200, `expected 200, got ${res.status}: ${text}`); + const body = JSON.parse(text) as { id: string; object: string; choices: Array<{ message: { role: string; content: string }; finish_reason: string }>; @@ -188,12 +190,26 @@ suite('OpenAI /v1/* gateway (modelsGateway)', (ctx: ContextWithHarper) => { // so the stream: true request lands in post() via Harper's REST layer. // This test validates the full SSE framing path end-to-end. // - // AUTHENTICATION_AUTHORIZELOCAL=true (set by the test harness) means all - // requests from loopback addresses bypass auth, so the SDK's `apiKey` is - // not validated — any non-empty string works. + // The SDK sends its apiKey as `Authorization: Bearer `. A present-but- + // invalid credential is rejected by Harper's auth (401 "invalid token") even + // under AUTHENTICATION_AUTHORIZELOCAL (which only covers requests with no + // credentials at all). Mint a real operation token — this is also the + // documented production flow: a Harper JWT is the OpenAI api key. + const tokenRes = await fetch(ctx.harper.operationsAPIURL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': authHeader(ctx) }, + body: JSON.stringify({ + operation: 'create_authentication_tokens', + username: ctx.harper.admin.username, + password: ctx.harper.admin.password, + }), + }); + const { operation_token } = (await tokenRes.json()) as { operation_token: string }; + assert.ok(operation_token, 'expected create_authentication_tokens to return an operation_token'); + const { OpenAI } = (await import('openai')) as { OpenAI: new (opts: object) => any }; const client = new OpenAI({ - apiKey: 'test-key', + apiKey: operation_token, baseURL: `${ctx.harper.httpURL}/v1`, }); diff --git a/package-lock.json b/package-lock.json index f5e583c162..76827ad5fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -192,7 +192,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1080.0.tgz", "integrity": "sha512-erKuxbwhYLKs0ZviBeTDvXxC0E4AtugHXLbt5kFk8u9/rQMglh/QJNK5f9KuLjlMrbFSJgkBmjNSY5O03YoK4A==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/checksums": "^3.1000.13", "@aws-sdk/core": "^3.974.28", @@ -518,8 +517,52 @@ "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", "license": "MIT", "optional": true, + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, "engines": { "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" } }, "node_modules/@babel/generator": { @@ -552,6 +595,7 @@ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", @@ -569,6 +613,7 @@ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "yallist": "^3.0.2" } @@ -579,6 +624,7 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "optional": true, + "peer": true, "bin": { "semver": "bin/semver.js" } @@ -596,6 +642,7 @@ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" @@ -610,6 +657,7 @@ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", @@ -628,6 +676,7 @@ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=6.9.0" } @@ -652,6 +701,7 @@ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=6.9.0" } @@ -662,6 +712,7 @@ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" @@ -689,6 +740,7 @@ "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -702,6 +754,7 @@ "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -715,6 +768,7 @@ "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.12.13" }, @@ -728,6 +782,7 @@ "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -744,6 +799,7 @@ "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, @@ -760,6 +816,7 @@ "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -773,6 +830,7 @@ "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -786,6 +844,7 @@ "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -799,6 +858,7 @@ "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -812,6 +872,7 @@ "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -825,6 +886,7 @@ "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -838,6 +900,7 @@ "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -851,6 +914,7 @@ "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -864,6 +928,7 @@ "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -880,6 +945,7 @@ "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -1028,6 +1094,7 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "node-gyp-build": "<4.0", "pprof-format": "^2.2.1", @@ -2065,7 +2132,6 @@ "version": "8.44.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.0", "@typescript-eslint/types": "8.44.0", @@ -2680,6 +2746,7 @@ "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", "license": "ISC", "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -2690,6 +2757,7 @@ "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", @@ -2707,6 +2775,7 @@ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "sprintf-js": "~1.0.2" } @@ -2717,6 +2786,7 @@ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=6" } @@ -2727,6 +2797,7 @@ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -2741,6 +2812,7 @@ "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -2755,6 +2827,7 @@ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "p-locate": "^4.1.0" }, @@ -2768,6 +2841,7 @@ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "p-try": "^2.0.0" }, @@ -2784,6 +2858,7 @@ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "p-limit": "^2.2.0" }, @@ -2797,6 +2872,7 @@ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -2807,6 +2883,7 @@ "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -2817,6 +2894,7 @@ "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@jest/types": "^29.6.3" }, @@ -2830,6 +2908,7 @@ "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", @@ -2846,6 +2925,7 @@ "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@jest/types": "^29.6.3", "@sinonjs/fake-timers": "^10.0.2", @@ -2864,6 +2944,7 @@ "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", "license": "BSD-3-Clause", "optional": true, + "peer": true, "dependencies": { "@sinonjs/commons": "^3.0.0" } @@ -2874,6 +2955,7 @@ "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@sinclair/typebox": "^0.27.8" }, @@ -2887,6 +2969,7 @@ "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/core": "^7.11.6", "@jest/types": "^29.6.3", @@ -2914,6 +2997,7 @@ "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", @@ -2940,6 +3024,7 @@ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" @@ -2958,6 +3043,7 @@ "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -3163,7 +3249,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { "version": "3.0.4", @@ -3177,7 +3264,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { "version": "3.0.4", @@ -3191,7 +3279,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { "version": "3.0.4", @@ -3205,7 +3294,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { "version": "3.0.4", @@ -3219,7 +3309,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { "version": "3.0.4", @@ -3233,7 +3324,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@noble/hashes": { "version": "1.8.0", @@ -3643,6 +3735,7 @@ "integrity": "sha512-B1SRwpntaAcckiatxbjzylvNK562Ayza05gdJCjDQHTiDafa1OABmyB5LHt7qWDOpNkaluD+w11vHF7pBmTpzQ==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 20.19.4" } @@ -3653,6 +3746,7 @@ "integrity": "sha512-ezXTN70ygVm9l2m0i+pAlct0RntoV4afftWMGUIeAWLgaca9qItQ54uOt32I/9dBJvzBibT33luIR/pBG0dQvg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.25.3", @@ -3674,7 +3768,8 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@react-native/codegen/node_modules/brace-expansion": { "version": "1.1.14", @@ -3682,6 +3777,7 @@ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3693,6 +3789,7 @@ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -3709,6 +3806,7 @@ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -3730,6 +3828,7 @@ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -3743,6 +3842,7 @@ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -3761,6 +3861,7 @@ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -3780,6 +3881,7 @@ "integrity": "sha512-H/eMdtOy9nEeX7YVeEG1N2vyCoifw3dr9OV8++xfUElNYV7LtSmJ6AqxZUUfxGJRDFPQvaU/8enmJlM/l11VxQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@react-native/dev-middleware": "0.82.1", "debug": "^4.4.0", @@ -3811,6 +3913,7 @@ "integrity": "sha512-a2O6M7/OZ2V9rdavOHyCQ+10z54JX8+B+apYKCQ6a9zoEChGTxUMG2YzzJ8zZJVvYf1ByWSNxv9Se0dca1hO9A==", "license": "BSD-3-Clause", "optional": true, + "peer": true, "engines": { "node": ">= 20.19.4" } @@ -3821,6 +3924,7 @@ "integrity": "sha512-fdRHAeqqPT93bSrxfX+JHPpCXHApfDUdrXMXhoxlPgSzgXQXJDykIViKhtpu0M6slX6xU/+duq+AtP/qWJRpBw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "cross-spawn": "^7.0.6", "fb-dotslash": "0.5.8" @@ -3835,6 +3939,7 @@ "integrity": "sha512-wuOIzms/Qg5raBV6Ctf2LmgzEOCqdP3p1AYN4zdhMT110c39TVMbunpBaJxm0Kbt2HQ762MQViF9naxk7SBo4w==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.82.1", @@ -3859,6 +3964,7 @@ "integrity": "sha512-PNIUUyLI5YpkJZj60YBzX1o0ByQ4ovvfmq9N/Kig/PAYbVlGyz4R6G0SEWrD0O9acc0sT2+IdMBVLFv8FSi0Nw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "async-limiter": "~1.0.0" } @@ -3869,6 +3975,7 @@ "integrity": "sha512-KkF/2T1NSn6EJ5ALNT/gx0MHlrntFHv8YdooH9OOGl9HQn5NM0ZmQSr86o5utJsGc7ME3R6p3SaQuzlsFDrn8Q==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 20.19.4" } @@ -3879,6 +3986,7 @@ "integrity": "sha512-tf70X7pUodslOBdLN37J57JmDPB/yiZcNDzS2m+4bbQzo8fhx3eG9QEBv5n4fmzqfGAgSB4BWRHgDMXmmlDSVA==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 20.19.4" } @@ -3888,7 +3996,8 @@ "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.82.1.tgz", "integrity": "sha512-CCfTR1uX+Z7zJTdt3DNX9LUXr2zWXsNOyLbwupW2wmRzrxlHRYfmLgTABzRL/cKhh0Ubuwn15o72MQChvCRaHw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@react-native/virtualized-lists": { "version": "0.82.1", @@ -3896,6 +4005,7 @@ "integrity": "sha512-f5zpJg9gzh7JtCbsIwV+4kP3eI0QBuA93JGmwFRd4onQ3DnCjV2J5pYqdWtM95sjSKK1dyik59Gj01lLeKqs1Q==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" @@ -3934,7 +4044,8 @@ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@sinonjs/commons": { "version": "3.0.1", @@ -3979,6 +4090,7 @@ "integrity": "sha512-GW2yqqOTzdz3K6z0XpPO1EjLzOw0kclmAcLeW6cBt0DYM7ZNLRKanpzXxaSXkePpo4ZYMWhddE4WpSWG8e/QaQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" @@ -4032,6 +4144,7 @@ "version": "4.2.2", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.6.2" }, @@ -4045,6 +4158,7 @@ "integrity": "sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/core": "^3.23.17", "@smithy/middleware-serde": "^4.2.20", @@ -4065,6 +4179,7 @@ "integrity": "sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/core": "^3.23.17", "@smithy/protocol-http": "^5.3.14", @@ -4081,6 +4196,7 @@ "integrity": "sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" @@ -4095,6 +4211,7 @@ "integrity": "sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", @@ -4125,6 +4242,7 @@ "integrity": "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" @@ -4139,6 +4257,7 @@ "integrity": "sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" @@ -4153,6 +4272,7 @@ "integrity": "sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" @@ -4167,6 +4287,7 @@ "integrity": "sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" @@ -4195,6 +4316,7 @@ "integrity": "sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/core": "^3.23.17", "@smithy/middleware-endpoint": "^4.4.32", @@ -4226,6 +4348,7 @@ "integrity": "sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/querystring-parser": "^4.2.14", "@smithy/types": "^4.14.1", @@ -4239,6 +4362,7 @@ "version": "4.3.2", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", @@ -4252,6 +4376,7 @@ "version": "4.2.2", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" @@ -4266,6 +4391,7 @@ "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.6.2" }, @@ -4279,6 +4405,7 @@ "integrity": "sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" @@ -4293,6 +4420,7 @@ "integrity": "sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/fetch-http-handler": "^5.3.17", "@smithy/node-http-handler": "^4.6.1", @@ -4311,6 +4439,7 @@ "version": "4.2.2", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" @@ -4552,6 +4681,7 @@ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", @@ -4566,6 +4696,7 @@ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/types": "^7.0.0" } @@ -4576,6 +4707,7 @@ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" @@ -4587,6 +4719,7 @@ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/types": "^7.28.2" } @@ -4630,6 +4763,7 @@ "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/node": "*" } @@ -4654,7 +4788,8 @@ "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.3", @@ -4662,6 +4797,7 @@ "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/istanbul-lib-coverage": "*" } @@ -4672,6 +4808,7 @@ "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/istanbul-lib-report": "*" } @@ -4724,7 +4861,6 @@ "node_modules/@types/node": { "version": "25.4.0", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -4763,7 +4899,8 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/tar-fs": { "version": "2.0.4", @@ -4798,6 +4935,7 @@ "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/yargs-parser": "*" } @@ -4807,7 +4945,8 @@ "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.63.0", @@ -4852,7 +4991,6 @@ "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", @@ -5087,7 +5225,6 @@ "version": "8.16.0", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5109,6 +5246,7 @@ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 14" } @@ -5194,7 +5332,8 @@ "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/ansi-escapes": { "version": "4.3.2", @@ -5235,6 +5374,7 @@ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -5278,6 +5418,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5289,6 +5430,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5321,7 +5463,8 @@ "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/asynckit": { "version": "0.4.0", @@ -5411,6 +5554,7 @@ "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", @@ -5433,6 +5577,7 @@ "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", "license": "BSD-3-Clause", "optional": true, + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", @@ -5450,6 +5595,7 @@ "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/template": "^7.3.3", "@babel/types": "^7.3.3", @@ -5466,6 +5612,7 @@ "integrity": "sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "hermes-parser": "0.32.0" } @@ -5476,6 +5623,7 @@ "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", @@ -5503,6 +5651,7 @@ "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" @@ -5626,6 +5775,7 @@ "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", "license": "Apache-2.0", "optional": true, + "peer": true, "bin": { "baseline-browser-mapping": "dist/cli.cjs" }, @@ -5777,12 +5927,48 @@ "pako": "~0.2.0" } }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "node-int64": "^0.4.0" } @@ -5803,6 +5989,32 @@ "version": "1.1.2", "license": "MIT" }, + "node_modules/bufferutil": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/bufferutil/node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -5910,7 +6122,8 @@ } ], "license": "CC-BY-4.0", - "optional": true + "optional": true, + "peer": true }, "node_modules/cbor-extract": { "version": "2.2.2", @@ -5943,7 +6156,6 @@ "version": "6.2.2", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -6012,6 +6224,7 @@ "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", @@ -6031,6 +6244,7 @@ "integrity": "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==", "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", @@ -6052,6 +6266,7 @@ ], "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -6131,6 +6346,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "is-regexp": "^1.0.0", "is-supported-regexp-flag": "^1.0.0" @@ -6177,6 +6393,7 @@ "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -6245,6 +6462,7 @@ "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", @@ -6261,6 +6479,7 @@ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -6270,7 +6489,8 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/content-disposition": { "version": "1.0.1", @@ -6298,7 +6518,8 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cookie": { "version": "1.1.1", @@ -6514,6 +6735,7 @@ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" @@ -6621,7 +6843,8 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", "license": "ISC", - "optional": true + "optional": true, + "peer": true }, "node_modules/emoji-regex": { "version": "8.0.0", @@ -6647,6 +6870,7 @@ "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "stackframe": "^1.3.4" } @@ -6761,7 +6985,6 @@ "version": "9.39.4", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6820,7 +7043,6 @@ "version": "10.1.8", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6957,6 +7179,7 @@ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "license": "BSD-2-Clause", "optional": true, + "peer": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -7059,6 +7282,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "clone-regexp": "^1.0.0" }, @@ -7071,7 +7295,8 @@ "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "license": "Apache-2.0", - "optional": true + "optional": true, + "peer": true }, "node_modules/express": { "version": "5.2.1", @@ -7510,6 +7735,7 @@ "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", "license": "(MIT OR Apache-2.0)", "optional": true, + "peer": true, "bin": { "dotslash": "bin/dotslash" }, @@ -7523,6 +7749,7 @@ "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "bser": "2.1.1" } @@ -7579,6 +7806,7 @@ "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", @@ -7598,6 +7826,7 @@ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -7608,6 +7837,7 @@ "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 0.8" } @@ -7617,7 +7847,8 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/finalhandler/node_modules/on-finished": { "version": "2.3.0", @@ -7625,6 +7856,7 @@ "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "ee-first": "1.1.1" }, @@ -7638,6 +7870,7 @@ "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 0.6" } @@ -7701,7 +7934,8 @@ "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/follow-redirects": { "version": "1.16.0", @@ -7817,7 +8051,8 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "license": "ISC", - "optional": true + "optional": true, + "peer": true }, "node_modules/fsevents": { "version": "2.3.3", @@ -7853,6 +8088,7 @@ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=6.9.0" } @@ -7920,6 +8156,7 @@ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=8.0.0" } @@ -8029,7 +8266,6 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -8095,6 +8331,7 @@ "integrity": "sha512-RRXMLbbdymiZsHOeg5b+DShzsMvVvkgsG9690BBCc7tzIpDb0CT7EgWEQo+rwCICr35EwZoLjtfwF6mMiCOenA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-sdk/client-s3": "^3.1012.0", "@aws-sdk/lib-storage": "3.964.0", @@ -8197,6 +8434,7 @@ "integrity": "sha512-ro6B04Q5TjPgIKdSWGJ+tj2ordVF1IfZJERwGpYkrwhboNEoXBXuzpfnh2LYBPvMmFJQ+8UXSFw1jkLLgxM+ig==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/abort-controller": "^4.2.7", "@smithy/middleware-endpoint": "^4.4.1", @@ -8219,6 +8457,7 @@ "integrity": "sha512-gipd/g0USN8ncvRMdoaru8PxYNUSEJp//+XbLf+3VNDQ6gcSsTcYqyNa3f+oEKIyV0clpOkxzautkN7hVPsn/g==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@harperfast/extended-iterable": "1.0.3", "msgpackr": "1.11.9", @@ -8251,6 +8490,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -8268,6 +8508,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -8285,6 +8526,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8302,6 +8544,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8319,6 +8562,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8336,6 +8580,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8353,6 +8598,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -8370,6 +8616,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -8386,7 +8633,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/harper/node_modules/@lmdb/lmdb-darwin-x64": { "version": "3.5.3", @@ -8400,7 +8648,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/harper/node_modules/@lmdb/lmdb-linux-arm": { "version": "3.5.3", @@ -8414,7 +8663,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/harper/node_modules/@lmdb/lmdb-linux-arm64": { "version": "3.5.3", @@ -8428,7 +8678,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/harper/node_modules/@lmdb/lmdb-linux-x64": { "version": "3.5.3", @@ -8442,7 +8693,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/harper/node_modules/@lmdb/lmdb-win32-arm64": { "version": "3.5.3", @@ -8456,7 +8708,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/harper/node_modules/@lmdb/lmdb-win32-x64": { "version": "3.5.3", @@ -8470,7 +8723,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/harper/node_modules/alasql": { "version": "4.6.6", @@ -8478,6 +8732,7 @@ "integrity": "sha512-kuRnDciFgtWSR2tpgraFwE6Q19PmeY9d2O2JzXdpEd38xoBrbp3qKDiGhzBvKfrxpuimwn+6w94FXE9NT3hJsg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cross-fetch": "4.1.0", "yargs": "16" @@ -8495,6 +8750,7 @@ "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", @@ -8510,6 +8766,7 @@ "integrity": "sha512-/xBKk3i6uqLyOPdeF+06WUkMEFtUgybr9PtOzJfACNiJqvZ8Ek3qwPW73IwkkDiXmLDWm7bAxRdkrYP6fXs/uw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "asn1js": "^3.0.5", "pkijs": "^3.2.4" @@ -8524,6 +8781,7 @@ "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -8539,6 +8797,7 @@ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -8552,6 +8811,7 @@ "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", @@ -8567,6 +8827,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@harperfast/extended-iterable": "^1.0.3", "msgpackr": "^1.11.2", @@ -8594,6 +8855,7 @@ "integrity": "sha512-FkoAAyyA6HM8wL882EcEyFZ9s7hVADSwG9xrVx3dxxNQAtgADTrJoEWivID82Iv1zWDsv/OtbrrcZAzGzOMdNw==", "dev": true, "license": "MIT", + "peer": true, "optionalDependencies": { "msgpackr-extract": "^3.0.2" } @@ -8604,6 +8866,7 @@ "integrity": "sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "iconv-lite": "^0.6.3", "sax": "^1.2.4" @@ -8620,7 +8883,8 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/harper/node_modules/node-gyp-build-optional-packages": { "version": "5.2.2", @@ -8628,6 +8892,7 @@ "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "detect-libc": "^2.0.1" }, @@ -8642,7 +8907,8 @@ "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/harper/node_modules/pino": { "version": "8.16.0", @@ -8650,6 +8916,7 @@ "integrity": "sha512-UUmvQ/7KTZt/vHjhRrnyS7h+J7qPBQnpG80V56xmIC+o9IqYmQOw/UIny9S9zYDfRBR0ClouCr464EkBMIT7Fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", @@ -8673,6 +8940,7 @@ "integrity": "sha512-lsleG3/2a/JIWUtf9Q5gUNErBqwIu1tUKTT3dUzaf5DySw9ra1wcqKjJjLX1VTY64Wk1eEOYsVGSaGfCK85ekA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "readable-stream": "^4.0.0", "split2": "^4.0.0" @@ -8684,6 +8952,7 @@ "integrity": "sha512-WX0la7n7CbnguuaIQoT4Fc0IJckPDOUldzOwlZ0nwpOcySS+Six/tXBdc0RX17J5o1To0SAr3xDJjDLsOfDFQA==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@noble/hashes": "^1.4.0", "asn1js": "^3.0.5", @@ -8701,7 +8970,8 @@ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-2.3.2.tgz", "integrity": "sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/harper/node_modules/readable-stream": { "version": "4.7.0", @@ -8709,6 +8979,7 @@ "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", @@ -8740,6 +9011,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" @@ -8751,6 +9023,7 @@ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -8764,6 +9037,7 @@ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">= 10.x" } @@ -8774,6 +9048,7 @@ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -8788,6 +9063,7 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", + "peer": true, "bin": { "uuid": "dist/esm/bin/uuid" } @@ -8798,6 +9074,7 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -8820,6 +9097,7 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -8887,6 +9165,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "parse-columns": "git+https://github.com/int0h/parse-columns.git" } @@ -8911,14 +9190,16 @@ "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-0.0.0.tgz", "integrity": "sha512-boVFutx6ME/Km2mB6vvsQcdnazEYYI/jV1pomx1wcFUG/EVqTkr5CU0CW9bKipOA/8Hyu3NYwW3THg2Q1kNCfA==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/hermes-estree": { "version": "0.32.0", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/hermes-parser": { "version": "0.32.0", @@ -8926,6 +9207,7 @@ "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "hermes-estree": "0.32.0" } @@ -8936,7 +9218,6 @@ "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -8965,6 +9246,7 @@ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -9026,6 +9308,7 @@ "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "queue": "6.0.2" }, @@ -9066,6 +9349,7 @@ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -9162,6 +9446,7 @@ "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "loose-envify": "^1.0.0" } @@ -9221,6 +9506,7 @@ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", "license": "MIT", "optional": true, + "peer": true, "bin": { "is-docker": "cli.js" }, @@ -9245,6 +9531,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" }, @@ -9339,6 +9626,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9350,6 +9638,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9370,6 +9659,7 @@ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "is-docker": "^2.0.0" }, @@ -9395,6 +9685,7 @@ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "license": "BSD-3-Clause", "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -9405,6 +9696,7 @@ "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", "license": "BSD-3-Clause", "optional": true, + "peer": true, "dependencies": { "@babel/core": "^7.12.3", "@babel/parser": "^7.14.7", @@ -9422,6 +9714,7 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "optional": true, + "peer": true, "bin": { "semver": "bin/semver.js" } @@ -9450,6 +9743,7 @@ "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", @@ -9468,6 +9762,7 @@ "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } @@ -9478,6 +9773,7 @@ "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@jest/types": "^29.6.3", "@types/graceful-fs": "^4.1.3", @@ -9504,6 +9800,7 @@ "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.12.13", "@jest/types": "^29.6.3", @@ -9525,6 +9822,7 @@ "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -9540,6 +9838,7 @@ "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } @@ -9550,6 +9849,7 @@ "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -9568,6 +9868,7 @@ "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", @@ -9586,6 +9887,7 @@ "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", @@ -9602,6 +9904,7 @@ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -9664,7 +9967,8 @@ "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==", "license": "0BSD", - "optional": true + "optional": true, + "peer": true }, "node_modules/jsesc": { "version": "2.5.2", @@ -9728,6 +10032,7 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "license": "MIT", "optional": true, + "peer": true, "bin": { "json5": "lib/cli.js" }, @@ -9811,6 +10116,7 @@ "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=6" } @@ -9866,6 +10172,7 @@ "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" @@ -9877,6 +10184,7 @@ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -9886,7 +10194,8 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/lmdb": { "version": "3.5.6", @@ -10035,7 +10344,8 @@ "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/lodash.toarray": { "version": "3.0.2", @@ -10067,6 +10377,7 @@ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -10087,6 +10398,7 @@ "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", "license": "BSD-3-Clause", "optional": true, + "peer": true, "dependencies": { "tmpl": "1.0.5" } @@ -10096,7 +10408,8 @@ "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", "license": "Apache-2.0", - "optional": true + "optional": true, + "peer": true }, "node_modules/math-intrinsics": { "version": "1.1.0", @@ -10141,7 +10454,8 @@ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/merge-descriptors": { "version": "2.0.0", @@ -10161,7 +10475,8 @@ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/merge2": { "version": "1.4.1", @@ -10184,6 +10499,7 @@ "integrity": "sha512-SPaPEyvTsTmd0LpT7RaZciQyDw2i/JB7+iY9L5VfBo72+psescFxBqpI1TL9dnL+pmnfkU+l/J1mEEGLeF65EQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/core": "^7.25.2", @@ -10238,6 +10554,7 @@ "integrity": "sha512-sBqBkt6kNut/88bv+Ucvm4yqdPetbvAEsHzi3MAgJEifOSYYzX5Z5Kgw3TFOrwf/mHJTOBG2ONlaMHoyfP15TA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", @@ -10254,7 +10571,8 @@ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/metro-babel-transformer/node_modules/hermes-parser": { "version": "0.35.0", @@ -10262,6 +10580,7 @@ "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "hermes-estree": "0.35.0" } @@ -10272,6 +10591,7 @@ "integrity": "sha512-E9SRePXQ1Zvlj79VcOk57q7VC7rMHMFQ+jhmPHBiq+dJ0bJB5BL87lWZF6oh5X76Cci5tpDuQNaDwwuSCToEeg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", @@ -10288,6 +10608,7 @@ "integrity": "sha512-W1c2Nmx8MiJTJt+eWhMO08z9VKi3kZOaz99IYGdqeqDgY9j+yZjXl62rUav4Di0heZfh4/n2s722PqRL1OODeg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6" }, @@ -10301,6 +10622,7 @@ "integrity": "sha512-83mjWFbFOt2GeJ6pFIum5mSnc1uTsZJAtD8o4ej0s4NVsYsA7fB+pHvTfHhFrpeMONaobu2riKavkPei05Er/Q==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", @@ -10321,6 +10643,7 @@ "integrity": "sha512-6yn3w1wnltT6RQl7p7YES2l95ArC+mWrOssEiH8p5/DDrJS65/szf9LsC9JrBv8c5DdvSY3V3f0GRYg0Ox7hCg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", @@ -10336,6 +10659,7 @@ "integrity": "sha512-+j0F1m+FQYVAQ6syf+mwhIPV5GoFQrkInX8bppuc50IzNsZbMrp8R5H/Sx/K2daQ3YEa9F/XwkeZT8gzJfgeCw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", @@ -10357,6 +10681,7 @@ "integrity": "sha512-MfJar2IS4tBRuLb9svwb0Gu5l9BsH+pcRm8eGcEi/wy8MzZinfinh5dFLt2nWkocnulIgtGB5NkFDdbXqMXKhQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" @@ -10371,6 +10696,7 @@ "integrity": "sha512-WSJIENlMcoSsuz66IfBHOkgfp3KJt2UW2TnEHPf1b8pIG2eEXNOVmo2+03A0H17WY2XGXWgxL0CG7FAopqgB1A==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6" }, @@ -10384,6 +10710,7 @@ "integrity": "sha512-9GKkJURaB2iyYoEExKnedzAHzxmKtSi+k0tsZUvMoU27tBZJElchYt7JH/Ai/XzYAI9lCAaV7u5HZSI8J5Z+wQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" @@ -10398,6 +10725,7 @@ "integrity": "sha512-JgA1h7oc1a1jydBe1GhVFsUoMYo3wLPk7oRA32rjlDsq+sP2JLt9x2p2lWbNSxTm/u8NV4VRid3hvEJgcX8tKw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", @@ -10419,6 +10747,7 @@ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10429,6 +10758,7 @@ "integrity": "sha512-g4suyxw20WOHWI680c+Kq4wC/NF+Hx5pRH9afrMp+sMTxqLeKcPR1Xf4wMhsjlbvx7LbIREdke6q928jEjvJWw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", @@ -10450,6 +10780,7 @@ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10460,6 +10791,7 @@ "integrity": "sha512-Ss0FpBiZDjX2kwhukMDl5sNdYK8T/06IPqxNE4H6PTlRlfs9q11cef13c/xESY/Pm4VCkp1yJUZO3kXzvMxQFA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", @@ -10478,6 +10810,7 @@ "integrity": "sha512-UegCo7ygB2fT64mRK2nbAjQVJ1zSwIIHy8d96jJv2nKZFDaViYBiughEdu5HM/Ceq0WN3LZrZk3zhl9aoiLYFw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", @@ -10502,7 +10835,8 @@ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/metro/node_modules/cliui": { "version": "8.0.1", @@ -10510,6 +10844,7 @@ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -10524,7 +10859,8 @@ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/metro/node_modules/hermes-parser": { "version": "0.35.0", @@ -10532,6 +10868,7 @@ "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "hermes-estree": "0.35.0" } @@ -10542,6 +10879,7 @@ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "mime-db": "^1.54.0" }, @@ -10559,6 +10897,7 @@ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10569,6 +10908,7 @@ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -10587,6 +10927,7 @@ "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=8.3.0" }, @@ -10609,6 +10950,7 @@ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -11117,6 +11459,7 @@ "version": "3.9.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", @@ -11141,7 +11484,8 @@ "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/node-releases": { "version": "2.0.46", @@ -11149,6 +11493,7 @@ "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -11168,6 +11513,7 @@ "version": "0.2.7", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 10" }, @@ -11194,6 +11540,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10" } @@ -11211,6 +11558,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10" } @@ -11228,6 +11576,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -11245,6 +11594,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -11262,6 +11612,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -11279,6 +11630,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -11296,6 +11648,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -11312,7 +11665,8 @@ "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/num-sort": { "version": "1.0.0", @@ -11321,6 +11675,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "number-is-nan": "^1.0.0" }, @@ -11344,6 +11699,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11354,6 +11710,7 @@ "integrity": "sha512-9M5kpuOLyTPogMtZiQUIxdAZxl7Dxs6tVBbJErSumsqGMuhVSoUbkfeZ3XNPpLpwBBtqY5QDUzGwggLHX3slQg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6" }, @@ -11446,6 +11803,7 @@ "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" @@ -11738,6 +12096,7 @@ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=6" } @@ -11774,6 +12133,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "escape-string-regexp": "^1.0.3", "execall": "^1.0.0", @@ -11791,6 +12151,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.8.0" } @@ -11859,6 +12220,7 @@ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12079,7 +12441,8 @@ "node_modules/pprof-format": { "version": "2.2.1", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prelude-ls": { "version": "1.2.1", @@ -12095,7 +12458,6 @@ "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12123,6 +12485,7 @@ "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", @@ -12138,6 +12501,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=10" }, @@ -12176,6 +12540,7 @@ "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "asap": "~2.0.6" } @@ -12299,6 +12664,7 @@ "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "inherits": "~2.0.3" } @@ -12367,12 +12733,24 @@ "quickselect": "^2.0.0" } }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-devtools-core": { "version": "6.1.5", "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -12384,6 +12762,7 @@ "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=8.3.0" }, @@ -12405,7 +12784,68 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true + }, + "node_modules/react-native": { + "version": "0.82.1", + "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.82.1.tgz", + "integrity": "sha512-tFAqcU7Z4g49xf/KnyCEzI4nRTu1Opcx05Ov2helr8ZTg1z7AJR/3sr2rZ+AAVlAs2IXk+B0WOxXGmdD3+4czA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jest/create-cache-key-function": "^29.7.0", + "@react-native/assets-registry": "0.82.1", + "@react-native/codegen": "0.82.1", + "@react-native/community-cli-plugin": "0.82.1", + "@react-native/gradle-plugin": "0.82.1", + "@react-native/js-polyfills": "0.82.1", + "@react-native/normalize-colors": "0.82.1", + "@react-native/virtualized-lists": "0.82.1", + "abort-controller": "^3.0.0", + "anser": "^1.4.9", + "ansi-regex": "^5.0.0", + "babel-jest": "^29.7.0", + "babel-plugin-syntax-hermes-parser": "0.32.0", + "base64-js": "^1.5.1", + "commander": "^12.0.0", + "flow-enums-runtime": "^0.0.6", + "glob": "^7.1.1", + "hermes-compiler": "0.0.0", + "invariant": "^2.2.4", + "jest-environment-node": "^29.7.0", + "memoize-one": "^5.0.0", + "metro-runtime": "^0.83.1", + "metro-source-map": "^0.83.1", + "nullthrows": "^1.1.1", + "pretty-format": "^29.7.0", + "promise": "^8.3.0", + "react-devtools-core": "^6.1.5", + "react-refresh": "^0.14.0", + "regenerator-runtime": "^0.13.2", + "scheduler": "0.26.0", + "semver": "^7.1.3", + "stacktrace-parser": "^0.1.10", + "whatwg-fetch": "^3.0.0", + "ws": "^6.2.3", + "yargs": "^17.6.2" + }, + "bin": { + "react-native": "cli.js" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@types/react": "^19.1.1", + "react": "^19.1.1" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } }, "node_modules/react-native-fs": { "version": "2.20.0", @@ -12427,12 +12867,136 @@ } } }, + "node_modules/react-native/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/react-native/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/react-native/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/react-native/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/react-native/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/react-native/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/react-native/node_modules/ws": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.4.tgz", + "integrity": "sha512-PNIUUyLI5YpkJZj60YBzX1o0ByQ4ovvfmq9N/Kig/PAYbVlGyz4R6G0SEWrD0O9acc0sT2+IdMBVLFv8FSi0Nw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "async-limiter": "~1.0.0" + } + }, + "node_modules/react-native/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12494,7 +13058,8 @@ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", @@ -12521,6 +13086,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "is-finite": "^1.0.0" }, @@ -12607,6 +13173,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -12622,7 +13189,8 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/rimraf/node_modules/brace-expansion": { "version": "1.1.14", @@ -12630,6 +13198,7 @@ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -12642,6 +13211,7 @@ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -12663,6 +13233,7 @@ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -12787,7 +13358,8 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/secure-json-parse": { "version": "4.1.0", @@ -12873,6 +13445,7 @@ "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12891,6 +13464,7 @@ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", @@ -12907,6 +13481,7 @@ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -12916,7 +13491,8 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/serve-static/node_modules/fresh": { "version": "0.5.2", @@ -12924,6 +13500,7 @@ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 0.6" } @@ -12934,6 +13511,7 @@ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "license": "MIT", "optional": true, + "peer": true, "bin": { "mime": "cli.js" }, @@ -12947,6 +13525,7 @@ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -13034,6 +13613,7 @@ "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 0.4" }, @@ -13162,6 +13742,7 @@ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -13203,6 +13784,7 @@ "version": "0.7.6", "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">= 12" } @@ -13213,6 +13795,7 @@ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -13224,6 +13807,7 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "license": "BSD-3-Clause", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13242,6 +13826,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "array-uniq": "^1.0.2", "arrify": "^1.0.0", @@ -13265,7 +13850,8 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause", - "optional": true + "optional": true, + "peer": true }, "node_modules/stack-trace": { "version": "0.0.10", @@ -13280,6 +13866,7 @@ "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "escape-string-regexp": "^2.0.0" }, @@ -13293,6 +13880,7 @@ "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -13302,7 +13890,8 @@ "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/stacktrace-parser": { "version": "0.1.11", @@ -13310,6 +13899,7 @@ "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "type-fest": "^0.7.1" }, @@ -13323,6 +13913,7 @@ "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", "license": "(MIT OR CC0-1.0)", "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -13610,6 +14201,7 @@ "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -13628,7 +14220,8 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/test-exclude": { "version": "6.0.0", @@ -13636,6 +14229,7 @@ "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", @@ -13650,7 +14244,8 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/test-exclude/node_modules/brace-expansion": { "version": "1.1.14", @@ -13658,6 +14253,7 @@ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -13670,6 +14266,7 @@ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -13691,6 +14288,7 @@ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -13717,7 +14315,8 @@ "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/through": { "version": "2.3.8", @@ -13776,7 +14375,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13789,7 +14387,8 @@ "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", "license": "BSD-3-Clause", - "optional": true + "optional": true, + "peer": true }, "node_modules/to-regex-range": { "version": "5.0.1", @@ -13948,7 +14547,6 @@ "version": "5.9.3", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14039,6 +14637,7 @@ ], "license": "MIT", "optional": true, + "peer": true, "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" @@ -14058,6 +14657,32 @@ "punycode": "^2.1.0" } }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/utf-8-validate/node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/utf8": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/utf8/-/utf8-3.0.0.tgz", @@ -14115,7 +14740,8 @@ "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/walker": { "version": "1.0.8", @@ -14123,6 +14749,7 @@ "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "makeerror": "1.0.12" } @@ -14147,7 +14774,8 @@ "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/whatwg-url": { "version": "5.0.0", @@ -14300,6 +14928,7 @@ "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" @@ -14313,7 +14942,8 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC", - "optional": true + "optional": true, + "peer": true }, "node_modules/ws": { "version": "8.21.0", @@ -14355,7 +14985,8 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC", - "optional": true + "optional": true, + "peer": true }, "node_modules/yaml": { "version": "2.9.0", @@ -14434,7 +15065,6 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 84b04071f09888ec9a52c5220a5a06deb7666592 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Fri, 17 Jul 2026 17:48:27 -0700 Subject: [PATCH 19/39] =?UTF-8?q?fix(models):=20make=20REST=20activation?= =?UTF-8?q?=20order-independent=20=E2=80=94=20activate=20after=20root=20pl?= =?UTF-8?q?ugin=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior shape (gateway calls REST.ensureStarted from its own handleApplication) was order-dependent: with both modelsGateway.enabled and a user rest: section, whichever loaded first won, and a gateway-first load registered REST's chain with empty options — silently dropping e.g. webSocket: false (claude review, server/REST.ts:349 thread). componentLoader now calls REST.ensureStarted({ server, resources }) after the root plugin loop completes, gated on modelsGateway.enabled: by then any rest/REST section — whatever its key order — has already registered with the user's options, making the call a no-op. ensureStarted takes {server, resources} structurally so a Scope still satisfies it. handleApplication hardens getAll() with ?? {} (section values like `rest: true` scope to undefined). Note for the record: an in-memory `config.rest = true` synthesis was tried and rejected — OptionsWatcher only emits `ready` for sections present in the config file (or env overlay), so a synthesized key hangs boot on scope.ready. Verified against live instances (dist build, HARPER_SET_CONFIG like the integration harness): - bare instance: /v1/models 200 - with rest: {webSocket: false}: http chain "authentication → rest" exactly once, websocket chain has no rest entry (option honored), /v1/models 200 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PgCUUCjEqChDgquKJhaNZr --- components/componentLoader.ts | 10 ++++++++++ integrationTests/server/v1-gateway.test.ts | 5 +++-- resources/models/v1/index.ts | 8 +++----- server/REST.ts | 22 +++++++++++++--------- 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/components/componentLoader.ts b/components/componentLoader.ts index 39c833ed37..80ef841234 100644 --- a/components/componentLoader.ts +++ b/components/componentLoader.ts @@ -668,6 +668,16 @@ export async function loadComponent( } } + // The /v1 models gateway registers REST-served resources (#631), but REST's chain + // only activates via a `rest`/`REST` config section, and a bare instance (no apps) + // has none — leaving the gateway's resources registered but unservable. Activate + // REST with default options here, after every root plugin has loaded: if any + // `rest`/`REST` section exists — whatever its key order — its handleApplication + // already ran with the user's options and this is a no-op. + if (isRoot && resources.isWorker && (config as any).modelsGateway?.enabled) { + REST.ensureStarted({ server, resources }); + } + compName = parentCompName; if (isMainThread && !watchesSetup && autoReload) { let debounceTimer: ReturnType | null = null; diff --git a/integrationTests/server/v1-gateway.test.ts b/integrationTests/server/v1-gateway.test.ts index 2a6fef12b6..da1b81a231 100644 --- a/integrationTests/server/v1-gateway.test.ts +++ b/integrationTests/server/v1-gateway.test.ts @@ -17,8 +17,9 @@ * gateway is off by default (`enabled: false` in defaultConfig.yaml). The test * harness plumbs this via HARPER_SET_CONFIG. This suite runs against a bare * instance (no deployed apps), so no component config contains a `rest` key — - * the gateway itself must activate REST serving (REST.ensureStarted) for these - * endpoints to be reachable. That activation is part of what this suite covers. + * componentLoader activates REST (REST.ensureStarted, after root plugin + * loading) when the gateway is enabled so its chain serves these endpoints. + * That activation path is part of what this suite covers. */ import { suite, test, before, after } from 'node:test'; import assert from 'node:assert'; diff --git a/resources/models/v1/index.ts b/resources/models/v1/index.ts index 765a208ece..6b12fa2570 100644 --- a/resources/models/v1/index.ts +++ b/resources/models/v1/index.ts @@ -27,18 +27,16 @@ */ import type { Scope } from '../../../components/Scope.ts'; -import { ensureStarted as ensureRestServing } from '../../../server/REST.ts'; import { V1Embeddings } from './embeddings.ts'; import { V1ChatCompletions } from './chatCompletions.ts'; import { V1Models } from './models.ts'; export function handleApplication(scope: Scope): void { if (!scope.options.get(['enabled'])) return; + // These resources are served by REST's middleware chain, which only activates via a + // `rest`/`REST` config section. Bare instances have none, so componentLoader calls + // REST.ensureStarted after root plugin loading when the gateway is enabled (#631). scope.resources.set('v1/models', V1Models); scope.resources.set('v1/embeddings', V1Embeddings); scope.resources.set('v1/chat/completions', V1ChatCompletions); - // REST's middleware chain only activates when some component config contains a - // `rest`/`REST` key; on a bare instance (no apps) nothing provides one, so the - // resources above would be registered but unservable (#631). - ensureRestServing(scope); } diff --git a/server/REST.ts b/server/REST.ts index c09e1f8251..50776d0033 100644 --- a/server/REST.ts +++ b/server/REST.ts @@ -330,7 +330,7 @@ let addedMetrics; let connectionCount = 0; export function handleApplication(scope: import('../components/Scope.ts').Scope) { - httpOptions = scope.options.getAll(); + httpOptions = scope.options.getAll() ?? {}; if ((httpOptions as any).includeExpensiveRecordCountEstimates) { // If they really want to enable expensive record count estimates (Request.prototype as any).includeExpensiveRecordCountEstimates = true; @@ -339,16 +339,20 @@ export function handleApplication(scope: import('../components/Scope.ts').Scope) } /** - * Idempotently register REST's HTTP/WS handlers on the middleware chain. Split from - * `handleApplication` so core plugins that register REST-served resources (e.g. the /v1 - * models gateway) can activate serving on instances where no component config contains a - * `rest`/`REST` key — without adopting their own config section as REST's http options. + * Idempotently register REST's HTTP/WS handlers on the middleware chain. The component + * loader calls this after all root plugins have loaded, so core-registered REST resources + * (e.g. the /v1 models gateway, #631) are servable on instances whose config has no + * `rest`/`REST` section. When one exists, its `handleApplication` has already run with the + * user's options and this is a no-op — activation is independent of config key order. */ -export function ensureStarted(scope: import('../components/Scope.ts').Scope) { - resources = scope.resources; +export function ensureStarted({ + server, + resources: scopeResources, +}: Pick) { + resources = scopeResources; if (started) return; started = true; - scope.server.http( + server.http( async (request: any, nextHandler) => { if (request.isWebSocket) return; return http(request, nextHandler); @@ -356,7 +360,7 @@ export function ensureStarted(scope: import('../components/Scope.ts').Scope) { { after: 'authentication', ...(httpOptions as any) } ); if ((httpOptions as any).webSocket === false) return; - scope.server.ws( + server.ws( async (ws, request: any, chainCompletion) => { connectionCount++; const incomingMessages = new IterableEventQueue(); From 7551dae25edb506d3b8ec67a6137ec49fd8a0ceb Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Mon, 20 Jul 2026 20:37:50 -0700 Subject: [PATCH 20/39] feat(models): mid-stream SSE error frames + embeddings input cap (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses @kriszyp's review on #1616: - openaiStream() now wraps its token-pull loop in try/catch: a mid-stream backend error (Models#wrapStream re-throws) is converted into a final OpenAI-shaped `data: {error}` SSE frame instead of an abrupt socket close, so streaming clients get the same error semantics as the non-streaming path. chatCompletions passes a formatError derived from toOpenAIError; the generic formatter stays decoupled from the v1 layer. - /v1/embeddings caps batched input at 2048 items (OpenAI's own limit), returning a 400 OpenAI-shape error above it. The third finding (embed() usage always zero) is an upstream Models facade gap — tracked in #1882, out of scope here. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RXx78W5LxbvxEdSyrB1vvn --- resources/models/openaiStream.ts | 63 +++++++++++++------ resources/models/v1/chatCompletions.ts | 6 +- resources/models/v1/embeddings.ts | 8 +++ .../resources/models/openaiStream.test.js | 44 +++++++++++++ .../resources/models/v1/embeddings.test.js | 14 +++++ 5 files changed, 116 insertions(+), 19 deletions(-) diff --git a/resources/models/openaiStream.ts b/resources/models/openaiStream.ts index 38cbbc442d..81b81aea35 100644 --- a/resources/models/openaiStream.ts +++ b/resources/models/openaiStream.ts @@ -21,6 +21,20 @@ export interface OpenAIStreamOptions { model?: string; /** Reuse a caller-supplied completion id across all chunks; one is generated when omitted. */ id?: string; + /** + * Map a mid-stream backend error to an OpenAI error body for a final `data: {error}` + * SSE frame. Lets the v1 gateway reuse its `toOpenAIError` mapping without this generic + * formatter depending on the v1 layer. When omitted, a generic server_error body is emitted. + */ + formatError?: (err: unknown) => OpenAIErrorFrameBody; +} + +/** OpenAI streaming error body (`{ message, type, code, param }` under an `error` key). */ +export interface OpenAIErrorFrameBody { + message: string; + type: string; + code: string | null; + param: string | null; } interface OpenAIToolCallDelta { @@ -46,7 +60,7 @@ interface OpenAIChunk { /** SSE message envelope consumed by Harper's `text/event-stream` serializer. */ export interface OpenAIStreamMessage { - data: OpenAIChunk | string; + data: OpenAIChunk | { error: OpenAIErrorFrameBody } | string; } /** @@ -83,26 +97,39 @@ export async function* openaiStream( }, }); - for await (const token of tokens) { - if (token.deltaContent !== undefined) { - const delta: OpenAIDelta = {}; - if (!roleSent) { - delta.role = 'assistant'; - roleSent = true; + try { + for await (const token of tokens) { + if (token.deltaContent !== undefined) { + const delta: OpenAIDelta = {}; + if (!roleSent) { + delta.role = 'assistant'; + roleSent = true; + } + delta.content = token.deltaContent; + yield chunk(delta, null); } - delta.content = token.deltaContent; - yield chunk(delta, null); - } - if (token.deltaToolCalls) { - for (const incoming of token.deltaToolCalls) { - if (!incoming.id) continue; - const existing = toolAssembly.get(incoming.id) ?? { index: toolAssembly.size, arguments: {} }; - if (incoming.name) existing.name = incoming.name; - if (incoming.arguments) existing.arguments = { ...existing.arguments, ...incoming.arguments }; - toolAssembly.set(incoming.id, existing); + if (token.deltaToolCalls) { + for (const incoming of token.deltaToolCalls) { + if (!incoming.id) continue; + const existing = toolAssembly.get(incoming.id) ?? { index: toolAssembly.size, arguments: {} }; + if (incoming.name) existing.name = incoming.name; + if (incoming.arguments) existing.arguments = { ...existing.arguments, ...incoming.arguments }; + toolAssembly.set(incoming.id, existing); + } } + if (token.finishReason) finishReason = token.finishReason; } - if (token.finishReason) finishReason = token.finishReason; + } catch (err) { + // The backend can throw partway through the stream (Models#wrapStream re-throws + // mid-stream backend errors). Headers/200 are already flushed, so this can't be an + // HTTP error status — emit a final OpenAI-shaped `data: {error}` frame so SDK clients + // see a parseable error (matching the non-streaming path) instead of an abrupt socket + // close. OpenAI terminates the stream on error and sends no `[DONE]`, so we do the same. + const error = opts.formatError + ? opts.formatError(err) + : { message: 'Internal server error', type: 'server_error', code: null, param: null }; + yield { data: { error } }; + return; } if (toolAssembly.size > 0) { diff --git a/resources/models/v1/chatCompletions.ts b/resources/models/v1/chatCompletions.ts index b3aa258981..f120fabb0e 100644 --- a/resources/models/v1/chatCompletions.ts +++ b/resources/models/v1/chatCompletions.ts @@ -54,7 +54,11 @@ export class V1ChatCompletions extends Resource { // serializeStream wraps the async iterable in a Node Readable so REST.ts // can return it without re-serialising. The `body` presence on the return // value skips REST.ts's own serialize() call (REST.ts:165-193). - const readable = sseHandler.serializeStream(openaiStream(tokenStream, { model })); + // formatError reuses the non-streaming error mapping so a mid-stream backend + // failure reaches the client as an OpenAI-shaped SSE error frame. + const readable = sseHandler.serializeStream( + openaiStream(tokenStream, { model, formatError: (err) => toOpenAIError(err).data.error }) + ); return { status: 200, headers: { diff --git a/resources/models/v1/embeddings.ts b/resources/models/v1/embeddings.ts index 0cea1cf993..fd0040d79d 100644 --- a/resources/models/v1/embeddings.ts +++ b/resources/models/v1/embeddings.ts @@ -10,6 +10,11 @@ import { models } from '../Models.ts'; import { toOpenAIError, badRequest, authorizeV1Request } from './errors.ts'; import { toEmbedOpts, toEmbedResponse } from './translation.ts'; +// Cap batched input, matching OpenAI's own 2048-item limit. The endpoint is +// super_user-only and off by default, so this is a sanity bound (avoid an +// unbounded fan-out to the backend), not a security control. +const MAX_EMBEDDING_INPUTS = 2048; + // @ts-ignore — Resource base class is not typed for static dispatch; pattern mirrors login.ts export class V1Embeddings extends Resource { static async post(_target: unknown, body: Record, request: unknown) { @@ -32,6 +37,9 @@ export class V1Embeddings extends Resource { if (Array.isArray(input) && !input.every((v) => typeof v === 'string')) { return badRequest("'input' array elements must be strings"); } + if (Array.isArray(input) && input.length > MAX_EMBEDDING_INPUTS) { + return badRequest(`'input' array must not exceed ${MAX_EMBEDDING_INPUTS} items`); + } const model = typeof raw.model === 'string' ? raw.model : 'default'; const opts = toEmbedOpts(raw as any); diff --git a/unitTests/resources/models/openaiStream.test.js b/unitTests/resources/models/openaiStream.test.js index f73a617fb5..e16d1a72e8 100644 --- a/unitTests/resources/models/openaiStream.test.js +++ b/unitTests/resources/models/openaiStream.test.js @@ -134,4 +134,48 @@ describe('openaiStream', () => { assert.ok(ids[0].startsWith('chatcmpl-')); assert.ok(new Set(ids).size === 1, 'id must be identical across every chunk'); }); + + // A backend that throws partway through the stream (Models#wrapStream re-throws + // mid-stream errors). The loop must convert that into a final data:{error} frame, + // not let the throw propagate and tear the connection down. + async function* throwingGen() { + yield { deltaContent: 'partial' }; + throw Object.assign(new Error('backend exploded'), { statusCode: 502 }); + } + + it('emits a formatError-shaped error frame and no [DONE] when the backend throws mid-stream', async () => { + const msgs = await collect( + openaiStream(throwingGen(), { + formatError: (err) => ({ message: err.message, type: 'server_error', code: 'backend_error', param: null }), + }) + ); + // the partial content chunk streamed before the throw + assert.equal(msgs[0].data.choices[0].delta.content, 'partial'); + const last = msgs[msgs.length - 1].data; + assert.ok(last.error, 'expected a terminal error frame'); + assert.equal(last.error.message, 'backend exploded'); + assert.equal(last.error.type, 'server_error'); + assert.equal(last.error.code, 'backend_error'); + // OpenAI terminates on error — no [DONE] sentinel after the error frame + assert.ok(!msgs.some((m) => m.data === '[DONE]'), 'must not emit [DONE] after a mid-stream error'); + }); + + it('falls back to a generic server_error frame when no formatError is supplied', async () => { + const msgs = await collect(openaiStream(throwingGen())); + const last = msgs[msgs.length - 1].data; + assert.equal(last.error.type, 'server_error'); + assert.equal(last.error.message, 'Internal server error'); + }); + + it('serializes the error frame through the SSE serializer as a data: line', async () => { + const msgs = await collect( + openaiStream(throwingGen(), { + formatError: () => ({ message: 'x', type: 'server_error', code: null, param: null }), + }) + ); + const errFrame = msgs.find((m) => typeof m.data === 'object' && m.data.error); + const wire = sse.serialize(errFrame); + assert.ok(wire.startsWith('data: '), `unexpected SSE framing: ${wire}`); + assert.ok(wire.includes('"error"')); + }); }); diff --git a/unitTests/resources/models/v1/embeddings.test.js b/unitTests/resources/models/v1/embeddings.test.js index 5c91876954..a9e25d023f 100644 --- a/unitTests/resources/models/v1/embeddings.test.js +++ b/unitTests/resources/models/v1/embeddings.test.js @@ -61,4 +61,18 @@ describe('V1Embeddings.post', () => { const result = await V1Embeddings.post(undefined, 'not an object', {}); assert.equal(result.status, 401, 'auth must be checked ahead of body validation'); }); + + it('accepts a batch at the 2048-item cap', async () => { + const body = { input: Array.from({ length: 2048 }, (_, i) => `item ${i}`) }; + const result = await V1Embeddings.post(undefined, body, { user: SUPER_USER }); + assert.equal(result.object, 'list'); + assert.equal(result.data.length, 2048); + }); + + it('rejects a batch over the 2048-item cap with a 400 OpenAI-shape envelope', async () => { + const body = { input: Array.from({ length: 2049 }, (_, i) => `item ${i}`) }; + const result = await V1Embeddings.post(undefined, body, { user: SUPER_USER }); + assert.equal(result.status, 400); + assert.equal(result.data.error.type, 'invalid_request_error'); + }); }); From 6e8cbc77e23eb9be7fd9096b867ff2f6a0bbe8df Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Thu, 23 Jul 2026 20:27:16 -0700 Subject: [PATCH 21/39] =?UTF-8?q?refactor(models):=20make=20the=20/v1=20ga?= =?UTF-8?q?teway=20a=20clean=20plugin=20=E2=80=94=20remove=20core=20specia?= =?UTF-8?q?l-casing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway had reached into core twice: componentLoader carried a branch naming `modelsGateway` by hand, and REST.ts grew an exported `ensureStarted()` that existed solely so the gateway could force REST to start. Beyond the layering problem, that forced start ran before application configs load, so an app's own `rest` options (webSocket, urlPath/host, middleware ordering) were silently discarded whenever the gateway was enabled — @kriszyp's finding. - server/REST.ts is restored byte-identical to main; `ensureStarted` is gone. - The componentLoader branch is removed; core no longer knows this feature by name. What remains is a lazy registry getter mirroring the existing `fastifyRoutes` precedent, so an install that never enables the gateway pays no module-load cost (also addresses the static-import review finding). - The gateway now documents that it requires a `rest` section rather than forcing one; the integration test declares one as a real deployment would. Core has no supported way for a component to declare "I serve REST resources"; closing that gap properly — including the after-app-load ordering — is tracked separately rather than worked around here. Verified by booting a real instance both ways: with `rest` configured GET /v1/models returns 200; without it, 404 (confirming the gateway no longer self-activates REST). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RXx78W5LxbvxEdSyrB1vvn --- components/componentLoader.ts | 19 +++++++------------ integrationTests/server/v1-gateway.test.ts | 6 ++++++ resources/models/v1/index.ts | 9 ++++++--- server/REST.ts | 22 ++++------------------ 4 files changed, 23 insertions(+), 33 deletions(-) diff --git a/components/componentLoader.ts b/components/componentLoader.ts index 4928afc01a..0a94e29002 100644 --- a/components/componentLoader.ts +++ b/components/componentLoader.ts @@ -20,7 +20,6 @@ import * as graphqlQueryHandler from '../server/graphqlQuerying.ts'; import * as roles from '../resources/roles.ts'; import * as jsHandler from '../resources/jsResource.ts'; import * as login from '../resources/login.ts'; -import * as modelsGateway from '../resources/models/v1/index.ts'; import * as REST from '../server/REST.ts'; import * as staticFiles from '../server/static.ts'; import * as loadEnv from '../resources/loadEnv.ts'; @@ -114,7 +113,13 @@ export const TRUSTED_RESOURCE_PLUGINS: any = { return require('../server/fastifyRoutes'); }, login, - modelsGateway, + // Lazy: the gateway is opt-in and off by default, so an install that never enables it + // pays no module-load cost for the gateway's graph in the main process or any worker. + get modelsGateway() { + // Extensionless, like the other require()s here: this path is resolved at runtime + // against dist/, where the emitted file is .js (TypeScript does not rewrite require). + return require('../resources/models/v1/index'); + }, static: staticFiles, customFunctions: {}, http: httpComponent, @@ -675,16 +680,6 @@ export async function loadComponent( } } - // The /v1 models gateway registers REST-served resources (#631), but REST's chain - // only activates via a `rest`/`REST` config section, and a bare instance (no apps) - // has none — leaving the gateway's resources registered but unservable. Activate - // REST with default options here, after every root plugin has loaded: if any - // `rest`/`REST` section exists — whatever its key order — its handleApplication - // already ran with the user's options and this is a no-op. - if (isRoot && resources.isWorker && (config as any).modelsGateway?.enabled) { - REST.ensureStarted({ server, resources }); - } - compName = parentCompName; if (isMainThread && !watchesSetup && autoReload) { let debounceTimer: ReturnType | null = null; diff --git a/integrationTests/server/v1-gateway.test.ts b/integrationTests/server/v1-gateway.test.ts index da1b81a231..87362b8e16 100644 --- a/integrationTests/server/v1-gateway.test.ts +++ b/integrationTests/server/v1-gateway.test.ts @@ -49,6 +49,12 @@ suite('OpenAI /v1/* gateway (modelsGateway)', (ctx: ContextWithHarper) => { before(async () => { await startHarper(ctx, { config: { + // The gateway's resources are served by REST's middleware chain, and the + // gateway does not force REST to start (see resources/models/v1/index.ts). + // defaultConfig.yaml ships no `rest` section, so a bare instance needs one + // declared here — exactly as a real deployment would. `webSocket` is a leaf + // value because a plain empty object is dropped by flattenObject(). + rest: { webSocket: true }, // Gateway is off by default (enabled: false in defaultConfig.yaml). Pass // enabled: true explicitly to activate it for these tests. A plain empty // object would be silently dropped by flattenObject() in harperConfigEnvVars.ts diff --git a/resources/models/v1/index.ts b/resources/models/v1/index.ts index 6b12fa2570..4a304c75a5 100644 --- a/resources/models/v1/index.ts +++ b/resources/models/v1/index.ts @@ -33,9 +33,12 @@ import { V1Models } from './models.ts'; export function handleApplication(scope: Scope): void { if (!scope.options.get(['enabled'])) return; - // These resources are served by REST's middleware chain, which only activates via a - // `rest`/`REST` config section. Bare instances have none, so componentLoader calls - // REST.ensureStarted after root plugin loading when the gateway is enabled (#631). + // These resources are served by REST's middleware chain, so the instance must also + // have a `rest`/`REST` config section — the gateway deliberately does NOT force REST + // to start. Doing so requires reaching into REST's module state before application + // configs have loaded, which silently discards an app's own `rest` options (webSocket, + // urlPath/host, middleware ordering). Core has no supported way yet for a component to + // declare "I serve REST resources"; that gap is tracked separately. scope.resources.set('v1/models', V1Models); scope.resources.set('v1/embeddings', V1Embeddings); scope.resources.set('v1/chat/completions', V1ChatCompletions); diff --git a/server/REST.ts b/server/REST.ts index c2a3e9545b..1c157bea6c 100644 --- a/server/REST.ts +++ b/server/REST.ts @@ -399,29 +399,15 @@ let addedMetrics; let connectionCount = 0; export function handleApplication(scope: import('../components/Scope.ts').Scope) { - httpOptions = scope.options.getAll() ?? {}; + httpOptions = scope.options.getAll(); if ((httpOptions as any).includeExpensiveRecordCountEstimates) { // If they really want to enable expensive record count estimates (Request.prototype as any).includeExpensiveRecordCountEstimates = true; } - ensureStarted(scope); -} - -/** - * Idempotently register REST's HTTP/WS handlers on the middleware chain. The component - * loader calls this after all root plugins have loaded, so core-registered REST resources - * (e.g. the /v1 models gateway, #631) are servable on instances whose config has no - * `rest`/`REST` section. When one exists, its `handleApplication` has already run with the - * user's options and this is a no-op — activation is independent of config key order. - */ -export function ensureStarted({ - server, - resources: scopeResources, -}: Pick) { - resources = scopeResources; + resources = scope.resources; if (started) return; started = true; - server.http( + scope.server.http( async (request: any, nextHandler) => { if (request.isWebSocket) return; return http(request, nextHandler); @@ -429,7 +415,7 @@ export function ensureStarted({ { after: 'authentication', ...(httpOptions as any) } ); if ((httpOptions as any).webSocket === false) return; - server.ws( + scope.server.ws( async (ws, request: any, chainCompletion) => { connectionCount++; const incomingMessages = new IterableEventQueue(); From 1aab4a995d59eeb0b3f225e0a2149e31bd47dc81 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Thu, 23 Jul 2026 20:49:54 -0700 Subject: [PATCH 22/39] fix(models): honor tool_choice, unwrap json_schema, bound tool assembly, serve explicit SSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the remaining plugin-local findings from @kriszyp's review on #1616. - response_format json_schema: the wire value is a `{ name, strict, schema }` wrapper, but Harper's contract wants the schema itself. Passing the wrapper made the backend wrap it again and send metadata where the provider expects the JSON Schema. Now extracted, and the unit test uses the real wrapper shape. - Malformed nested wire shapes (`messages:[null]`, `tool_calls:[{}]`, `tools:[{}]`, non-array `tools`) threw TypeErrors inside the mappers, escaping as RFC 9457 500s. A shared `validateChatRequest` now rejects them with an OpenAI 400 before mapping; the mapping moved inside the error boundary, and a rejected body promise (malformed JSON) is a 400 rather than a 500. - tool_choice was advertised but never read: `none`, `required` and named selection all behaved as `auto` with every tool still forwarded. `'none'` now omits tools entirely, and choices the internal contract cannot represent return a clear 400 instead of being silently downgraded. - openaiStream tool assembly re-spread every accumulated argument on each partial delta (O(n²)) and grew `toolAssembly` without a cap on a public HTTP path. Now mutates via Object.assign with per-stream call-count and argument-field bounds, terminating through the sanitized error-frame path. - A client sending an explicit `Accept: text/event-stream` is dispatched by REST as CONNECT, which this Resource did not implement — so valid SSE clients got method-not-allowed (the OpenAI SDK only escapes this by always sending application/json). `connect()` now delegates to the same `post()` implementation, recovering the body REST passes as null. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RXx78W5LxbvxEdSyrB1vvn --- integrationTests/server/v1-gateway.test.ts | 47 ++++++++++ resources/models/openaiStream.ts | 38 +++++++- resources/models/v1/chatCompletions.ts | 51 ++++++++--- resources/models/v1/translation.ts | 78 ++++++++++++++-- .../models/v1/chatCompletions.test.js | 89 +++++++++++++++++++ .../resources/models/v1/translation.test.js | 73 ++++++++++++++- 6 files changed, 355 insertions(+), 21 deletions(-) diff --git a/integrationTests/server/v1-gateway.test.ts b/integrationTests/server/v1-gateway.test.ts index 87362b8e16..2e57143930 100644 --- a/integrationTests/server/v1-gateway.test.ts +++ b/integrationTests/server/v1-gateway.test.ts @@ -188,6 +188,53 @@ suite('OpenAI /v1/* gateway (modelsGateway)', (ctx: ContextWithHarper) => { assert.equal(body.error.type, 'invalid_request_error'); }); + test('POST /v1/chat/completions returns 400 (not 500) for malformed nested wire shapes', async () => { + // Each of these used to throw a TypeError inside the mappers, surfacing as an + // RFC 9457 500 rather than an OpenAI 400. + const malformed: Array<[string, unknown]> = [ + ['null message element', { model: 'default', messages: [null] }], + [ + 'tool_calls entry with no function', + { model: 'default', messages: [{ role: 'assistant', content: null, tool_calls: [{}] }] }, + ], + ['tools entry with no function', { model: 'default', messages: [{ role: 'user', content: 'hi' }], tools: [{}] }], + ]; + for (const [label, payload] of malformed) { + const res = await harperFetch(ctx, restUrl(ctx, '/v1/chat/completions'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify(payload), + }); + assert.equal(res.status, 400, `${label}: expected 400, got ${res.status}`); + const body = (await res.json()) as { error: { type: string } }; + assert.equal(body.error.type, 'invalid_request_error', `${label}: wrong error type`); + } + }); + + // ----------------------------------------------------------------------- + // Explicit SSE Accept header — dispatched as CONNECT by REST, not POST + // ----------------------------------------------------------------------- + + test('an SSE client sending exact Accept: text/event-stream is served, not method-not-allowed', async () => { + // REST rewrites POST + `Accept: text/event-stream` to CONNECT. The OpenAI SDK dodges + // this by always sending application/json, but other valid SSE clients do not — this + // exercises the connect() delegation. + const res = await harperFetch(ctx, restUrl(ctx, '/v1/chat/completions'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }, + body: JSON.stringify({ + model: 'default', + messages: [{ role: 'user', content: 'tell me something' }], + stream: true, + }), + }); + assert.notEqual(res.status, 405, 'explicit SSE Accept must not be method-not-allowed'); + assert.equal(res.status, 200, `expected 200, got ${res.status}`); + const text = await res.text(); + assert.ok(text.includes('data:'), `expected SSE data frames, got: ${text.slice(0, 200)}`); + assert.ok(text.includes('[echo stream]'), 'expected the fixture stream content'); + }); + // ----------------------------------------------------------------------- // POST /v1/chat/completions — streaming via real OpenAI SDK // ----------------------------------------------------------------------- diff --git a/resources/models/openaiStream.ts b/resources/models/openaiStream.ts index 81b81aea35..19acc5bb96 100644 --- a/resources/models/openaiStream.ts +++ b/resources/models/openaiStream.ts @@ -29,6 +29,23 @@ export interface OpenAIStreamOptions { formatError?: (err: unknown) => OpenAIErrorFrameBody; } +// Bounds on per-stream tool-call assembly. The backend supplies both the call ids and the +// argument fields, and this runs on a public HTTP path, so neither can be unbounded. Overflow +// terminates the stream through the same sanitized error-frame path as any backend failure. +const MAX_TOOL_CALLS_PER_STREAM = 256; +const MAX_TOOL_ARGUMENT_KEYS = 1024; + +/** Signals that a stream exceeded the tool-assembly bounds; surfaced as an SSE error frame. */ +class ToolAssemblyOverflowError extends Error { + statusCode = 502; +} + +function countKeys(obj: object): number { + let n = 0; + for (const _ in obj) n++; + return n; +} + /** OpenAI streaming error body (`{ message, type, code, param }` under an `error` key). */ export interface OpenAIErrorFrameBody { message: string; @@ -111,10 +128,25 @@ export async function* openaiStream( if (token.deltaToolCalls) { for (const incoming of token.deltaToolCalls) { if (!incoming.id) continue; - const existing = toolAssembly.get(incoming.id) ?? { index: toolAssembly.size, arguments: {} }; + let existing = toolAssembly.get(incoming.id); + if (!existing) { + // Cap distinct calls per stream: ids come from the backend, and an + // unbounded map on a public HTTP path is a memory risk. + if (toolAssembly.size >= MAX_TOOL_CALLS_PER_STREAM) { + throw new ToolAssemblyOverflowError(`stream exceeded ${MAX_TOOL_CALLS_PER_STREAM} tool calls`); + } + existing = { index: toolAssembly.size, arguments: {} }; + toolAssembly.set(incoming.id, existing); + } if (incoming.name) existing.name = incoming.name; - if (incoming.arguments) existing.arguments = { ...existing.arguments, ...incoming.arguments }; - toolAssembly.set(incoming.id, existing); + if (incoming.arguments) { + // Mutate rather than re-spread: spreading copied every previously + // accumulated property on each partial delta (O(n²) as fields grow). + Object.assign(existing.arguments, incoming.arguments); + if (countKeys(existing.arguments) > MAX_TOOL_ARGUMENT_KEYS) { + throw new ToolAssemblyOverflowError(`tool call arguments exceeded ${MAX_TOOL_ARGUMENT_KEYS} fields`); + } + } } } if (token.finishReason) finishReason = token.finishReason; diff --git a/resources/models/v1/chatCompletions.ts b/resources/models/v1/chatCompletions.ts index f120fabb0e..be4fdbbaec 100644 --- a/resources/models/v1/chatCompletions.ts +++ b/resources/models/v1/chatCompletions.ts @@ -17,7 +17,14 @@ import { Resource } from '../../Resource.ts'; import { models } from '../Models.ts'; import { openaiStream } from '../openaiStream.ts'; import { toOpenAIError, badRequest, authorizeV1Request } from './errors.ts'; -import { translateMessages, translateTools, toGenerateInput, toGenerateOpts, toChatCompletion } from './translation.ts'; +import { + translateMessages, + translateTools, + toGenerateInput, + toGenerateOpts, + toChatCompletion, + validateChatRequest, +} from './translation.ts'; import type { OAIChatRequest } from './translation.ts'; type SseHandler = { serializeStream: (iterable: AsyncIterable) => Readable }; @@ -31,24 +38,34 @@ export class V1ChatCompletions extends Resource { // REST.ts passes `request.data` directly, which is the (unawaited) streaming // JSON deserializer's Promise — awaiting here is a no-op for callers (e.g. - // unit tests) that already pass a plain object. - body = await body; + // unit tests) that already pass a plain object. A malformed JSON body rejects + // this promise, which is a client error, not a 500. + try { + body = await body; + } catch (err) { + return badRequest(`Could not parse request body: ${err instanceof Error ? err.message : 'invalid JSON'}`); + } if (!body || typeof body !== 'object' || Array.isArray(body)) { return badRequest('Request body must be a JSON object'); } const req = body as OAIChatRequest; - if (!Array.isArray(req.messages) || req.messages.length === 0) { - return badRequest("'messages' must be a non-empty array"); - } + // Validate the nested wire shapes before mapping: the mappers assume well-formed + // input, so an unvalidated `messages:[null]` / `tools:[{}]` would throw a TypeError + // and surface as an RFC 9457 500 instead of an OpenAI 400. + const invalid = validateChatRequest(req); + if (invalid) return badRequest(invalid); const model = typeof req.model === 'string' ? req.model : 'default'; - const messages = translateMessages(req.messages); - const tools = req.tools?.length ? translateTools(req.tools) : undefined; - const input = toGenerateInput(messages, tools); - const opts = toGenerateOpts(req); try { + const messages = translateMessages(req.messages); + // tool_choice: 'none' means "do not call tools" — the only faithful way to honor + // that against a returns-tool-calls backend is to not offer the tools at all. + // 'required'/named selection are rejected in validateChatRequest. + const tools = req.tool_choice === 'none' || !req.tools?.length ? undefined : translateTools(req.tools); + const input = toGenerateInput(messages, tools); + const opts = toGenerateOpts(req); if (req.stream) { const tokenStream = models.generateStream(input, opts); // serializeStream wraps the async iterable in a Node Readable so REST.ts @@ -76,4 +93,18 @@ export class V1ChatCompletions extends Resource { return toOpenAIError(err); } } + + /** + * A client that sends an explicit `Accept: text/event-stream` with its POST is + * dispatched by REST as CONNECT (REST.ts), not POST — so without this the request + * would get method-not-allowed. The OpenAI SDK happens to send + * `Accept: application/json` even when streaming, but other valid SSE clients do not. + * + * REST passes `null` as the CONNECT body (`resource.connect(target, null, request)`), + * so the parsed body is taken off the request and handed to the same `post()` + * implementation — one code path, identical validation and error shaping. + */ + static async connect(target: unknown, _data: unknown, request: unknown) { + return this.post(target, (request as { data?: unknown })?.data, request); + } } diff --git a/resources/models/v1/translation.ts b/resources/models/v1/translation.ts index 42ffe9a02d..ef7c56ef7f 100644 --- a/resources/models/v1/translation.ts +++ b/resources/models/v1/translation.ts @@ -110,13 +110,76 @@ export function toGenerateInput(messages: Message[], tools: ToolDef[] | undefine return messages; } +/** `tool_choice` values the internal contract can faithfully represent. */ +function isRepresentableToolChoice(choice: unknown): boolean { + return choice === undefined || choice === null || choice === 'auto' || choice === 'none'; +} + +/** + * Validate the OpenAI wire shapes this gateway maps, returning a client-facing + * message for a 400 or `null` when the request is well-formed. + * + * Kept separate from the mappers so malformed nested input (`messages: [null]`, + * `tool_calls: [{}]`, `tools: [{}]`) becomes an OpenAI-shaped 400 instead of a + * TypeError escaping the handler as an RFC 9457 500. + */ +export function validateChatRequest(body: OAIChatRequest): string | null { + const req = body as any; + if (!Array.isArray(req.messages) || req.messages.length === 0) return "'messages' must be a non-empty array"; + for (let i = 0; i < req.messages.length; i++) { + const m = req.messages[i]; + if (!m || typeof m !== 'object' || Array.isArray(m)) return `'messages[${i}]' must be an object`; + if (typeof m.role !== 'string') return `'messages[${i}].role' must be a string`; + if (m.tool_calls !== undefined) { + if (!Array.isArray(m.tool_calls)) return `'messages[${i}].tool_calls' must be an array`; + for (let j = 0; j < m.tool_calls.length; j++) { + const tc = m.tool_calls[j]; + const at = `'messages[${i}].tool_calls[${j}]`; + if (!tc || typeof tc !== 'object') return `${at}' must be an object`; + if (!tc.function || typeof tc.function !== 'object') return `${at}.function' is required`; + if (typeof tc.function.name !== 'string') return `${at}.function.name' must be a string`; + if (typeof tc.function.arguments !== 'string') return `${at}.function.arguments' must be a JSON string`; + } + } + } + if (req.tools !== undefined) { + if (!Array.isArray(req.tools)) return "'tools' must be an array"; + for (let i = 0; i < req.tools.length; i++) { + const t = req.tools[i]; + if (!t || typeof t !== 'object') return `'tools[${i}]' must be an object`; + if (!t.function || typeof t.function !== 'object') return `'tools[${i}].function' is required`; + if (typeof t.function.name !== 'string') return `'tools[${i}].function.name' must be a string`; + } + } + if (!isRepresentableToolChoice(req.tool_choice)) { + // Better a clear 400 than silently downgrading 'required'/named selection to 'auto'. + return "'tool_choice' supports 'auto' and 'none'; 'required' and named function selection are not supported yet"; + } + if (req.response_format !== undefined) { + const rf = req.response_format; + if (!rf || typeof rf !== 'object' || Array.isArray(rf)) return "'response_format' must be an object"; + if (rf.type === 'json_schema') { + const wrapper = rf.json_schema; + if (!wrapper || typeof wrapper !== 'object') { + return "'response_format.json_schema' is required when type is 'json_schema'"; + } + if (!wrapper.schema || typeof wrapper.schema !== 'object') { + return "'response_format.json_schema.schema' must be a JSON Schema object"; + } + } + } + return null; +} + /** * Map an OpenAI chat-completion request body to `GenerateOpts`. * - * `tool_choice: 'auto' | 'required'` both map to `toolMode: 'return'` — - * full in-process tool-call orchestration is tracked in #612 (out of scope - * for #631). The caller still receives `finish_reason: 'tool_calls'` and - * may invoke tools itself. + * `tool_choice` is honored by the caller, not here: `'none'` omits tools from the + * generate input entirely, and unrepresentable choices are rejected up front by + * `validateChatRequest`. Full in-process tool-call orchestration is #612 (out of + * scope for #631), so tool calls are always returned to the caller to invoke. + * + * Assumes `validateChatRequest` has already passed. */ export function toGenerateOpts(body: OAIChatRequest): GenerateOpts { const opts: GenerateOpts = { toolMode: 'return' }; @@ -128,8 +191,11 @@ export function toGenerateOpts(body: OAIChatRequest): GenerateOpts { const rf = body.response_format; if (rf.type === 'json_object') { opts.responseFormat = 'json'; - } else if (rf.type === 'json_schema' && rf.json_schema) { - opts.responseFormat = { schema: rf.json_schema as object }; + } else if (rf.type === 'json_schema') { + // The wire value is a wrapper ({ name, strict, schema }); Harper's contract wants + // the JSON Schema itself. Passing the wrapper makes the backend wrap it again and + // send metadata where the provider expects the schema. + opts.responseFormat = { schema: (rf.json_schema as { schema: object }).schema }; } else { opts.responseFormat = 'text'; } diff --git a/unitTests/resources/models/v1/chatCompletions.test.js b/unitTests/resources/models/v1/chatCompletions.test.js index 99c8b45f8c..417c130994 100644 --- a/unitTests/resources/models/v1/chatCompletions.test.js +++ b/unitTests/resources/models/v1/chatCompletions.test.js @@ -70,4 +70,93 @@ describe('V1ChatCompletions.post', () => { const result = await V1ChatCompletions.post(undefined, 'not an object', {}); assert.equal(result.status, 401, 'auth must be checked ahead of body validation'); }); + + // Malformed nested shapes must become OpenAI-shaped 400s, not TypeErrors escaping as 500s + const malformed = { + 'a null message element': { messages: [null] }, + 'a tool_calls entry with no function': { + messages: [{ role: 'assistant', content: null, tool_calls: [{}] }], + }, + 'a tools entry with no function': { messages: [{ role: 'user', content: 'hi' }], tools: [{}] }, + 'a non-array tools': { messages: [{ role: 'user', content: 'hi' }], tools: 'nope' }, + }; + for (const [label, body] of Object.entries(malformed)) { + it(`returns a 400 OpenAI envelope for ${label}`, async () => { + const result = await V1ChatCompletions.post(undefined, body, { user: SUPER_USER }); + assert.equal(result.status, 400, `expected 400 for ${label}`); + assert.equal(result.data.error.type, 'invalid_request_error'); + }); + } + + it('returns a 400 when the body promise rejects (malformed JSON), not a 500', async () => { + const result = await V1ChatCompletions.post(undefined, Promise.reject(new SyntaxError('Unexpected token')), { + user: SUPER_USER, + }); + assert.equal(result.status, 400); + assert.equal(result.data.error.type, 'invalid_request_error'); + }); + + it("rejects tool_choice 'required' rather than silently treating it as auto", async () => { + const body = { messages: [{ role: 'user', content: 'hi' }], tool_choice: 'required' }; + const result = await V1ChatCompletions.post(undefined, body, { user: SUPER_USER }); + assert.equal(result.status, 400); + assert.match(result.data.error.message, /tool_choice/); + }); + + /** Backend that records the exact input it was asked to generate from. */ + function recordingBackend(record) { + return { + name: 'recorder', + capabilities: () => ({ embed: false, generate: true, stream: false, tools: true, adapters: false }), + async generate(input) { + record(input); + return { status: 'completed', output: { content: 'ok', finishReason: 'stop' }, usage: {} }; + }, + }; + } + + it("omits tools entirely for tool_choice 'none' so the backend cannot return a tool call", async () => { + let seen; + setGenerative( + 'default', + recordingBackend((i) => (seen = i)) + ); + const body = { + messages: [{ role: 'user', content: 'hi' }], + tool_choice: 'none', + tools: [{ type: 'function', function: { name: 'get_weather' } }], + }; + const result = await V1ChatCompletions.post(undefined, body, { user: SUPER_USER }); + assert.equal(result.object, 'chat.completion'); + // No tools → plain Message[] input rather than the { messages, tools } object form + assert.ok(Array.isArray(seen), 'tools must not be forwarded when tool_choice is none'); + }); + + it("still forwards tools for tool_choice 'auto'", async () => { + let seen; + setGenerative( + 'default', + recordingBackend((i) => (seen = i)) + ); + const body = { + messages: [{ role: 'user', content: 'hi' }], + tool_choice: 'auto', + tools: [{ type: 'function', function: { name: 'get_weather' } }], + }; + await V1ChatCompletions.post(undefined, body, { user: SUPER_USER }); + assert.ok(!Array.isArray(seen) && seen.tools?.length === 1, 'tools should be offered for auto'); + }); + + // A client sending an explicit `Accept: text/event-stream` is dispatched as CONNECT by + // REST, which passes a null body — connect() must recover it from the request. + it('connect() delegates to the same implementation, taking the body off the request', async () => { + const body = { messages: [{ role: 'user', content: 'hi' }] }; + const result = await V1ChatCompletions.connect(undefined, null, { user: SUPER_USER, data: body }); + assert.equal(result.object, 'chat.completion'); + }); + + it('connect() applies the same auth gate', async () => { + const result = await V1ChatCompletions.connect(undefined, null, { data: { messages: [] } }); + assert.equal(result.status, 401); + }); }); diff --git a/unitTests/resources/models/v1/translation.test.js b/unitTests/resources/models/v1/translation.test.js index c27371cc15..f0fbea7ce9 100644 --- a/unitTests/resources/models/v1/translation.test.js +++ b/unitTests/resources/models/v1/translation.test.js @@ -12,6 +12,7 @@ const { translateTools, toGenerateInput, toGenerateOpts, + validateChatRequest, toEmbedOpts, toChatCompletion, toEmbedResponse, @@ -154,13 +155,17 @@ describe('toGenerateOpts', () => { assert.equal(opts.responseFormat, 'json'); }); - it('maps response_format json_schema to { schema }', () => { + it('extracts the inner schema from the real OpenAI json_schema wrapper', () => { + // The wire value is { name, strict, schema } — passing the whole wrapper through + // would make the backend wrap it again and send metadata where the provider + // expects the JSON Schema itself. const schema = { type: 'object', properties: {} }; const opts = toGenerateOpts({ - response_format: { type: 'json_schema', json_schema: schema }, + response_format: { type: 'json_schema', json_schema: { name: 'my_schema', strict: true, schema } }, messages: [], }); assert.deepEqual(opts.responseFormat, { schema }); + assert.equal(opts.responseFormat.schema.name, undefined, 'must not carry wrapper metadata'); }); it('maps response_format text to text', () => { @@ -174,6 +179,70 @@ describe('toGenerateOpts', () => { }); }); +// --------------------------------------------------------------------------- +// validateChatRequest — malformed wire shapes must become 400s, not TypeErrors +// --------------------------------------------------------------------------- + +describe('validateChatRequest', () => { + const ok = { messages: [{ role: 'user', content: 'hi' }] }; + + it('accepts a well-formed request', () => { + assert.equal(validateChatRequest(ok), null); + }); + + it('rejects a missing or empty messages array', () => { + assert.ok(validateChatRequest({})); + assert.ok(validateChatRequest({ messages: [] })); + assert.ok(validateChatRequest({ messages: 'nope' })); + }); + + // Each of these previously threw a TypeError inside the mappers → RFC 9457 500 + it('rejects a null message element instead of throwing', () => { + const msg = validateChatRequest({ messages: [null] }); + assert.match(msg, /messages\[0\]/); + }); + + it('rejects a tool_calls entry with no function', () => { + const msg = validateChatRequest({ messages: [{ role: 'assistant', content: null, tool_calls: [{}] }] }); + assert.match(msg, /tool_calls\[0\]\.function/); + }); + + it('rejects non-string tool_calls arguments (mapper assumes a JSON string)', () => { + const msg = validateChatRequest({ + messages: [ + { role: 'assistant', content: null, tool_calls: [{ id: 'a', function: { name: 'f', arguments: {} } }] }, + ], + }); + assert.match(msg, /arguments/); + }); + + it('rejects a non-array tools and a tools entry with no function', () => { + assert.match(validateChatRequest({ ...ok, tools: 'nope' }), /'tools'/); + assert.match(validateChatRequest({ ...ok, tools: [{}] }), /tools\[0\]\.function/); + }); + + it("accepts tool_choice 'auto' and 'none'", () => { + assert.equal(validateChatRequest({ ...ok, tool_choice: 'auto' }), null); + assert.equal(validateChatRequest({ ...ok, tool_choice: 'none' }), null); + }); + + it("rejects tool_choice values the internal contract can't represent, rather than silently downgrading", () => { + assert.match(validateChatRequest({ ...ok, tool_choice: 'required' }), /tool_choice/); + assert.match( + validateChatRequest({ ...ok, tool_choice: { type: 'function', function: { name: 'f' } } }), + /tool_choice/ + ); + }); + + it('rejects a json_schema response_format missing the inner schema', () => { + assert.match(validateChatRequest({ ...ok, response_format: { type: 'json_schema' } }), /json_schema/); + assert.match( + validateChatRequest({ ...ok, response_format: { type: 'json_schema', json_schema: { name: 'x' } } }), + /json_schema\.schema/ + ); + }); +}); + // --------------------------------------------------------------------------- // toEmbedOpts // --------------------------------------------------------------------------- From 7b150b87ba83dd7e52ab339a4f0ad23116e6e4c8 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Thu, 23 Jul 2026 21:15:44 -0700 Subject: [PATCH 23/39] fix(models): null-prototype tool-argument accumulator; reassemble SSE deltas in test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from cross-model review and CI on the previous commit. - The switch from spread to Object.assign (to kill the O(n^2) re-copy) changed the write semantics: spread uses CreateDataProperty, Object.assign uses [[Set]]. Tool arguments come from JSON.parse, where a field literally named `__proto__` is an own property — so on an ordinary object Object.assign hit Object.prototype's inherited setter and silently dropped it. Verified: an ordinary accumulator yields {"safe":1}, a null-prototype one preserves the field. Accumulator is now Object.create(null), with a regression test. (Caught by the Codex leg.) - The explicit-SSE integration assertion could never pass: the fixture streams word by word, so `[echo stream]` is split across `data:` frames and never appears contiguously in the raw text. It now parses the frames and reassembles the deltas the way a client does. The endpoint itself was correct — verified against a live instance. (Caught by CI.) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RXx78W5LxbvxEdSyrB1vvn --- integrationTests/server/v1-gateway.test.ts | 17 ++++++++++- resources/models/openaiStream.ts | 6 +++- .../resources/models/openaiStream.test.js | 29 +++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/integrationTests/server/v1-gateway.test.ts b/integrationTests/server/v1-gateway.test.ts index 2e57143930..16b12ebeb5 100644 --- a/integrationTests/server/v1-gateway.test.ts +++ b/integrationTests/server/v1-gateway.test.ts @@ -230,9 +230,24 @@ suite('OpenAI /v1/* gateway (modelsGateway)', (ctx: ContextWithHarper) => { }); assert.notEqual(res.status, 405, 'explicit SSE Accept must not be method-not-allowed'); assert.equal(res.status, 200, `expected 200, got ${res.status}`); + assert.equal(res.headers.get('content-type'), 'text/event-stream'); const text = await res.text(); assert.ok(text.includes('data:'), `expected SSE data frames, got: ${text.slice(0, 200)}`); - assert.ok(text.includes('[echo stream]'), 'expected the fixture stream content'); + + // Reassemble the deltas the way a client does: the fixture streams word by word, so + // the content is split ACROSS frames and never appears contiguously in the raw text. + const content = text + .split('\n') + .filter((line) => line.startsWith('data: ') && !line.includes('[DONE]')) + .map((line) => { + try { + return JSON.parse(line.slice(6))?.choices?.[0]?.delta?.content ?? ''; + } catch { + return ''; + } + }) + .join(''); + assert.ok(content.includes('[echo stream]'), `unexpected reassembled content: ${content}`); }); // ----------------------------------------------------------------------- diff --git a/resources/models/openaiStream.ts b/resources/models/openaiStream.ts index 19acc5bb96..8d99ac21e5 100644 --- a/resources/models/openaiStream.ts +++ b/resources/models/openaiStream.ts @@ -135,7 +135,11 @@ export async function* openaiStream( if (toolAssembly.size >= MAX_TOOL_CALLS_PER_STREAM) { throw new ToolAssemblyOverflowError(`stream exceeded ${MAX_TOOL_CALLS_PER_STREAM} tool calls`); } - existing = { index: toolAssembly.size, arguments: {} }; + // Null-prototype: arguments come from JSON.parse, so a field literally + // named `__proto__` is an own property. Object.assign uses [[Set]], which + // on an ordinary object would hit Object.prototype's inherited `__proto__` + // setter and silently drop the field (the previous spread did not). + existing = { index: toolAssembly.size, arguments: Object.create(null) }; toolAssembly.set(incoming.id, existing); } if (incoming.name) existing.name = incoming.name; diff --git a/unitTests/resources/models/openaiStream.test.js b/unitTests/resources/models/openaiStream.test.js index e16d1a72e8..3c7b142f79 100644 --- a/unitTests/resources/models/openaiStream.test.js +++ b/unitTests/resources/models/openaiStream.test.js @@ -135,6 +135,35 @@ describe('openaiStream', () => { assert.ok(new Set(ids).size === 1, 'id must be identical across every chunk'); }); + it('accumulates tool arguments across partial deltas without re-copying', async () => { + const msgs = await collect( + openaiStream( + gen( + { deltaToolCalls: [{ id: 'c1', name: 'fn', arguments: { a: 1 } }] }, + { deltaToolCalls: [{ id: 'c1', arguments: { b: 2 } }] }, + { deltaToolCalls: [{ id: 'c1', arguments: { a: 3 } }] } + ) + ) + ); + const toolChunk = msgs.map((m) => m.data).find((d) => typeof d === 'object' && d.choices?.[0]?.delta?.tool_calls); + const args = JSON.parse(toolChunk.choices[0].delta.tool_calls[0].function.arguments); + assert.deepEqual(args, { a: 3, b: 2 }, 'later deltas must win, earlier fields preserved'); + }); + + it('stores a tool argument literally named __proto__ instead of hitting the prototype setter', async () => { + // Arguments arrive from JSON.parse, where `__proto__` is an own property. Object.assign + // uses [[Set]], so an ordinary accumulator would invoke Object.prototype's inherited + // setter and silently drop the field. + const incoming = JSON.parse('{"__proto__": {"polluted": true}, "safe": 1}'); + const msgs = await collect(openaiStream(gen({ deltaToolCalls: [{ id: 'c1', name: 'fn', arguments: incoming }] }))); + const toolChunk = msgs.map((m) => m.data).find((d) => typeof d === 'object' && d.choices?.[0]?.delta?.tool_calls); + const raw = toolChunk.choices[0].delta.tool_calls[0].function.arguments; + const args = JSON.parse(raw); + assert.equal(args.safe, 1); + assert.ok(raw.includes('__proto__'), `__proto__ field must survive serialization, got: ${raw}`); + assert.equal({}.polluted, undefined, 'must not pollute Object.prototype'); + }); + // A backend that throws partway through the stream (Models#wrapStream re-throws // mid-stream errors). The loop must convert that into a final data:{error} frame, // not let the throw propagate and tear the connection down. From 702d153f687271ba054c8501d2864e968f536774 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Thu, 23 Jul 2026 21:30:48 -0700 Subject: [PATCH 24/39] =?UTF-8?q?fix(models):=20address=20cross-model=20re?= =?UTF-8?q?view=20=E2=80=94=20registry=20specifier,=20symmetry,=20content?= =?UTF-8?q?=20parts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from the thorough review pass on the rework. - Registry entry is now the string `#src/resources/models/v1/index` rather than a lazy require() getter. The loader already await-import()s string plugin values, and `#src/*` resolves under both conditions (source under --conditions=typestrip, dist otherwise) — the extensionless relative require only resolved against dist, which was a one-entry regression from the previous static `.ts` import. Also drops the getter's overclaiming comment: defaultConfig ships `modelsGateway.enabled: false`, so the config block is present and the module loads regardless. - Symmetry: `/v1/embeddings` never got the rejected-body-promise guard its sibling received, so malformed JSON there still escaped as an RFC 9457 500. Same try/catch as chatCompletions. - Enabling the gateway by its own documented example produced a silently unservable gateway (defaultConfig ships no `rest` section → every /v1 path 404s with no diagnostic). The example now shows `rest: true`, and handleApplication warns when enabled with no `rest`/`REST` configured. - OpenAI content parts (`content: [{type:'text',text}]`) passed through unvalidated into a `Message.content` declared as string. Text parts are now flattened and unsupported part types return a 400. - The tool-argument bound counted all accumulated keys per delta, so the O(n^2) the Object.assign change was meant to remove was still there in the check. Now counts only newly-introduced keys, and ignores a contract-violating string `arguments` rather than counting its characters. Bounds now have tests, including the at-the-cap off-by-one. - connect(): the docstring's premise was wrong — the pre-override behavior was Resource's default connect returning an empty subscription (a forever-open SSE), not method-not-allowed. Corrected, and the WebSocket call path (no request.data) now returns a client error instead of an envelope that path cannot iterate. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RXx78W5LxbvxEdSyrB1vvn --- components/componentLoader.ts | 13 +++--- resources/models/openaiStream.ts | 29 +++++++++----- resources/models/v1/chatCompletions.ts | 19 +++++++-- resources/models/v1/embeddings.ts | 9 ++++- resources/models/v1/index.ts | 18 ++++++++- resources/models/v1/translation.ts | 20 +++++++++- .../resources/models/openaiStream.test.js | 40 +++++++++++++++++++ .../resources/models/v1/translation.test.js | 25 ++++++++++++ 8 files changed, 148 insertions(+), 25 deletions(-) diff --git a/components/componentLoader.ts b/components/componentLoader.ts index 0a94e29002..09f172904f 100644 --- a/components/componentLoader.ts +++ b/components/componentLoader.ts @@ -113,13 +113,12 @@ export const TRUSTED_RESOURCE_PLUGINS: any = { return require('../server/fastifyRoutes'); }, login, - // Lazy: the gateway is opt-in and off by default, so an install that never enables it - // pays no module-load cost for the gateway's graph in the main process or any worker. - get modelsGateway() { - // Extensionless, like the other require()s here: this path is resolved at runtime - // against dist/, where the emitted file is .js (TypeScript does not rewrite require). - return require('../resources/models/v1/index'); - }, + // String entry: the loader `await import()`s these lazily when the component is actually + // processed, so the gateway's module graph is not pulled into componentLoader's own + // evaluation. `#src/*` is used rather than a relative path because it resolves under both + // conditions (source under --conditions=typestrip, dist otherwise); a relative extensionless + // require would only resolve against dist. + modelsGateway: '#src/resources/models/v1/index', static: staticFiles, customFunctions: {}, http: httpComponent, diff --git a/resources/models/openaiStream.ts b/resources/models/openaiStream.ts index 8d99ac21e5..0e2eea3d95 100644 --- a/resources/models/openaiStream.ts +++ b/resources/models/openaiStream.ts @@ -40,10 +40,14 @@ class ToolAssemblyOverflowError extends Error { statusCode = 502; } -function countKeys(obj: object): number { - let n = 0; - for (const _ in obj) n++; - return n; +/** Count only the keys `source` adds to `target`, so accumulation stays O(delta), not O(total). */ +function assignCountingNewKeys(target: object, source: object): number { + let added = 0; + for (const key in source) { + if (!(key in target)) added++; + (target as Record)[key] = (source as Record)[key]; + } + return added; } /** OpenAI streaming error body (`{ message, type, code, param }` under an `error` key). */ @@ -102,7 +106,7 @@ export async function* openaiStream( // Emitting incremental fragments would corrupt the OpenAI client's concatenation // (`{"a":1}` + `{"b":2}` → invalid JSON) — Harper's already-buffered upstream model // means we cannot faithfully reproduce per-token argument fragments anyway. - const toolAssembly = new Map(); + const toolAssembly = new Map(); const chunk = (delta: OpenAIDelta, finish: OpenAIFinishReason | null): OpenAIStreamMessage => ({ data: { @@ -139,15 +143,18 @@ export async function* openaiStream( // named `__proto__` is an own property. Object.assign uses [[Set]], which // on an ordinary object would hit Object.prototype's inherited `__proto__` // setter and silently drop the field (the previous spread did not). - existing = { index: toolAssembly.size, arguments: Object.create(null) }; + existing = { index: toolAssembly.size, arguments: Object.create(null), argumentCount: 0 }; toolAssembly.set(incoming.id, existing); } if (incoming.name) existing.name = incoming.name; - if (incoming.arguments) { - // Mutate rather than re-spread: spreading copied every previously - // accumulated property on each partial delta (O(n²) as fields grow). - Object.assign(existing.arguments, incoming.arguments); - if (countKeys(existing.arguments) > MAX_TOOL_ARGUMENT_KEYS) { + // Guard the contract (`ToolCall.arguments` is an object): a string would be + // assigned index-wise, inflating the field count from characters. + if (incoming.arguments && typeof incoming.arguments === 'object') { + // Mutate rather than re-spread — spreading copied every previously + // accumulated property on each partial delta (O(n²) as fields grow) — and + // count only newly-introduced keys so the bound check stays O(delta) too. + existing.argumentCount += assignCountingNewKeys(existing.arguments, incoming.arguments); + if (existing.argumentCount > MAX_TOOL_ARGUMENT_KEYS) { throw new ToolAssemblyOverflowError(`tool call arguments exceeded ${MAX_TOOL_ARGUMENT_KEYS} fields`); } } diff --git a/resources/models/v1/chatCompletions.ts b/resources/models/v1/chatCompletions.ts index be4fdbbaec..683918acc8 100644 --- a/resources/models/v1/chatCompletions.ts +++ b/resources/models/v1/chatCompletions.ts @@ -96,15 +96,28 @@ export class V1ChatCompletions extends Resource { /** * A client that sends an explicit `Accept: text/event-stream` with its POST is - * dispatched by REST as CONNECT (REST.ts), not POST — so without this the request - * would get method-not-allowed. The OpenAI SDK happens to send + * dispatched by REST as CONNECT (REST.ts), not POST. The OpenAI SDK happens to send * `Accept: application/json` even when streaming, but other valid SSE clients do not. * + * Without this override the request reached `Resource`'s default `connect`, whose + * instance path returns `subscribe()` — an empty `IterableEventQueue` — so the client + * got a 200 SSE response that stayed open forever emitting nothing, rather than an + * error it could act on. + * * REST passes `null` as the CONNECT body (`resource.connect(target, null, request)`), * so the parsed body is taken off the request and handed to the same `post()` * implementation — one code path, identical validation and error shaping. + * + * `connect` is also reachable from the WebSocket handler with a different signature + * (`resourceRequest, incomingMessages, request`), where there is no `request.data`; + * that case is rejected as a client error rather than returning an envelope the WS + * path would fail to iterate. */ static async connect(target: unknown, _data: unknown, request: unknown) { - return this.post(target, (request as { data?: unknown })?.data, request); + const data = (request as { data?: unknown })?.data; + if (data === undefined) { + return badRequest('This endpoint requires a JSON request body; WebSocket connections are not supported'); + } + return this.post(target, data, request); } } diff --git a/resources/models/v1/embeddings.ts b/resources/models/v1/embeddings.ts index fd0040d79d..e921d82238 100644 --- a/resources/models/v1/embeddings.ts +++ b/resources/models/v1/embeddings.ts @@ -23,8 +23,13 @@ export class V1Embeddings extends Resource { // REST.ts passes `request.data` directly, which is the (unawaited) streaming // JSON deserializer's Promise — awaiting here is a no-op for callers (e.g. - // unit tests) that already pass a plain object. - body = await body; + // unit tests) that already pass a plain object. A malformed JSON body rejects + // this promise, which is a client error, not a 500 (matches chatCompletions). + try { + body = await body; + } catch (err) { + return badRequest(`Could not parse request body: ${err instanceof Error ? err.message : 'invalid JSON'}`); + } if (!body || typeof body !== 'object' || Array.isArray(body)) return badRequest('Request body must be a JSON object'); const raw = body as Record; diff --git a/resources/models/v1/index.ts b/resources/models/v1/index.ts index 4a304c75a5..8ca76cbdb7 100644 --- a/resources/models/v1/index.ts +++ b/resources/models/v1/index.ts @@ -10,9 +10,12 @@ * block of `harperdb-config.yaml`. Opt out explicitly with `enabled: false`. * This mirrors the `agent` component's enabled-flag pattern. * - * Example (opt in): + * Example (opt in). `rest` is required: these are REST-served resources and the + * gateway deliberately does not force REST to start (see `handleApplication`). + * Without it the resources register but every `/v1/*` path 404s. * * ```yaml + * rest: true * modelsGateway: * enabled: true * models: @@ -27,6 +30,8 @@ */ import type { Scope } from '../../../components/Scope.ts'; +import harperLogger from '../../../utility/logging/harper_logger.ts'; +import { getConfigObj } from '../../../config/configUtils.ts'; import { V1Embeddings } from './embeddings.ts'; import { V1ChatCompletions } from './chatCompletions.ts'; import { V1Models } from './models.ts'; @@ -39,6 +44,17 @@ export function handleApplication(scope: Scope): void { // configs have loaded, which silently discards an app's own `rest` options (webSocket, // urlPath/host, middleware ordering). Core has no supported way yet for a component to // declare "I serve REST resources"; that gap is tracked separately. + // + // Warn rather than fail: an app loaded later may still declare `rest`, so absence here + // is not conclusive. But defaultConfig ships no `rest` section, so enabling the gateway + // alone yields three registered resources and a 404 on every /v1 path — worth a line in + // the log instead of silence. + const rootConfig = getConfigObj() as Record | undefined; + if (rootConfig && !rootConfig.rest && !rootConfig.REST) { + harperLogger.warn( + 'modelsGateway is enabled but no `rest` section is configured; /v1/* endpoints are only served when REST is active' + ); + } scope.resources.set('v1/models', V1Models); scope.resources.set('v1/embeddings', V1Embeddings); scope.resources.set('v1/chat/completions', V1ChatCompletions); diff --git a/resources/models/v1/translation.ts b/resources/models/v1/translation.ts index ef7c56ef7f..108c073711 100644 --- a/resources/models/v1/translation.ts +++ b/resources/models/v1/translation.ts @@ -69,9 +69,14 @@ export interface OAIChatRequest { */ export function translateMessages(oaiMessages: OAIMessageIn[]): Message[] { return oaiMessages.map((m): Message => { + // Content parts are flattened to the text Harper's Message.content expects; shapes + // that cannot be flattened are rejected up front by validateChatRequest. + const content = Array.isArray(m.content) + ? (m.content as Array<{ text: string }>).map((part) => part.text).join('') + : (m.content ?? ''); const base: Message = { role: m.role as Message['role'], - content: m.content ?? '', + content, }; if (m.tool_calls?.length) { base.toolCalls = m.tool_calls.map((tc): ToolCall => { @@ -130,6 +135,19 @@ export function validateChatRequest(body: OAIChatRequest): string | null { const m = req.messages[i]; if (!m || typeof m !== 'object' || Array.isArray(m)) return `'messages[${i}]' must be an object`; if (typeof m.role !== 'string') return `'messages[${i}].role' must be a string`; + // OpenAI allows content parts: [{ type: 'text', text: '...' }, ...]. Harper's + // Message.content is a string, so those are flattened in translateMessages; reject + // shapes we cannot flatten rather than passing a non-string downstream. + if (m.content !== undefined && m.content !== null && typeof m.content !== 'string') { + if (!Array.isArray(m.content)) return `'messages[${i}].content' must be a string, array of parts, or null`; + for (let p = 0; p < m.content.length; p++) { + const part = m.content[p]; + if (!part || typeof part !== 'object') return `'messages[${i}].content[${p}]' must be an object`; + if (part.type !== 'text' || typeof part.text !== 'string') { + return `'messages[${i}].content[${p}]' must be a text part ({ type: 'text', text: string }); other part types are not supported yet`; + } + } + } if (m.tool_calls !== undefined) { if (!Array.isArray(m.tool_calls)) return `'messages[${i}].tool_calls' must be an array`; for (let j = 0; j < m.tool_calls.length; j++) { diff --git a/unitTests/resources/models/openaiStream.test.js b/unitTests/resources/models/openaiStream.test.js index 3c7b142f79..c82e72c9df 100644 --- a/unitTests/resources/models/openaiStream.test.js +++ b/unitTests/resources/models/openaiStream.test.js @@ -150,6 +150,46 @@ describe('openaiStream', () => { assert.deepEqual(args, { a: 3, b: 2 }, 'later deltas must win, earlier fields preserved'); }); + it('terminates with an error frame when a stream exceeds the tool-call cap', async () => { + // 257 distinct call ids — one over MAX_TOOL_CALLS_PER_STREAM (256) + const deltas = Array.from({ length: 257 }, (_, i) => ({ + deltaToolCalls: [{ id: `c${i}`, name: 'fn', arguments: { a: 1 } }], + })); + const msgs = await collect(openaiStream(gen(...deltas))); + const last = msgs[msgs.length - 1].data; + assert.ok(last.error, 'expected a terminal error frame'); + assert.ok(!msgs.some((m) => m.data === '[DONE]'), 'must not emit [DONE] after overflow'); + }); + + it('accepts a stream exactly at the tool-call cap (off-by-one guard)', async () => { + const deltas = Array.from({ length: 256 }, (_, i) => ({ + deltaToolCalls: [{ id: `c${i}`, name: 'fn', arguments: { a: 1 } }], + })); + const msgs = await collect(openaiStream(gen(...deltas))); + assert.ok( + msgs.some((m) => m.data === '[DONE]'), + 'exactly at the cap must still complete' + ); + }); + + it('terminates with an error frame when one call accumulates too many argument fields', async () => { + const wide = {}; + for (let i = 0; i <= 1024; i++) wide[`k${i}`] = i; // 1025 fields > MAX_TOOL_ARGUMENT_KEYS + const msgs = await collect(openaiStream(gen({ deltaToolCalls: [{ id: 'c1', name: 'fn', arguments: wide }] }))); + const last = msgs[msgs.length - 1].data; + assert.ok(last.error, 'expected a terminal error frame'); + }); + + it('ignores a contract-violating string arguments value rather than counting characters', async () => { + const msgs = await collect( + openaiStream(gen({ deltaToolCalls: [{ id: 'c1', name: 'fn', arguments: 'x'.repeat(5000) }] })) + ); + assert.ok( + msgs.some((m) => m.data === '[DONE]'), + 'a string arguments value must not blow the field cap' + ); + }); + it('stores a tool argument literally named __proto__ instead of hitting the prototype setter', async () => { // Arguments arrive from JSON.parse, where `__proto__` is an own property. Object.assign // uses [[Set]], so an ordinary accumulator would invoke Object.prototype's inherited diff --git a/unitTests/resources/models/v1/translation.test.js b/unitTests/resources/models/v1/translation.test.js index f0fbea7ce9..9471770109 100644 --- a/unitTests/resources/models/v1/translation.test.js +++ b/unitTests/resources/models/v1/translation.test.js @@ -76,6 +76,20 @@ describe('translateMessages', () => { const result = translateMessages([{ role: 'tool', content: '42', tool_call_id: 'call_1' }]); assert.equal(result[0].toolCallId, 'call_1'); }); + + it('flattens OpenAI content parts to the string Message.content expects', () => { + const result = translateMessages([ + { + role: 'user', + content: [ + { type: 'text', text: 'hello ' }, + { type: 'text', text: 'world' }, + ], + }, + ]); + assert.equal(result[0].content, 'hello world'); + assert.equal(typeof result[0].content, 'string', 'must not pass an array downstream'); + }); }); // --------------------------------------------------------------------------- @@ -234,6 +248,17 @@ describe('validateChatRequest', () => { ); }); + it('accepts OpenAI text content parts', () => { + assert.equal(validateChatRequest({ messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }] }), null); + }); + + it('rejects content part types the gateway cannot flatten, rather than passing a non-string downstream', () => { + const msg = validateChatRequest({ + messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'http://x' } }] }], + }); + assert.match(msg, /content\[0\]/); + }); + it('rejects a json_schema response_format missing the inner schema', () => { assert.match(validateChatRequest({ ...ok, response_format: { type: 'json_schema' } }), /json_schema/); assert.match( From da1d845e364e5c13477180fa066fd60c3ea38bbd Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Thu, 23 Jul 2026 22:17:13 -0700 Subject: [PATCH 25/39] =?UTF-8?q?docs(models):=20correct=20v1-gateway=20te?= =?UTF-8?q?st=20header=20=E2=80=94=20REST=20is=20configured,=20not=20auto-?= =?UTF-8?q?started?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header still described the removed REST.ensureStarted auto-activation; the suite now declares an explicit `rest` section like a real deployment. (Bot review.) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RXx78W5LxbvxEdSyrB1vvn --- integrationTests/server/v1-gateway.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/integrationTests/server/v1-gateway.test.ts b/integrationTests/server/v1-gateway.test.ts index 16b12ebeb5..b968ad8cb3 100644 --- a/integrationTests/server/v1-gateway.test.ts +++ b/integrationTests/server/v1-gateway.test.ts @@ -16,10 +16,10 @@ * NOTE: `modelsGateway: { enabled: true }` is passed explicitly because the * gateway is off by default (`enabled: false` in defaultConfig.yaml). The test * harness plumbs this via HARPER_SET_CONFIG. This suite runs against a bare - * instance (no deployed apps), so no component config contains a `rest` key — - * componentLoader activates REST (REST.ensureStarted, after root plugin - * loading) when the gateway is enabled so its chain serves these endpoints. - * That activation path is part of what this suite covers. + * instance (no deployed apps), so it also declares an explicit `rest` section + * below: the gateway deliberately does NOT force REST to start (see + * resources/models/v1/index.ts), so a real deployment must configure `rest` + * itself, and this suite mirrors that requirement. */ import { suite, test, before, after } from 'node:test'; import assert from 'node:assert'; From 42c9bb5514619183402a5b9efa485bffa487a4ad Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Tue, 28 Jul 2026 06:16:48 -0700 Subject: [PATCH 26/39] fix(models): zero-cost disabled /v1 gateway; register modelsGateway_enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway is registered in componentLoader's built-in table, and the root loader's only pre-resolution guard is `if (!componentConfig) continue`. A shipped `modelsGateway: { enabled: false }` block is a truthy object, so every instance resolved the entry and imported the whole /v1 module graph on every startup and every worker — handleApplication then returned immediately on its `enabled` check, having already paid for the import. Dropping the block from defaultConfig.yaml makes "off" genuinely free: with the key absent the loader skips the component before resolving it. Nothing is stranded — #1616 has not merged, so no install has ever carried the key, and CONFIG_PARAM_MAP is populated from the CONFIG_PARAMS enum rather than from defaultConfig.yaml, so removing it orphans nothing. The shipped config is not a discovery surface — docs are — but the ops API should still work. modelsGateway was absent from CONFIG_PARAMS, so set_configuration rejected `modelsGateway_enabled` as an unrecognized config parameter, leaving hand-editing YAML as the only way to enable the gateway. Registering MODELSGATEWAY_ENABLED matches localStudio (LOCALSTUDIO_ENABLED), its closest analogue. packaging.test.js pins both invariants so neither is silently undone. Addresses @kriszyp's zero-cost-when-disabled review thread on #1616. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014fE3ARcG11oxJXRy3SvWYG --- components/componentLoader.ts | 4 ++ resources/models/v1/index.ts | 10 +++- static/defaultConfig.yaml | 2 - .../resources/models/v1/packaging.test.js | 57 +++++++++++++++++++ utility/hdbTerms.ts | 1 + 5 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 unitTests/resources/models/v1/packaging.test.js diff --git a/components/componentLoader.ts b/components/componentLoader.ts index 09f172904f..fe84f870b3 100644 --- a/components/componentLoader.ts +++ b/components/componentLoader.ts @@ -118,6 +118,10 @@ export const TRUSTED_RESOURCE_PLUGINS: any = { // evaluation. `#src/*` is used rather than a relative path because it resolves under both // conditions (source under --conditions=typestrip, dist otherwise); a relative extensionless // require would only resolve against dist. + // + // The component is only *processed* when a `modelsGateway` key exists in the config, since + // the root loop skips absent keys (`if (!componentConfig) continue`). `defaultConfig.yaml` + // ships no such key, so an instance that never opts in pays nothing for this entry. modelsGateway: '#src/resources/models/v1/index', static: staticFiles, customFunctions: {}, diff --git a/resources/models/v1/index.ts b/resources/models/v1/index.ts index 8ca76cbdb7..85024456e7 100644 --- a/resources/models/v1/index.ts +++ b/resources/models/v1/index.ts @@ -6,9 +6,13 @@ * POST /v1/chat/completions → V1ChatCompletions * GET /v1/models → V1Models * - * Off by default. Opt in by setting `enabled: true` in the `modelsGateway` - * block of `harperdb-config.yaml`. Opt out explicitly with `enabled: false`. - * This mirrors the `agent` component's enabled-flag pattern. + * Off by default, and `defaultConfig.yaml` deliberately ships no `modelsGateway` + * block: with the key absent the root loader skips the component before resolving + * it, so none of this module graph is imported on an instance that does not use + * the gateway. Opt in by adding the block to `harperdb-config.yaml` with + * `enabled: true`, or via `set_configuration` (`modelsGateway_enabled`). + * `enabled: false` is honored too, for an instance that wants the block present + * but inert — that costs the import, which is why it is not the shipped default. * * Example (opt in). `rest` is required: these are REST-served resources and the * gateway deliberately does not force REST to start (see `handleApplication`). diff --git a/static/defaultConfig.yaml b/static/defaultConfig.yaml index 4408c60362..174ba560e0 100644 --- a/static/defaultConfig.yaml +++ b/static/defaultConfig.yaml @@ -36,8 +36,6 @@ applications: componentsRoot: null localStudio: enabled: true -modelsGateway: - enabled: false logging: auditAuthEvents: logFailed: false diff --git a/unitTests/resources/models/v1/packaging.test.js b/unitTests/resources/models/v1/packaging.test.js new file mode 100644 index 0000000000..d98c50bd59 --- /dev/null +++ b/unitTests/resources/models/v1/packaging.test.js @@ -0,0 +1,57 @@ +'use strict'; + +/** + * Packaging invariants for the `/v1/*` gateway (#631). + * + * The gateway is registered in componentLoader's built-in table, so the root + * loader reaches it for any config that declares a `modelsGateway` key — the + * only pre-resolution guard is `if (!componentConfig) continue`, and + * `{ enabled: false }` is a truthy object. Shipping the block in + * `defaultConfig.yaml` would therefore import the whole `/v1` module graph on + * every startup and every worker of every instance, including the overwhelming + * majority that never enable it — `handleApplication` would then immediately + * return on its `enabled` check, having already paid for the import. + * + * Keeping the key out of the shipped default config is what makes "off" cost + * nothing. These tests pin that, plus the `set_configuration` route that + * replaces the config-file block as the discoverable way to turn it on. + */ + +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const YAML = require('yaml'); + +const { CONFIG_PARAM_MAP, CONFIG_PARAMS } = require('#src/utility/hdbTerms'); + +// unitTests/resources/models/v1 -> repo root +const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', '..', '..', '..', 'static', 'defaultConfig.yaml'); + +describe('/v1 gateway packaging', () => { + it('defaultConfig.yaml ships no modelsGateway block, so a disabled instance imports nothing', () => { + const raw = fs.readFileSync(DEFAULT_CONFIG_PATH, 'utf8'); + const parsed = YAML.parse(raw); + + assert.ok(parsed, 'defaultConfig.yaml should parse'); + assert.strictEqual( + Object.hasOwn(parsed, 'modelsGateway'), + false, + 'defaultConfig.yaml must not declare `modelsGateway`: a present key (even `enabled: false`) makes the ' + + 'root loader resolve and import the /v1 module graph on every startup and worker. Document the ' + + 'option in the docs, not by shipping an inert config block.' + ); + }); + + it('modelsGateway_enabled is settable via set_configuration', () => { + // With no block in the shipped config, the ops API is the discoverable way to + // enable the gateway. set_configuration resolves params through CONFIG_PARAM_MAP + // and throws `unrecognized config parameter` for anything missing from it. + assert.strictEqual(CONFIG_PARAMS.MODELSGATEWAY_ENABLED, 'modelsGateway_enabled'); + assert.strictEqual( + CONFIG_PARAM_MAP['modelsgateway_enabled'], + 'modelsGateway_enabled', + 'CONFIG_PARAM_MAP is populated from CONFIG_PARAMS by lowercased name; without the entry, ' + + 'set_configuration rejects modelsGateway_enabled as unrecognized' + ); + }); +}); diff --git a/utility/hdbTerms.ts b/utility/hdbTerms.ts index 2c1f04bfe8..a6de85787a 100644 --- a/utility/hdbTerms.ts +++ b/utility/hdbTerms.ts @@ -517,6 +517,7 @@ export const CONFIG_PARAMS = { LICENSE_MODE: 'license_mode', LICENSE_REGION: 'license_region', LOCALSTUDIO_ENABLED: 'localStudio_enabled', + MODELSGATEWAY_ENABLED: 'modelsGateway_enabled', LOGGING_COLORS: 'logging_colors', LOGGING_CONSOLE: 'logging_console', LOGGING_FILE: 'logging_file', From 2324cc66d9e99b0c369b5d4e9377726f2040f64f Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Tue, 28 Jul 2026 06:22:01 -0700 Subject: [PATCH 27/39] test(models): mirror the malformed-JSON reject-path case on /v1/embeddings chatCompletions covered the rejecting body promise; embeddings had the same guard but no test pinning it. Closes the asymmetry noted on the review thread. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014fE3ARcG11oxJXRy3SvWYG --- unitTests/resources/models/v1/embeddings.test.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/unitTests/resources/models/v1/embeddings.test.js b/unitTests/resources/models/v1/embeddings.test.js index a9e25d023f..44d77a7b6a 100644 --- a/unitTests/resources/models/v1/embeddings.test.js +++ b/unitTests/resources/models/v1/embeddings.test.js @@ -62,6 +62,17 @@ describe('V1Embeddings.post', () => { assert.equal(result.status, 401, 'auth must be checked ahead of body validation'); }); + // Mirrors the chatCompletions case: REST hands over the streaming JSON deserializer's + // promise, and a malformed body rejects it. That rejection must be shaped as an OpenAI + // 400 rather than escaping to REST's RFC 9457 path as a 500. + it('returns a 400 when the body promise rejects (malformed JSON), not a 500', async () => { + const result = await V1Embeddings.post(undefined, Promise.reject(new SyntaxError('Unexpected token')), { + user: SUPER_USER, + }); + assert.equal(result.status, 400); + assert.equal(result.data.error.type, 'invalid_request_error'); + }); + it('accepts a batch at the 2048-item cap', async () => { const body = { input: Array.from({ length: 2048 }, (_, i) => `item ${i}`) }; const result = await V1Embeddings.post(undefined, body, { user: SUPER_USER }); From fcab43d338e091cd95b2dd7c7ba7ce666450189a Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Tue, 28 Jul 2026 06:56:19 -0700 Subject: [PATCH 28/39] test(models): mixed-app regression test for the /v1 gateway's REST behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the property the removed force-start was traded for: enabling modelsGateway must not stand REST up on another component's behalf. The fixture app exports a table but declares no `rest` section. Finding worth recording: once a root `rest` section exists, REST serves the shared Resources registry and that table is reachable regardless of the app's own `rest` — that is existing Harper behavior and not caused by the gateway. So the gateway's contribution is only observable with no `rest` configured anywhere, which is how the primary suite is set up. Previously, enabling the gateway alone was enough to start REST and expose the app. A control suite runs the identical fixture and URL with `rest` configured and asserts the table IS reachable. Without it the primary assertions could pass vacuously — a typo'd path or an undeployed fixture would also look "not reachable". The control fails loudly if the guard ever goes hollow. Also corrects two comments in v1-gateway.test.ts that said the gateway is off by default via `enabled: false` in defaultConfig.yaml; since 42c9bb551 the block is absent entirely, which is what makes the disabled path cost nothing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014fE3ARcG11oxJXRy3SvWYG --- .../server/v1-gateway-mixed-app.test.ts | 140 ++++++++++++++++++ .../server/v1-gateway-mixed-app/config.yaml | 7 + .../v1-gateway-mixed-app/schema.graphql | 9 ++ integrationTests/server/v1-gateway.test.ts | 12 +- 4 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 integrationTests/server/v1-gateway-mixed-app.test.ts create mode 100644 integrationTests/server/v1-gateway-mixed-app/config.yaml create mode 100644 integrationTests/server/v1-gateway-mixed-app/schema.graphql diff --git a/integrationTests/server/v1-gateway-mixed-app.test.ts b/integrationTests/server/v1-gateway-mixed-app.test.ts new file mode 100644 index 0000000000..4410080b6f --- /dev/null +++ b/integrationTests/server/v1-gateway-mixed-app.test.ts @@ -0,0 +1,140 @@ +/** + * Mixed-app regression test for the `/v1/*` gateway (#631, PR #1616 review). + * + * An earlier revision made componentLoader start REST on the gateway's behalf + * whenever `modelsGateway` was enabled: + * + * if (isRoot && resources.isWorker && config.modelsGateway?.enabled) { + * REST.ensureStarted({ server, resources }); + * } + * + * Root components load before application directories, and REST serves the + * shared `Resources` registry, so that force-start exposed every REST-exportable + * entry — including apps that had deliberately omitted `rest`. It also set + * `REST.started` before a later app could contribute its own port/host/urlPath/ + * WebSocket options. + * + * The force-start is gone; the gateway is a plain plugin that requires REST to + * already be configured. This pins that. + * + * IMPORTANT — what this test can and cannot show. Once a root `rest` section + * exists, REST serves the shared registry and an app's `@export`ed table is + * reachable whether or not that app declared `rest` itself. That is existing + * Harper behavior and is not what this test is about. The gateway-specific + * regression is only observable with NO `rest` section anywhere: previously, + * enabling the gateway alone was enough to stand REST up and expose the app. + * So this suite deliberately configures no `rest` at all. + * + * Expected now: enabling the gateway starts nothing, so the app stays off the + * wire. `/v1/*` is unserved too — that is the documented trade-off in + * resources/models/v1/index.ts, which logs a warning rather than forcing REST. + */ +import { suite, test, before, after } from 'node:test'; +import assert from 'node:assert'; +import { resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { setupHarperWithFixture, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const FIXTURE_PATH = resolvePath(__dirname, 'v1-gateway-mixed-app'); +const ECHO_BACKEND_PATH = resolvePath(__dirname, 'fixtures/v1-gateway-test-backend.cjs'); + +function authHeader(ctx: ContextWithHarper): string { + return `Basic ${Buffer.from(`${ctx.harper.admin.username}:${ctx.harper.admin.password}`).toString('base64')}`; +} + +/** + * With no REST server expected, a request may be refused outright rather than + * answered. Both outcomes mean "not served"; only a 200 is a regression. + */ +async function statusOrRefused(ctx: ContextWithHarper, path: string): Promise { + try { + const res = await fetch(`${ctx.harper.httpURL}${path}`, { + headers: { Authorization: authHeader(ctx) }, + signal: AbortSignal.timeout(5_000), + }); + return res.status; + } catch { + return 'refused'; + } +} + +suite('/v1 gateway does not stand REST up on another component behalf', (ctx: ContextWithHarper) => { + before(async () => { + await setupHarperWithFixture(ctx, FIXTURE_PATH, { + config: { + // Deliberately NO `rest` section — see the header. This is the only + // configuration in which the gateway's own effect on REST is observable. + modelsGateway: { enabled: true }, + models: { + generative: { default: { backend: ECHO_BACKEND_PATH } }, + embedding: { default: { backend: ECHO_BACKEND_PATH } }, + }, + }, + env: {}, + }); + }); + + after(async () => { + await teardownHarper(ctx); + }); + + test('an app that omitted `rest` is not exposed by enabling the gateway', async () => { + const status = await statusOrRefused(ctx, '/MixedAppPrivate/'); + assert.notEqual( + status, + 200, + 'enabling modelsGateway must not start REST, which would expose an app that ' + + 'declared no `rest` section of its own' + ); + }); + + test('the gateway does not serve itself either, rather than forcing REST', async () => { + // The other half of the same property: the gateway declines to start REST for + // its own benefit too. resources/models/v1/index.ts warns about exactly this. + const status = await statusOrRefused(ctx, '/v1/models'); + assert.notEqual(status, 200, 'the gateway must not force REST to start for its own resources'); + }); +}); + +/** + * Control for the suite above. + * + * Without this, the primary assertions could pass vacuously — a typo'd path or a + * fixture that never deployed would also "not be reachable". Here the identical + * fixture and URL are exercised with a root `rest` section present, which is the + * state the removed force-start effectively created. The table becomes reachable, + * so `notEqual(status, 200)` above is a real constraint and not an artifact. + * + * This also documents current Harper behavior: once REST is running it serves the + * shared `Resources` registry, so an `@export`ed table is reachable even though + * this app declares no `rest` of its own. Whether that ought to be per-app scoped + * is a separate question (#1931) — this test only pins that enabling the gateway + * is not what causes it. + */ +suite('control: the same table IS reachable once REST is configured', (ctx: ContextWithHarper) => { + before(async () => { + await setupHarperWithFixture(ctx, FIXTURE_PATH, { + config: { + // `webSocket` is a leaf value because a plain empty object is dropped by + // flattenObject() in harperConfigEnvVars.ts. + rest: { webSocket: true }, + modelsGateway: { enabled: true }, + models: { + generative: { default: { backend: ECHO_BACKEND_PATH } }, + embedding: { default: { backend: ECHO_BACKEND_PATH } }, + }, + }, + env: {}, + }); + }); + + after(async () => { + await teardownHarper(ctx); + }); + + test('proves the probe URL is live when REST is running', async () => { + const status = await statusOrRefused(ctx, '/MixedAppPrivate/'); + assert.equal(status, 200, 'if this stops returning 200 the regression guard above has gone vacuous'); + }); +}); diff --git a/integrationTests/server/v1-gateway-mixed-app/config.yaml b/integrationTests/server/v1-gateway-mixed-app/config.yaml new file mode 100644 index 0000000000..da8c12b8b3 --- /dev/null +++ b/integrationTests/server/v1-gateway-mixed-app/config.yaml @@ -0,0 +1,7 @@ +# Deliberately declares NO `rest` section. +# +# This app exports a table, so it has a REST-exportable entry in the shared +# Resources registry — but by omitting `rest` it opts out of being served over +# REST. Enabling the /v1 gateway must not change that. +graphqlSchema: + files: '*.graphql' diff --git a/integrationTests/server/v1-gateway-mixed-app/schema.graphql b/integrationTests/server/v1-gateway-mixed-app/schema.graphql new file mode 100644 index 0000000000..e8074e5590 --- /dev/null +++ b/integrationTests/server/v1-gateway-mixed-app/schema.graphql @@ -0,0 +1,9 @@ +# An @export table in an app that does not declare `rest`. +# +# @export puts it in the shared Resources registry; the missing `rest` section is +# what should keep it off the wire. If enabling modelsGateway ever starts REST on +# this app's behalf, this table becomes reachable and the regression test fails. +type MixedAppPrivate @table @export { + id: ID @primaryKey + value: String +} diff --git a/integrationTests/server/v1-gateway.test.ts b/integrationTests/server/v1-gateway.test.ts index b968ad8cb3..d5acc19d39 100644 --- a/integrationTests/server/v1-gateway.test.ts +++ b/integrationTests/server/v1-gateway.test.ts @@ -13,9 +13,11 @@ * unmodified OpenAI client. See the SSE serving-path note in chatCompletions.ts * for why `stream: true` routes through `post()` rather than `connect()`. * - * NOTE: `modelsGateway: { enabled: true }` is passed explicitly because the - * gateway is off by default (`enabled: false` in defaultConfig.yaml). The test - * harness plumbs this via HARPER_SET_CONFIG. This suite runs against a bare + * NOTE: `modelsGateway: { enabled: true }` is passed explicitly because + * defaultConfig.yaml ships no `modelsGateway` block at all — an absent key is + * what keeps the loader from importing this module graph on instances that do + * not use the gateway. The test harness plumbs this via HARPER_SET_CONFIG, + * which is also what makes the block exist for this run. This suite runs against a bare * instance (no deployed apps), so it also declares an explicit `rest` section * below: the gateway deliberately does NOT force REST to start (see * resources/models/v1/index.ts), so a real deployment must configure `rest` @@ -55,8 +57,8 @@ suite('OpenAI /v1/* gateway (modelsGateway)', (ctx: ContextWithHarper) => { // declared here — exactly as a real deployment would. `webSocket` is a leaf // value because a plain empty object is dropped by flattenObject(). rest: { webSocket: true }, - // Gateway is off by default (enabled: false in defaultConfig.yaml). Pass - // enabled: true explicitly to activate it for these tests. A plain empty + // Gateway is off by default — defaultConfig.yaml ships no modelsGateway + // block. Pass enabled: true explicitly to activate it. A plain empty // object would be silently dropped by flattenObject() in harperConfigEnvVars.ts // because it has no leaf paths to flatten. modelsGateway: { enabled: true }, From bc31ac75e434b9a02b60e87e3236cb8c1069da75 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Thu, 30 Jul 2026 18:23:06 -0700 Subject: [PATCH 29/39] fix(deps): restore production-native lockfile metadata lost in regeneration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An earlier lockfile regeneration (npm 10, from the #1856 branch work) flipped all six @msgpackr-extract/* platform entries to dev:true — which would make build-tools/prune-shrinkwrap-dev.mjs delete the host msgpackr binary from the published shrinkwrap — and dropped the libc selectors from the four @harperfast/rocksdb-js-linux-* optionals, which makes npm on Linux install both the glibc and musl variants (verified empirically in a node:24.18.0 container: dry-run and real `npm ci --omit=dev` both selected two variants). Fix: regenerated with the canonical toolchain (Node 24.18.0 per .node-version, full install so real manifests are read), which heals the dev flags, then restored the four libc fields verbatim from main's identical 2.5.0 entries. The libc restore has to be verbatim-from-main because NO npm install path can produce it today: the registry's abbreviated packument omits `libc` entirely (only cpu/dist/engines/name/os/version), so any re-resolution of a bumped version silently drops it — which is exactly how this regressed when 2.4.0 became 2.5.0. (That also means the next rocksdb bump on main will hit the same trap; flagged on the PR.) Verified in a pristine node:24.18.0-bookworm container: - `npm ci --omit=dev` (dry-run and real) selects exactly one libc variant - @msgpackr-extract binaries survive --omit=dev on disk - prune-shrinkwrap-dev.mjs retains all 6 msgpackr entries and all 4 libc fields - a follow-up canonical `npm install --package-lock-only` leaves the lock byte-stable (no re-resolution, metadata preserved) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RXx78W5LxbvxEdSyrB1vvn --- package-lock.json | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index f944b3a35c..c2218252ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2522,6 +2522,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2538,6 +2541,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2554,6 +2560,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2570,6 +2579,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3286,13 +3298,11 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { "version": "3.0.4", @@ -3301,13 +3311,11 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { "version": "3.0.4", @@ -3316,13 +3324,11 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { "version": "3.0.4", @@ -3331,13 +3337,11 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { "version": "3.0.4", @@ -3346,13 +3350,11 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { "version": "3.0.4", @@ -3361,13 +3363,11 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@noble/hashes": { "version": "1.8.0", From cfb3b5cd967ed6c7b7006ddbf0218cacfcd5d19a Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Thu, 30 Jul 2026 18:26:42 -0700 Subject: [PATCH 30/39] fix(components): request restart when a component's config block is removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a component's block from the config emits `remove` from OptionsWatcher, but Scope only wired `change` — so a running component kept serving until some unrelated restart. That gap became user-visible with the /v1 models gateway, where an absent block is the canonical disabled state (@kriszyp's review): deleting `modelsGateway:` left the three routes live. Fixed generically in Scope rather than per-plugin: a `remove` listener that mirrors the change listener's contract — if the plugin registers its own `remove` handler it owns the response; otherwise Scope requests a restart. Regression tests cover both the restart-on-deletion path and the plugin-owned-removal path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RXx78W5LxbvxEdSyrB1vvn --- components/Scope.ts | 18 ++++++++++ unitTests/components/Scope.test.js | 57 ++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/components/Scope.ts b/components/Scope.ts index 11fcd79e12..4801db2ea8 100644 --- a/components/Scope.ts +++ b/components/Scope.ts @@ -145,6 +145,7 @@ export class Scope extends EventEmitter { this.options = new OptionsWatcher(pluginName, configFilePath, this.#logger, isRootConfig) .on('error', this.#handleError.bind(this)) .on('change', this.#optionsWatcherChangeListener.bind(this)()) + .on('remove', this.#optionsWatcherRemoveListener()) .on('ready', this.#handleOptionsWatcherReady.bind(this)); // Bridge cross-thread deploy lifecycle events for this component. The @@ -361,6 +362,23 @@ export class Scope extends EventEmitter { }; } + #optionsWatcherRemoveListener() { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const scope = this; + // Deleting a component's config block emits `remove` (not `change`), so without + // this listener a running component keeps serving until some unrelated restart — + // even though absence is the canonical disabled state for opt-in built-ins like + // the /v1 models gateway. Mirrors the change listener: a plugin that registers + // its own `remove` handler owns the response and no restart is requested. + return function handleOptionsWatcherRemove(this: OptionsWatcher) { + if (this.listenerCount('remove') > 1) { + return; + } + scope.#logger.debug?.('Options removed, requesting restart'); + scope.requestRestart(); + }; + } + #getFilesOption(): FileAndURLPathConfig | undefined { const config = this.options.getAll(); if ( diff --git a/unitTests/components/Scope.test.js b/unitTests/components/Scope.test.js index 3f108cc05f..f6723d411c 100644 --- a/unitTests/components/Scope.test.js +++ b/unitTests/components/Scope.test.js @@ -220,6 +220,63 @@ describe('Scope', () => { await scope.close(); }); + it('should call requestRestart when the plugin config block is deleted', async () => { + // Deleting a component's block emits `remove`, not `change`. Absence is the + // canonical disabled state for opt-in built-ins (e.g. the /v1 models gateway), + // so removal must restart just like a change would — otherwise the component + // keeps serving until some unrelated restart. + writeFileSync(this.configFilePath, stringify({ [this.pluginName]: { enabled: true } })); + + const scope = new Scope( + this.appName, + this.pluginName, + this.directory, + this.configFilePath, + this.resources, + this.server + ); + + await scope.ready; + + assert.equal(restartNeeded(), false, 'requestRestart should not be called yet'); + + // Rewrite the config with the plugin's block deleted entirely + await writeFile(this.configFilePath, stringify({ otherPlugin: { enabled: true } })); + + await waitFor(() => restartNeeded()); + + assert.equal(restartNeeded(), true, 'requestRestart should be called on block removal'); + + await scope.close(); + }); + + it('should NOT call requestRestart on block removal when the plugin handles remove itself', async () => { + writeFileSync(this.configFilePath, stringify({ [this.pluginName]: { enabled: true } })); + + const scope = new Scope( + this.appName, + this.pluginName, + this.directory, + this.configFilePath, + this.resources, + this.server + ); + + await scope.ready; + + const removeSpy = spy(); + scope.options.on('remove', removeSpy); + + await writeFile(this.configFilePath, stringify({ otherPlugin: { enabled: true } })); + + await waitFor(() => removeSpy.callCount > 0); + + assert.equal(removeSpy.callCount, 1, 'plugin remove handler should be invoked'); + assert.equal(restartNeeded(), false, 'plugin owns removal handling; no restart requested'); + + await scope.close(); + }); + it('should emit error for missing default entry handler', async () => { writeFileSync(this.configFilePath, stringify({ [this.pluginName]: { foo: 'bar' } })); From f43b7acfbd5ef66b15002cb4d8287b1f8b807add Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Thu, 30 Jul 2026 18:49:46 -0700 Subject: [PATCH 31/39] fix(models): reserve /v1 gateway paths against silent app-Resource replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resources.set identifies collisions by databaseName/tableName, which are both undefined for non-table Resources — so an application class with `static path = 'v1/models'` silently replaced the gateway (and its super_user gate) with whatever auth the app class carries. Reviewed as a route-hijack risk on PR #1616. Fix: a `static reservedPath = true` marker on the three gateway Resources; Resources.set now treats a differing-class registration against a reserved path as a conflict (existing behavior: ErrorResource at that path + logged error), while same-class re-registration stays idempotent and `force` still overrides. Plain app-vs-app replacement semantics are unchanged. Verified live via direct spawn: contested v1/models returns a 500 conflict envelope (never the imposter payload), uncontested gateway routes and the app's own routes serve normally. Also covered by a new collision fixture in the mixed-app integration suite and unit tests over set/getMatch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RXx78W5LxbvxEdSyrB1vvn --- .../v1-gateway-collision-app/config.yaml | 7 ++ .../v1-gateway-collision-app/resources.js | 19 ++++++ .../server/v1-gateway-mixed-app.test.ts | 62 +++++++++++++++++ resources/Resources.ts | 9 ++- resources/models/v1/chatCompletions.ts | 5 ++ resources/models/v1/embeddings.ts | 5 ++ resources/models/v1/models.ts | 5 ++ .../resources/Resources.reservedPath.test.js | 67 +++++++++++++++++++ 8 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 integrationTests/server/v1-gateway-collision-app/config.yaml create mode 100644 integrationTests/server/v1-gateway-collision-app/resources.js create mode 100644 unitTests/resources/Resources.reservedPath.test.js diff --git a/integrationTests/server/v1-gateway-collision-app/config.yaml b/integrationTests/server/v1-gateway-collision-app/config.yaml new file mode 100644 index 0000000000..a5b507d0f6 --- /dev/null +++ b/integrationTests/server/v1-gateway-collision-app/config.yaml @@ -0,0 +1,7 @@ +# Fixture for the /v1 route-reservation test: an app that (wrongly) tries to claim +# the gateway's fixed `v1/models` path with its own custom Resource. The gateway's +# reservedPath marking must turn this into a loud conflict rather than a silent +# replacement of the endpoint and its super_user gate. +rest: true +jsResource: + files: resources.js diff --git a/integrationTests/server/v1-gateway-collision-app/resources.js b/integrationTests/server/v1-gateway-collision-app/resources.js new file mode 100644 index 0000000000..2704ac4c6e --- /dev/null +++ b/integrationTests/server/v1-gateway-collision-app/resources.js @@ -0,0 +1,19 @@ +// An app Resource that tries to claim the gateway's fixed `v1/models` route. +// Before path reservation, this silently replaced the gateway endpoint (both are +// non-table Resources, so Resources.set saw no conflict). Now it must produce a +// loud conflict (ErrorResource → 500 + logged error), never this payload. +class Imposter extends Resource { + static path = 'v1/models'; + get() { + return { imposter: true }; + } +} + +// A legitimately-named app resource, proving the app itself still loads and serves. +export class Legit extends Resource { + get() { + return { legit: true }; + } +} + +export { Imposter }; diff --git a/integrationTests/server/v1-gateway-mixed-app.test.ts b/integrationTests/server/v1-gateway-mixed-app.test.ts index 4410080b6f..fa7b56026f 100644 --- a/integrationTests/server/v1-gateway-mixed-app.test.ts +++ b/integrationTests/server/v1-gateway-mixed-app.test.ts @@ -138,3 +138,65 @@ suite('control: the same table IS reachable once REST is configured', (ctx: Cont assert.equal(status, 200, 'if this stops returning 200 the regression guard above has gone vacuous'); }); }); + +/** + * Route reservation (#1616 review): the gateway's fixed routes are non-table + * Resources, and `Resources.set` compares databaseName/tableName (both undefined) + * for conflict identity — so before `reservedPath`, an app registering its own + * Resource at `v1/models` silently replaced the gateway endpoint and its + * super_user gate, with no startup error. Now it must be a loud conflict: the + * path serves an ErrorResource (500), never the app's payload, and the rest of + * the gateway keeps working. + */ +suite('an app claiming a reserved /v1 route is a loud conflict, not a silent takeover', (ctx: ContextWithHarper) => { + before(async () => { + await setupHarperWithFixture(ctx, resolvePath(__dirname, 'v1-gateway-collision-app'), { + config: { + rest: { webSocket: true }, + modelsGateway: { enabled: true }, + models: { + generative: { default: { backend: ECHO_BACKEND_PATH } }, + embedding: { default: { backend: ECHO_BACKEND_PATH } }, + }, + }, + env: {}, + }); + }); + + after(async () => { + await teardownHarper(ctx); + }); + + test('the contested path never serves the imposter payload', async () => { + const res = await fetch(`${ctx.harper.httpURL}/v1/models`, { + headers: { Authorization: authHeader(ctx) }, + signal: AbortSignal.timeout(5_000), + }); + const text = await res.text(); + assert.ok( + !text.includes('imposter'), + `app resource must not take over a reserved route, got: ${text.slice(0, 200)}` + ); + // Loud conflict: the reserved path serves the conflict ErrorResource (500), + // which is the same behavior table-path conflicts have always had. + assert.equal(res.status, 500, `expected the conflict ErrorResource, got ${res.status}: ${text.slice(0, 200)}`); + }); + + test('the rest of the gateway still serves (conflict is contained to the contested path)', async () => { + const res = await fetch(`${ctx.harper.httpURL}/v1/embeddings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': authHeader(ctx) }, + body: JSON.stringify({ model: 'default', input: 'hello' }), + signal: AbortSignal.timeout(5_000), + }); + assert.equal(res.status, 200, 'uncontested gateway routes must be unaffected'); + }); + + test('the app itself still loads and serves its legitimately-named resource', async () => { + const res = await fetch(`${ctx.harper.httpURL}/Legit/`, { + headers: { Authorization: authHeader(ctx) }, + signal: AbortSignal.timeout(5_000), + }); + assert.equal(res.status, 200, 'the collision must not break the rest of the app'); + }); +}); diff --git a/resources/Resources.ts b/resources/Resources.ts index 95546047a2..e26ac3ba4c 100644 --- a/resources/Resources.ts +++ b/resources/Resources.ts @@ -143,8 +143,15 @@ export class Resources extends Map { const existingEntry = super.get(path); if ( existingEntry && + existingEntry.Resource !== resource && (existingEntry.Resource.databaseName !== resource.databaseName || - existingEntry.Resource.tableName !== resource.tableName) && + existingEntry.Resource.tableName !== resource.tableName || + // Reserved paths (e.g. the /v1 gateway's fixed routes): two non-table Resources + // both have undefined databaseName/tableName, so without this a later app + // registration would silently replace the reserved entry — and its auth gate — + // with no startup error. The identity check above keeps same-class + // re-registration idempotent. + existingEntry.Resource.reservedPath === true) && !force ) { // there was a conflict in endpoint paths. We don't want this to be ignored, so we log it diff --git a/resources/models/v1/chatCompletions.ts b/resources/models/v1/chatCompletions.ts index 683918acc8..2d69567808 100644 --- a/resources/models/v1/chatCompletions.ts +++ b/resources/models/v1/chatCompletions.ts @@ -32,6 +32,11 @@ const sseHandler = contentTypes.get('text/event-stream') as SseHandler; // @ts-ignore — Resource base class is not typed for static dispatch; pattern mirrors login.ts export class V1ChatCompletions extends Resource { + // Reserve this fixed route: a later app registration at the same path becomes a + // loud conflict (ErrorResource) instead of silently replacing the gateway and its + // super_user gate. See Resources.set. + static reservedPath = true; + static async post(_target: unknown, body: unknown, request: unknown) { const authError = authorizeV1Request(request as any); if (authError) return authError; diff --git a/resources/models/v1/embeddings.ts b/resources/models/v1/embeddings.ts index e921d82238..f7e43824d1 100644 --- a/resources/models/v1/embeddings.ts +++ b/resources/models/v1/embeddings.ts @@ -17,6 +17,11 @@ const MAX_EMBEDDING_INPUTS = 2048; // @ts-ignore — Resource base class is not typed for static dispatch; pattern mirrors login.ts export class V1Embeddings extends Resource { + // Reserve this fixed route: a later app registration at the same path becomes a + // loud conflict (ErrorResource) instead of silently replacing the gateway and its + // super_user gate. See Resources.set. + static reservedPath = true; + static async post(_target: unknown, body: Record, request: unknown) { const authError = authorizeV1Request(request as any); if (authError) return authError; diff --git a/resources/models/v1/models.ts b/resources/models/v1/models.ts index 572b6056a6..495ad427f0 100644 --- a/resources/models/v1/models.ts +++ b/resources/models/v1/models.ts @@ -27,6 +27,11 @@ export interface OAIModelList { // @ts-ignore — Resource base class is not typed for static dispatch; pattern mirrors login.ts export class V1Models extends Resource { + // Reserve this fixed route: a later app registration at the same path becomes a + // loud conflict (ErrorResource) instead of silently replacing the gateway and its + // super_user gate. See Resources.set. + static reservedPath = true; + static get(_target: unknown, request: unknown): OAIModelList | OpenAIErrorResponse { const authError = authorizeV1Request(request as any); if (authError) return authError; diff --git a/unitTests/resources/Resources.reservedPath.test.js b/unitTests/resources/Resources.reservedPath.test.js new file mode 100644 index 0000000000..f8035213e4 --- /dev/null +++ b/unitTests/resources/Resources.reservedPath.test.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Collision identity for reserved (non-table) resource paths (#631, PR #1616 review). + * + * `Resources.set` detects conflicts by comparing databaseName/tableName — both + * `undefined` for two plain Resource classes, so a later registration at the same + * path silently replaced the earlier one. For reserved fixed routes (the /v1 + * gateway), that let an app overwrite the endpoint — and its super_user gate — + * with no startup error. `reservedPath = true` makes that a loud conflict. + */ + +const assert = require('node:assert'); +const { Resources } = require('#src/resources/Resources'); +const { ErrorResource } = require('#src/resources/ErrorResource'); + +class ReservedThing { + static reservedPath = true; +} +class PlainThing {} +class OtherPlainThing {} + +describe('Resources reserved paths', () => { + let resources; + beforeEach(() => { + resources = new Resources(); + }); + + it('turns a later registration over a reserved path into a loud conflict (ErrorResource)', () => { + resources.set('v1/models', ReservedThing); + resources.set('v1/models', PlainThing); + const entry = resources.get('v1/models'); + assert.ok(entry.Resource instanceof ErrorResource, 'conflict must be loud, not a silent replacement'); + }); + + it('keeps same-class re-registration of a reserved path idempotent', () => { + resources.set('v1/models', ReservedThing); + resources.set('v1/models', ReservedThing); + assert.equal(resources.get('v1/models').Resource, ReservedThing); + }); + + it('allows force to replace a reserved path (explicit override)', () => { + resources.set('v1/models', ReservedThing); + resources.set('v1/models', PlainThing, undefined, true); + assert.equal(resources.get('v1/models').Resource, PlainThing); + }); + + it('preserves existing behavior: a plain non-table resource can still be replaced', () => { + resources.set('widget', PlainThing); + resources.set('widget', OtherPlainThing); + assert.equal(resources.get('widget').Resource, OtherPlainThing, 'non-reserved replacement is unchanged'); + }); + + it('preserves existing behavior: table identity mismatch still conflicts', () => { + class TableA { + static databaseName = 'data'; + static tableName = 'a'; + } + class TableB { + static databaseName = 'data'; + static tableName = 'b'; + } + resources.set('t', TableA); + resources.set('t', TableB); + assert.ok(resources.get('t').Resource instanceof ErrorResource); + }); +}); From 75da1507121c135642d50c85c87481a8405183e1 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Thu, 30 Jul 2026 18:49:59 -0700 Subject: [PATCH 32/39] fix(models): explicit protocol visibility for /v1 routes; getMatch honors blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway registrations carried no exportTypes policy, so the shared registry matched them for every protocol lookup — WS dispatch could reach V1ChatCompletions.connect(), get the non-iterable badRequest envelope, and fail async-iterating it; the endpoints also surfaced through MQTT/GraphQL/MCP enumeration. Reviewed on PR #1616. Registration now passes REST-only visibility for all three endpoints plus SSE for chat/completions (explicit Accept: text/event-stream dispatch). Writing the routing assertions exposed a latent Resources.getMatch bug: the exportTypes check only guarded the relativeURL assignment on the exact-path and root-fallback paths, so a protocol-blocked entry was still returned to the caller (MCP worked around this with its own isMcpExposed re-check). Blocked entries are now treated as not-found and fall through to paramRoutes/root like any other miss. Covered by a new routing unit suite (REST serves all three, SSE chat-only, ws/mqtt/graphql/mcp invisible); full resources + components + mcp unit suites pass unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RXx78W5LxbvxEdSyrB1vvn --- resources/Resources.ts | 17 +++++-- resources/models/v1/index.ts | 13 ++++-- unitTests/resources/models/v1/routing.test.js | 46 +++++++++++++++++++ 3 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 unitTests/resources/models/v1/routing.test.js diff --git a/resources/Resources.ts b/resources/Resources.ts index e26ac3ba4c..8ffcbd6032 100644 --- a/resources/Resources.ts +++ b/resources/Resources.ts @@ -318,9 +318,16 @@ export class Resources extends Map { if (!foundEntry && path.indexOf('.') > -1) { foundEntry = this.get(path.split('.')[0]); } - if (foundEntry && (!exportType || foundEntry.exportTypes?.[exportType] !== false)) { + // An entry whose exportTypes disables this protocol is invisible to it — treat as + // not-found and keep searching. Previously the check only gated relativeURL and the + // blocked entry was still returned (the MCP layer re-checks exportTypes.mcp itself + // to work around exactly this). + if (foundEntry && exportType && foundEntry.exportTypes?.[exportType] === false) { + foundEntry = undefined; + } + if (foundEntry) { foundEntry.relativeURL = searchIndex > -1 ? url.slice(searchIndex) : ''; - } else if (!foundEntry) { + } else { // no static resource matched; try parameterised routes before falling back to an explicit root resource if (this.paramRoutes.length) { const paramMatch = this.matchParamRoute(url, exportType); @@ -328,7 +335,11 @@ export class Resources extends Map { } // still not found, see if there is an explicit root path foundEntry = this.get(''); - if (foundEntry && (!exportType || foundEntry.exportTypes?.[exportType] !== false)) { + if (foundEntry && exportType && foundEntry.exportTypes?.[exportType] === false) { + // root resource not exported for this protocol either + foundEntry = undefined; + } + if (foundEntry) { if (url.charAt(0) !== '/') url = '/' + url; foundEntry.relativeURL = url; } diff --git a/resources/models/v1/index.ts b/resources/models/v1/index.ts index 85024456e7..199bc526fb 100644 --- a/resources/models/v1/index.ts +++ b/resources/models/v1/index.ts @@ -59,7 +59,14 @@ export function handleApplication(scope: Scope): void { 'modelsGateway is enabled but no `rest` section is configured; /v1/* endpoints are only served when REST is active' ); } - scope.resources.set('v1/models', V1Models); - scope.resources.set('v1/embeddings', V1Embeddings); - scope.resources.set('v1/chat/completions', V1ChatCompletions); + // Explicit protocol visibility: these are REST-only wire-protocol endpoints. Without a + // policy, the shared registry matches them for every protocol lookup — WS dispatch could + // reach V1ChatCompletions.connect() and then fail iterating its non-iterable badRequest + // envelope, and they would surface through MQTT/GraphQL/MCP enumeration too. `sse` stays + // enabled for chat only: an explicit `Accept: text/event-stream` POST is dispatched via + // the sse lookup (REST.ts) and is a supported streaming client shape (see connect()). + const restOnly = { rest: true, sse: false, ws: false, mqtt: false, graphql: false, mcp: false }; + scope.resources.set('v1/models', V1Models, restOnly); + scope.resources.set('v1/embeddings', V1Embeddings, restOnly); + scope.resources.set('v1/chat/completions', V1ChatCompletions, { ...restOnly, sse: true }); } diff --git a/unitTests/resources/models/v1/routing.test.js b/unitTests/resources/models/v1/routing.test.js new file mode 100644 index 0000000000..5016ae4ba6 --- /dev/null +++ b/unitTests/resources/models/v1/routing.test.js @@ -0,0 +1,46 @@ +'use strict'; + +/** + * Protocol visibility for the `/v1/*` gateway registrations (#631, PR #1616 review). + * + * Without an exportTypes policy, entries in the shared registry match every + * protocol lookup — WS dispatch could reach V1ChatCompletions.connect() and fail + * iterating its non-iterable badRequest envelope, and the endpoints would surface + * through MQTT/GraphQL/MCP enumeration. The gateway registers REST for all three, + * SSE for chat only, and false for everything else. + */ + +const assert = require('node:assert'); +require('#src/resources/databases'); +const { Resources } = require('#src/resources/Resources'); +const { handleApplication } = require('#src/resources/models/v1/index'); +const { V1Models } = require('#src/resources/models/v1/models'); +const { V1ChatCompletions } = require('#src/resources/models/v1/chatCompletions'); + +describe('/v1 gateway protocol routing', () => { + let resources; + beforeEach(() => { + resources = new Resources(); + handleApplication({ options: { get: () => true, on: () => {} }, resources }); + }); + + it('serves all three endpoints over REST', () => { + assert.equal(resources.getMatch('v1/models', 'rest')?.Resource, V1Models); + assert.ok(resources.getMatch('v1/embeddings', 'rest')); + assert.equal(resources.getMatch('v1/chat/completions', 'rest')?.Resource, V1ChatCompletions); + }); + + it('serves SSE for chat only (explicit Accept: text/event-stream dispatch)', () => { + assert.equal(resources.getMatch('v1/chat/completions', 'sse')?.Resource, V1ChatCompletions); + assert.equal(resources.getMatch('v1/models', 'sse'), undefined); + assert.equal(resources.getMatch('v1/embeddings', 'sse'), undefined); + }); + + for (const protocol of ['ws', 'mqtt', 'graphql', 'mcp']) { + it(`is invisible to ${protocol} lookups`, () => { + assert.equal(resources.getMatch('v1/models', protocol), undefined); + assert.equal(resources.getMatch('v1/embeddings', protocol), undefined); + assert.equal(resources.getMatch('v1/chat/completions', protocol), undefined); + }); + } +}); From 32016eed576ff1cbc8978f80ebf3e64e78d88f5d Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Thu, 30 Jul 2026 18:50:11 -0700 Subject: [PATCH 33/39] fix(models): validate top-level chat fields instead of coercing or ignoring them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validateChatRequest only checked messages/tools shape, so malformed top-level fields slipped through: a non-string model silently ran the configured default backend, a truthy non-boolean stream (e.g. the JSON string "false") returned SSE the client didn't ask for, and malformed temperature/max_tokens values were forwarded to backends to fail in provider-specific ways. Reviewed on PR #1616. Now rejected up front with OpenAI invalid_request_error envelopes: non-string model (mirrored on /v1/embeddings), non-boolean stream, non-finite or out-of-range temperature (0–2 per OpenAI), and non-positive-integer max_tokens / max_completion_tokens. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RXx78W5LxbvxEdSyrB1vvn --- resources/models/v1/embeddings.ts | 4 +++ resources/models/v1/translation.ts | 18 +++++++++++++ .../resources/models/v1/embeddings.test.js | 6 +++++ .../resources/models/v1/translation.test.js | 25 +++++++++++++++++++ 4 files changed, 53 insertions(+) diff --git a/resources/models/v1/embeddings.ts b/resources/models/v1/embeddings.ts index f7e43824d1..9ffb9f7c95 100644 --- a/resources/models/v1/embeddings.ts +++ b/resources/models/v1/embeddings.ts @@ -39,6 +39,10 @@ export class V1Embeddings extends Resource { return badRequest('Request body must be a JSON object'); const raw = body as Record; + // Mirrors validateChatRequest: a non-string model would silently invoke the + // configured default rather than being rejected. + if (raw.model !== undefined && typeof raw.model !== 'string') return badRequest("'model' must be a string"); + const input = raw.input; if (input === undefined || input === null) return badRequest("'input' is required"); if (typeof input !== 'string' && !Array.isArray(input)) { diff --git a/resources/models/v1/translation.ts b/resources/models/v1/translation.ts index 108c073711..8c1124c20d 100644 --- a/resources/models/v1/translation.ts +++ b/resources/models/v1/translation.ts @@ -130,6 +130,24 @@ function isRepresentableToolChoice(choice: unknown): boolean { */ export function validateChatRequest(body: OAIChatRequest): string | null { const req = body as any; + // Top-level fields that control routing and framing first: a non-string `model` + // would silently invoke the configured default model (potentially expensive), and a + // truthy non-boolean `stream` ("false") would return SSE the client didn't ask for. + if (req.model !== undefined && typeof req.model !== 'string') return "'model' must be a string"; + if (req.stream !== undefined && typeof req.stream !== 'boolean') return "'stream' must be a boolean"; + if (req.temperature !== undefined) { + if (typeof req.temperature !== 'number' || !Number.isFinite(req.temperature)) { + return "'temperature' must be a finite number"; + } + if (req.temperature < 0 || req.temperature > 2) return "'temperature' must be between 0 and 2"; + } + for (const field of ['max_tokens', 'max_completion_tokens']) { + const value = req[field]; + if (value === undefined) continue; + if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) { + return `'${field}' must be a positive integer`; + } + } if (!Array.isArray(req.messages) || req.messages.length === 0) return "'messages' must be a non-empty array"; for (let i = 0; i < req.messages.length; i++) { const m = req.messages[i]; diff --git a/unitTests/resources/models/v1/embeddings.test.js b/unitTests/resources/models/v1/embeddings.test.js index 44d77a7b6a..d31c9a10eb 100644 --- a/unitTests/resources/models/v1/embeddings.test.js +++ b/unitTests/resources/models/v1/embeddings.test.js @@ -62,6 +62,12 @@ describe('V1Embeddings.post', () => { assert.equal(result.status, 401, 'auth must be checked ahead of body validation'); }); + it('rejects a non-string model with a 400 instead of silently running the default', async () => { + const result = await V1Embeddings.post(undefined, { input: 'hi', model: 7 }, { user: SUPER_USER }); + assert.equal(result.status, 400); + assert.match(result.data.error.message, /'model'/); + }); + // Mirrors the chatCompletions case: REST hands over the streaming JSON deserializer's // promise, and a malformed body rejects it. That rejection must be shaped as an OpenAI // 400 rather than escaping to REST's RFC 9457 path as a 500. diff --git a/unitTests/resources/models/v1/translation.test.js b/unitTests/resources/models/v1/translation.test.js index 9471770109..286f64d554 100644 --- a/unitTests/resources/models/v1/translation.test.js +++ b/unitTests/resources/models/v1/translation.test.js @@ -204,6 +204,31 @@ describe('validateChatRequest', () => { assert.equal(validateChatRequest(ok), null); }); + // Top-level routing/framing fields (review round 3): a non-string model silently ran + // the configured default; a truthy non-boolean stream returned SSE the client + // didn't ask for. + it('rejects a non-string model rather than silently running the default', () => { + assert.match(validateChatRequest({ ...ok, model: 7 }), /'model'/); + assert.match(validateChatRequest({ ...ok, model: { name: 'x' } }), /'model'/); + }); + + it('rejects a non-boolean stream rather than treating "false" as truthy SSE', () => { + assert.match(validateChatRequest({ ...ok, stream: 'false' }), /'stream'/); + assert.match(validateChatRequest({ ...ok, stream: 1 }), /'stream'/); + assert.equal(validateChatRequest({ ...ok, stream: true }), null); + assert.equal(validateChatRequest({ ...ok, stream: false }), null); + }); + + it('rejects malformed numeric options instead of ignoring or forwarding them', () => { + assert.match(validateChatRequest({ ...ok, temperature: 'hot' }), /'temperature'/); + assert.match(validateChatRequest({ ...ok, temperature: NaN }), /'temperature'/); + assert.match(validateChatRequest({ ...ok, temperature: 3 }), /between 0 and 2/); + assert.match(validateChatRequest({ ...ok, max_tokens: '100' }), /'max_tokens'/); + assert.match(validateChatRequest({ ...ok, max_tokens: 0 }), /'max_tokens'/); + assert.match(validateChatRequest({ ...ok, max_completion_tokens: 1.5 }), /'max_completion_tokens'/); + assert.equal(validateChatRequest({ ...ok, temperature: 0.7, max_tokens: 100 }), null); + }); + it('rejects a missing or empty messages array', () => { assert.ok(validateChatRequest({})); assert.ok(validateChatRequest({ messages: [] })); From ca162e923bc3ad0d1f767e97e61cfe1aa58026ca Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Thu, 30 Jul 2026 18:51:58 -0700 Subject: [PATCH 34/39] fix(models): report capability mismatches as OpenAI 400s, not sanitized 500s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ModelCapabilityError extends ServerError, so toOpenAIError's statusCode branch treated it as a genuine backend failure: logged, sanitized to a generic 'Internal server error' 500. But a capability mismatch is caller-driven — sending `tools` (or requesting streaming) to a configured backend whose capabilities say otherwise — and the client needs to see what to change. Reviewed on PR #1616. Now mapped to 400 invalid_request_error with code capability_unsupported, message passed through (it names only the backend and the capability asked for). Covers both response paths: the JSON envelope and the SSE error frame share this mapping via formatError. Genuine 5xx sanitization is unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RXx78W5LxbvxEdSyrB1vvn --- resources/models/v1/errors.ts | 11 +++++++++++ unitTests/resources/models/v1/errors.test.js | 12 ++++++++++++ 2 files changed, 23 insertions(+) diff --git a/resources/models/v1/errors.ts b/resources/models/v1/errors.ts index e03262c201..fbd5927d75 100644 --- a/resources/models/v1/errors.ts +++ b/resources/models/v1/errors.ts @@ -7,6 +7,7 @@ */ import { ModelBackendNotFoundError } from '../backendRegistry.ts'; +import { ModelCapabilityError } from '../Models.ts'; import harperLogger from '../../../utility/logging/harper_logger.ts'; type OpenAIErrorType = @@ -40,6 +41,16 @@ export function toOpenAIError(err: unknown): OpenAIErrorResponse { status = 404; type = 'invalid_request_error'; code = 'model_not_found'; + } else if (err instanceof ModelCapabilityError) { + // Caller-driven mismatch (e.g. `tools` or streaming against a backend that + // doesn't support it): the request is what's wrong, not the server. It extends + // ServerError (statusCode 500), so this must precede the statusCode branch — + // falling through would report a generic sanitized 500 for a client-actionable + // condition. The message is safe to pass through: it names only the backend and + // the capability the caller asked for. + status = 400; + type = 'invalid_request_error'; + code = 'capability_unsupported'; } else if (err instanceof Error && typeof (err as any).statusCode === 'number') { status = (err as any).statusCode; if (status === 401) { diff --git a/unitTests/resources/models/v1/errors.test.js b/unitTests/resources/models/v1/errors.test.js index f536a43293..1bc37f6c5e 100644 --- a/unitTests/resources/models/v1/errors.test.js +++ b/unitTests/resources/models/v1/errors.test.js @@ -9,6 +9,7 @@ const assert = require('node:assert'); const { toOpenAIError, badRequest, authorizeV1Request } = require('#src/resources/models/v1/errors'); const { ModelBackendNotFoundError } = require('#src/resources/models/backendRegistry'); +const { ModelCapabilityError } = require('#src/resources/models/Models'); function makeClientError(message, statusCode) { const err = new Error(message); @@ -26,6 +27,17 @@ describe('toOpenAIError', () => { assert.ok(resp.data.error.message.includes('missing-model')); }); + it('maps ModelCapabilityError to 400 invalid_request_error, not a sanitized 500', () => { + // It extends ServerError (statusCode 500), but a capability mismatch is + // caller-driven — e.g. sending `tools` to a backend that doesn't support them — + // so the client must see what to change, not "Internal server error". + const resp = toOpenAIError(new ModelCapabilityError('my-backend', 'tools')); + assert.equal(resp.status, 400); + assert.equal(resp.data.error.type, 'invalid_request_error'); + assert.equal(resp.data.error.code, 'capability_unsupported'); + assert.ok(resp.data.error.message.includes("'tools'"), 'message must name the unmet capability'); + }); + it('maps 400 statusCode errors to invalid_request_error', () => { const resp = toOpenAIError(makeClientError('bad input', 400)); assert.equal(resp.status, 400); From 37b33598c79b4dd6b97888d3e3aa597d5bff379b Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Thu, 30 Jul 2026 18:54:44 -0700 Subject: [PATCH 35/39] fix(models): carry OpenAI 'developer' role as 'system'; reject unknown roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validateChatRequest accepted any string role and translateMessages cast it into Harper's Message union, so 'developer' — a current Chat Completions role — passed through untyped and provider adapters degraded it to 'user', silently demoting the instruction's priority. Reviewed on PR #1616. 'developer' is OpenAI's successor to 'system' (their API treats the two identically, converting system to developer on newer models), and every provider adapter maps Harper's 'system' to its system-instruction slot — so the gateway now normalizes developer → system at translation, preserving the channel across all backends without widening the internal Message union that custom backends implement. Roles outside system/developer/user/assistant/tool are now a 400 (including the deprecated 'function' role) instead of a silent cast. Covered on both paths kris named: pure translation (developer → system) and a composed gateway→AnthropicBackend test asserting the developer message lands in Anthropic's top-level system field, not a user turn. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RXx78W5LxbvxEdSyrB1vvn --- resources/models/v1/translation.ts | 17 +++++- .../resources/models/v1/translation.test.js | 52 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/resources/models/v1/translation.ts b/resources/models/v1/translation.ts index 8c1124c20d..09be35e2ee 100644 --- a/resources/models/v1/translation.ts +++ b/resources/models/v1/translation.ts @@ -75,7 +75,14 @@ export function translateMessages(oaiMessages: OAIMessageIn[]): Message[] { ? (m.content as Array<{ text: string }>).map((part) => part.text).join('') : (m.content ?? ''); const base: Message = { - role: m.role as Message['role'], + // `developer` is OpenAI's successor to `system` (the API itself treats the two + // identically, converting `system` to `developer` on models that use the newer + // hierarchy). Harper's internal Message union carries the channel as `system`, + // which every provider adapter maps to its system-instruction slot — so the + // instruction's priority is preserved rather than degraded to `user`. Roles + // outside the union are rejected up front by validateChatRequest, so the cast + // below is over a vetted set. + role: m.role === 'developer' ? 'system' : (m.role as Message['role']), content, }; if (m.tool_calls?.length) { @@ -153,6 +160,14 @@ export function validateChatRequest(body: OAIChatRequest): string | null { const m = req.messages[i]; if (!m || typeof m !== 'object' || Array.isArray(m)) return `'messages[${i}]' must be an object`; if (typeof m.role !== 'string') return `'messages[${i}].role' must be a string`; + // Closed set: translateMessages casts into Harper's Message union, so an + // unknown role must be a loud 400 here — previously it was cast through and + // provider adapters degraded it to 'user', silently changing its priority. + // 'developer' is accepted and carried as 'system' (see translateMessages); + // the deprecated 'function' role is deliberately not supported. + if (!['system', 'developer', 'user', 'assistant', 'tool'].includes(m.role)) { + return `'messages[${i}].role' must be one of 'system', 'developer', 'user', 'assistant', 'tool'`; + } // OpenAI allows content parts: [{ type: 'text', text: '...' }, ...]. Harper's // Message.content is a string, so those are flattened in translateMessages; reject // shapes we cannot flatten rather than passing a non-string downstream. diff --git a/unitTests/resources/models/v1/translation.test.js b/unitTests/resources/models/v1/translation.test.js index 286f64d554..1e407cde29 100644 --- a/unitTests/resources/models/v1/translation.test.js +++ b/unitTests/resources/models/v1/translation.test.js @@ -77,6 +77,51 @@ describe('translateMessages', () => { assert.equal(result[0].toolCallId, 'call_1'); }); + it("carries 'developer' as 'system' — same channel, priority preserved (review round 3)", () => { + // OpenAI treats developer as the successor to system (the API converts system to + // developer on newer models); Harper's internal union spells the channel 'system'. + // Before this mapping the cast passed 'developer' through and provider adapters + // degraded it to 'user'. + const result = translateMessages([ + { role: 'developer', content: 'be brief' }, + { role: 'user', content: 'q' }, + ]); + assert.equal(result[0].role, 'system'); + assert.equal(result[0].content, 'be brief'); + assert.equal(result[1].role, 'user'); + }); + + it("a translated 'developer' message reaches Anthropic's system slot, not a user turn", async () => { + // Composed path: gateway translation → AnthropicBackend wire request. Guards the + // full property kris's finding names — the instruction's priority survives to the + // provider — against either layer drifting independently. + const { AnthropicBackend } = require('#src/components/anthropic/index'); + const calls = []; + const fetch = async (url, init) => { + calls.push({ url, init }); + return new Response( + JSON.stringify({ + id: 'msg_x', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'ok' }], + stop_reason: 'end_turn', + usage: { input_tokens: 1, output_tokens: 1 }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + }; + const backend = new AnthropicBackend({ apiKey: 'sk-ant-test', model: 'claude' }, fetch); + const messages = translateMessages([ + { role: 'developer', content: 'be brief' }, + { role: 'user', content: 'q' }, + ]); + await backend.generate(messages, { accounting: { tenantId: 't', app: '/test' } }); + const sent = JSON.parse(calls[0].init.body); + assert.equal(sent.system, 'be brief'); + assert.deepEqual(sent.messages, [{ role: 'user', content: 'q' }]); + }); + it('flattens OpenAI content parts to the string Message.content expects', () => { const result = translateMessages([ { @@ -204,6 +249,13 @@ describe('validateChatRequest', () => { assert.equal(validateChatRequest(ok), null); }); + it("accepts the 'developer' role and rejects roles outside the supported set", () => { + assert.equal(validateChatRequest({ messages: [{ role: 'developer', content: 'x' }] }), null); + // Previously any string role was cast through and silently degraded by adapters. + assert.match(validateChatRequest({ messages: [{ role: 'function', content: 'x' }] }), /role/); + assert.match(validateChatRequest({ messages: [{ role: 'moderator', content: 'x' }] }), /role/); + }); + // Top-level routing/framing fields (review round 3): a non-string model silently ran // the configured default; a truthy non-boolean stream returned SSE the client // didn't ask for. From edccb0d0b92571bdc1ed6dc556c466c4831011d5 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Fri, 31 Jul 2026 08:59:37 -0700 Subject: [PATCH 36/39] fix(models): report real embedding token usage on /v1/embeddings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every successful embeddings response reported zero usage: models.embed() unwraps ModelCallResult and drops the usage all built-in embedding backends already provide, and the gateway passed nothing to toEmbedResponse. Reviewed on PR #1616. Models gains embedWithUsage() — embed() plus the winning backend's result-level usage, with embed() now delegating to it — as the internal gateway path kris prescribed; the public embed() contract is unchanged and the analytics/fallback behavior is shared, not duplicated. The gateway passes that usage through, and toEmbedResponse now maps embeddingTokens (promptTokens fallback) into BOTH prompt_tokens and total_tokens — the previous asymmetric mapping left prompt_tokens at 0 for backends that only report embeddingTokens, which is all of the built-ins. Covered at three layers: embedWithUsage preserves usage on the same analytics path, the wire mapping is symmetric, and a gateway post() test asserts nonzero real counts end-to-end through TestBackend. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RXx78W5LxbvxEdSyrB1vvn --- resources/models/Models.ts | 15 ++++++++++++++- resources/models/v1/embeddings.ts | 6 ++++-- resources/models/v1/translation.ts | 7 +++++-- unitTests/resources/models/Models.test.js | 10 ++++++++++ unitTests/resources/models/v1/embeddings.test.js | 11 +++++++++++ unitTests/resources/models/v1/translation.test.js | 14 ++++++++++++++ 6 files changed, 58 insertions(+), 5 deletions(-) diff --git a/resources/models/Models.ts b/resources/models/Models.ts index c12552054f..668fca4176 100644 --- a/resources/models/Models.ts +++ b/resources/models/Models.ts @@ -91,6 +91,19 @@ export class Models implements ModelsContract { } async embed(input: string | string[], opts: EmbedOpts = {}): Promise { + return (await this.embedWithUsage(input, opts)).vectors; + } + + /** + * `embed()` plus the result-level `usage` the winning backend reported (all + * built-in embedding backends provide it). Internal path for callers that must + * surface usage on the wire — the `/v1/embeddings` gateway — without changing + * the public `embed()` contract. Not part of the stable models API. + */ + async embedWithUsage( + input: string | string[], + opts: EmbedOpts = {} + ): Promise<{ vectors: Float32Array[]; usage?: TokenUsage }> { const { accounting, signal } = resolveCallContext(opts.signal); const startedAt = performance.now(); const resolved = resolveCandidates('embedding', opts.model, buildRequires('embed', opts.requires, false)); @@ -118,7 +131,7 @@ export class Models implements ModelsContract { // success row followed by a failure row from the catch (duplicate). if (result.status !== 'completed') throw new ModelPendingNotSupportedError(backend.name); this.#record(backend, 'embed', opts.model, accounting, undefined, result, attemptStart); - return result.output; + return { vectors: result.output, usage: result.usage }; } catch (err) { this.#recordFailure(backend, 'embed', opts.model, accounting, undefined, attemptStart, err); if (!hasError) { diff --git a/resources/models/v1/embeddings.ts b/resources/models/v1/embeddings.ts index 9ffb9f7c95..ec9f8b1e4c 100644 --- a/resources/models/v1/embeddings.ts +++ b/resources/models/v1/embeddings.ts @@ -59,8 +59,10 @@ export class V1Embeddings extends Resource { const opts = toEmbedOpts(raw as any); try { - const vecs = await models.embed(input as string | string[], opts); - return toEmbedResponse(vecs, model); + // embedWithUsage, not embed(): the public facade drops the result-level usage + // backends report, and OpenAI clients read real token counts off the response. + const { vectors, usage } = await models.embedWithUsage(input as string | string[], opts); + return toEmbedResponse(vectors, model, usage); } catch (err) { return toOpenAIError(err); } diff --git a/resources/models/v1/translation.ts b/resources/models/v1/translation.ts index 09be35e2ee..a868067f1a 100644 --- a/resources/models/v1/translation.ts +++ b/resources/models/v1/translation.ts @@ -350,9 +350,12 @@ export function toEmbedResponse(vecs: Float32Array[], model: string, usage?: Tok object: 'embedding', })), model, + // Embeddings have no completion side, so OpenAI reports the same count in both + // fields. Harper backends spell it `embeddingTokens` (with `promptTokens` as a + // fallback for backends that report it that way); mapping must be symmetric — + // a backend supplying only embeddingTokens must not leave prompt_tokens at 0. usage: { - prompt_tokens: usage?.promptTokens ?? 0, - // OpenAI uses `embeddingTokens` aliased here; fall back to promptTokens. + prompt_tokens: usage?.embeddingTokens ?? usage?.promptTokens ?? 0, total_tokens: usage?.embeddingTokens ?? usage?.promptTokens ?? 0, }, }; diff --git a/unitTests/resources/models/Models.test.js b/unitTests/resources/models/Models.test.js index 5db9ea45de..1b46de4296 100644 --- a/unitTests/resources/models/Models.test.js +++ b/unitTests/resources/models/Models.test.js @@ -80,6 +80,16 @@ describe('Models facade', () => { assert.ok(vectors[0] instanceof Float32Array); }); + it('embedWithUsage preserves the result-level usage that embed() drops (internal gateway path)', async () => { + const { vectors, usage } = await models.embedWithUsage('hello'); + assert.ok(vectors[0] instanceof Float32Array); + // TestBackend reports embeddingTokens = total input length. + assert.strictEqual(usage.embeddingTokens, 'hello'.length); + // Same analytics path as embed(): one success row, not a new code path. + assert.strictEqual(writer.records.length, 1); + assert.strictEqual(writer.records[0].method, 'embed'); + }); + it('writes an analytics record with backend=test, method=embed, success=true', async () => { await models.embed('hello'); assert.strictEqual(writer.records.length, 1); diff --git a/unitTests/resources/models/v1/embeddings.test.js b/unitTests/resources/models/v1/embeddings.test.js index d31c9a10eb..a091cd6a38 100644 --- a/unitTests/resources/models/v1/embeddings.test.js +++ b/unitTests/resources/models/v1/embeddings.test.js @@ -36,6 +36,17 @@ describe('V1Embeddings.post', () => { assert.equal(result.data.length, 1); }); + it('reports the backend-provided token usage, nonzero and in both fields (review round 3)', async () => { + // Previously every successful response reported zero usage: models.embed() + // drops ModelCallResult.usage. TestBackend reports embeddingTokens = input + // length, so this asserts the real count survives to the wire. + const input = 'hello world'; + const result = await V1Embeddings.post(undefined, { input }, { user: SUPER_USER }); + assert.equal(result.usage.prompt_tokens, input.length); + assert.equal(result.usage.total_tokens, input.length); + assert.ok(result.usage.prompt_tokens > 0); + }); + it('awaits a Promise-wrapped body, matching REST.ts passing request.data unawaited', async () => { const body = Promise.resolve({ input: ['a', 'b'] }); const result = await V1Embeddings.post(undefined, body, { user: SUPER_USER }); diff --git a/unitTests/resources/models/v1/translation.test.js b/unitTests/resources/models/v1/translation.test.js index 1e407cde29..a6803ee88b 100644 --- a/unitTests/resources/models/v1/translation.test.js +++ b/unitTests/resources/models/v1/translation.test.js @@ -445,4 +445,18 @@ describe('toEmbedResponse', () => { assert.equal(resp.usage.prompt_tokens, 0); assert.equal(resp.usage.total_tokens, 0); }); + + it('reports embeddingTokens in BOTH prompt_tokens and total_tokens (review round 3)', () => { + // Embeddings have no completion side: OpenAI reports the same count twice. + // A backend supplying only embeddingTokens must not leave prompt_tokens at 0. + const resp = toEmbedResponse([new Float32Array(1)], 'm', { embeddingTokens: 7 }); + assert.equal(resp.usage.prompt_tokens, 7); + assert.equal(resp.usage.total_tokens, 7); + }); + + it('falls back to promptTokens symmetrically for backends that report it that way', () => { + const resp = toEmbedResponse([new Float32Array(1)], 'm', { promptTokens: 5 }); + assert.equal(resp.usage.prompt_tokens, 5); + assert.equal(resp.usage.total_tokens, 5); + }); }); From 88af2574fdbf8ddfdd49a93486276e57162d5a24 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Fri, 31 Jul 2026 09:02:18 -0700 Subject: [PATCH 37/39] fix(models): cumulative serialized budget for stream tool-call assembly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The call-count and key-count caps didn't bound memory: a single argument key can retain an arbitrarily large value from a custom backend, and the final JSON.stringify duplicates the retained allocation — up to 256 calls of amplification on a public HTTP path. Reviewed on PR #1616. Assembly now charges one cumulative per-stream budget (1,048,576 serialized chars) covering call ids, names, and every argument value as it arrives. Charging is per delta — no re-serialization of the accumulator, keeping the O(delta) accounting — and monotonic: replacing an existing key charges the new value too, so churn under a stable key count cannot smuggle unbounded values past the bound. Overflow terminates through the existing ToolAssemblyOverflowError sanitized SSE error-frame path. Tests: one oversized single value (past the budget, under both count caps), replacement churn on one key, and an under-budget large value completing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RXx78W5LxbvxEdSyrB1vvn --- resources/models/openaiStream.ts | 47 +++++++++++++++---- .../resources/models/openaiStream.test.js | 37 +++++++++++++++ 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/resources/models/openaiStream.ts b/resources/models/openaiStream.ts index 0e2eea3d95..2521c291fd 100644 --- a/resources/models/openaiStream.ts +++ b/resources/models/openaiStream.ts @@ -34,20 +34,39 @@ export interface OpenAIStreamOptions { // terminates the stream through the same sanitized error-frame path as any backend failure. const MAX_TOOL_CALLS_PER_STREAM = 256; const MAX_TOOL_ARGUMENT_KEYS = 1024; +// Cumulative serialized-character budget across the WHOLE stream's assembly (ids, names, +// and every argument value as it arrives). Call/key counts alone don't bound memory — one +// key can hold an arbitrarily large value, and the final JSON.stringify duplicates the +// retained allocation — so the budget is charged per delta (O(delta), no re-serialization +// of the accumulator) and monotonically: replacing an existing key charges the new value +// too, so churn cannot smuggle unbounded values under a stable key count. +const MAX_TOOL_ASSEMBLY_CHARS = 1_048_576; /** Signals that a stream exceeded the tool-assembly bounds; surfaced as an SSE error frame. */ class ToolAssemblyOverflowError extends Error { statusCode = 502; } -/** Count only the keys `source` adds to `target`, so accumulation stays O(delta), not O(total). */ -function assignCountingNewKeys(target: object, source: object): number { - let added = 0; +/** + * Merge `source` into `target`, returning the keys added and the serialized characters + * charged (key length on first add; value length every assignment, replacements included). + * Keeps accumulation O(delta), not O(total). + */ +function assignCountingNewKeys(target: object, source: object): { addedKeys: number; addedChars: number } { + let addedKeys = 0; + let addedChars = 0; for (const key in source) { - if (!(key in target)) added++; - (target as Record)[key] = (source as Record)[key]; + if (!(key in target)) { + addedKeys++; + addedChars += key.length; + } + const value = (source as Record)[key]; + // `?? ''`: JSON.stringify returns undefined for undefined/function/symbol values — + // impossible from JSON.parse but reachable from a custom backend's crafted object. + addedChars += (JSON.stringify(value) ?? '').length; + (target as Record)[key] = value; } - return added; + return { addedKeys, addedChars }; } /** OpenAI streaming error body (`{ message, type, code, param }` under an `error` key). */ @@ -107,6 +126,7 @@ export async function* openaiStream( // (`{"a":1}` + `{"b":2}` → invalid JSON) — Harper's already-buffered upstream model // means we cannot faithfully reproduce per-token argument fragments anyway. const toolAssembly = new Map(); + let assemblyChars = 0; const chunk = (delta: OpenAIDelta, finish: OpenAIFinishReason | null): OpenAIStreamMessage => ({ data: { @@ -145,19 +165,30 @@ export async function* openaiStream( // setter and silently drop the field (the previous spread did not). existing = { index: toolAssembly.size, arguments: Object.create(null), argumentCount: 0 }; toolAssembly.set(incoming.id, existing); + assemblyChars += incoming.id.length; + } + if (incoming.name && incoming.name !== existing.name) { + assemblyChars += incoming.name.length; + existing.name = incoming.name; } - if (incoming.name) existing.name = incoming.name; // Guard the contract (`ToolCall.arguments` is an object): a string would be // assigned index-wise, inflating the field count from characters. if (incoming.arguments && typeof incoming.arguments === 'object') { // Mutate rather than re-spread — spreading copied every previously // accumulated property on each partial delta (O(n²) as fields grow) — and // count only newly-introduced keys so the bound check stays O(delta) too. - existing.argumentCount += assignCountingNewKeys(existing.arguments, incoming.arguments); + const { addedKeys, addedChars } = assignCountingNewKeys(existing.arguments, incoming.arguments); + existing.argumentCount += addedKeys; + assemblyChars += addedChars; if (existing.argumentCount > MAX_TOOL_ARGUMENT_KEYS) { throw new ToolAssemblyOverflowError(`tool call arguments exceeded ${MAX_TOOL_ARGUMENT_KEYS} fields`); } } + if (assemblyChars > MAX_TOOL_ASSEMBLY_CHARS) { + throw new ToolAssemblyOverflowError( + `stream tool-call assembly exceeded ${MAX_TOOL_ASSEMBLY_CHARS} serialized characters` + ); + } } } if (token.finishReason) finishReason = token.finishReason; diff --git a/unitTests/resources/models/openaiStream.test.js b/unitTests/resources/models/openaiStream.test.js index c82e72c9df..2c02d6684c 100644 --- a/unitTests/resources/models/openaiStream.test.js +++ b/unitTests/resources/models/openaiStream.test.js @@ -180,6 +180,43 @@ describe('openaiStream', () => { assert.ok(last.error, 'expected a terminal error frame'); }); + it('terminates with an error frame when a single argument value is oversized (review round 3)', async () => { + // One key, one value — under both the call cap and the key cap, but past the + // cumulative serialized budget (1,048,576 chars). Count-based bounds alone + // would retain this and duplicate it in the final JSON.stringify. + const huge = 'x'.repeat(1_100_000); + const msgs = await collect( + openaiStream(gen({ deltaToolCalls: [{ id: 'c1', name: 'fn', arguments: { a: huge } }] })) + ); + const last = msgs[msgs.length - 1].data; + assert.ok(last.error, 'expected a terminal error frame'); + assert.ok(!msgs.some((m) => m.data === '[DONE]'), 'must not emit [DONE] after overflow'); + }); + + it('charges replacements of an existing key against the budget, not just new keys', async () => { + // The key count stays 1 the whole time — only the budget can catch this. + const big = 'y'.repeat(300_000); + const deltas = Array.from({ length: 4 }, () => ({ + deltaToolCalls: [{ id: 'c1', name: 'fn', arguments: { a: big } }], + })); + const msgs = await collect(openaiStream(gen(...deltas))); + const last = msgs[msgs.length - 1].data; + assert.ok(last.error, 'expected a terminal error frame'); + }); + + it('completes normally for a large value still under the serialized budget', async () => { + const large = 'z'.repeat(500_000); + const msgs = await collect( + openaiStream(gen({ deltaToolCalls: [{ id: 'c1', name: 'fn', arguments: { a: large } }] })) + ); + assert.ok( + msgs.some((m) => m.data === '[DONE]'), + 'under the budget must still complete' + ); + const flush = msgs.find((m) => m.data.choices?.[0]?.delta?.tool_calls); + assert.ok(flush.data.choices[0].delta.tool_calls[0].function.arguments.includes('zzz')); + }); + it('ignores a contract-violating string arguments value rather than counting characters', async () => { const msgs = await collect( openaiStream(gen({ deltaToolCalls: [{ id: 'c1', name: 'fn', arguments: 'x'.repeat(5000) }] })) From 6cdc4010c0a914890375ddf10a9942ddaf9237b6 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Fri, 31 Jul 2026 09:20:22 -0700 Subject: [PATCH 38/39] fix(models): charge JSON syntax overhead in the stream assembly budget Cross-model review of 88af2574f caught the counter under-approximating the final serialization: it charged raw key chars and serialized values but not the quotes/colon/comma each entry contributes (~4 chars) or the flush frame's fixed per-call envelope, so a flood of tiny entries could reach ~3x the nominal budget in real serialized size. First adds now charge key + 4 and call creation charges id + 96, making the cumulative count an upper bound on JSON.stringify of the retained arguments (the SSE frame adds only bounded escaping on top). Replacement charges are still never refunded, preserving the monotonic O(delta) accounting. Test: a 150-call x 1024-tiny-key flood whose raw chars fit the budget but whose serialized JSON exceeds it now trips, under both count caps. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RXx78W5LxbvxEdSyrB1vvn --- resources/models/openaiStream.ts | 29 +++++++++++++------ .../resources/models/openaiStream.test.js | 15 ++++++++++ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/resources/models/openaiStream.ts b/resources/models/openaiStream.ts index 2521c291fd..0328236877 100644 --- a/resources/models/openaiStream.ts +++ b/resources/models/openaiStream.ts @@ -35,11 +35,15 @@ export interface OpenAIStreamOptions { const MAX_TOOL_CALLS_PER_STREAM = 256; const MAX_TOOL_ARGUMENT_KEYS = 1024; // Cumulative serialized-character budget across the WHOLE stream's assembly (ids, names, -// and every argument value as it arrives). Call/key counts alone don't bound memory — one -// key can hold an arbitrarily large value, and the final JSON.stringify duplicates the -// retained allocation — so the budget is charged per delta (O(delta), no re-serialization -// of the accumulator) and monotonically: replacing an existing key charges the new value -// too, so churn cannot smuggle unbounded values under a stable key count. +// and every argument value as it arrives, plus per-entry JSON syntax so the count is an +// upper bound on `JSON.stringify(arguments)`, not just the raw content). Call/key counts +// alone don't bound memory — one key can hold an arbitrarily large value, and the final +// JSON.stringify duplicates the retained allocation — so the budget is charged per delta +// (O(delta), no re-serialization of the accumulator) and monotonically: replacing an +// existing key charges the new value too, so churn cannot smuggle unbounded values under +// a stable key count. The SSE frame that flushes the calls adds only a bounded constant +// envelope per call plus string-escaping of the arguments blob (< 2x), so the frame size +// is bounded by a small multiple of this budget. const MAX_TOOL_ASSEMBLY_CHARS = 1_048_576; /** Signals that a stream exceeded the tool-assembly bounds; surfaced as an SSE error frame. */ @@ -49,8 +53,12 @@ class ToolAssemblyOverflowError extends Error { /** * Merge `source` into `target`, returning the keys added and the serialized characters - * charged (key length on first add; value length every assignment, replacements included). - * Keeps accumulation O(delta), not O(total). + * charged. Charging over-approximates `JSON.stringify(target).length`: each first add + * charges the key plus 4 chars of JSON syntax (`"key":` quotes and colon, plus the + * comma/brace share), and every assignment — replacements included — charges the + * serialized value. Since a replacement's earlier charge is never refunded, the + * cumulative total stays an upper bound on the retained serialization while keeping + * accumulation O(delta), not O(total). */ function assignCountingNewKeys(target: object, source: object): { addedKeys: number; addedChars: number } { let addedKeys = 0; @@ -58,7 +66,7 @@ function assignCountingNewKeys(target: object, source: object): { addedKeys: num for (const key in source) { if (!(key in target)) { addedKeys++; - addedChars += key.length; + addedChars += key.length + 4; } const value = (source as Record)[key]; // `?? ''`: JSON.stringify returns undefined for undefined/function/symbol values — @@ -165,7 +173,10 @@ export async function* openaiStream( // setter and silently drop the field (the previous spread did not). existing = { index: toolAssembly.size, arguments: Object.create(null), argumentCount: 0 }; toolAssembly.set(incoming.id, existing); - assemblyChars += incoming.id.length; + // + 96: the flush frame's fixed per-call envelope (index/id/type/function + // syntax and the argument object's braces), so 256 calls of envelope are + // inside the budget too, not on top of it. + assemblyChars += incoming.id.length + 96; } if (incoming.name && incoming.name !== existing.name) { assemblyChars += incoming.name.length; diff --git a/unitTests/resources/models/openaiStream.test.js b/unitTests/resources/models/openaiStream.test.js index 2c02d6684c..4bbce2947a 100644 --- a/unitTests/resources/models/openaiStream.test.js +++ b/unitTests/resources/models/openaiStream.test.js @@ -193,6 +193,21 @@ describe('openaiStream', () => { assert.ok(!msgs.some((m) => m.data === '[DONE]'), 'must not emit [DONE] after overflow'); }); + it('charges JSON syntax overhead so tiny-entry floods cannot outgrow the nominal budget', async () => { + // 150 calls x 1024 one-char values: raw key+value chars total ~755K (under the + // 1,048,576 budget), but the serialized JSON — quotes, colons, commas, per-call + // envelope — is ~1.4M. Charging syntax per entry makes the budget an upper bound + // on the real serialization, so this must trip while staying under both count caps. + const wide = {}; + for (let i = 0; i < 1024; i++) wide[`k${i}`] = 1; + const deltas = Array.from({ length: 150 }, (_, i) => ({ + deltaToolCalls: [{ id: `c${i}`, name: 'fn', arguments: wide }], + })); + const msgs = await collect(openaiStream(gen(...deltas))); + const last = msgs[msgs.length - 1].data; + assert.ok(last.error, 'expected a terminal error frame'); + }); + it('charges replacements of an existing key against the budget, not just new keys', async () => { // The key count stays 1 the whole time — only the budget can catch this. const big = 'y'.repeat(300_000); From 2251931293ecd3c91245cff883e86f42f07e81ed Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Fri, 31 Jul 2026 09:39:05 -0700 Subject: [PATCH 39/39] test(components): reset the restart buffer at requestRestart.test entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught the shared-buffer test failing after cfb3b5cd9: the buffer is process-wide, and Scope's new remove→restart behavior means earlier suites now legitimately set it — componentLoader.test.js fixtures leave scopes open whose watched harperdb-config.yaml is deleted at teardown, and chokidar's delayed unlink timer delivers the remove (correctly requesting a restart) several tests later. The test's entry assertion encoded 'no prior test ever requested a restart', an ordering assumption, not this module's contract. Reset at entry — the same convention Scope.test.js already uses — so the test pins the false→true transition it was written for. (Also corrects an earlier triage note: this failure was called pre-existing based on a stash control run against a stale dist build; with a rebuilt control it is attributable to the Scope change's interaction with unclosed fixture scopes, which this makes order-robust.) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RXx78W5LxbvxEdSyrB1vvn --- unitTests/components/requestRestart.test.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/unitTests/components/requestRestart.test.js b/unitTests/components/requestRestart.test.js index 7ca425f55c..f2e04f1ff7 100644 --- a/unitTests/components/requestRestart.test.js +++ b/unitTests/components/requestRestart.test.js @@ -1,7 +1,17 @@ -const { requestRestart, restartNeeded } = require('#src/components/requestRestart'); +const { requestRestart, restartNeeded, resetRestartNeeded } = require('#src/components/requestRestart'); const assert = require('node:assert'); describe('requestRestart', () => { + beforeEach(() => { + // The buffer is process-wide and legitimately set by earlier suites: Scope + // requests a restart when a watched config block or file is removed, and + // fixture teardown in other test files deletes watched harperdb-config.yaml + // files on chokidar's delayed unlink timer — sometimes several tests later. + // This test's contract is the false→true transition, not that no prior test + // ever requested a restart (same entry-reset convention as Scope.test.js). + resetRestartNeeded(); + }); + it('should update the shared buffer', () => { assert.strictEqual(restartNeeded(), false); requestRestart();