trustgraph/ts/packages/base/src/services/llm-service.ts

238 lines
6.6 KiB
TypeScript
Raw Normal View History

2026-04-05 21:09:33 -05:00
/**
2026-06-01 16:22:25 -05:00
* Base LLM capability contract and message-bus adapter.
2026-04-05 21:09:33 -05:00
*
* Python reference: trustgraph-base/trustgraph/base/llm_service.py
*/
2026-06-02 00:22:04 -05:00
import { Context, Effect, Stream } from "effect";
2026-06-01 16:22:25 -05:00
import * as S from "effect/Schema";
import {
2026-06-01 16:22:25 -05:00
errorMessage,
type FlowResourceNotFoundError,
type MessagingDeliveryError,
} from "../errors.js";
import type { FlowContext } from "../messaging/consumer.js";
2026-06-01 20:26:47 -05:00
import { makeFlowProcessor } from "../processor/index.ts";
import type { FlowProcessorRuntime, ProcessorConfig } from "../processor/index.ts";
2026-04-05 21:09:33 -05:00
import type {
2026-06-01 16:22:25 -05:00
TextCompletionRequest,
TextCompletionResponse,
2026-04-05 21:09:33 -05:00
} from "../schema/messages.js";
2026-06-01 20:26:47 -05:00
import type { LlmChunk, LlmResult } from "../schema/index.ts";
import { makeConsumerSpec } from "../spec/index.ts";
import { makeParameterSpec } from "../spec/index.ts";
import { makeProducerSpec } from "../spec/index.ts";
2026-06-01 16:22:25 -05:00
import type { Spec } from "../spec/types.js";
export class LlmServiceError extends S.TaggedErrorClass<LlmServiceError>()(
"LlmServiceError",
{
message: S.String,
operation: S.String,
},
) {}
2026-06-06 10:33:10 -05:00
export interface LlmProvider<ProviderError = never> {
2026-06-01 16:22:25 -05:00
readonly generateContent: (
system: string,
prompt: string,
model?: string,
temperature?: number,
2026-06-06 10:33:10 -05:00
) => Effect.Effect<LlmResult, ProviderError>;
2026-06-01 16:22:25 -05:00
readonly generateContentStream: (
system: string,
prompt: string,
model?: string,
temperature?: number,
2026-06-06 10:33:10 -05:00
) => Stream.Stream<LlmChunk, ProviderError>;
2026-06-01 16:22:25 -05:00
readonly supportsStreaming: () => boolean;
}
export interface LlmServiceShape {
readonly generateContent: (
system: string,
prompt: string,
model?: string,
temperature?: number,
) => Effect.Effect<LlmResult, LlmServiceError>;
readonly generateContentStream: (
system: string,
prompt: string,
model?: string,
temperature?: number,
2026-06-06 10:33:10 -05:00
) => Stream.Stream<LlmChunk, LlmServiceError>;
2026-06-01 16:22:25 -05:00
readonly supportsStreaming: () => boolean;
}
export class Llm extends Context.Service<Llm, LlmServiceShape>()(
"@trustgraph/base/services/llm-service/Llm",
) {}
const llmServiceError = (operation: string, cause: unknown) =>
2026-06-02 00:22:04 -05:00
LlmServiceError.make({
2026-06-01 16:22:25 -05:00
operation,
message: errorMessage(cause),
});
2026-06-06 10:33:10 -05:00
export const makeLlmServiceShape = <ProviderError>(
provider: LlmProvider<ProviderError>,
): LlmServiceShape => ({
2026-06-01 16:22:25 -05:00
generateContent: Effect.fn("Llm.generateContent")((
system,
prompt,
model,
temperature,
) =>
2026-06-06 10:33:10 -05:00
provider.generateContent(system, prompt, model, temperature).pipe(
Effect.mapError((cause) => llmServiceError("generate-content", cause)),
),
2026-06-01 16:22:25 -05:00
),
generateContentStream: (
system,
prompt,
model,
temperature,
2026-06-06 10:33:10 -05:00
) =>
provider.generateContentStream(system, prompt, model, temperature).pipe(
Stream.mapError((cause) => llmServiceError("generate-content-stream", cause)),
),
2026-06-01 16:22:25 -05:00
supportsStreaming: () => provider.supportsStreaming(),
});
type LlmHandlerError =
| FlowResourceNotFoundError
| MessagingDeliveryError;
const resultToResponse = (result: LlmResult): TextCompletionResponse => ({
response: result.text,
model: result.model,
inToken: result.inToken,
outToken: result.outToken,
endOfStream: true,
});
const chunkToResponse = (chunk: LlmChunk): TextCompletionResponse => ({
response: chunk.text,
model: chunk.model,
...(chunk.inToken !== null ? { inToken: chunk.inToken } : {}),
...(chunk.outToken !== null ? { outToken: chunk.outToken } : {}),
endOfStream: chunk.isFinal,
});
const llmErrorResponse = (error: LlmServiceError): TextCompletionResponse => ({
response: "",
error: {
type: "llm-error",
message: error.message,
},
endOfStream: true,
});
2026-06-02 03:23:23 -05:00
const TextCompletionResponseProducer = makeProducerSpec<TextCompletionResponse>("text-completion-response");
2026-06-01 16:22:25 -05:00
const sendStreamingResponse = Effect.fn("LlmService.sendStreamingResponse")(function* (
llm: LlmServiceShape,
requestId: string,
msg: TextCompletionRequest,
responseProducer: {
readonly send: (
id: string,
message: TextCompletionResponse,
) => Effect.Effect<void, MessagingDeliveryError>;
},
) {
2026-06-06 10:33:10 -05:00
yield* llm.generateContentStream(
msg.system,
msg.prompt,
msg.model,
msg.temperature,
2026-06-02 00:22:04 -05:00
).pipe(
Stream.runForEach((chunk) =>
responseProducer.send(requestId, chunkToResponse(chunk)),
),
);
2026-06-01 16:22:25 -05:00
});
const onLlmRequest = Effect.fn("LlmService.onRequest")(function* (
msg: TextCompletionRequest,
properties: Record<string, string>,
flowCtx: FlowContext<Llm>,
): Effect.fn.Return<void, LlmHandlerError, Llm> {
const requestId = properties.id;
if (requestId === undefined || requestId.length === 0) return;
2026-06-02 03:23:23 -05:00
const responseProducer = yield* flowCtx.flow.producerEffect(TextCompletionResponseProducer);
2026-06-01 16:22:25 -05:00
const llm = yield* Llm;
if (msg.streaming === true && llm.supportsStreaming()) {
yield* sendStreamingResponse(llm, requestId, msg, responseProducer).pipe(
2026-06-02 00:22:04 -05:00
Effect.catchTags({
LlmServiceError: (error) =>
Effect.logError("[LlmService] Error processing streaming request", {
error: error.message,
operation: error.operation,
}).pipe(
Effect.flatMap(() =>
responseProducer.send(requestId, llmErrorResponse(error)),
),
2026-06-01 16:22:25 -05:00
),
2026-06-02 00:22:04 -05:00
MessagingDeliveryError: (error) =>
Effect.logError("[LlmService] Error sending streaming response", {
error: error.message,
operation: error.operation,
}),
}),
2026-06-01 16:22:25 -05:00
);
return;
}
const response = yield* llm.generateContent(
msg.system,
msg.prompt,
msg.model,
msg.temperature,
).pipe(
Effect.map(resultToResponse),
Effect.catch((error) =>
Effect.logError("[LlmService] Error processing request", {
error: error.message,
operation: error.operation,
}).pipe(
Effect.as(llmErrorResponse(error)),
),
),
);
yield* responseProducer.send(requestId, response);
});
export const makeLlmSpecs = (): ReadonlyArray<Spec<Llm>> => [
2026-06-01 20:26:47 -05:00
makeConsumerSpec<TextCompletionRequest, LlmHandlerError, Llm>(
2026-06-01 16:22:25 -05:00
"text-completion-request",
onLlmRequest,
),
2026-06-02 03:23:23 -05:00
TextCompletionResponseProducer,
2026-06-01 20:26:47 -05:00
makeParameterSpec("model"),
makeParameterSpec("temperature"),
2026-06-01 16:22:25 -05:00
];
2026-06-06 10:33:10 -05:00
export type LlmService<ProviderError = never> =
& FlowProcessorRuntime<Llm>
& LlmProvider<ProviderError>;
2026-06-01 20:26:47 -05:00
2026-06-06 10:33:10 -05:00
export function makeLlmService<ProviderError>(
2026-06-01 20:26:47 -05:00
config: ProcessorConfig,
2026-06-06 10:33:10 -05:00
provider: LlmProvider<ProviderError>,
): LlmService<ProviderError> {
2026-06-01 20:26:47 -05:00
const service = makeFlowProcessor(config, {
specifications: makeLlmSpecs(),
provide: (effect) =>
effect.pipe(
Effect.provideService(Llm, Llm.of(makeLlmServiceShape(provider))),
),
});
return Object.assign(service, provider);
2026-04-05 21:09:33 -05:00
}
2026-06-01 20:26:47 -05:00
export const LlmService = makeLlmService;