Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions src/langfuse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ import { Context as EffectContext, Effect } from "effect";

import { PLUGIN_VERSION } from "./version.js";

function getCostAttributes(cost: number) {
// Custom/proxy providers without per-model pricing in opencode.json
// can report cost: 0 on completed assistant messages / generation steps.
// Omit these costs so Langfuse can calculate them from its model pricing instead.
// OpenCode's pricing fallback: https://github.com/anomalyco/opencode/blob/4c9a4309ce7f4f5926416792a30a3b27281d9923/packages/opencode/src/provider/provider.ts#L1502-L1509
if (cost > 0) {
return {
"langfuse.observation.cost_details": JSON.stringify({ total: cost }),
};
}
return {};
}

export class LangfuseClient {
readonly baseUrl: string;
readonly forceFlush: Effect.Effect<void, unknown>;
Expand Down Expand Up @@ -758,10 +771,7 @@ export class LangfuseClient {
input.tokens.input + input.tokens.output + input.tokens.reasoning,
}),
);
step.span.setAttribute(
"langfuse.observation.cost_details",
JSON.stringify({ total: input.cost }),
);
step.span.setAttributes(getCostAttributes(input.cost));
step.span.setAttribute(
"langfuse.observation.metadata",
JSON.stringify({
Expand Down Expand Up @@ -821,9 +831,7 @@ export class LangfuseClient {
input.tokens.total ??
input.tokens.input + input.tokens.output + input.tokens.reasoning,
}),
"langfuse.observation.cost_details": JSON.stringify({
total: input.cost,
}),
...getCostAttributes(input.cost),
"langfuse.observation.metadata": JSON.stringify({
messageID: input.messageID,
parentID: input.parentID,
Expand Down
53 changes: 52 additions & 1 deletion test/integration/v1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ const completeGeneration = async (input: {
started: number;
completed: number;
text?: string;
cost?: number;
}) => {
storeMessage(input.sessionID, {
info: {
Expand Down Expand Up @@ -443,7 +444,7 @@ const completeGeneration = async (input: {
providerID: "test-provider",
path: { cwd: "/test", root: "/test" },
finish: "stop",
cost: 0.01,
cost: input.cost ?? 0.01,
tokens: {
input: 10,
output: 5,
Expand Down Expand Up @@ -3491,6 +3492,56 @@ describe("built plugin", { concurrent: false }, () => {
});
});

test("omits cost_details when OpenCode reports zero cost", async () => {
const sessionID = "zero-cost-session";
const started = startedAt;

await sendUserMessage({
sessionID,
messageID: "zero-cost-user",
text: "Use a custom provider without OpenCode cost config",
started,
});
await startGeneration({
id: "zero-cost-step",
sessionID,
started: started + 100,
});
await completeGeneration({
sessionID,
userMessageID: "zero-cost-user",
assistantMessageID: "zero-cost-assistant-1",
started: started + 100,
completed: started + 500,
text: "Step output",
cost: 0,
});
await completeGeneration({
sessionID,
userMessageID: "zero-cost-user",
assistantMessageID: "zero-cost-assistant-2",
started: started + 600,
completed: started + 900,
text: "Fallback output",
cost: 0,
});

const { spans } = await flushSession(sessionID);
const generations = spans.filter(
(span) => span.name === "opencode.generation",
);

expect(generations).toHaveLength(2);
for (const generation of generations) {
expect(getAttributes(generation)).toHaveProperty(
"langfuse.observation.usage_details",
);
expect(getAttributes(generation)).not.toHaveProperty(
"langfuse.observation.cost_details",
);
}
});

test("can be disposed repeatedly", async () => {
expect(hooks.dispose).toBeDefined();
await hooks.dispose?.();
Expand Down
Loading
Loading