mirror of
https://github.com/FJurmanovic/sugarr.git
synced 2026-11-11 15:51:09 +00:00
The previous cached-tokens commit added cache_read_input_tokens (a flat-schema key) alongside prompt_tokens/completion_tokens (OpenAI's native schema) in the same usageDetails object. Langfuse's ingestion explicitly does not support mixing those two schemas, and doing so broke generation.update()'s output field - confirmed live: prompt/ response content was landing in the Usage panel instead of Input/ Output. Fixed by staying entirely within OpenAI's native schema: cached tokens now nest under prompt_tokens_details.cached_tokens instead of using the flat cache_read_input_tokens key. Cast past the installed @langfuse/tracing SDK's usageDetails type (which only exposes a 3-field camelCase shape with no _details nesting, unlike the wire schema in @langfuse/core) since the nesting is valid on the wire even though the SDK's public type doesn't model it. Verified: full suite (82/82) passing, tsc/eslint clean.
97 lines
3.6 KiB
TypeScript
97 lines
3.6 KiB
TypeScript
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
import { LangfuseSpanProcessor } from '@langfuse/otel';
|
|
import { propagateAttributes, startObservation } from '@langfuse/tracing';
|
|
import type { LangfuseGeneration, LangfuseSpan } from '@langfuse/tracing';
|
|
import { Tracer } from '../llm/Tracer';
|
|
import type {
|
|
GenerationHandle,
|
|
GenerationInput,
|
|
GenerationOutput,
|
|
SessionInfo,
|
|
TraceHandle,
|
|
} from '../llm/Tracer';
|
|
|
|
interface OpenAiUsageDetails {
|
|
prompt_tokens?: number;
|
|
completion_tokens?: number;
|
|
total_tokens?: number;
|
|
prompt_tokens_details?: { cached_tokens: number };
|
|
}
|
|
|
|
/**
|
|
* Langfuse recognizes usage in either a flat generic schema (input/output/
|
|
* cache_read_input_tokens/...) or OpenAI's native nested schema
|
|
* (prompt_tokens/completion_tokens/prompt_tokens_details.cached_tokens/...)
|
|
* - but NOT a mix of the two ("do not mix formats" per Langfuse's docs).
|
|
* Mixing them previously caused the whole generation.update() payload
|
|
* (including output) to land wrong, so this stays entirely within the
|
|
* OpenAI-native schema.
|
|
*
|
|
* The installed @langfuse/tracing SDK's usageDetails type is
|
|
* `Record<string, number> | OpenAiUsage` (a 3-field camelCase shape with
|
|
* no _details nesting) even though the wire schema (@langfuse/core's
|
|
* OpenAiCompletionUsageSchema) supports this nested form - a gap in the
|
|
* SDK's public types, not a real runtime restriction, hence the cast.
|
|
*/
|
|
function toUsageDetails(usage: GenerationOutput['usage']): OpenAiUsageDetails | undefined {
|
|
if (!usage) return undefined;
|
|
const details: OpenAiUsageDetails = {};
|
|
if (usage.promptTokens !== undefined) details.prompt_tokens = usage.promptTokens;
|
|
if (usage.completionTokens !== undefined) details.completion_tokens = usage.completionTokens;
|
|
if (usage.totalTokens !== undefined) details.total_tokens = usage.totalTokens;
|
|
if (usage.cachedTokens !== undefined) details.prompt_tokens_details = { cached_tokens: usage.cachedTokens };
|
|
return Object.keys(details).length > 0 ? details : undefined;
|
|
}
|
|
|
|
export class LangfuseTracer extends Tracer {
|
|
private readonly processor: LangfuseSpanProcessor;
|
|
private readonly sdk: NodeSDK;
|
|
|
|
constructor(opts: { publicKey: string; secretKey: string; baseUrl: string }) {
|
|
super();
|
|
this.processor = new LangfuseSpanProcessor({
|
|
publicKey: opts.publicKey,
|
|
secretKey: opts.secretKey,
|
|
baseUrl: opts.baseUrl,
|
|
});
|
|
this.sdk = new NodeSDK({ spanProcessors: [this.processor] });
|
|
this.sdk.start();
|
|
}
|
|
|
|
startTrace(name: string, metadata?: Record<string, unknown>): TraceHandle {
|
|
const span: LangfuseSpan = startObservation(name, { metadata });
|
|
return {
|
|
id: span.traceId,
|
|
startGeneration(input: GenerationInput): GenerationHandle {
|
|
const generation: LangfuseGeneration = span.startObservation(
|
|
input.name,
|
|
{ model: input.model, input: input.input, metadata: input.metadata },
|
|
{ asType: 'generation' }
|
|
);
|
|
return {
|
|
end(output: GenerationOutput) {
|
|
generation.update({
|
|
output: output.output,
|
|
usageDetails: toUsageDetails(output.usage) as Record<string, number> | undefined,
|
|
metadata: output.metadata,
|
|
});
|
|
generation.end();
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
async flush(): Promise<void> {
|
|
await this.processor.forceFlush();
|
|
}
|
|
|
|
runWithSession<T>(session: SessionInfo, fn: () => Promise<T>): Promise<T> {
|
|
return propagateAttributes({ sessionId: session.sessionId, tags: session.tags }, fn);
|
|
}
|
|
|
|
async shutdown(): Promise<void> {
|
|
await this.sdk.shutdown();
|
|
}
|
|
}
|