Skip to content

Commit 469262e

Browse files
authored
fix(server-utils): Keep OpenRouter evaluation confidence and legend on Vercel AI spans (#25644)
1 parent 86758ce commit 469262e

2 files changed

Lines changed: 120 additions & 38 deletions

File tree

‎packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts‎

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ import {
5252
import type { TracingChannel } from 'node:diagnostics_channel';
5353
import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../ai/core/gen-ai-attributes';
5454
import { isEveGenAiRecordingDefault } from './gen-ai-recording-mode';
55-
import type { GenAiOptions } from '../../ai/core/utils';
55+
import { getEvaluationOutputMessages, type GenAiOptions } from '../../ai/core/utils';
5656
import { getProviderMetadataAttributes, LAST_STEP_ONLY_USAGE_KEYS } from '../../ai/vercel-ai';
5757
import { WORKERS_AI_INTEGRATION_NAME } from '../../ai/workers-ai/constants';
5858
import { bindTracingChannelToSpan } from '../../tracing-channel';
@@ -689,7 +689,7 @@ function getOutputMessages(
689689
finishReason: string | undefined,
690690
): string | undefined {
691691
if (type === 'experimental_evaluate' || type === 'experimental_decide') {
692-
return stringify([{ type: 'evaluation', answers: withProviderConfidence(result) }]);
692+
return getEvaluationOutputMessages(withProviderAnswerFields(result));
693693
}
694694
// `languageModelCall` exposes the response as a `content` parts array; top-level results expose
695695
// `text` + `toolCalls`. Both normalize into the OTel `gen_ai.output.messages` assistant message.
@@ -701,26 +701,36 @@ function getOutputMessages(
701701
}
702702

703703
/**
704-
* The AI SDK TypeSafe and OpenAI providers move each answer's `confidence` out of the answers into
705-
* `providerMetadata.<provider>.confidence` (keyed by question id). Put it back so decide answers keep it.
704+
* Providers move answer fields into `providerMetadata`: TypeSafe and OpenAI as `<provider>.confidence[id]`,
705+
* OpenRouter as `openrouter.answers[id].{confidence,legend}`. Put them back on the answers.
706706
*/
707-
function withProviderConfidence(result: Record<string, unknown>): unknown {
707+
function withProviderAnswerFields(result: Record<string, unknown>): unknown {
708708
const { answers, providerMetadata } = result;
709-
const metadata = isObjectLike(providerMetadata)
710-
? [providerMetadata.typesafe, providerMetadata.openai].find(m => isObjectLike(m) && isObjectLike(m.confidence))
711-
: undefined;
712-
const confidence = isObjectLike(metadata) && isObjectLike(metadata.confidence) ? metadata.confidence : undefined;
713-
if (!confidence || !isObjectLike(answers)) {
709+
if (!isObjectLike(answers) || !isObjectLike(providerMetadata)) {
710+
return answers;
711+
}
712+
const confidenceById = [providerMetadata.typesafe, providerMetadata.openai]
713+
.map(metadata => (isObjectLike(metadata) ? metadata.confidence : undefined))
714+
.find(isObjectLike);
715+
const { openrouter } = providerMetadata;
716+
const openrouterById = isObjectLike(openrouter) && isObjectLike(openrouter.answers) ? openrouter.answers : undefined;
717+
if (!confidenceById && !openrouterById) {
714718
return answers;
715719
}
716720

717721
return Object.fromEntries(
718-
Object.entries(answers).map(([id, answer]) => [
719-
id,
720-
isObjectLike(answer) && typeof confidence[id] === 'number' && answer.confidence === undefined
721-
? { ...answer, confidence: confidence[id] }
722-
: answer,
723-
]),
722+
Object.entries(answers).map(([id, answer]) => {
723+
if (!isObjectLike(answer)) {
724+
return [id, answer];
725+
}
726+
const openrouterAnswer = openrouterById?.[id];
727+
const fields = isObjectLike(openrouterAnswer) ? openrouterAnswer : {};
728+
const {
729+
confidence = asNumber(confidenceById?.[id]) ?? asNumber(fields.confidence),
730+
legend = isObjectLike(fields.legend) ? fields.legend : undefined,
731+
} = answer;
732+
return [id, { ...answer, confidence, legend }];
733+
}),
724734
);
725735
}
726736

‎packages/server-utils/test/integrations/vercel-ai/decide.test.ts‎

Lines changed: 94 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,27 @@ describe('Vercel AI decide spans', () => {
2929
getMainCarrier().__SENTRY__ = undefined;
3030
});
3131

32+
function recordedAnswers(provider: string, result: Message['result']): unknown {
33+
const message = {
34+
type: 'experimental_decide',
35+
event: {
36+
provider,
37+
modelId: 'typesafe/jev-1.13',
38+
state: 'I was charged twice and the app keeps crashing',
39+
questions: {},
40+
recordInputs: true,
41+
recordOutputs: true,
42+
},
43+
result,
44+
} satisfies Message;
45+
46+
const span = createSpanFromMessage(message, {} as Parameters<typeof createSpanFromMessage>[1])!;
47+
enrichSpanOnEnd(span, message, {} as Parameters<typeof enrichSpanOnEnd>[2]);
48+
span.end();
49+
50+
return JSON.parse(spanToStaticSpanJSON(endedSpans[0]!).data?.[GEN_AI_OUTPUT_MESSAGES] as string)[0].answers;
51+
}
52+
3253
// `ai` 7.0.128 renamed the channel event from `experimental_evaluate` to `experimental_decide`.
3354
it.each(['experimental_evaluate', 'experimental_decide'] as const)('creates a decide span for `%s`', type => {
3455
const message = {
@@ -58,29 +79,80 @@ describe('Vercel AI decide spans', () => {
5879
});
5980

6081
it.each(['typesafe', 'openai'])('restores answer confidence from `providerMetadata.%s`', provider => {
61-
const message = {
62-
type: 'experimental_decide',
63-
event: {
64-
provider,
65-
modelId: 'gpt-6-luna',
66-
state: 'I was charged twice',
67-
questions: { department: { type: 'choice', criteria: { billing: 'Charges', support: 'Other' } } },
68-
recordInputs: true,
69-
recordOutputs: true,
70-
},
71-
result: {
72-
answers: { department: { type: 'choice', choice: 'billing' } },
73-
providerMetadata: { [provider]: { confidence: { department: 0.91 } } },
74-
},
75-
} satisfies Message;
82+
const answers = recordedAnswers(provider, {
83+
answers: { department: { type: 'choice', choice: 'billing' } },
84+
providerMetadata: { [provider]: { confidence: { department: 0.91 } } },
85+
});
7686

77-
const span = createSpanFromMessage(message, {} as Parameters<typeof createSpanFromMessage>[1])!;
78-
enrichSpanOnEnd(span, message, {} as Parameters<typeof enrichSpanOnEnd>[2]);
79-
span.end();
87+
expect(answers).toEqual({ department: { type: 'choice', choice: 'billing', confidence: 0.91 } });
88+
});
8089

81-
const json = spanToStaticSpanJSON(endedSpans[0]!);
82-
expect(JSON.parse(json.data?.[GEN_AI_OUTPUT_MESSAGES] as string)).toEqual([
83-
{ type: 'evaluation', answers: { department: { type: 'choice', choice: 'billing', confidence: 0.91 } } },
84-
]);
90+
describe('OpenRouter evaluation models', () => {
91+
it('restores choice confidence from `providerMetadata.openrouter.answers`', () => {
92+
const answers = recordedAnswers('openrouter', {
93+
answers: {
94+
department: { type: 'choice', choice: 'billing', probabilities: { billing: 0.82, technical: 0.18 } },
95+
wantsRefund: { type: 'boolean', probability: 0.97 },
96+
},
97+
providerMetadata: {
98+
openrouter: { provider: 'TypeSafe', answers: { department: { confidence: 0.64 }, wantsRefund: {} } },
99+
},
100+
});
101+
102+
expect(answers).toEqual({
103+
department: {
104+
type: 'choice',
105+
choice: 'billing',
106+
probabilities: { billing: 0.82, technical: 0.18 },
107+
confidence: 0.64,
108+
},
109+
wantsRefund: { type: 'boolean', probability: 0.97 },
110+
});
111+
});
112+
113+
it('restores score confidence and legend from `providerMetadata.openrouter.answers`', () => {
114+
const legend = { '0': 'Not urgent', '1': 'Handle today', '2': 'Page on-call' };
115+
116+
const answers = recordedAnswers('openrouter', {
117+
answers: { urgency: { type: 'score', score: 1.8, probabilities: { '0': 0, '1': 0.2, '2': 0.8 } } },
118+
providerMetadata: { openrouter: { answers: { urgency: { confidence: 0.31, legend } } } },
119+
});
120+
121+
expect(answers).toEqual({
122+
urgency: {
123+
type: 'score',
124+
score: 1.8,
125+
probabilities: { '0': 0, '1': 0.2, '2': 0.8 },
126+
confidence: 0.31,
127+
legend,
128+
},
129+
});
130+
});
131+
132+
it.each([
133+
['no provider metadata', undefined],
134+
['no `answers` entry', { openrouter: { provider: 'TypeSafe', usage: { cost: 0.0004 } } }],
135+
['no entry for the question', { openrouter: { answers: { department: { confidence: 0.64 } } } }],
136+
])('leaves answers unchanged with %s', (_, providerMetadata) => {
137+
const answers = recordedAnswers('openrouter', {
138+
answers: { urgency: { type: 'score', score: 1.8 } },
139+
providerMetadata,
140+
});
141+
142+
expect(answers).toEqual({ urgency: { type: 'score', score: 1.8 } });
143+
});
144+
145+
it('keeps confidence and legend already on the answer', () => {
146+
const answers = recordedAnswers('openrouter', {
147+
answers: { urgency: { type: 'score', score: 1.8, confidence: 0.9, legend: { '2': 'Page on-call' } } },
148+
providerMetadata: {
149+
openrouter: { answers: { urgency: { confidence: 0.31, legend: { '2': 'Escalate' } } } },
150+
},
151+
});
152+
153+
expect(answers).toEqual({
154+
urgency: { type: 'score', score: 1.8, confidence: 0.9, legend: { '2': 'Page on-call' } },
155+
});
156+
});
85157
});
86158
});

0 commit comments

Comments
 (0)