This commit is contained in:
elpresidank 2026-04-05 22:44:45 -05:00
parent c386f68743
commit b6536eca38
100 changed files with 17680 additions and 377 deletions

View file

@ -15,6 +15,7 @@
"prom-client": "^15.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.8.0",
"vitest": "^3.1.0"
}

View file

@ -13,10 +13,11 @@ import {
type NatsConnection,
type JetStreamClient,
type JetStreamManager,
type ConsumerMessages,
type Consumer as NatsJsConsumer,
type JsMsg,
StringCodec,
AckPolicy,
DeliverPolicy,
} from "nats";
import type {
@ -31,17 +32,22 @@ import type {
const sc = StringCodec();
class NatsMessage<T> implements Message<T> {
/** Exposed so acknowledge/negativeAcknowledge can access the raw JsMsg */
readonly _jsMsg: JsMsg;
constructor(
private readonly msg: JsMsg,
msg: JsMsg,
private readonly decoded: T,
) {}
) {
this._jsMsg = msg;
}
value(): T {
return this.decoded;
}
properties(): Record<string, string> {
const headers = this.msg.headers;
const headers = this._jsMsg.headers;
const props: Record<string, string> = {};
if (headers) {
for (const [key, values] of headers) {
@ -84,7 +90,7 @@ class NatsProducer<T> implements BackendProducer<T> {
}
class NatsConsumer<T> implements BackendConsumer<T> {
private messages: ConsumerMessages | null = null;
private consumer: NatsJsConsumer | null = null;
constructor(
private readonly js: JetStreamClient,
@ -106,43 +112,57 @@ class NatsConsumer<T> implements BackendConsumer<T> {
});
}
// Create or bind to durable consumer
const consumer = await this.js.consumers.get(streamName, this.subscription);
this.messages = await consumer.consume();
// Create or bind to durable consumer.
// Try to get an existing durable consumer first; if it doesn't exist, create it.
try {
this.consumer = await this.js.consumers.get(streamName, this.subscription);
} catch {
const deliverPolicy =
this.initialPosition === "earliest"
? DeliverPolicy.All
: DeliverPolicy.New;
await this.jsm.consumers.add(streamName, {
durable_name: this.subscription,
ack_policy: AckPolicy.Explicit,
deliver_policy: deliverPolicy,
filter_subject: this.subject,
});
this.consumer = await this.js.consumers.get(streamName, this.subscription);
}
}
async receive(timeoutMs = 2000): Promise<Message<T> | null> {
if (!this.messages) throw new Error("Consumer not initialized");
if (!this.consumer) throw new Error("Consumer not initialized");
const deadline = Date.now() + timeoutMs;
for await (const msg of this.messages) {
const decoded = JSON.parse(sc.decode(msg.data)) as T;
return new NatsMessage(msg, decoded);
}
// Pull a single message with a timeout using the pull-based API.
// consumer.next() returns a JsMsg or null when the timeout expires.
const msg = await this.consumer.next({ expires: timeoutMs });
if (!msg) return null;
if (Date.now() >= deadline) return null;
return null;
const decoded = JSON.parse(sc.decode(msg.data)) as T;
return new NatsMessage(msg, decoded);
}
async acknowledge(message: Message<T>): Promise<void> {
const natsMsg = message as NatsMessage<T>;
// Access internal JsMsg for ack — in practice we'd store the ref
// This is a simplified version; real impl tracks msg refs
void natsMsg;
natsMsg._jsMsg.ack();
}
async negativeAcknowledge(message: Message<T>): Promise<void> {
void message;
const natsMsg = message as NatsMessage<T>;
natsMsg._jsMsg.nak();
}
async unsubscribe(): Promise<void> {
// Drain and close consumer
// The pull-based consumer does not have a persistent subscription to drain.
// Clearing the reference is sufficient; the durable consumer persists server-side.
this.consumer = null;
}
async close(): Promise<void> {
if (this.messages) {
this.messages.stop();
}
this.consumer = null;
}
private streamNameFromSubject(subject: string): string {

View file

@ -5,6 +5,7 @@
*/
import type { PubSubBackend, BackendConsumer, Message } from "../backend/types.js";
import type { Flow } from "../processor/flow.js";
import { TooManyRequestsError } from "../errors.js";
export type MessageHandler<T> = (
@ -16,6 +17,8 @@ export type MessageHandler<T> = (
export interface FlowContext {
id: string;
name: string;
/** Reference to the owning Flow instance, giving handlers access to producers and parameters. */
flow: Flow;
}
export interface ConsumerOptions<T> {

View file

@ -34,9 +34,9 @@ export class Flow {
await spec.add(this, this.pubsub, this.definition);
}
// Start all consumers
// Start all consumers, passing this Flow instance via FlowContext
for (const consumer of this.consumers.values()) {
consumer.start({ id: this.processorId, name: this.name }).catch((err) => {
consumer.start({ id: this.processorId, name: this.name, flow: this }).catch((err) => {
console.error(`[Flow:${this.name}] Consumer error:`, err);
});
}

View file

@ -29,16 +29,24 @@ export abstract class EmbeddingsService extends FlowProcessor {
private async onRequest(
msg: EmbeddingsRequest,
properties: Record<string, string>,
_flowCtx: FlowContext,
flowCtx: FlowContext,
): Promise<void> {
const requestId = properties.id;
if (!requestId) return;
const responseProducer = flowCtx.flow.producer<EmbeddingsResponse>("response");
try {
const vectors = await this.onEmbeddings(msg.text, msg.model);
void vectors; // Producer send would go here
await responseProducer.send(requestId, { vectors });
} catch (err) {
console.error(`[EmbeddingsService] Error processing request:`, err);
const message = err instanceof Error ? err.message : String(err);
await responseProducer.send(requestId, {
vectors: [],
error: { type: "embeddings-error", message },
});
}
}

View file

@ -10,7 +10,6 @@ import { ProducerSpec } from "../spec/producer-spec.js";
import { ParameterSpec } from "../spec/parameter-spec.js";
import type { ProcessorConfig } from "../processor/async-processor.js";
import type { FlowContext } from "../messaging/consumer.js";
import type { Flow } from "../processor/flow.js";
import type {
TextCompletionRequest,
TextCompletionResponse,
@ -37,12 +36,11 @@ export abstract class LlmService extends FlowProcessor {
properties: Record<string, string>,
flowCtx: FlowContext,
): Promise<void> {
// We need the actual flow instance to access producers/parameters.
// In the full implementation, FlowContext would carry a flow reference.
// For now this shows the pattern.
const requestId = properties.id;
if (!requestId) return;
const responseProducer = flowCtx.flow.producer<TextCompletionResponse>("response");
try {
if (msg.streaming && this.supportsStreaming()) {
for await (const chunk of this.generateContentStream(
@ -51,8 +49,13 @@ export abstract class LlmService extends FlowProcessor {
msg.model,
msg.temperature,
)) {
// Send each chunk as a response with the same request ID
void chunk; // Producer send would go here
await responseProducer.send(requestId, {
response: chunk.text,
model: chunk.model,
inToken: chunk.inToken ?? undefined,
outToken: chunk.outToken ?? undefined,
endOfStream: chunk.isFinal,
});
}
} else {
const result = await this.generateContent(
@ -61,10 +64,24 @@ export abstract class LlmService extends FlowProcessor {
msg.model,
msg.temperature,
);
void result; // Producer send would go here
await responseProducer.send(requestId, {
response: result.text,
model: result.model,
inToken: result.inToken,
outToken: result.outToken,
endOfStream: true,
});
}
} catch (err) {
console.error(`[LlmService] Error processing request:`, err);
const message = err instanceof Error ? err.message : String(err);
await responseProducer.send(requestId, {
response: "",
error: { type: "llm-error", message },
endOfStream: true,
});
}
}

View file

@ -2,7 +2,8 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
"rootDir": "src",
"composite": true
},
"include": ["src"]
}