Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
f785a85
feat(models): add OpenAI-compatible /v1/* REST gateway (#631)
heskew Jul 6, 2026
b4c6647
test(models): integration test for /v1/* gateway with real OpenAI SDK…
heskew Jul 6, 2026
82b4443
fix(deps): regenerate package-lock.json with openai resolved
heskew Jul 6, 2026
0eda027
fix(models): resolve gateway 404s and address bot review comments
heskew Jul 6, 2026
5690be5
fix(models): await the /v1/* request body before reading fields
heskew Jul 6, 2026
469c5e1
fix(models): require super_user auth on all /v1/* gateway handlers
heskew Jul 6, 2026
89f2e11
fix(models): guarantee modelsGateway config key; default-OFF with ena…
heskew Jul 6, 2026
732b708
Merge branch 'main' into feat/models-v1-gateway
kriszyp Jul 7, 2026
2597cc2
style: format v1 gateway files with prettier 3.9.3 (lockfile version)
heskew Jul 8, 2026
58bb6e8
debug: TEMP instrumentation to pin CI-only modelsGateway 404 (#1616) …
heskew Jul 8, 2026
6a0deeb
fix(models): keep /v1 5xx error messages generic; log the real error
heskew Jul 8, 2026
1d7dc8e
Revert "debug: TEMP instrumentation to pin CI-only modelsGateway 404 …
heskew Jul 8, 2026
2f000ea
debug: TEMP round-2 instrumentation for CI-only modelsGateway 404 (#1…
heskew Jul 8, 2026
a5f2f41
Revert "debug: TEMP round-2 instrumentation for CI-only modelsGateway…
heskew Jul 8, 2026
0cdd0f3
fix(models): map /v1 403 errors to permission_error per OpenAI semantics
heskew Jul 8, 2026
05fe16d
docs: add openai devDependency entry to dependencies.md
heskew Jul 12, 2026
5858cc2
Revert "docs: add openai devDependency entry to dependencies.md"
heskew Jul 12, 2026
897e987
Merge remote-tracking branch 'origin/main' into feat/models-v1-gateway
heskew Jul 17, 2026
69a9807
fix(models): activate REST serving from the /v1 gateway; dedupe model…
heskew Jul 18, 2026
1c5cc32
test(models): fix v1-gateway self-sabotaging assertions; use a real t…
heskew Jul 18, 2026
84b0407
fix(models): make REST activation order-independent — activate after …
heskew Jul 18, 2026
96e875f
Merge branch 'main' into feat/models-v1-gateway
heskew Jul 21, 2026
7551dae
feat(models): mid-stream SSE error frames + embeddings input cap (rev…
heskew Jul 21, 2026
87c90ab
Merge branch 'main' into feat/models-v1-gateway
heskew Jul 24, 2026
6e8cbc7
refactor(models): make the /v1 gateway a clean plugin — remove core s…
heskew Jul 24, 2026
1aab4a9
fix(models): honor tool_choice, unwrap json_schema, bound tool assemb…
heskew Jul 24, 2026
7b150b8
fix(models): null-prototype tool-argument accumulator; reassemble SSE…
heskew Jul 24, 2026
702d153
fix(models): address cross-model review — registry specifier, symmetr…
heskew Jul 24, 2026
da1d845
docs(models): correct v1-gateway test header — REST is configured, no…
heskew Jul 24, 2026
42c9bb5
fix(models): zero-cost disabled /v1 gateway; register modelsGateway_e…
heskew Jul 28, 2026
2324cc6
test(models): mirror the malformed-JSON reject-path case on /v1/embed…
heskew Jul 28, 2026
fcab43d
test(models): mixed-app regression test for the /v1 gateway's REST be…
heskew Jul 28, 2026
bc31ac7
fix(deps): restore production-native lockfile metadata lost in regene…
heskew Jul 31, 2026
cfb3b5c
fix(components): request restart when a component's config block is r…
heskew Jul 31, 2026
f43b7ac
fix(models): reserve /v1 gateway paths against silent app-Resource re…
heskew Jul 31, 2026
75da150
fix(models): explicit protocol visibility for /v1 routes; getMatch ho…
heskew Jul 31, 2026
32016ee
fix(models): validate top-level chat fields instead of coercing or ig…
heskew Jul 31, 2026
ca162e9
fix(models): report capability mismatches as OpenAI 400s, not sanitiz…
heskew Jul 31, 2026
37b3359
fix(models): carry OpenAI 'developer' role as 'system'; reject unknow…
heskew Jul 31, 2026
edccb0d
fix(models): report real embedding token usage on /v1/embeddings
heskew Jul 31, 2026
88af257
fix(models): cumulative serialized budget for stream tool-call assembly
heskew Jul 31, 2026
6cdc401
fix(models): charge JSON syntax overhead in the stream assembly budget
heskew Jul 31, 2026
2251931
test(components): reset the restart buffer at requestRestart.test entry
heskew Jul 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions components/Scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export class Scope extends EventEmitter<ScopeEventsMap> {
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
Expand Down Expand Up @@ -361,6 +362,23 @@ export class Scope extends EventEmitter<ScopeEventsMap> {
};
}

#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 (
Expand Down
10 changes: 10 additions & 0 deletions components/componentLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,16 @@ export const TRUSTED_RESOURCE_PLUGINS: any = {
return require('../server/fastifyRoutes');
},
login,
// 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.
//
// 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',
Comment thread
heskew marked this conversation as resolved.
static: staticFiles,
customFunctions: {},
http: httpComponent,
Expand Down
66 changes: 66 additions & 0 deletions integrationTests/server/fixtures/v1-gateway-test-backend.cjs
Original file line number Diff line number Diff line change
@@ -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: <absolute-path>`
* 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);
}
};
7 changes: 7 additions & 0 deletions integrationTests/server/v1-gateway-collision-app/config.yaml
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions integrationTests/server/v1-gateway-collision-app/resources.js
Original file line number Diff line number Diff line change
@@ -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 };
202 changes: 202 additions & 0 deletions integrationTests/server/v1-gateway-mixed-app.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/**
* 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<number | 'refused'> {
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');
});
});

/**
* 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');
});
});
7 changes: 7 additions & 0 deletions integrationTests/server/v1-gateway-mixed-app/config.yaml
Original file line number Diff line number Diff line change
@@ -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'
9 changes: 9 additions & 0 deletions integrationTests/server/v1-gateway-mixed-app/schema.graphql
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading