HarperFast / HarperFast/harper

Add unified model-access API (scope.models)

Open
#510 4 comments 0 reactions 1 assignee Claimed by @heskew View on GitHub
enhancement
Dominant language
JavaScript
Stars
89
Forks
10
Avg merge
1d 23h
Merged PRs (30d)
215

Description

## Context

The shared GPU model work (FAB-502 embeddings, FAB-503 generative + LoRA) requires a stable in-runtime API that apps target.

Without a unified API, Fabric apps and Pro / self-hosted apps will fork — Fabric using a "magic" backend, Pro users wiring their own Ollama / OpenAI SDK. That breaks the source-portability claim ("the same runtime works in all contexts").

This issue proposes `scope.models` as the single API for embedding and LLM inference, with pluggable backends, an OpenAI-compatible gateway endpoint, schema-level `@embed` directives, and built-in token accounting.

## API surface

```ts
scope.models.embed(
text: string | string[],
opts?: EmbedOpts
): Promise;

scope.models.generate(
input: GenerateInput,
opts?: GenerateOpts
): Promise;

scope.models.generateStream(
input: GenerateInput,
opts?: GenerateOpts
): AsyncIterable;

type GenerateInput = string | Message[] | { messages, tools?, system? };

type GenerateOpts = {
model?: string; // logical name; resolves via config
adapter?: string; // LoRA adapter id (FAB-503)
temperature?: number;
maxTokens?: number;
responseFormat?: 'text' | 'json' | { schema };
tools?: ToolDef[];
toolMode?: 'return' | 'auto'; // 'auto' runs tools in-process to completion
conversationId?: string; // auto-bind to ConversationResource (separate issue)
signal?: AbortSignal;
};
```

## Configuration

```yaml
models:
embedding:
default:
backend: fabric # fabric | ollama | openai | bedrock | custom
model: harper-embed-v1
generative:
default:
backend: fabric
model: llama-3.3-70b
adapter: harper-tenant-v1 # optional; omit to use base model
fast:
backend: ollama
host: localhost:11434
model: llama-3.2-3b
```

Resolution: logical name (`'default'`) → `models.{embedding|generative}.`. Apps reference logical names so the physical model can be swapped without code changes.

Each Harper instance has its own config file, so adapter (and model) are set statically per instance. App code can also pass `adapter:` in call opts to override for a specific call.

## Backend interface

```ts
interface ModelBackend {
embed?(input, opts: BackendOpts): Promise;
generate?(input, opts: BackendOpts): Promise;
generateStream?(input, opts: BackendOpts): AsyncIterable;
capabilities(): {
embed: boolean;
generate: boolean;
stream: boolean;
tools: boolean;
adapters: boolean;
};
}
```

Ship with: `ollama`, `openai`, `anthropic`, `bedrock`. The `fabric` backend lands with FAB-502 / FAB-503. Components can register custom backends.

## OpenAI-compatible gateway endpoint

Harper exposes `/v1/chat/completions` and `/v1/embeddings` on the same port as REST, routing internally to `scope.models`. Means:

- LangChain.js, Vercel AI SDK, OpenAI SDK clients, MCP sampling — all work unmodified.
- Multi-tenant: each Harper instance has its own model config; the gateway routes to the configured model for that instance.
- Token logging into analytics is automatic.

This is the single highest-leverage piece for the LangChain comparison in the guide (Part 4).

## Schema-level `@embed` directive

```graphql
type Document @export {
id: ID @primaryKey
content: String
embedding: Vector @embed(source: "content", model: "default")
}
```

Behavior: on write/update of `content`, embedding is computed (synchronously by default, queued via component config), stored in `embedding`, indexed via HNSW. No app code needed.

## Conversation binding

If `opts.conversationId` is set:

1. The user/system input is appended as a turn before calling the model.
2. `buildContext()` (from `ConversationResource`, separate issue) assembles the message list.
3. The model response is appended as an assistant turn on completion (or streamed-append for `generateStream`).

This is a no-op until `ConversationResource` lands (separate issue) — wired but inactive.

## Tool calls

- `toolMode: 'return'` (default): model returns tool-call requests; caller resolves.
- `toolMode: 'auto'`: `scope.models` resolves tool calls against `scope.resources` and any registered MCP tools, loops until terminal answer.

See #612 for the full `toolMode: 'auto'` design.

## Token accounting

Every call writes `analytics.model_call`:

```
{ tenant, app, model, backend, prompt_tokens, completion_tokens,
embedding_tokens, gpu_ms, latency_ms, success, conversation_id, adapter }
```

`tenant` here is the Harper instance identity — recorded at write time for per-tenant chargeback on shared Fabric capacity. It is not resolved dynamically from auth context during model calls.

Aggregations exposed via existing analytics resource. Enables per-tenant chargeback for shared models (Outcome #2 in the guide: "provable with a spreadsheet").

## Dependencies

- **No hard runtime dependencies.** The async-iterator streaming path already works end-to-end through the existing `serializeStream` mechanism in `core/server/serverHelpers/contentTypes.ts` — methods returning `AsyncIterable` are dispatched to SSE / JSON / CBOR / msgpack / CSV streamers automatically, with full content negotiation and backpressure. `generateStream()` returning an async iterator from `post()` Just Works for SSE clients.
- **Soft**: `openaiStream()` formatter helper in core, for the OpenAI-compatible `/v1/chat/completions` endpoint (small, separate issue).
- **Soft**: `AbortSignal` (`request.signal`) propagated into Resource method scope, so model calls can be cancelled when clients disconnect (small, separate issue).
- **Soft**: ConversationResource (#511) — `conversationId` binding is wired but inert until that lands.
- **Soft**: native MCP server (#465) — needed for `toolMode: 'auto'` to resolve MCP tools (resolution against `scope.resources` works without it).

## Related work

- Unblocks FAB-502 (Fabric shared embedding) and FAB-503 (Fabric shared generative + LoRA).
- Pairs with #465 (MCP admin) — admin operations exposed via MCP can use `scope.models` internally.

## Open decisions

1. Embedding batching — caller-driven or transparent batch window? Suggested: transparent, 10ms window, configurable. *(Still open.)*
2. Fallback chains (`fabric → openai on failure`) — **resolved by #1326** (pluggable routing, shipped in #1533, v5.1.15): fallback is a router concern, not baked into core — ordered `fallback:` groups + `models.registerRouter`.
3. Streaming append granularity — chunk-per-turn-update or buffered? Suggested: buffered with periodic flush, single final commit on stream end; conversation `pending_turn` row visible to subscribers. *(Open; ties to ConversationResource, #511.)*

## Acceptance

- [x] `scope.models` interface defined; `ModelBackend` interface defined. (#628)
- [x] `ollama` / `openai` / `anthropic` / `bedrock` backends ship — as **built-in components** under `components//`, selected via the `models:` config (#629 / #630 / #633). Custom + in-process backends are also registerable via `registerBackend` / `defineBackend` (#1325, merged), with config-driven module selection in #1471.
- [x] OpenAI-compatible `/v1/chat/completions` and `/v1/embeddings` endpoints route to `scope.models` — plus `GET /v1/models`. (#631, shipped in #1616, merged 2026-08-04.) Served as built-in Resources gated on `modelsGateway.enabled`; `defaultConfig.yaml` ships no `modelsGateway` block, so an instance that does not opt in never loads the module graph. Two core gaps surfaced by the review and split out: #1931 (no supported way for a component to declare it serves REST resources) and #1932 (no per-component hook to shape auth error responses).
- [x] `@embed` schema directive triggers embedding-on-write via the configured backend. (#632)
- [x] Every call is recorded with token counts and latency — shipped as the `hdb_model_calls` analytics table.
- [ ] Tool calls work in both `'return'` and `'auto'` modes; `'auto'` resolves against `scope.resources`. *(Both modes shipped (#612 / #848). `'auto'` still dispatches a caller-supplied `toolHandlers` table; resolving against `scope.resources` is tracked in **#1740**, now linked as a sub-issue. The seam comments in `agentLoop.ts` (twice) and the `toolHandlers` doc in `types.ts` cite #615, which is closed and unrelated — repointing them is part of #1740.)*
- [ ] Unmodified LangChain.js / OpenAI SDK client successfully completes a chat against Harper. *(The gateway (#631) has landed, and its own integration suite already round-trips the unmodified OpenAI Node SDK including streaming. The LangChain.js leg is #1856 — approved, open, waiting on unrelated integration-shard failures (`QA-782` stale-read / F-225 phantom), not on gateway work.)*
- [x] Documented: writing a custom backend, configuring fallback, local-dev setup matching production. *(Custom-backend docs merged (HarperFast/documentation#554; corrected and config-selectable folded into documentation#558). Fallback docs merged (documentation#558 — `reference/models/routing.md`, documenting #1326's router-concern conclusion). Local-dev setup merged 2026-07-27 (documentation#597, closed documentation#596).)*

## Out of scope

- The `fabric` backend (FAB-502 / FAB-503).
- ConversationResource itself (separate issue).
- Full fine-tuning (LoRA adapters only via FAB-503).

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.