Skip to content
Merged
547 changes: 547 additions & 0 deletions components/anthropic/index.ts

Large diffs are not rendered by default.

823 changes: 823 additions & 0 deletions components/bedrock/index.ts

Large diffs are not rendered by default.

69 changes: 17 additions & 52 deletions components/ollama/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@
* `handleApplication(scope)` self-loader.
*/
import { setEmbedding, setGenerative } from '../../resources/models/backendRegistry.ts';
import {
assignFiniteTokenCount,
composeSignal,
normalizeOrigin,
parseJsonResponse,
requireModel,
} from '../../resources/models/backendHelpers.ts';
import { ServerError } from '../../utility/errors/hdbError.ts';
import type {
BackendOpts,
Expand Down Expand Up @@ -60,7 +67,7 @@ export class OllamaBackend implements ModelBackend {
readonly #fetch: typeof fetch;

constructor(config: OllamaBackendConfig = {}, fetchImpl: typeof fetch = fetch) {
this.#origin = normalizeOrigin(config.host);
this.#origin = normalizeOrigin(config.host, { host: DEFAULT_HOST, secure: false });
this.#defaultModel = config.model;
this.#requestTimeoutMs = config.requestTimeoutMs;
this.#fetch = fetchImpl;
Expand All @@ -72,11 +79,11 @@ export class OllamaBackend implements ModelBackend {

async embed(input: string | string[], opts: BackendOpts<EmbedOpts>): Promise<ModelCallResult<Float32Array[]>> {
const model = opts.model ?? this.#defaultModel;
requireModel(model, 'embed');
requireModel(model, 'embed', OllamaBackendError);
const texts = Array.isArray(input) ? input : [input];
const prepared = texts.map((t) => applyEmbedPrefix(model, t, opts.inputType));
const res = await this.#post('/api/embed', { model, input: prepared }, opts.signal);
const data = await parseJsonResponse<OllamaEmbedResponse>(res, '/api/embed');
const data = await parseJsonResponse<OllamaEmbedResponse>(res, 'Ollama /api/embed', OllamaBackendError);
if (!Array.isArray(data.embeddings)) {
throw new OllamaBackendError("Ollama /api/embed response missing 'embeddings' array");
}
Expand All @@ -98,10 +105,14 @@ export class OllamaBackend implements ModelBackend {

async generate(input: GenerateInput, opts: BackendOpts<GenerateOpts>): Promise<ModelCallResult<GenerateResult>> {
const model = opts.model ?? this.#defaultModel;
requireModel(model, 'generate');
requireModel(model, 'generate', OllamaBackendError);
const { endpoint, body } = buildGenerateRequest(model, input, opts, false);
const res = await this.#post(endpoint, body, opts.signal);
const data = await parseJsonResponse<OllamaGenerateResponse & OllamaChatResponse>(res, endpoint);
const data = await parseJsonResponse<OllamaGenerateResponse & OllamaChatResponse>(
res,
`Ollama ${endpoint}`,
OllamaBackendError
);
const rawContent = endpoint === '/api/chat' ? data.message?.content : data.response;
if (rawContent !== undefined && typeof rawContent !== 'string') {
throw new OllamaBackendError(`Ollama ${endpoint} response content is not a string`);
Expand All @@ -118,7 +129,7 @@ export class OllamaBackend implements ModelBackend {

async *generateStream(input: GenerateInput, opts: BackendOpts<GenerateOpts>): AsyncIterable<GenerateChunk> {
const model = opts.model ?? this.#defaultModel;
requireModel(model, 'generateStream');
requireModel(model, 'generateStream', OllamaBackendError);
const { endpoint, body } = buildGenerateRequest(model, input, opts, true);
const res = await this.#post(endpoint, body, opts.signal);
if (!res.body) throw new OllamaBackendError(`Ollama ${endpoint} returned no body for streaming`);
Expand Down Expand Up @@ -166,23 +177,6 @@ export class OllamaBackendError extends ServerError {

// ---------- internals ----------

function normalizeOrigin(host?: string): string {
const value = host?.trim() || DEFAULT_HOST;
const withScheme = /^https?:\/\//i.test(value) ? value : `http://${value}`;
return withScheme.replace(/\/+$/, '');
}

function requireModel(model: string | undefined, op: string): asserts model is string {
if (!model) throw new OllamaBackendError(`No model specified for ${op}; set 'model' in config or pass opts.model`);
}

function composeSignal(caller?: AbortSignal, timeoutMs?: number): AbortSignal | undefined {
if (!timeoutMs) return caller;
const timeout = AbortSignal.timeout(timeoutMs);
if (!caller) return timeout;
return AbortSignal.any([caller, timeout]);
}

function applyEmbedPrefix(model: string, text: string, inputType?: 'document' | 'query'): string {
if (!inputType) return text;
// nomic-embed-text v1.5+ uses these application-layer prefixes to distinguish
Expand Down Expand Up @@ -293,35 +287,6 @@ function parseJsonLine(line: string): OllamaStreamChunk {
}
}

/**
* Read a JSON response body and throw `OllamaBackendError` on parse failure
* instead of leaking the raw `SyntaxError` (whose message can include
* upstream-derived bytes). Mirrors `parseJsonLine`'s sanitization posture.
*/
async function parseJsonResponse<T>(res: Response, endpoint: string): Promise<T> {
try {
return (await res.json()) as T;
} catch {
throw new OllamaBackendError(`Ollama ${endpoint} returned a non-JSON response body`);
}
}

/**
* Write a token count to `usage` only when the value is a finite, non-negative
* integer. Rejects `NaN`, `Infinity`, `-Infinity`, negatives, and non-integers —
* any of which would poison `SUM(prompt_tokens)`-style aggregates over
* `hdb_model_calls`.
*/
function assignFiniteTokenCount(
usage: TokenUsage,
key: 'promptTokens' | 'completionTokens' | 'embeddingTokens',
value: unknown
): void {
if (typeof value !== 'number') return;
if (!Number.isFinite(value) || value < 0 || !Number.isInteger(value)) return;
usage[key] = value;
}

interface OllamaEmbedResponse {
embeddings: number[][];
prompt_eval_count?: number;
Expand Down
73 changes: 15 additions & 58 deletions components/openai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,15 @@
* mapping is mechanical.
*/
import { setEmbedding, setGenerative } from '../../resources/models/backendRegistry.ts';
import {
assignFiniteTokenCount,
composeSignal,
normalizeOrigin,
parseJsonResponse,
requireCredential,
requireModel,
} from '../../resources/models/backendHelpers.ts';
import { ServerError } from '../../utility/errors/hdbError.ts';
import { isUnresolvedEnvVarPlaceholder } from '../../utility/expandEnvVar.ts';
import harperLogger from '../../utility/logging/harper_logger.ts';
import type {
BackendOpts,
Expand Down Expand Up @@ -95,8 +102,8 @@ export class OpenAIBackend implements ModelBackend {
readonly #fetch: typeof fetch;

constructor(config: OpenAIBackendConfig = {}, fetchImpl: typeof fetch = fetch) {
this.#apiKey = requireApiKey(config.apiKey);
this.#baseUrl = normalizeBaseUrl(config.baseUrl);
this.#apiKey = requireCredential(config.apiKey, 'OpenAI', 'apiKey', OpenAIBackendError);
this.#baseUrl = normalizeOrigin(config.baseUrl, { host: DEFAULT_BASE_URL, secure: true });
this.#defaultModel = config.model;
this.#organization = config.organization;
this.#requestTimeoutMs = config.requestTimeoutMs;
Expand All @@ -109,13 +116,13 @@ export class OpenAIBackend implements ModelBackend {

async embed(input: string | string[], opts: BackendOpts<EmbedOpts>): Promise<ModelCallResult<Float32Array[]>> {
const model = opts.model ?? this.#defaultModel;
requireModel(model, 'embed');
requireModel(model, 'embed', OpenAIBackendError);
// inputType is honored as a hint but OpenAI's embedding models don't
// currently differentiate by it on the wire — pass through unchanged.
const texts = Array.isArray(input) ? input : [input];
const body: Record<string, unknown> = { model, input: texts };
const res = await this.#post('/embeddings', body, opts.signal);
const data = await parseJsonResponse<OpenAIEmbedResponse>(res, '/embeddings');
const data = await parseJsonResponse<OpenAIEmbedResponse>(res, 'OpenAI /embeddings', OpenAIBackendError);
if (!Array.isArray(data.data)) {
throw new OpenAIBackendError("OpenAI /embeddings response missing 'data' array");
}
Expand All @@ -139,10 +146,10 @@ export class OpenAIBackend implements ModelBackend {

async generate(input: GenerateInput, opts: BackendOpts<GenerateOpts>): Promise<ModelCallResult<GenerateResult>> {
const model = opts.model ?? this.#defaultModel;
requireModel(model, 'generate');
requireModel(model, 'generate', OpenAIBackendError);
const body = buildChatRequest(model, input, opts, false);
const res = await this.#post('/chat/completions', body, opts.signal);
const data = await parseJsonResponse<OpenAIChatResponse>(res, '/chat/completions');
const data = await parseJsonResponse<OpenAIChatResponse>(res, 'OpenAI /chat/completions', OpenAIBackendError);
const choice = data.choices?.[0];
if (!choice) {
throw new OpenAIBackendError('OpenAI /chat/completions response missing choices[0]');
Expand All @@ -165,7 +172,7 @@ export class OpenAIBackend implements ModelBackend {

async *generateStream(input: GenerateInput, opts: BackendOpts<GenerateOpts>): AsyncIterable<GenerateChunk> {
const model = opts.model ?? this.#defaultModel;
requireModel(model, 'generateStream');
requireModel(model, 'generateStream', OpenAIBackendError);
const body = buildChatRequest(model, input, opts, true);
const res = await this.#post('/chat/completions', body, opts.signal);
if (!res.body) throw new OpenAIBackendError('OpenAI /chat/completions returned no body for streaming');
Expand Down Expand Up @@ -281,38 +288,6 @@ export class OpenAIBackendError extends ServerError {

// ---------- internals ----------

function normalizeBaseUrl(baseUrl?: string): string {
const value = baseUrl?.trim() || DEFAULT_BASE_URL;
const withScheme = /^https?:\/\//i.test(value) ? value : `https://${value}`;
return withScheme.replace(/\/+$/, '');
}

function requireApiKey(apiKey: string | undefined): string {
if (!apiKey || apiKey.length === 0) {
throw new OpenAIBackendError('OpenAI backend requires an apiKey');
}
if (isUnresolvedEnvVarPlaceholder(apiKey)) {
// Bootstrap ran `expandEnvVarsDeep` but the env var was unset, so the
// literal `${VAR_NAME}` survived. Tell the operator exactly what they
// need to set — much better than a 401 from upstream.
throw new OpenAIBackendError(
`OpenAI apiKey is the literal placeholder ${apiKey}; set the matching env var before starting Harper`
);
}
return apiKey;
}

function requireModel(model: string | undefined, op: string): asserts model is string {
if (!model) throw new OpenAIBackendError(`No model specified for ${op}; set 'model' in config or pass opts.model`);
}

function composeSignal(caller?: AbortSignal, timeoutMs?: number): AbortSignal | undefined {
if (!timeoutMs) return caller;
const timeout = AbortSignal.timeout(timeoutMs);
if (!caller) return timeout;
return AbortSignal.any([caller, timeout]);
}

function buildChatRequest(
model: string,
input: GenerateInput,
Expand Down Expand Up @@ -548,24 +523,6 @@ function parseSseEvent(block: string): OpenAIStreamEvent | 'done' | null {
}
}

async function parseJsonResponse<T>(res: Response, endpoint: string): Promise<T> {
try {
return (await res.json()) as T;
} catch {
throw new OpenAIBackendError(`OpenAI ${endpoint} returned a non-JSON response body`);
}
}

function assignFiniteTokenCount(
usage: TokenUsage,
key: 'promptTokens' | 'completionTokens' | 'embeddingTokens',
value: unknown
): void {
if (typeof value !== 'number') return;
if (!Number.isFinite(value) || value < 0 || !Number.isInteger(value)) return;
usage[key] = value;
}

// ---------- OpenAI wire types (subset we actually read) ----------

interface OpenAIEmbedResponse {
Expand Down
13 changes: 13 additions & 0 deletions dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,19 @@ Generally, dependencies are added by simply adding them to the dependencies list
- Binary compilation: No
- Eventual removal: Required as long as we use pkijs. Could be replaced if Node.js adds native ASN.1 parsing or if we implement our own X.509 parser.

## @aws-sdk/client-bedrock-runtime (optional peerDependency)

- Need for usage: AWS Bedrock backend for `scope.models` (#510 Phase 6 / #633). Bedrock requires SigV4-signed requests against region-specific endpoints; rolling SigV4 ourselves is non-trivial and the AWS SDK does it correctly. The SDK also handles the standard AWS credential chain (env vars, shared profile, IAM roles, IRSA) which is exactly what we want.
- Classification: **optional `peerDependency`**, not a direct dependency. Harper itself does not install the SDK — `package.json` declares it in `peerDependenciesMeta.@aws-sdk/client-bedrock-runtime.optional: true`. Modern npm / pnpm / yarn skip the auto-install and do not warn. The backend dynamic-imports the SDK on first call and throws `BedrockBackendError('@aws-sdk/client-bedrock-runtime is not installed. Add it to your project ...')` if it's missing. Customers that don't use the Bedrock backend pay zero install or runtime cost.
- Size/memory cost: ~5 MB unpacked including transitive `@smithy/*`, `@aws-sdk/*` packages. Only loaded for users who explicitly opt in by adding the SDK to their own project's `package.json`.
- Security: AWS-maintained, weekly-cadence releases. CVE history is in the standard AWS SDK channel; Harper does not freeze the patch range — operators install the version their project pins.
- Environment interaction: None at Harper load time (dynamic import only fires when a Bedrock backend is registered AND a `scope.models` call is made). At runtime, the SDK uses the standard AWS credential chain.
- Overlap: None. The other model backends (`ollama`, `openai`, `anthropic`) use native `fetch` directly; SigV4 is the genuine reason we use an SDK here and not on the other three.
- Transitive dependencies: Large `@smithy/*` set required by the SDK runtime. Acceptable because installation is opt-in via peerDep.
- Can be deferred: Yes, by design — dynamic-imported on first Bedrock call. Customers without Bedrock never load it.
- Binary compilation: No.
- Eventual removal: We could implement SigV4 ourselves (~300 lines) and call Bedrock's HTTP endpoint with native `fetch`, matching the pattern used by the other three backends. Worth revisiting if SDK version churn becomes a maintenance burden or if the optional-peerDep pattern proves operator-unfriendly. The dynamic-import boundary means the swap is contained to `components/bedrock/index.ts`.

## busboy

- Need for usage: Streaming multipart/form-data parser for the operations API. Required so `deploy_component` payloads can exceed the Node.js 2 GB Buffer cap by being piped straight into extraction (gunzip + tar-fs) instead of buffered. Used only on the operations API ingest path; outbound multipart bodies on the CLI are formatted inline in `bin/multipartBuilder.ts` and do not depend on busboy.
Expand Down
121 changes: 121 additions & 0 deletions integrationTests/server/anthropic-backend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* Anthropic backend integration test (#633, Phase 6 of #510).
*
* Exercises `AnthropicBackend` against the real Anthropic API to validate
* that the mocked wire format used in unit tests matches what the API
* actually produces. SKIPS when `ANTHROPIC_API_KEY` is unset.
*
* Override defaults via env:
* - `ANTHROPIC_API_KEY` (required to run)
* - `ANTHROPIC_MODEL` (default `claude-opus-4-7`)
* - `ANTHROPIC_BASE_URL` (default `https://api.anthropic.com`)
*
* Dynamic import inside before() — same workaround as openai for the
* pre-existing CJS require cycle (`harper_logger` ↔ `common_utils`).
*/
import { suite, test, before } from 'node:test';
import { strictEqual, ok } from 'node:assert/strict';

type AnthropicBackendCtor = new (
config: { apiKey: string; model?: string; baseUrl?: string; requestTimeoutMs?: number },
fetchImpl?: typeof fetch
) => {
generate: (
input: unknown,
opts: object
) => Promise<{
status: string;
output: { content: string; finishReason: string; toolCalls?: unknown[] };
usage: object;
}>;
generateStream: (
input: unknown,
opts: object
) => AsyncIterable<{ deltaContent?: string; deltaToolCalls?: unknown[]; finishReason?: string }>;
};

const API_KEY = process.env.ANTHROPIC_API_KEY;
const MODEL = process.env.ANTHROPIC_MODEL ?? 'claude-opus-4-7';
const BASE_URL = process.env.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com';

const ACCOUNTING = { tenantId: 'integration', app: '/integration' };

const skip = !API_KEY;

suite('AnthropicBackend against the real Anthropic API', { skip }, () => {
let backend: InstanceType<AnthropicBackendCtor>;

before(async () => {
const mod = (await import('../../components/anthropic/index.ts')) as { AnthropicBackend: AnthropicBackendCtor };
backend = new mod.AnthropicBackend({ apiKey: API_KEY!, baseUrl: BASE_URL });
});

test('generate produces non-empty content', async () => {
const result = await backend.generate('Reply with just OK.', {
accounting: ACCOUNTING,
model: MODEL,
maxTokens: 10,
temperature: 0,
});
strictEqual(result.status, 'completed');
ok(typeof result.output.content === 'string' && result.output.content.length > 0);
ok(['stop', 'length', 'tool_calls'].includes(result.output.finishReason));
});

test('generate via messages-array with system prompt', async () => {
const result = await backend.generate(
{
messages: [{ role: 'user', content: 'reply OK' }],
system: 'be brief',
},
{ accounting: ACCOUNTING, model: MODEL, maxTokens: 10, temperature: 0 }
);
strictEqual(result.status, 'completed');
ok(typeof result.output.content === 'string' && result.output.content.length > 0);
});

test('generate with tools (toolMode: return) surfaces tool_use blocks', async () => {
const tools = [
{
name: 'get_weather',
description: 'Get current weather for a location',
parameters: {
type: 'object',
properties: { location: { type: 'string' } },
required: ['location'],
},
},
];
const result = await backend.generate(
{
messages: [{ role: 'user', content: 'Weather in Tokyo? Call the tool.' }],
tools,
},
{ accounting: ACCOUNTING, model: MODEL, maxTokens: 256, temperature: 0 }
);
strictEqual(result.status, 'completed');
if (result.output.finishReason === 'tool_calls') {
ok(Array.isArray(result.output.toolCalls));
ok((result.output.toolCalls?.length ?? 0) > 0);
const tc = result.output.toolCalls![0] as { name: string; arguments: object };
strictEqual(tc.name, 'get_weather');
}
});

test('generateStream yields content + finishReason', async () => {
const chunks: { deltaContent?: string; finishReason?: string }[] = [];
for await (const chunk of backend.generateStream('Count: 1 2 3.', {
accounting: ACCOUNTING,
model: MODEL,
maxTokens: 30,
temperature: 0,
})) {
chunks.push(chunk);
}
ok(chunks.length > 0);
const hasContent = chunks.some((c) => typeof c.deltaContent === 'string' && c.deltaContent.length > 0);
ok(hasContent);
const terminal = chunks[chunks.length - 1];
ok(['stop', 'length', 'tool_calls'].includes(terminal.finishReason ?? ''));
});
});
Loading
Loading