Compare commits

...

1 Commits

Author SHA1 Message Date
nityanandagohain
5fa85064e9 feat: add more thread normalizers 2026-10-13 17:37:52 +05:30
29 changed files with 2457 additions and 156 deletions

View File

@@ -10517,13 +10517,13 @@ components:
items:
$ref: '#/components/schemas/SpantypesEvent'
type: array
formatted_input:
formattedInput:
$ref: '#/components/schemas/AiobservabilitytypesInputMessages'
formatted_output:
formattedOutput:
$ref: '#/components/schemas/AiobservabilitytypesOutputMessages'
formatter:
type: string
formatter_warnings:
formatterWarnings:
items:
type: string
type: array
@@ -10569,10 +10569,10 @@ components:
- attributes
- events
- references
- formatted_input
- formatted_output
- formattedInput
- formattedOutput
- formatter
- formatter_warnings
- formatterWarnings
type: object
SpantypesTraceAISummary:
properties:
@@ -16442,8 +16442,8 @@ paths:
deprecated: false
description: Returns the spans carrying gen_ai input or output messages, or
a tool execution, in timestamp order. Messages already in the OTel GenAI shape
are decoded into formatted_input and formatted_output, with formatter naming
the converter and formatter_warnings what it could not resolve. Pass nextCursor
are decoded into formattedInput and formattedOutput, with formatter naming
the converter and formatterWarnings what it could not resolve. Pass nextCursor
as after or prevCursor as before to page, or spanId to open the page around
a span.
operationId: GetTraceThread

View File

@@ -11789,8 +11789,8 @@ export interface SpantypesThreadSpanDTO {
* @type array
*/
events: SpantypesEventDTO[];
formatted_input: AiobservabilitytypesInputMessagesDTO;
formatted_output: AiobservabilitytypesOutputMessagesDTO;
formattedInput: AiobservabilitytypesInputMessagesDTO;
formattedOutput: AiobservabilitytypesOutputMessagesDTO;
/**
* @type string
*/
@@ -11798,7 +11798,7 @@ export interface SpantypesThreadSpanDTO {
/**
* @type array
*/
formatter_warnings: string[];
formatterWarnings: string[];
/**
* @type boolean
*/

View File

@@ -261,7 +261,7 @@ export const invalidateGetTraceSummary = async (
};
/**
* Returns the spans carrying gen_ai input or output messages, or a tool execution, in timestamp order. Messages already in the OTel GenAI shape are decoded into formatted_input and formatted_output, with formatter naming the converter and formatter_warnings what it could not resolve. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
* Returns the spans carrying gen_ai input or output messages, or a tool execution, in timestamp order. Messages already in the OTel GenAI shape are decoded into formattedInput and formattedOutput, with formatter naming the converter and formatterWarnings what it could not resolve. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
* @summary Get thread view for a trace
*/
export const getTraceThread = (

View File

@@ -90,7 +90,7 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
ID: "GetTraceThread",
Tags: []string{"tracedetail"},
Summary: "Get thread view for a trace",
Description: "Returns the spans carrying gen_ai input or output messages, or a tool execution, in timestamp order. Messages already in the OTel GenAI shape are decoded into formatted_input and formatted_output, with formatter naming the converter and formatter_warnings what it could not resolve. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.",
Description: "Returns the spans carrying gen_ai input or output messages, or a tool execution, in timestamp order. Messages already in the OTel GenAI shape are decoded into formattedInput and formattedOutput, with formatter naming the converter and formatterWarnings what it could not resolve. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.",
RequestQuery: new(spantypes.GetTraceThreadParams),
Response: new(spantypes.GettableTraceThread),
ResponseContentType: "application/json",

View File

@@ -15,6 +15,7 @@ import (
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/spantypes/spantypestest"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -72,6 +73,11 @@ func genAIMetadataStore(jsonRelease *time.Time) *telemetrytypestest.MockMetadata
key := def
metadataStore.KeysMap[name] = []*telemetrytypes.TelemetryFieldKey{&key}
}
for _, name := range aiobservabilitytypes.GenAIThreadKeys {
if _, ok := metadataStore.KeysMap[name]; !ok {
metadataStore.KeysMap[name] = []*telemetrytypes.TelemetryFieldKey{{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString}}
}
}
if jsonRelease != nil {
selector := telemetrytypes.EvolutionSelector{Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldName: "__all__"}
metadataStore.ColumnEvolutionMetadataMap[selector.QualifiedName()] = tracestelemetryschema.MockAttributeEvolutionData(*jsonRelease)
@@ -220,14 +226,14 @@ func TestGetThreadSpans(t *testing.T) {
name: "FlagOff_ReadsAndFiltersLegacyMaps",
jsonRelease: &jsonInsideTrace,
selectSQL: selectSQL,
whereSQL: "(mapContains(attributes_string, 'gen_ai.input.messages') OR mapContains(attributes_string, 'gen_ai.output.messages') OR mapContains(attributes_string, 'gen_ai.tool.name'))",
whereSQL: "(mapContains(attributes_string, 'gen_ai.input.messages') OR mapContains(attributes_string, 'gen_ai.output.messages') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.prompt.0.role') OR mapContains(attributes_string, 'gen_ai.completion.0.role') OR mapContains(attributes_string, 'llm.input_messages.0.message.role') OR mapContains(attributes_string, 'llm.output_messages.0.message.role'))",
},
{
name: "FlagOn_ReleasedDuringTrace_ReadsJSONFiltersJSONThenMaps",
jsonOn: true,
jsonRelease: &jsonInsideTrace,
selectSQL: selectSQL + ", attributes",
whereSQL: "(multiIf(attributes.`gen_ai.input.messages` IS NOT NULL, attributes.`gen_ai.input.messages`::String, mapContains(attributes_string, 'gen_ai.input.messages'), attributes_string['gen_ai.input.messages'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.output.messages` IS NOT NULL, attributes.`gen_ai.output.messages`::String, mapContains(attributes_string, 'gen_ai.output.messages'), attributes_string['gen_ai.output.messages'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.tool.name` IS NOT NULL, attributes.`gen_ai.tool.name`::String, mapContains(attributes_string, 'gen_ai.tool.name'), attributes_string['gen_ai.tool.name'], NULL) IS NOT NULL)",
whereSQL: "(multiIf(attributes.`gen_ai.input.messages` IS NOT NULL, attributes.`gen_ai.input.messages`::String, mapContains(attributes_string, 'gen_ai.input.messages'), attributes_string['gen_ai.input.messages'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.output.messages` IS NOT NULL, attributes.`gen_ai.output.messages`::String, mapContains(attributes_string, 'gen_ai.output.messages'), attributes_string['gen_ai.output.messages'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.tool.name` IS NOT NULL, attributes.`gen_ai.tool.name`::String, mapContains(attributes_string, 'gen_ai.tool.name'), attributes_string['gen_ai.tool.name'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.prompt.0.role` IS NOT NULL, attributes.`gen_ai.prompt.0.role`::String, mapContains(attributes_string, 'gen_ai.prompt.0.role'), attributes_string['gen_ai.prompt.0.role'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.completion.0.role` IS NOT NULL, attributes.`gen_ai.completion.0.role`::String, mapContains(attributes_string, 'gen_ai.completion.0.role'), attributes_string['gen_ai.completion.0.role'], NULL) IS NOT NULL OR multiIf(attributes.`llm.input_messages.0.message.role` IS NOT NULL, attributes.`llm.input_messages.0.message.role`::String, mapContains(attributes_string, 'llm.input_messages.0.message.role'), attributes_string['llm.input_messages.0.message.role'], NULL) IS NOT NULL OR multiIf(attributes.`llm.output_messages.0.message.role` IS NOT NULL, attributes.`llm.output_messages.0.message.role`::String, mapContains(attributes_string, 'llm.output_messages.0.message.role'), attributes_string['llm.output_messages.0.message.role'], NULL) IS NOT NULL)",
},
}

View File

@@ -17,8 +17,14 @@ const (
GenAIUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens"
GenAIUsageReasoningOutputTokens = "gen_ai.usage.reasoning.output_tokens"
GenAIInputMessages = "gen_ai.input.messages"
GenAIOutputMessages = "gen_ai.output.messages"
GenAIInputMessages = "gen_ai.input.messages"
GenAIOutputMessages = "gen_ai.output.messages"
GenAISystemInstructions = "gen_ai.system_instructions"
GenAIResponseFinishReasons = "gen_ai.response.finish_reasons"
GenAIToolCallID = "gen_ai.tool.call.id"
GenAIToolCallArguments = "gen_ai.tool.call.arguments"
GenAIToolCallResult = "gen_ai.tool.call.result"
)
// Per-span costs the SigNoz LLM pricing processor attaches; SigNoz semconv
@@ -35,8 +41,13 @@ const (
// agent span. A trace belongs to the AI explorer when any span carries one.
var GenAISpanGateKeys = []string{GenAIRequestModel, GenAIToolName, GenAIAgentName}
// GenAIThreadKeys mark a span as a thread row: a model call with its messages, or a tool execution.
var GenAIThreadKeys = []string{GenAIInputMessages, GenAIOutputMessages, GenAIToolName}
// GenAIThreadKeys mark a span as a thread row: a model call with its messages, in the semconv keys
// or the flattened keys of openllmetry and OpenInference, or a tool execution.
var GenAIThreadKeys = []string{
GenAIInputMessages, GenAIOutputMessages, GenAIToolName,
"gen_ai.prompt.0.role", "gen_ai.completion.0.role",
"llm.input_messages.0.message.role", "llm.output_messages.0.message.role",
}
// GenAISpanFilterExpression renders the gate as a query-builder filter
// expression: each gate key ORed on EXISTS.

View File

@@ -4,27 +4,41 @@ import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
FormatterSemconv = "semconv"
FormatterOpenAIChat = "openai.chat"
FormatterText = "text"
FormatterGeneric = "generic"
FormatterSemconv = "semconv"
FormatterTool = "tool"
FormatterOpenInference = "openinference"
FormatterTraceloop = "traceloop"
FormatterVercel = "vercel"
FormatterLangChain = "langchain"
FormatterOpenRouter = "openrouter"
FormatterOpenAIChat = "openai.chat"
FormatterOpenAIResponses = "openai.responses"
FormatterText = "text"
FormatterGeneric = "generic"
)
var dataURL = regexp.MustCompile(`^data:([^;,]+);base64,(.+)$`)
// Provider spellings of the finish reasons; the enum values themselves pass through.
var finishReasonAliases = map[string]FinishReason{
"tool_calls": FinishReasonToolCall, "function_call": FinishReasonToolCall,
"end_turn": FinishReasonStop, "stop_sequence": FinishReasonStop,
"max_tokens": FinishReasonLength,
"tool_calls": FinishReasonToolCall, "function_call": FinishReasonToolCall, "tool_use": FinishReasonToolCall, "tool-calls": FinishReasonToolCall,
"end_turn": FinishReasonStop, "stop_sequence": FinishReasonStop, "completed": FinishReasonStop, "eos": FinishReasonStop,
"max_tokens": FinishReasonLength, "max_output_tokens": FinishReasonLength, "model_length": FinishReasonLength,
"safety": FinishReasonContentFilter, "recitation": FinishReasonContentFilter, "content_filtered": FinishReasonContentFilter, "content-filter": FinishReasonContentFilter, "guardrail_intervened": FinishReasonContentFilter,
"failed": FinishReasonError, "cancelled": FinishReasonError, "incomplete": FinishReasonError,
}
// Chat Completions input_audio formats, ChatCompletionRequestMessageContentPartAudio in the OpenAI spec.
var audioFormatMimeTypes = map[string]string{"wav": "audio/wav", "mp3": "audio/mpeg"}
// FormattedMessages is the OTel GenAI view of one span's messages. Formatter names the converter that
// produced it, Warnings what the converter could not resolve. The lists are never nil.
type FormattedMessages struct {
@@ -34,57 +48,109 @@ type FormattedMessages struct {
Warnings []string
}
// FormatMessages builds the OTel GenAI view of gen_ai.input.messages and gen_ai.output.messages from the
// formats SDKs write. Each value is a JSON string or a structured value; nil means absent.
func FormatMessages(input, output any) FormattedMessages {
f := &formatting{out: FormattedMessages{Input: InputMessages{}, Output: OutputMessages{}, Warnings: []string{}}}
var inputLabel, outputLabel string
if input != nil {
var msgs OutputMessages
msgs, inputLabel = f.side(decode(input), RoleUser)
for _, m := range msgs {
f.out.Input = append(f.out.Input, ChatMessage{Role: m.Role, Parts: m.Parts, Name: m.Name})
}
}
if output != nil {
f.out.Output, outputLabel = f.side(decode(output), RoleAssistant)
}
f.out.Formatter = inputLabel
if inputLabel == "" {
f.out.Formatter = outputLabel
} else if outputLabel != "" && outputLabel != inputLabel {
f.warn("output formatted as %s, input as %s", outputLabel, inputLabel)
// FormatMessages builds the OTel GenAI view of a span's messages from the formats SDKs write. attributes
// is the span's JSON attribute document, nested by key segment, so an object value is whole and the
// flattened llm.input_messages.N.* keys form a tree. Each message value is a JSON string or a
// structured value. gen_ai.system_instructions leads the input and gen_ai.response.finish_reasons
// overrides the finish reasons the converter found, whichever converter ran.
func FormatMessages(attributes map[string]any) FormattedMessages {
f := &formatting{attrs: attributes, out: FormattedMessages{Input: InputMessages{}, Output: OutputMessages{}, Warnings: []string{}}}
switch {
case f.openInference():
case f.attr(GenAIToolCallArguments) != nil || f.attr(GenAIToolCallResult) != nil:
f.tool()
case f.vercel():
case f.attr(GenAIInputMessages) != nil || f.attr(GenAIOutputMessages) != nil:
f.messages()
case f.traceloop():
}
f.prependSystemInstructions()
f.fillFinishReasons()
return f.out
}
type formatting struct {
out FormattedMessages
attrs map[string]any
out FormattedMessages
}
// Not handled: a scalar at a prefix of the path, such as gen_ai.input beside gen_ai.input.messages,
// which ends the walk early.
func (f *formatting) attr(key string) any {
return attribute(f.attrs, key)
}
func (f *formatting) warn(format string, args ...any) {
f.out.Warnings = append(f.out.Warnings, fmt.Sprintf(format, args...))
}
// messages converts gen_ai.input.messages and gen_ai.output.messages, each by its own shape. An
// output message left without parts takes the tool calls from the flattened gen_ai.completion.N
// keys litellm writes beside the semconv value.
func (f *formatting) messages() {
var inputLabel, outputLabel string
if input := f.attr(GenAIInputMessages); input != nil {
var msgs OutputMessages
msgs, inputLabel = f.side(decode(input), RoleUser)
f.out.Input = inputMessages(msgs)
}
if output := f.attr(GenAIOutputMessages); output != nil {
f.out.Output, outputLabel = f.side(decode(output), RoleAssistant)
for i := range f.out.Output {
if len(f.out.Output[i].Parts) == 0 {
f.out.Output[i].Parts = append(f.out.Output[i].Parts, traceloopToolCalls(indexedAt(f.attrs, traceloopCompletions, i))...)
}
}
}
f.label(inputLabel, outputLabel)
}
// label names the formatter after the input side, or the output side when the input has none, and
// warns when the two sides came from different converters. A gateway answer object beside the
// OpenAI chat request it answers is one format.
func (f *formatting) label(inputLabel, outputLabel string) {
f.out.Formatter = inputLabel
switch {
case inputLabel == "":
f.out.Formatter = outputLabel
case inputLabel == FormatterOpenAIChat && outputLabel == FormatterOpenRouter:
f.out.Formatter = outputLabel
case outputLabel != "" && outputLabel != inputLabel:
f.warn("output formatted as %s, input as %s", outputLabel, inputLabel)
}
}
// side converts one attribute value by its shape; role is what a bare string on this side is
// spoken as.
func (f *formatting) side(value any, role Role) (OutputMessages, string) {
switch v := value.(type) {
case []any:
if first, ok := firstObject(v); ok {
if _, ok := first["parts"]; ok {
switch {
case first["parts"] != nil:
return f.semconv(v), FormatterSemconv
}
if _, ok := first["role"]; ok {
case first["lc"] != nil:
return f.langChainMessages(v), FormatterLangChain
case first["role"] != nil:
return f.openAIChatMessages(v), FormatterOpenAIChat
}
}
case map[string]any:
switch {
case isLangChainRun(v):
return f.langChainRun(v), FormatterLangChain
case isOpenRouterCompletion(v):
return f.openRouterCompletion(v), FormatterOpenRouter
case isResponsesResponse(v):
return f.responsesResponse(v), FormatterOpenAIResponses
case isResponsesRequest(v):
return f.responsesRequest(v), FormatterOpenAIResponses
}
if choices, ok := v["choices"].([]any); ok {
return f.openAIChatResponse(choices), FormatterOpenAIChat
}
if messages, ok := v["messages"].([]any); ok {
return f.openAIChatMessages(messages), FormatterOpenAIChat
return f.openAIChatRequest(v, messages), FormatterOpenAIChat
}
case string:
if v == "" {
@@ -97,6 +163,94 @@ func (f *formatting) side(value any, role Role) (OutputMessages, string) {
return OutputMessages{genericMessage(value)}, FormatterGeneric
}
// prependSystemInstructions adds gen_ai.system_instructions as a leading system message, unless the
// input already starts with the same text. A span without messages, such as create_agent, stays
// without them.
func (f *formatting) prependSystemInstructions() {
raw := f.attr(GenAISystemInstructions)
if raw == nil || (len(f.out.Input) == 0 && len(f.out.Output) == 0) {
return
}
system := systemMessage(decode(raw))
if system == nil {
return
}
if len(f.out.Input) > 0 && f.out.Input[0].Role == RoleSystem && textOf(f.out.Input[0].Parts) == textOf(system.Parts) {
return
}
f.out.Input = append(InputMessages{{Role: RoleSystem, Parts: system.Parts}}, f.out.Input...)
}
// fillFinishReasons applies gen_ai.response.finish_reasons, one entry per output message, over
// whatever the converter found in the payload.
func (f *formatting) fillFinishReasons() {
raw := f.attr(GenAIResponseFinishReasons)
if raw == nil || len(f.out.Output) == 0 {
return
}
reasons := stringList(decode(raw))
if len(reasons) != len(f.out.Output) {
f.warn("gen_ai.response.finish_reasons has %d entries for %d output messages", len(reasons), len(f.out.Output))
return
}
for i, reason := range reasons {
if fr := finishReason(reason); fr != nil {
f.out.Output[i].FinishReason = fr
}
}
}
// attribute walks the document along the dotted key; a nested object comes back whole.
func attribute(doc map[string]any, key string) any {
var current any = doc
for segment := range strings.SplitSeq(key, ".") {
object, ok := current.(map[string]any)
if !ok {
return nil
}
current = object[segment]
}
return current
}
// indexed reads a flattened list, "<key>.<N>.*" keys stored as an object keyed by N, in numeric order.
// A list value is returned as is.
func indexed(doc map[string]any, key string) []map[string]any {
var items []any
switch v := attribute(doc, key).(type) {
case []any:
items = v
case map[string]any:
indexes := make([]int, 0, len(v))
byIndex := make(map[int]any, len(v))
for k, item := range v {
n, err := strconv.Atoi(k)
if err != nil {
continue
}
indexes = append(indexes, n)
byIndex[n] = item
}
sort.Ints(indexes)
for _, n := range indexes {
items = append(items, byIndex[n])
}
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
if m, ok := item.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// indexedAt reads one entry of a flattened list by its index.
func indexedAt(doc map[string]any, key string, i int) map[string]any {
m, _ := attribute(doc, key+"."+strconv.Itoa(i)).(map[string]any)
return m
}
func firstObject(list []any) (map[string]any, bool) {
if len(list) == 0 {
return nil, false
@@ -109,6 +263,67 @@ func genericMessage(value any) OutputMessage {
return OutputMessage{Parts: Parts{part(GenericPart{"type": FormatterGeneric, "content": stringOf(value)})}}
}
func inputMessages(msgs OutputMessages) InputMessages {
out := make(InputMessages, 0, len(msgs))
for _, m := range msgs {
out = append(out, ChatMessage{Role: m.Role, Parts: m.Parts, Name: m.Name})
}
return out
}
// foldAssistant merges runs of assistant messages into one and drops messages without parts, for
// SDKs that write one message per output item.
func foldAssistant(msgs OutputMessages) OutputMessages {
out := make(OutputMessages, 0, len(msgs))
for _, msg := range msgs {
if len(out) > 0 && msg.Role == RoleAssistant && out[len(out)-1].Role == RoleAssistant {
last := &out[len(out)-1]
last.Parts = append(last.Parts, msg.Parts...)
if last.FinishReason == nil {
last.FinishReason = msg.FinishReason
}
continue
}
if len(msg.Parts) == 0 {
continue
}
out = append(out, msg)
}
return out
}
// systemMessage turns a system prompt given outside the history, a string or a list of parts, into
// a leading system message.
func systemMessage(value any) *OutputMessage {
var parts Parts
switch v := value.(type) {
case string:
if v != "" {
parts = Parts{part(textPart(v))}
}
case []any:
for _, item := range v {
parts = append(parts, contentPart(item))
}
}
if len(parts) == 0 {
return nil
}
return &OutputMessage{Role: RoleSystem, Parts: parts}
}
// textOf joins the text parts, for comparing a system message with gen_ai.system_instructions and
// for a tool result given as text items.
func textOf(parts Parts) string {
var texts []string
for _, p := range parts {
if t, ok := p.Value.(TextPart); ok {
texts = append(texts, t.Content)
}
}
return strings.Join(texts, "\n")
}
// decode parses a JSON-encoded attribute value, twice when an SDK encoded the string again.
// Anything that is not JSON is returned as is.
func decode(value any) any {
@@ -155,6 +370,23 @@ func finishReason(value string) *FinishReason {
return &fr
}
func stringList(value any) []string {
switch v := value.(type) {
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
out = append(out, stringOf(item))
}
return out
case string:
if v == "" {
return nil
}
return []string{v}
}
return nil
}
func optionalString(value any) *string {
s := stringOf(value)
if s == "" {
@@ -171,8 +403,8 @@ func textPart(content string) TextPart {
return TextPart{Type: PartTypeText, Content: content}
}
// contentPart converts one OpenAI content block, which semconv parts may also carry; a block of
// any other type is kept as sent.
// contentPart converts one content block in the OpenAI Chat Completions or Responses spelling,
// which semconv parts may also carry; a block of any other type is kept as sent.
func contentPart(item any) Part {
p, ok := item.(map[string]any)
if !ok {
@@ -182,11 +414,14 @@ func contentPart(item any) Part {
return part(GenericPart{"type": FormatterGeneric, "content": stringOf(item)})
}
switch stringOf(p["type"]) {
case "text":
case "text", "input_text", "output_text", "summary_text":
if p["text"] == nil {
return part(textPart(stringOf(p["content"])))
}
return part(textPart(stringOf(p["text"])))
case "refusal":
return part(textPart(stringOf(p["refusal"])))
case "image_url":
case "image_url", "input_image":
url := stringOf(p["image_url"])
if ref, ok := p["image_url"].(map[string]any); ok {
url = stringOf(ref["url"])
@@ -194,10 +429,26 @@ func contentPart(item any) Part {
if url != "" {
return part(mediaPart(url, ModalityImage))
}
if id := stringOf(p["file_id"]); id != "" {
return part(FilePart{Type: PartTypeFile, Modality: ModalityImage, FileID: id})
}
case "input_audio":
if audio, ok := p["input_audio"].(map[string]any); ok {
if data := stringOf(audio["data"]); data != "" {
return part(audioPart(data, audioFormatMimeTypes[stringOf(audio["format"])]))
}
}
case "file":
if f, ok := filePart(p); ok {
return f
}
case "input_file":
if f, ok := filePart(map[string]any{"file": p}); ok {
return f
}
if url := stringOf(p["file_url"]); url != "" {
return part(mediaPart(url, ModalityDocument))
}
}
return part(GenericPart(p))
}
@@ -223,6 +474,24 @@ func mediaPart(url string, modality Modality) any {
return UriPart{Type: PartTypeURI, Modality: modality, URI: url}
}
// audioPart holds base64 audio bytes; mimeType is left unset when the format is not known.
func audioPart(data, mimeType string) BlobPart {
return BlobPart{Type: PartTypeBlob, Modality: ModalityAudio, Content: data, MimeType: optionalString(mimeType)}
}
// modalityOf maps a MIME type to the schema modality, document for anything that is not media.
func modalityOf(mimeType string) Modality {
switch {
case strings.HasPrefix(mimeType, "image/"):
return ModalityImage
case strings.HasPrefix(mimeType, "audio/"):
return ModalityAudio
case strings.HasPrefix(mimeType, "video/"):
return ModalityVideo
}
return ModalityDocument
}
// stringOf renders nil as "" and non-strings as compact JSON.
func stringOf(value any) string {
switch v := value.(type) {

View File

@@ -0,0 +1,140 @@
package aiobservabilitytypes
import "strings"
// Format: LangChain's serialised run inputs and outputs, {messages: [[Serializable]]} and
// {generations: [[{text, generation_info, message}]]}, each message {lc: 1, type: "constructor",
// id: [..., class], kwargs: {content, tool_calls, tool_call_id, name, ...}} (langchain-ai/langchain
// libs/core/langchain_core/load/serializable.py; langchain_core.messages for the classes).
// Written by: the LangSmith OTel exporter as gen_ai.prompt and gen_ai.completion
// (langchain-ai/langsmith-sdk python/langsmith/_internal/_otel_exporter.py).
// Handled: the message classes, string and block content, tool_calls, the legacy function_call,
// tool messages, the finish reason of a generation.
// LangChain message classes to roles; ChatMessage carries its own role in kwargs.
var langChainRoles = map[string]Role{
"SystemMessage": RoleSystem, "SystemMessageChunk": RoleSystem,
"HumanMessage": RoleUser, "HumanMessageChunk": RoleUser,
"AIMessage": RoleAssistant, "AIMessageChunk": RoleAssistant,
"ToolMessage": RoleTool, "ToolMessageChunk": RoleTool, "FunctionMessage": RoleTool, "FunctionMessageChunk": RoleTool,
}
func isLangChainRun(m map[string]any) bool {
if _, ok := m["generations"].([]any); ok {
return true
}
batches, ok := m["messages"].([]any)
if !ok || len(batches) == 0 {
return false
}
first, ok := batches[0].([]any)
if !ok {
return false
}
msg, ok := firstObject(first)
return ok && msg["lc"] != nil
}
// langChainRun converts the run inputs, every batch of messages in order, or the run outputs, one
// message per generation.
func (f *formatting) langChainRun(m map[string]any) OutputMessages {
var out OutputMessages
if batches, ok := m["messages"].([]any); ok {
for _, batch := range batches {
if list, ok := batch.([]any); ok {
out = append(out, f.langChainMessages(list)...)
}
}
return out
}
batches, _ := m["generations"].([]any)
for _, batch := range batches {
list, _ := batch.([]any)
for _, item := range list {
generation, ok := item.(map[string]any)
if !ok {
out = append(out, genericMessage(item))
continue
}
msg := OutputMessage{Role: RoleAssistant, Parts: Parts{}}
if message, ok := generation["message"].(map[string]any); ok {
msg = f.langChainMessage(message)
} else if text := stringOf(generation["text"]); text != "" {
msg.Parts = Parts{part(textPart(text))}
}
if info, ok := generation["generation_info"].(map[string]any); ok && msg.FinishReason == nil {
msg.FinishReason = finishReason(stringOf(info["finish_reason"]))
}
out = append(out, msg)
}
}
return out
}
func (f *formatting) langChainMessages(list []any) OutputMessages {
out := make(OutputMessages, 0, len(list))
for _, item := range list {
m, ok := item.(map[string]any)
if !ok {
out = append(out, genericMessage(item))
continue
}
out = append(out, f.langChainMessage(m))
}
return out
}
func (f *formatting) langChainMessage(m map[string]any) OutputMessage {
kwargs, _ := m["kwargs"].(map[string]any)
msg := OutputMessage{Role: langChainRole(m, kwargs), Parts: Parts{}, Name: optionalString(kwargs["name"])}
if id := stringOf(kwargs["tool_call_id"]); msg.Role == RoleTool && id != "" {
msg.Parts = Parts{part(ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: &id, Response: toolResponse(kwargs["content"])})}
return msg
}
calls, _ := kwargs["tool_calls"].([]any)
switch content := kwargs["content"].(type) {
case string:
if content != "" {
msg.Parts = append(msg.Parts, part(textPart(content)))
}
case []any:
for _, item := range content {
if block, ok := item.(map[string]any); ok && len(calls) > 0 && (block["type"] == "tool_call" || block["type"] == "tool_use") {
continue
}
msg.Parts = append(msg.Parts, contentPart(item))
}
}
for _, item := range calls {
if call, ok := item.(map[string]any); ok {
msg.Parts = append(msg.Parts, part(ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(call["name"]), ID: optionalString(call["id"]), Arguments: decode(call["args"])}))
}
}
if extra, ok := kwargs["additional_kwargs"].(map[string]any); ok && len(calls) == 0 {
if call, ok := extra["function_call"].(map[string]any); ok {
msg.Parts = append(msg.Parts, part(ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(call["name"]), Arguments: decode(call["arguments"])}))
}
}
if metadata, ok := kwargs["response_metadata"].(map[string]any); ok {
msg.FinishReason = finishReason(stringOf(metadata["finish_reason"]))
if body, ok := metadata["body"].(map[string]any); ok && body["error"] != nil {
msg.FinishReason = finishReason("error")
}
}
return msg
}
// langChainRole reads the message class from the serialised id path, falling back to the type
// alias in kwargs.
func langChainRole(m, kwargs map[string]any) Role {
if id, ok := m["id"].([]any); ok && len(id) > 0 {
class := stringOf(id[len(id)-1])
if r, ok := langChainRoles[class]; ok {
return r
}
if strings.HasPrefix(class, "ChatMessage") {
return role(stringOf(kwargs["role"]))
}
}
return role(stringOf(kwargs["type"]))
}

View File

@@ -0,0 +1,60 @@
package aiobservabilitytypes
import "testing"
// Captured cases are gen_ai.input.messages and gen_ai.output.messages as the collector stored them for
// the SigNoz/scripts langsmith capture: the LangSmith OTel exporter's serialised run inputs and outputs.
func TestFormat_LangChain(t *testing.T) {
runFormatCases(t, []formatCase{
{
// langsmith capture, ChatOpenAI span after the tool ran
name: "SerialisedMessages_RolesFromClasses_ToolCallAndToolMessage",
input: `{"messages":[[{"lc":1,"type":"constructor","id":["langchain","schema","messages","SystemMessage"],"kwargs":{"content":"You are a weather assistant. Call get_weather once per city.","type":"system"}},{"lc":1,"type":"constructor","id":["langchain","schema","messages","HumanMessage"],"kwargs":{"content":"What's the weather in Bengaluru?","type":"human"}},{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"","additional_kwargs":{"refusal":null},"response_metadata":{"finish_reason":"tool_calls","logprobs":null},"type":"ai","id":"lc_run--019e844a","tool_calls":[{"name":"get_weather","args":{"city":"Bengaluru"},"id":"call_nbrbkc5fom95q8uMA2bCvH6s","type":"tool_call"}],"invalid_tool_calls":[]}},{"lc":1,"type":"constructor","id":["langchain","schema","messages","ToolMessage"],"kwargs":{"content":"{\"city\": \"Bengaluru\", \"temp_c\": 18, \"summary\": \"Clear\"}","type":"tool","tool_call_id":"call_nbrbkc5fom95q8uMA2bCvH6s","status":"success"}}]]}`,
output: `{"generations":[[{"text":"The weather in Bengaluru is clear, 18°C.","generation_info":{"finish_reason":"stop","logprobs":null},"type":"ChatGeneration","message":{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"The weather in Bengaluru is clear, 18°C.","additional_kwargs":{"refusal":null},"response_metadata":{"finish_reason":"stop","model_name":"gpt-4o-mini-2024-07-18"},"type":"ai","tool_calls":[],"invalid_tool_calls":[]}}}]],"llm_output":{"model_name":"gpt-4o-mini-2024-07-18"},"run":null,"type":"LLMResult"}`,
formatter: FormatterLangChain,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are a weather assistant. Call get_weather once per city."}]},
{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Bengaluru?"}]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_nbrbkc5fom95q8uMA2bCvH6s", "arguments": {"city": "Bengaluru"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_nbrbkc5fom95q8uMA2bCvH6s", "response": {"city": "Bengaluru", "temp_c": 18, "summary": "Clear"}}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "The weather in Bengaluru is clear, 18°C."}], "finish_reason": "stop"}]`,
},
{
// langsmith capture, ChatOpenAI span answering with a tool call: the generation text is empty
name: "GenerationWithToolCalls_ToolCallPart_FinishFromGenerationInfo",
output: `{"generations":[[{"text":"","generation_info":{"finish_reason":"tool_calls"},"type":"ChatGeneration","message":{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"","response_metadata":{},"type":"ai","tool_calls":[{"name":"get_weather","args":{"city":"Bengaluru"},"id":"call_1","type":"tool_call"}]}}}]]}`,
formatter: FormatterLangChain,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]`,
},
{
// langsmith capture, ChatOpenAI span of a failed call: the error body sits in response_metadata
name: "GenerationWithErrorBody_EmptyMessage_FinishError",
output: `{"generations":[[{"text":"","generation_info":null,"type":"ChatGeneration","message":{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"","response_metadata":{"body":{"error":{"message":"Incorrect API key provided","type":"invalid_request_error","code":"invalid_api_key"},"status":401},"headers":{}},"type":"ai"}}}]]}`,
formatter: FormatterLangChain,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [], "finish_reason": "error"}]`,
},
{
// langchain_core.messages: ChatMessage carries its role, content blocks may repeat a tool call
// the tool_calls list already has, and the legacy function_call lives in additional_kwargs
name: "ChatMessageRole_ContentBlocks_LegacyFunctionCall",
input: `{"messages":[[{"lc":1,"type":"constructor","id":["langchain","schema","messages","ChatMessage"],"kwargs":{"role":"developer","content":[{"type":"text","text":"Be brief."}]}},{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":[{"type":"text","text":"Calling."},{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"Paris"}}],"tool_calls":[{"name":"get_weather","args":{"city":"Paris"},"id":"toolu_1"}]}},{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"","additional_kwargs":{"function_call":{"name":"multiply","arguments":"{\"x\": 2}"}},"name":"math"}}]]}`,
formatter: FormatterLangChain,
wantInput: `[
{"role": "developer", "parts": [{"type": "text", "content": "Be brief."}]},
{"role": "assistant", "parts": [{"type": "text", "content": "Calling."}, {"type": "tool_call", "name": "get_weather", "id": "toolu_1", "arguments": {"city": "Paris"}}]},
{"role": "assistant", "name": "math", "parts": [{"type": "tool_call", "name": "multiply", "arguments": {"x": 2}}]}
]`,
wantOutput: `[]`,
},
{
name: "BareSerialisedList_ConvertedAsMessages",
input: `[{"lc":1,"type":"constructor","id":["langchain","schema","messages","HumanMessageChunk"],"kwargs":{"content":"hi"}}]`,
formatter: FormatterLangChain,
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "hi"}]}]`,
wantOutput: `[]`,
},
})
}

View File

@@ -4,11 +4,25 @@ import (
"strings"
)
// Format: OpenAI Chat Completions, a request {messages}, a response {choices}, or a bare message list.
// Format: OpenAI Chat Completions, a request {messages}, a response {choices}, or a bare message list
// (openai/openai-openapi@ee9458d, openapi.yaml: CreateChatCompletionRequest, CreateChatCompletionResponse).
// Written by: bifrost, openrouter requests, the raw OpenAI calls OpenInference records.
// Handled: string and block content, tool_calls nested or flattened, tool messages, refusal,
// reasoning_content, finish reasons.
// Not yet: streaming delta chunks, legacy completions choices[].text, audio output.
// Handled: string and block content of every ChatCompletionRequestMessageContentPart* type, tool_calls
// nested or flattened, tool messages, refusal, reasoning_content, the response audio object, finish reasons.
// Not yet: streaming delta chunks, legacy completions choices[].text; a request-side {audio: {id}}
// reference to an earlier audio response is dropped.
// openAIChatRequest converts a request whose system prompt sits outside the history: Anthropic's
// system, LangChain's system_prompt.
func (f *formatting) openAIChatRequest(request map[string]any, messages []any) OutputMessages {
msgs := f.openAIChatMessages(messages)
for _, key := range []string{"system", "system_prompt"} {
if system := systemMessage(request[key]); system != nil {
return append(OutputMessages{*system}, msgs...)
}
}
return msgs
}
func (f *formatting) openAIChatMessages(list []any) OutputMessages {
out := make(OutputMessages, 0, len(list))
@@ -68,6 +82,14 @@ func (f *formatting) openAIChatMessage(m map[string]any) OutputMessage {
if refusal := stringOf(m["refusal"]); refusal != "" {
msg.Parts = append(msg.Parts, part(textPart(refusal)))
}
if audio, ok := m["audio"].(map[string]any); ok {
if transcript := stringOf(audio["transcript"]); transcript != "" {
msg.Parts = append(msg.Parts, part(textPart(transcript)))
}
if data := stringOf(audio["data"]); data != "" {
msg.Parts = append(msg.Parts, part(audioPart(data, "")))
}
}
reasoning, _ := m["reasoning_content"].(string)
if reasoning == "" {
reasoning, _ = m["reasoning"].(string)

View File

@@ -170,7 +170,8 @@ func TestFormat_OpenAIChat(t *testing.T) {
]`,
},
{
// openinference capture, input.value of the ChatCompletion span
// ChatCompletionRequestMessageContentPartFile {file: {file_id}} (openai/openai-openapi@ee9458d,
// openapi.yaml), as OpenInference records the request in input.value
name: "FileByID_FilePart",
input: `{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": [{"type": "text", "text": "Summarise this document in one sentence."}, {"type": "file", "file": {"file_id": "file-6F2ksmvXxt4VdoqmHRw6kL"}}]}], "max_tokens": 60}`,
output: `{"id":"chatcmpl-DktFileCapture0001","object":"chat.completion","model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"finish_reason":"stop","logprobs":null,"message":{"role":"assistant","content":"The document is a short PDF about dragons.","refusal":null,"annotations":[]}}]}`,
@@ -193,7 +194,7 @@ func TestFormat_OpenAIChat(t *testing.T) {
]`,
},
{
// openinference capture, input.value of the ChatCompletion span
// ChatCompletionRequestMessageContentPartFile {file: {filename, file_data}}, file_data a data URL
name: "FileInline_BlobPart",
input: `{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": [{"type": "text", "text": "Summarise this document in one sentence."}, {"type": "file", "file": {"filename": "draconomicon.pdf", "file_data": "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbXS9Db3VudCAwPj5lbmRvYmoKdHJhaWxlcjw8L1Jvb3QgMSAwIFI+PgolJUVPRgo="}}]}], "max_tokens": 60}`,
formatter: FormatterOpenAIChat,
@@ -208,6 +209,57 @@ func TestFormat_OpenAIChat(t *testing.T) {
]`,
wantOutput: `[]`,
},
{
// ChatCompletionRequestMessageContentPartAudio {input_audio: {data, format}}; format is wav or mp3
name: "InputAudio_BlobPartWithMimeFromFormat",
input: `{"model": "gpt-4o-audio-preview", "messages": [{"role": "user", "content": [{"type": "text", "text": "What is in this recording?"}, {"type": "input_audio", "input_audio": {"data": "UklGRg==", "format": "wav"}}, {"type": "input_audio", "input_audio": {"data": "SUQz", "format": "flac"}}]}]}`,
formatter: FormatterOpenAIChat,
wantInput: `[
{
"role": "user",
"parts": [
{"type": "text", "content": "What is in this recording?"},
{"type": "blob", "modality": "audio", "mime_type": "audio/wav", "content": "UklGRg=="},
{"type": "blob", "modality": "audio", "content": "SUQz"}
]
}
]`,
wantOutput: `[]`,
},
{
// ChatCompletionResponseMessage.audio {id, expires_at, data, transcript}; the transcript is the
// answer text, the bytes come without a format, and a later request refers to it by id only
name: "ResponseAudio_TranscriptTextAndBlob_RequestReferenceDropped",
input: `{"model": "gpt-4o-audio-preview", "messages": [{"role": "user", "content": "Say hi"}, {"role": "assistant", "audio": {"id": "audio_abc123"}}, {"role": "user", "content": "Again"}]}`,
output: `{"choices":[{"finish_reason":"stop","index":0,"message":{"role":"assistant","content":null,"refusal":null,"audio":{"id":"audio_abc123","expires_at":1729018800,"data":"UklGRg==","transcript":"Hi there!"}}}]}`,
formatter: FormatterOpenAIChat,
wantInput: `[
{"role": "user", "parts": [{"type": "text", "content": "Say hi"}]},
{"role": "assistant", "parts": []},
{"role": "user", "parts": [{"type": "text", "content": "Again"}]}
]`,
wantOutput: `[
{
"role": "assistant",
"parts": [
{"type": "text", "content": "Hi there!"},
{"type": "blob", "modality": "audio", "content": "UklGRg=="}
],
"finish_reason": "stop"
}
]`,
},
{
// a system prompt outside the history, as the Anthropic Messages API and LangChain take it
name: "SystemOutsideHistory_LeadingSystemMessage",
input: `{"model": "claude-3-5-sonnet", "system": "You are terse.", "messages": [{"role": "user", "content": "hi"}]}`,
formatter: FormatterOpenAIChat,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are terse."}]},
{"role": "user", "parts": [{"type": "text", "content": "hi"}]}
]`,
wantOutput: `[]`,
},
{
name: "ResponseWithScalarChoice_KeptAsGeneric",
output: `{"choices":["oops",{"finish_reason":"stop","index":1,"message":{"role":"assistant","content":"hi"}}]}`,

View File

@@ -0,0 +1,138 @@
package aiobservabilitytypes
import "strings"
// Format: OpenAI Responses, a request {input, instructions} or a response {output} of items
// (openai/openai-openapi@ee9458d, openapi.yaml: CreateResponse, Response, InputItem, OutputItem,
// InputContent, OutputContent).
// Written by: gateways and the raw calls OpenInference records for the Responses API and the
// OpenAI Agents SDK.
// Handled: message items with input_text, output_text, refusal, input_image, input_file and
// input_audio content; function_call and custom_tool_call with their outputs; reasoning summaries;
// compaction; the built-in tool call items as server tool calls and their outputs; the status and
// incomplete_details of a response.
// Dropped: reasoning encrypted_content, item ids.
// Responses API items that carry their result inside the call item.
var responsesCallTypes = map[string]bool{
"web_search_call": true, "file_search_call": true, "code_interpreter_call": true, "computer_call": true,
"image_generation_call": true, "mcp_call": true, "mcp_list_tools": true, "mcp_approval_request": true,
"local_shell_call": true, "shell_call": true, "apply_patch_call": true, "tool_search_call": true,
}
// isResponsesRequest tells a Responses request from an embeddings one, which also carries input
// and model: items are objects, and only Responses takes instructions or tools.
func isResponsesRequest(m map[string]any) bool {
if m["input"] == nil {
return false
}
if m["instructions"] != nil || m["tools"] != nil || m["previous_response_id"] != nil {
return true
}
items, ok := m["input"].([]any)
if !ok {
return false
}
_, isItem := firstObject(items)
return isItem
}
func isResponsesResponse(m map[string]any) bool {
if _, ok := m["output"].([]any); !ok {
return false
}
return stringOf(m["object"]) == "response" || m["status"] != nil
}
func (f *formatting) responsesRequest(m map[string]any) OutputMessages {
var msgs OutputMessages
if system := systemMessage(m["instructions"]); system != nil {
msgs = append(msgs, *system)
}
switch input := m["input"].(type) {
case string:
msgs = append(msgs, OutputMessage{Role: RoleUser, Parts: Parts{part(textPart(input))}})
case []any:
msgs = append(msgs, foldAssistant(f.responsesItems(input))...)
}
return msgs
}
// responsesResponse folds the output items into one message: the API returns a single choice.
func (f *formatting) responsesResponse(m map[string]any) OutputMessages {
output, _ := m["output"].([]any)
msgs := foldAssistant(f.responsesItems(output))
if len(msgs) == 0 {
return msgs
}
last := &msgs[len(msgs)-1]
if details, ok := m["incomplete_details"].(map[string]any); ok {
last.FinishReason = finishReason(stringOf(details["reason"]))
} else if status := stringOf(m["status"]); status != "" {
last.FinishReason = finishReason(status)
}
return msgs
}
// responsesItems converts items one message each; the caller folds assistant runs.
func (f *formatting) responsesItems(items []any) OutputMessages {
msgs := make(OutputMessages, 0, len(items))
for _, item := range items {
m, ok := item.(map[string]any)
if !ok {
if s, ok := item.(string); ok {
msgs = append(msgs, OutputMessage{Role: RoleUser, Parts: Parts{part(textPart(s))}})
continue
}
msgs = append(msgs, genericMessage(item))
continue
}
typ := stringOf(m["type"])
switch {
case typ == "" || typ == "message":
msgs = append(msgs, f.openAIChatMessage(m))
case typ == "function_call" || typ == "custom_tool_call":
args := m["arguments"]
if args == nil {
args = m["input"]
}
call := ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(m["name"]), ID: optionalString(m["call_id"]), Arguments: decode(args)}
msgs = append(msgs, OutputMessage{Role: RoleAssistant, Parts: Parts{part(call)}})
case typ == "function_call_output" || typ == "custom_tool_call_output":
result := ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: optionalString(m["call_id"]), Response: toolResponse(m["output"])}
msgs = append(msgs, OutputMessage{Role: RoleTool, Parts: Parts{part(result)}})
case typ == "reasoning":
if p, ok := reasoningItem(m); ok {
msgs = append(msgs, OutputMessage{Role: RoleAssistant, Parts: Parts{p}})
}
case typ == "compaction":
msgs = append(msgs, OutputMessage{Role: RoleAssistant, Parts: Parts{part(CompactionPart{Type: PartTypeCompaction, ID: optionalString(m["id"]), Content: optionalString(m["content"])})}})
case responsesCallTypes[typ]:
call := ServerToolCallPart{Type: PartTypeServerToolCall, Name: strings.TrimSuffix(typ, "_call"), ID: optionalString(m["call_id"]), ServerToolCall: GenericServerToolCall(m)}
msgs = append(msgs, OutputMessage{Role: RoleAssistant, Parts: Parts{part(call)}})
case strings.HasSuffix(typ, "_output") || typ == "mcp_approval_response":
result := ServerToolCallResponsePart{Type: PartTypeServerToolCallResponse, ID: optionalString(m["call_id"]), ServerToolCallResponse: GenericServerToolCallResponse(m)}
msgs = append(msgs, OutputMessage{Role: RoleTool, Parts: Parts{part(result)}})
default:
msgs = append(msgs, OutputMessage{Role: RoleAssistant, Parts: Parts{part(GenericPart(m))}})
}
}
return msgs
}
// reasoningItem joins the summary texts. An item with neither a summary nor encrypted content
// carries nothing to show.
func reasoningItem(m map[string]any) (Part, bool) {
var texts []string
if summary, ok := m["summary"].([]any); ok {
for _, item := range summary {
if s, ok := item.(map[string]any); ok && stringOf(s["text"]) != "" {
texts = append(texts, stringOf(s["text"]))
}
}
}
if len(texts) == 0 && stringOf(m["encrypted_content"]) == "" {
return Part{}, false
}
return part(ReasoningPart{Type: PartTypeReasoning, Content: strings.Join(texts, "\n")}), true
}

View File

@@ -0,0 +1,86 @@
package aiobservabilitytypes
import "testing"
// Cases follow the OpenAI Responses schemas (openai/openai-openapi@ee9458d, openapi.yaml: CreateResponse,
// Response, InputItem, OutputItem); the reasoning and tool round cases mirror the raw bodies in the
// SigNoz/scripts openinference-openai-agents capture.
func TestFormat_OpenAIResponses(t *testing.T) {
runFormatCases(t, []formatCase{
{
// openinference-openai-agents capture, Response span: instructions outside the history, a reasoning
// item without a summary, then the message
name: "InstructionsAndStringInput_ReasoningWithoutSummaryDropped",
input: `{"include": [], "input": [{"content": "A bat and a ball cost $1.10. How much is the ball?", "role": "user"}], "instructions": "You solve problems step by step and give a final answer.", "model": "o3-mini", "tools": []}`,
output: `{"id":"resp_1","object":"response","status":"completed","model":"o3-mini-2025-01-31","output":[{"id":"rs_1","summary":[],"type":"reasoning"},{"id":"msg_1","content":[{"annotations":[],"text":"Final Answer: The ball costs $0.05.","type":"output_text","logprobs":[]}],"role":"assistant","status":"completed","type":"message"}],"usage":{"input_tokens":49,"output_tokens":431}}`,
formatter: FormatterOpenAIResponses,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You solve problems step by step and give a final answer."}]},
{"role": "user", "parts": [{"type": "text", "content": "A bat and a ball cost $1.10. How much is the ball?"}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Final Answer: The ball costs $0.05."}], "finish_reason": "stop"}]`,
},
{
// InputMessage content of every InputContent type, then a FunctionToolCall and its output
name: "InputItems_EveryContentType_FunctionCallRound",
input: `{"model":"gpt-4o-mini","input":[{"role":"user","content":[{"type":"input_text","text":"What is in this image and these files?"},{"type":"input_image","image_url":"https://example.com/duck.jpg","detail":"auto"},{"type":"input_image","file_id":"file-img","detail":"low"},{"type":"input_file","file_id":"file-abc"},{"type":"input_file","filename":"a.pdf","file_data":"data:application/pdf;base64,JVBERi0="},{"type":"input_file","file_url":"https://example.com/a.pdf"},{"type":"input_audio","input_audio":{"data":"UklGRg==","format":"mp3"}}]},{"type":"function_call","call_id":"call_1","name":"get_weather","arguments":"{\"city\":\"Paris\"}"},{"type":"function_call_output","call_id":"call_1","output":"{\"temp_c\":18}"},{"type":"custom_tool_call","call_id":"c1","name":"shell","input":"ls"},{"type":"custom_tool_call_output","call_id":"c1","output":"a.txt"},{"type":"compaction","id":"cmp_1","encrypted_content":"x"},{"type":"computer_call_output","call_id":"cc_1","output":{"type":"computer_screenshot","image_url":"data:image/png;base64,iVBORw0KGgo="}}]}`,
formatter: FormatterOpenAIResponses,
wantInput: `[
{"role": "user", "parts": [
{"type": "text", "content": "What is in this image and these files?"},
{"type": "uri", "modality": "image", "uri": "https://example.com/duck.jpg"},
{"type": "file", "modality": "image", "file_id": "file-img"},
{"type": "file", "modality": "document", "file_id": "file-abc", "mime_type": "application/pdf"},
{"type": "blob", "modality": "document", "mime_type": "application/pdf", "content": "JVBERi0="},
{"type": "uri", "modality": "document", "uri": "https://example.com/a.pdf"},
{"type": "blob", "modality": "audio", "mime_type": "audio/mpeg", "content": "UklGRg=="}
]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Paris"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_1", "response": {"temp_c": 18}}]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "shell", "id": "c1", "arguments": "ls"}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "c1", "response": "a.txt"}]},
{"role": "assistant", "parts": [{"type": "compaction", "id": "cmp_1"}]},
{"role": "tool", "parts": [{"type": "server_tool_call_response", "id": "cc_1", "server_tool_call_response": {"type": "computer_call_output", "call_id": "cc_1", "output": {"type": "computer_screenshot", "image_url": "data:image/png;base64,iVBORw0KGgo="}}}]}
]`,
wantOutput: `[]`,
},
{
// OutputItems folded into the single choice: ReasoningItem summary, a WebSearchToolCall, the
// OutputMessage with output_text and refusal, then a FunctionToolCall
name: "OutputItems_FoldedIntoOneMessage_StatusCompleted",
output: `{"id":"resp_2","object":"response","status":"completed","output":[{"id":"rs_1","type":"reasoning","summary":[{"type":"summary_text","text":"Need the weather first."}],"encrypted_content":"gAAA"},{"id":"ws_1","type":"web_search_call","status":"completed","action":{"type":"search","query":"weather Paris"}},{"id":"msg_1","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Checking.","annotations":[]},{"type":"refusal","refusal":"I cannot do that."}]},{"type":"function_call","call_id":"call_1","name":"get_weather","arguments":"{\"city\":\"Paris\"}","id":"fc_1","status":"completed"}]}`,
formatter: FormatterOpenAIResponses,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "reasoning", "content": "Need the weather first."},
{"type": "server_tool_call", "name": "web_search", "server_tool_call": {"id": "ws_1", "type": "web_search_call", "status": "completed", "action": {"type": "search", "query": "weather Paris"}}},
{"type": "text", "content": "Checking."},
{"type": "text", "content": "I cannot do that."},
{"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Paris"}}
], "finish_reason": "stop"}]`,
},
{
name: "IncompleteResponse_FinishFromIncompleteDetails",
output: `{"object":"response","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Partial"}]}]}`,
formatter: FormatterOpenAIResponses,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Partial"}], "finish_reason": "length"}]`,
},
{
name: "StringInput_UserMessage",
input: `{"model":"gpt-4o-mini","input":"Say hi","instructions":"Be brief."}`,
formatter: FormatterOpenAIResponses,
wantInput: `[{"role": "system", "parts": [{"type": "text", "content": "Be brief."}]}, {"role": "user", "parts": [{"type": "text", "content": "Say hi"}]}]`,
wantOutput: `[]`,
},
{
// an embeddings request also has input and model
name: "EmbeddingsRequest_NotResponses_KeptAsGeneric",
input: `{"input": ["observability is the new monitoring"], "model": "text-embedding-3-small", "encoding_format": "base64"}`,
formatter: FormatterGeneric,
warnings: []string{"message format not recognised, kept as generic"},
wantInput: `[{"role": "", "parts": [{"type": "generic", "content": "{\"encoding_format\":\"base64\",\"input\":[\"observability is the new monitoring\"],\"model\":\"text-embedding-3-small\"}"}]}]`,
wantOutput: `[]`,
},
})
}

View File

@@ -0,0 +1,140 @@
package aiobservabilitytypes
import "github.com/SigNoz/signoz/pkg/valuer"
// Format: the flattened llm.input_messages.N.message.* and llm.output_messages.N.message.* keys
// (Arize-ai/openinference@12f2cc03, spec/semantic_conventions.md, spec/tool_calling.md,
// spec/multimodal_attributes.md), the provider-neutral copy of the conversation every OpenInference
// instrumentation writes; the raw provider request the mapper moved onto gen_ai.*.messages is the
// fallback for a side without them.
// Handled: message.content, the ordered message.contents items of every type the spec names (text,
// image, audio, video, reasoning, tool_use), message.tool_calls, the legacy message.function_call_*,
// tool results, llm.finish_reason.
// Dropped: reasoning id, signature, data and encrypted_content; audio transcripts.
const (
openInferenceInputMessages = "llm.input_messages"
openInferenceOutputMessages = "llm.output_messages"
openInferenceFinishReason = "llm.finish_reason"
)
func (f *formatting) openInference() bool {
input := indexed(f.attrs, openInferenceInputMessages)
output := indexed(f.attrs, openInferenceOutputMessages)
if len(input) == 0 && len(output) == 0 {
return false
}
inputLabel, outputLabel := FormatterOpenInference, FormatterOpenInference
if len(input) > 0 {
f.out.Input = inputMessages(f.openInferenceMessages(input))
} else if raw := f.attr(GenAIInputMessages); raw != nil {
var msgs OutputMessages
msgs, inputLabel = f.side(decode(raw), RoleUser)
f.out.Input = inputMessages(msgs)
}
if len(output) > 0 {
f.out.Output = f.openInferenceMessages(output)
if fr := finishReason(stringOf(f.attr(openInferenceFinishReason))); fr != nil {
for i := range f.out.Output {
f.out.Output[i].FinishReason = fr
}
}
} else if raw := f.attr(GenAIOutputMessages); raw != nil {
f.out.Output, outputLabel = f.side(decode(raw), RoleAssistant)
}
f.label(inputLabel, outputLabel)
return true
}
// openInferenceMessages converts the message buckets, folding runs of assistant messages into one and
// dropping messages without parts: the Responses path writes one message per output item, including
// an empty one for a reasoning item without a summary.
func (f *formatting) openInferenceMessages(buckets []map[string]any) OutputMessages {
out := make(OutputMessages, 0, len(buckets))
for _, bucket := range buckets {
message, _ := bucket["message"].(map[string]any)
msg := f.openInferenceMessage(message)
if len(out) > 0 && msg.Role == RoleAssistant && out[len(out)-1].Role == RoleAssistant {
out[len(out)-1].Parts = append(out[len(out)-1].Parts, msg.Parts...)
continue
}
if len(msg.Parts) == 0 {
continue
}
out = append(out, msg)
}
return out
}
// openInferenceMessage converts one message.* object. A tool message's content is its tool call
// response; otherwise message.content, the ordered message.contents and the tool calls are parts.
func (f *formatting) openInferenceMessage(m map[string]any) OutputMessage {
msg := OutputMessage{Role: role(stringOf(m["role"])), Parts: Parts{}, Name: optionalString(m["name"])}
content, hasContent := m["content"]
contentText := stringOf(content)
if id := stringOf(m["tool_call_id"]); msg.Role == RoleTool && id != "" {
var response any
if hasContent {
response = decode(content)
} else {
response = textOf(f.openInferenceContents(m, false))
}
msg.Parts = Parts{part(ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: &id, Response: response})}
return msg
}
calls := indexed(m, "tool_calls")
msg.Parts = f.openInferenceContents(m, len(calls) == 0)
if contentText != "" && !(len(msg.Parts) == 1 && textOf(msg.Parts) == contentText) {
msg.Parts = append(Parts{part(textPart(contentText))}, msg.Parts...)
}
for _, call := range calls {
if c, ok := call["tool_call"].(map[string]any); ok {
msg.Parts = append(msg.Parts, openInferenceToolCall(c))
}
}
if name := stringOf(m["function_call_name"]); name != "" {
msg.Parts = append(msg.Parts, part(ToolCallRequestPart{Type: PartTypeToolCall, Name: name, Arguments: decode(m["function_call_arguments_json"])}))
}
return msg
}
// openInferenceContents converts the ordered message.contents items. A tool_use item repeats a call
// from message.tool_calls, so it is converted only when the message has no tool_calls.
func (f *formatting) openInferenceContents(m map[string]any, keepToolUse bool) Parts {
items := indexed(m, "contents")
parts := make(Parts, 0, len(items))
for _, item := range items {
content, _ := item["message_content"].(map[string]any)
typ := stringOf(content["type"])
switch typ {
case "text":
parts = append(parts, part(textPart(stringOf(content["text"]))))
case "reasoning":
parts = append(parts, part(ReasoningPart{Type: PartTypeReasoning, Content: stringOf(content["text"])}))
case "image", "audio", "video":
if url := stringOf(attribute(content, typ+"."+typ+".url")); url != "" {
parts = append(parts, part(mediaPart(url, Modality{valuer.NewString(typ)})))
}
case "tool_use":
if call, ok := item["tool_call"].(map[string]any); ok && keepToolUse {
parts = append(parts, openInferenceToolCall(call))
}
default:
parts = append(parts, part(GenericPart(content)))
}
}
return parts
}
// openInferenceToolCall reads a tool_call.* object, {id, function: {name, arguments}}.
func openInferenceToolCall(call map[string]any) Part {
fn, _ := call["function"].(map[string]any)
p := ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(fn["name"]), ID: optionalString(call["id"])}
if args, ok := fn["arguments"]; ok {
p.Arguments = decode(args)
}
return part(p)
}

View File

@@ -0,0 +1,377 @@
package aiobservabilitytypes
import "testing"
// Captured cases are the llm.input_messages.N.* and llm.output_messages.N.* keys as the collector
// stored them for the SigNoz/scripts static-telemetry-generator captures.
func TestFormat_OpenInference(t *testing.T) {
runFormatCases(t, []formatCase{
{
// openinference capture, ChatCompletion span; the raw request the mapper moved onto
// gen_ai.input.messages is ignored while the flattened keys are there
name: "FlattenedMessages_RawRequestIgnored",
attributes: map[string]any{
"llm.input_messages.0.message.role": "system",
"llm.input_messages.0.message.content": "You are concise.",
"llm.input_messages.1.message.role": "user",
"llm.input_messages.1.message.content": "Say hello in five words.",
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.content": "Hello! How are you today?",
"llm.finish_reason": "stop",
GenAIInputMessages: `{"messages": [{"role": "system", "content": "You are concise."}, {"role": "user", "content": "Say hello in five words."}], "model": "gpt-4o-mini", "max_tokens": 50, "temperature": 0.2}`,
},
formatter: FormatterOpenInference,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are concise."}]},
{"role": "user", "parts": [{"type": "text", "content": "Say hello in five words."}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello! How are you today?"}], "finish_reason": "stop"}]`,
},
{
// openinference capture, ChatCompletion span with a tool call
name: "ToolCalls_ToolCallParts_FinishReasonFromLLMKey",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.content": "What is the weather in Bengaluru in celsius?",
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.tool_calls.0.tool_call.id": "call_MdYKTRrkshuBG9W5oBXYZaRc",
"llm.output_messages.0.message.tool_calls.0.tool_call.function.name": "get_current_weather",
"llm.output_messages.0.message.tool_calls.0.tool_call.function.arguments": `{"city":"Bengaluru","unit":"c"}`,
"llm.finish_reason": "tool_calls",
},
formatter: FormatterOpenInference,
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "What is the weather in Bengaluru in celsius?"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_MdYKTRrkshuBG9W5oBXYZaRc", "arguments": {"city": "Bengaluru", "unit": "c"}}], "finish_reason": "tool_call"}]`,
},
{
// openinference capture, the follow-up ChatCompletion span carrying the tool results
name: "ToolRoleMessages_ToolCallResponseParts",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.content": "What's the weather in Paris and in London? Use the tool for each.",
"llm.input_messages.1.message.role": "assistant",
"llm.input_messages.1.message.tool_calls.0.tool_call.id": "call_SW4hst7nzysgvWSAz4OdyK9D",
"llm.input_messages.1.message.tool_calls.0.tool_call.function.name": "get_current_weather",
"llm.input_messages.1.message.tool_calls.0.tool_call.function.arguments": `{"city": "Paris"}`,
"llm.input_messages.1.message.tool_calls.1.tool_call.id": "call_si8wiETYGpooMB7gMXSgMQpk",
"llm.input_messages.1.message.tool_calls.1.tool_call.function.name": "get_current_weather",
"llm.input_messages.1.message.tool_calls.1.tool_call.function.arguments": `{"city": "London"}`,
"llm.input_messages.2.message.role": "tool",
"llm.input_messages.2.message.tool_call_id": "call_SW4hst7nzysgvWSAz4OdyK9D",
"llm.input_messages.2.message.content": `{"city": "Paris", "temp_c": 18, "summary": "Clear"}`,
"llm.input_messages.3.message.role": "tool",
"llm.input_messages.3.message.tool_call_id": "call_si8wiETYGpooMB7gMXSgMQpk",
"llm.input_messages.3.message.content": `{"city": "London", "temp_c": 18, "summary": "Clear"}`,
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.content": "The current weather is as follows:\n\n- **Paris**: 18°C, Clear\n- **London**: 18°C, Clear",
"llm.finish_reason": "stop",
},
formatter: FormatterOpenInference,
wantInput: `[
{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Paris and in London? Use the tool for each."}]},
{"role": "assistant", "parts": [
{"type": "tool_call", "name": "get_current_weather", "id": "call_SW4hst7nzysgvWSAz4OdyK9D", "arguments": {"city": "Paris"}},
{"type": "tool_call", "name": "get_current_weather", "id": "call_si8wiETYGpooMB7gMXSgMQpk", "arguments": {"city": "London"}}
]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_SW4hst7nzysgvWSAz4OdyK9D", "response": {"city": "Paris", "temp_c": 18, "summary": "Clear"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_si8wiETYGpooMB7gMXSgMQpk", "response": {"city": "London", "temp_c": 18, "summary": "Clear"}}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "The current weather is as follows:\n\n- **Paris**: 18°C, Clear\n- **London**: 18°C, Clear"}], "finish_reason": "stop"}]`,
},
{
// openinference capture, vision call; base64 trimmed
name: "ContentsTextAndImage_TextAndBlobParts",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.contents.0.message_content.type": "text",
"llm.input_messages.0.message.contents.0.message_content.text": "What animal is in this image? One sentence.",
"llm.input_messages.0.message.contents.1.message_content.type": "image",
"llm.input_messages.0.message.contents.1.message_content.image.image.url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBD",
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.content": "The animal in the image is a duck.",
"llm.finish_reason": "stop",
},
formatter: FormatterOpenInference,
wantInput: `[{"role": "user", "parts": [
{"type": "text", "content": "What animal is in this image? One sentence."},
{"type": "blob", "modality": "image", "mime_type": "image/jpeg", "content": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBD"}
]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "The animal in the image is a duck."}], "finish_reason": "stop"}]`,
},
{
// openinference-openai-agents capture, Response span: the Responses path writes one output
// message per item, an empty one for the reasoning item without a summary
name: "ResponsesOutputItems_AssistantMessagesFolded_EmptyDropped",
attributes: map[string]any{
"llm.input_messages.0.message.role": "system",
"llm.input_messages.0.message.content": "You solve problems step by step and give a final answer.",
"llm.input_messages.1.message.role": "user",
"llm.input_messages.1.message.content": "A bat and a ball cost $1.10. How much is the ball?",
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.1.message.role": "assistant",
"llm.output_messages.1.message.contents.0.message_content.type": "text",
"llm.output_messages.1.message.contents.0.message_content.text": "Final Answer: The ball costs $0.05.",
"llm.output_messages.2.message.role": "assistant",
"llm.output_messages.2.message.tool_calls.0.tool_call.id": "call_1",
"llm.output_messages.2.message.tool_calls.0.tool_call.function.name": "done",
},
formatter: FormatterOpenInference,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You solve problems step by step and give a final answer."}]},
{"role": "user", "parts": [{"type": "text", "content": "A bat and a ball cost $1.10. How much is the ball?"}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "text", "content": "Final Answer: The ball costs $0.05."},
{"type": "tool_call", "name": "done", "id": "call_1"}
]}]`,
},
{
// openinference capture, the ChatCompletion span of a failed call
name: "InputOnly_NoOutput",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.content": "this should fail",
},
formatter: FormatterOpenInference,
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "this should fail"}]}]`,
wantOutput: `[]`,
},
{
name: "OutputKeysAbsent_RawResponseFormatted_Warns",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.content": "hi",
GenAIOutputMessages: `{"choices":[{"finish_reason":"stop","index":0,"message":{"role":"assistant","content":"hello"}}]}`,
},
formatter: FormatterOpenInference,
warnings: []string{"output formatted as openai.chat, input as openinference"},
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "hi"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "hello"}], "finish_reason": "stop"}]`,
},
})
}
// Every message_content.type the OpenInference spec names (Arize-ai/openinference@12f2cc03,
// spec/multimodal_attributes.md, spec/tool_calling.md, spec/llm_spans.md), in the shape the named
// instrumentation writes it.
func TestFormat_OpenInference_Parts(t *testing.T) {
runFormatCases(t, []formatCase{
{
// openinference-instrumentation-openai _request_attributes_extractor.py: image_url {url} as sent
name: "ImageURL_UriPart",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.contents.0.message_content.type": "image",
"llm.input_messages.0.message.contents.0.message_content.image.image.url": "https://example.com/duck.jpg",
},
formatter: FormatterOpenInference,
wantInput: `[{"role": "user", "parts": [{"type": "uri", "modality": "image", "uri": "https://example.com/duck.jpg"}]}]`,
wantOutput: `[]`,
},
{
// openinference-instrumentation-anthropic _wrappers.py: {source: {media_type, data}} rebuilt as a data URL
name: "ImageDataURL_BlobPart",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.contents.0.message_content.type": "image",
"llm.input_messages.0.message.contents.0.message_content.image.image.url": "data:image/png;base64,iVBORw0KGgo=",
},
formatter: FormatterOpenInference,
wantInput: `[{"role": "user", "parts": [{"type": "blob", "modality": "image", "mime_type": "image/png", "content": "iVBORw0KGgo="}]}]`,
wantOutput: `[]`,
},
{
// openinference-instrumentation config.py REDACTED_VALUE, written for OPENINFERENCE_HIDE_INPUT_IMAGES
name: "ImageRedacted_UriPartWithMarker",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.contents.0.message_content.type": "image",
"llm.input_messages.0.message.contents.0.message_content.image.image.url": "__REDACTED__",
},
formatter: FormatterOpenInference,
wantInput: `[{"role": "user", "parts": [{"type": "uri", "modality": "image", "uri": "__REDACTED__"}]}]`,
wantOutput: `[]`,
},
{
// spec/multimodal_attributes.md audio: a URL with its transcript, or the data URI built from
// a Chat Completions input_audio block; the transcript has no part to go to
name: "Audio_UriAndBlobParts_TranscriptDropped",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.contents.0.message_content.type": "audio",
"llm.input_messages.0.message.contents.0.message_content.audio.audio.url": "https://example.com/hello.mp3",
"llm.input_messages.0.message.contents.0.message_content.audio.audio.transcript": "Hello, how are you?",
"llm.input_messages.0.message.contents.1.message_content.type": "audio",
"llm.input_messages.0.message.contents.1.message_content.audio.audio.url": "data:audio/wav;base64,UklGRg==",
},
formatter: FormatterOpenInference,
wantInput: `[{"role": "user", "parts": [
{"type": "uri", "modality": "audio", "uri": "https://example.com/hello.mp3"},
{"type": "blob", "modality": "audio", "mime_type": "audio/wav", "content": "UklGRg=="}
]}]`,
wantOutput: `[]`,
},
{
// spec/multimodal_attributes.md video: object-store URIs copied verbatim
name: "Video_UriPart",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.contents.0.message_content.type": "video",
"llm.input_messages.0.message.contents.0.message_content.video.video.url": "gs://bucket/clip.mp4",
},
formatter: FormatterOpenInference,
wantInput: `[{"role": "user", "parts": [{"type": "uri", "modality": "video", "uri": "gs://bucket/clip.mp4"}]}]`,
wantOutput: `[]`,
},
{
// openinference-instrumentation-anthropic _wrappers.py thinking and redacted_thinking blocks;
// openinference-instrumentation-openai _responses_api.py reasoning items; the signature, data,
// id and encrypted_content keys have no field on the reasoning part
name: "Reasoning_ReasoningParts_ProviderKeysDropped",
attributes: map[string]any{
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.contents.0.message_content.type": "reasoning",
"llm.output_messages.0.message.contents.0.message_content.text": "The ball must cost less than a dollar.",
"llm.output_messages.0.message.contents.0.message_content.signature": "EuYBCkQYAiJA",
"llm.output_messages.0.message.contents.1.message_content.type": "reasoning",
"llm.output_messages.0.message.contents.1.message_content.data": "EmwKAhgBEgy3",
"llm.output_messages.0.message.contents.2.message_content.type": "reasoning",
"llm.output_messages.0.message.contents.2.message_content.id": "rs_abc123",
"llm.output_messages.0.message.contents.2.message_content.encrypted_content": "gAAAAA==",
"llm.output_messages.0.message.contents.3.message_content.type": "text",
"llm.output_messages.0.message.contents.3.message_content.text": "The ball costs 5 cents.",
},
formatter: FormatterOpenInference,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "reasoning", "content": "The ball must cost less than a dollar."},
{"type": "reasoning", "content": ""},
{"type": "reasoning", "content": ""},
{"type": "text", "content": "The ball costs 5 cents."}
]}]`,
},
{
// openinference-instrumentation-anthropic _wrappers.py and -groq _utils.py: a tool_use block
// is written both as an ordered content item and under message.tool_calls
name: "ToolUseBesideToolCalls_OneToolCallPart",
attributes: map[string]any{
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.contents.0.message_content.type": "text",
"llm.output_messages.0.message.contents.0.message_content.text": "Let me check.",
"llm.output_messages.0.message.contents.1.message_content.type": "tool_use",
"llm.output_messages.0.message.contents.1.tool_call.id": "toolu_01",
"llm.output_messages.0.message.contents.1.tool_call.function.name": "get_weather",
"llm.output_messages.0.message.contents.1.tool_call.function.arguments": `{"city": "Paris"}`,
"llm.output_messages.0.message.tool_calls.0.tool_call.id": "toolu_01",
"llm.output_messages.0.message.tool_calls.0.tool_call.function.name": "get_weather",
"llm.output_messages.0.message.tool_calls.0.tool_call.function.arguments": `{"city": "Paris"}`,
"llm.finish_reason": "tool_use",
},
formatter: FormatterOpenInference,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "text", "content": "Let me check."},
{"type": "tool_call", "name": "get_weather", "id": "toolu_01", "arguments": {"city": "Paris"}}
], "finish_reason": "tool_call"}]`,
},
{
name: "ToolUseWithoutToolCalls_ConvertedInOrder",
attributes: map[string]any{
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.contents.0.message_content.type": "tool_use",
"llm.output_messages.0.message.contents.0.tool_call.id": "toolu_01",
"llm.output_messages.0.message.contents.0.tool_call.function.name": "get_weather",
"llm.output_messages.0.message.contents.0.tool_call.function.arguments": `{"city": "Paris"}`,
"llm.output_messages.0.message.contents.1.message_content.type": "text",
"llm.output_messages.0.message.contents.1.message_content.text": "Checking.",
},
formatter: FormatterOpenInference,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "tool_call", "name": "get_weather", "id": "toolu_01", "arguments": {"city": "Paris"}},
{"type": "text", "content": "Checking."}
]}]`,
},
{
name: "UnknownContentType_GenericPart",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.contents.0.message_content.type": "document",
"llm.input_messages.0.message.contents.0.message_content.document.document.id": "file_1",
},
formatter: FormatterOpenInference,
wantInput: `[{"role": "user", "parts": [{"type": "document", "document": {"document": {"id": "file_1"}}}]}]`,
wantOutput: `[]`,
},
{
name: "ContentRepeatsOnlyTextItem_NotDuplicated",
attributes: map[string]any{
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.content": "hello",
"llm.output_messages.0.message.contents.0.message_content.type": "text",
"llm.output_messages.0.message.contents.0.message_content.text": "hello",
},
formatter: FormatterOpenInference,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "hello"}]}]`,
},
{
name: "ContentBesideOtherItems_TextPartFirst",
attributes: map[string]any{
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.content": "hello",
"llm.output_messages.0.message.contents.0.message_content.type": "reasoning",
"llm.output_messages.0.message.contents.0.message_content.text": "greet",
},
formatter: FormatterOpenInference,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "hello"}, {"type": "reasoning", "content": "greet"}]}]`,
},
{
// openinference-instrumentation-langchain _tracer.py: the legacy OpenAI function_call
name: "LegacyFunctionCall_ToolCallPart",
attributes: map[string]any{
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.function_call_name": "multiply",
"llm.output_messages.0.message.function_call_arguments_json": `{"x": 2}`,
},
formatter: FormatterOpenInference,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "multiply", "arguments": {"x": 2}}]}]`,
},
{
// spec/tool_calling.md tool results: message.name names the function; a result given as
// ordered text items is joined
name: "ToolResultWithNameAndContents_ResponseJoined",
attributes: map[string]any{
"llm.input_messages.0.message.role": "tool",
"llm.input_messages.0.message.name": "get_weather",
"llm.input_messages.0.message.tool_call_id": "call_1",
"llm.input_messages.0.message.contents.0.message_content.type": "text",
"llm.input_messages.0.message.contents.0.message_content.text": "18C",
"llm.input_messages.0.message.contents.1.message_content.type": "text",
"llm.input_messages.0.message.contents.1.message_content.text": "Clear",
},
formatter: FormatterOpenInference,
wantInput: `[{"role": "tool", "name": "get_weather", "parts": [{"type": "tool_call_response", "id": "call_1", "response": "18C\nClear"}]}]`,
wantOutput: `[]`,
},
{
name: "RoleAliases_Mapped",
attributes: map[string]any{
"llm.input_messages.0.message.role": "human",
"llm.input_messages.0.message.content": "hi",
"llm.input_messages.1.message.role": "ai",
"llm.input_messages.1.message.content": "hello",
"llm.input_messages.2.message.role": "model",
"llm.input_messages.2.message.content": "still here",
},
formatter: FormatterOpenInference,
wantInput: `[
{"role": "user", "parts": [{"type": "text", "content": "hi"}]},
{"role": "assistant", "parts": [{"type": "text", "content": "hello"}, {"type": "text", "content": "still here"}]}
]`,
wantOutput: `[]`,
},
})
}

View File

@@ -0,0 +1,46 @@
package aiobservabilitytypes
import "strings"
// Format: the {completion, reasoning, tools, rawRequest} answer object of the OpenRouter trace
// broadcast (capture openrouter-gateway, LLM Generation spans); the request side is the OpenAI chat
// {messages} object. A tool call arrives as the tool name followed by its JSON arguments in
// completion, so it is split against the names in tools.
func isOpenRouterCompletion(m map[string]any) bool {
_, ok := m["completion"].(string)
return ok
}
func (f *formatting) openRouterCompletion(m map[string]any) OutputMessages {
msg := OutputMessage{Role: RoleAssistant, Parts: Parts{}}
if reasoning := stringOf(m["reasoning"]); reasoning != "" {
msg.Parts = append(msg.Parts, part(ReasoningPart{Type: PartTypeReasoning, Content: reasoning}))
}
completion := stringOf(m["completion"])
if call, ok := openRouterToolCall(completion, m["tools"]); ok {
msg.Parts = append(msg.Parts, call)
msg.FinishReason = finishReason("tool_calls")
} else if completion != "" {
msg.Parts = append(msg.Parts, part(textPart(completion)))
}
return OutputMessages{msg}
}
func openRouterToolCall(completion string, tools any) (Part, bool) {
list, _ := tools.([]any)
for _, item := range list {
tool, _ := item.(map[string]any)
fn, _ := tool["function"].(map[string]any)
name := stringOf(fn["name"])
if name == "" || !strings.HasPrefix(completion, name) {
continue
}
args, ok := decode(strings.TrimPrefix(completion, name)).(map[string]any)
if !ok {
continue
}
return part(ToolCallRequestPart{Type: PartTypeToolCall, Name: name, Arguments: args}), true
}
return Part{}, false
}

View File

@@ -0,0 +1,37 @@
package aiobservabilitytypes
import "testing"
// Captured cases are gen_ai.input.messages and gen_ai.output.messages as the collector stored them for
// the SigNoz/scripts openrouter-gateway capture, LLM Generation spans.
func TestFormat_OpenRouter(t *testing.T) {
runFormatCases(t, []formatCase{
{
// the completion is the tool name followed by the JSON arguments
name: "CompletionIsToolNameAndArguments_ToolCallPart",
input: `{"messages":[{"role":"user","content":"What's the weather in Bengaluru? Use the tool."}]}`,
output: `{"completion":"get_current_weather{\"city\":\"Bengaluru\"}","reasoning":null,"tools":[{"type":"function","function":{"name":"get_current_weather","description":"Get the current weather for a city.","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}],"rawRequest":{"model":"openai/gpt-4o-mini","max_tokens":80,"tool_choice":"auto"}}`,
formatter: FormatterOpenRouter,
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Bengaluru? Use the tool."}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]`,
},
{
name: "CompletionWithReasoning_ReasoningThenText",
input: `{"messages":[{"role":"system","content":"You are concise."},{"role":"user","content":"Say hello in five words."}]}`,
output: `{"completion":"Hello there, how are you?","reasoning":"A greeting of exactly five words.","tools":[],"rawRequest":{"model":"openai/gpt-4o-mini"}}`,
formatter: FormatterOpenRouter,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are concise."}]},
{"role": "user", "parts": [{"type": "text", "content": "Say hello in five words."}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "reasoning", "content": "A greeting of exactly five words."}, {"type": "text", "content": "Hello there, how are you?"}]}]`,
},
{
name: "CompletionStartsWithToolNameButNoArguments_Text",
output: `{"completion":"get_current_weather is the tool to call.","reasoning":null,"tools":[{"type":"function","function":{"name":"get_current_weather"}}]}`,
formatter: FormatterOpenRouter,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "get_current_weather is the tool to call."}]}]`,
},
})
}

View File

@@ -6,15 +6,16 @@ import (
)
// Format: the OTel shape itself, [{role, parts}].
// Written by: litellm, langchain, openllmetry, portkey, traceloop LLM spans.
// Decoding is the genai types. Repairs: "text" used for "content" on text parts; OpenAI content
// blocks left inside parts.
// Not yet: "thinking" for "reasoning"; "result" or "output" for "response" on tool call responses.
// Written by: litellm, langchain, openllmetry, portkey, traceloop LLM spans, pydantic-ai.
// Decoding is the genai types. Repairs: "text" used for "content" on text parts; "thinking" for
// "reasoning"; "result" or "output" for "response" on tool call responses (pydantic-ai); tool call
// arguments as a JSON string; provider spellings of the finish reason; OpenAI content blocks left
// inside parts.
// semconv decodes messages that already follow the schema through the genai types. A part of a
// type the schema does not name is a provider content block, which contentPart converts.
func (f *formatting) semconv(list []any) OutputMessages {
data, err := json.Marshal(semconvTextKey(list))
data, err := json.Marshal(semconvRepairs(list))
if err != nil {
f.warn("messages could not be encoded: %s", err)
return OutputMessages{genericMessage(list)}
@@ -28,6 +29,9 @@ func (f *formatting) semconv(list []any) OutputMessages {
if msgs[i].Parts == nil {
msgs[i].Parts = Parts{}
}
if msgs[i].FinishReason != nil {
msgs[i].FinishReason = finishReason(msgs[i].FinishReason.StringValue())
}
for j, p := range msgs[i].Parts {
if block, ok := p.Value.(GenericPart); ok {
msgs[i].Parts[j] = contentPart(map[string]any(block))
@@ -37,9 +41,9 @@ func (f *formatting) semconv(list []any) OutputMessages {
return msgs
}
// semconvTextKey copies the text and reasoning parts some SDKs write with "text" in place of the
// schema's "content". The input is left untouched, it is also the span's raw attribute.
func semconvTextKey(list []any) []any {
// semconvRepairs copies the parts whose keys SDKs misspell into the schema's spelling. The input
// is left untouched, it is also the span's raw attribute.
func semconvRepairs(list []any) []any {
out := make([]any, 0, len(list))
for _, item := range list {
m, ok := item.(map[string]any)
@@ -50,13 +54,8 @@ func semconvTextKey(list []any) []any {
}
fixedParts := make([]any, 0, len(parts))
for _, p := range parts {
part, ok := p.(map[string]any)
typ := stringOf(part["type"])
if ok && (typ == "text" || typ == "reasoning") && part["content"] == nil && part["text"] != nil {
fixed := maps.Clone(part)
fixed["content"] = fixed["text"]
delete(fixed, "text")
p = fixed
if part, ok := p.(map[string]any); ok {
p = semconvRepairPart(part)
}
fixedParts = append(fixedParts, p)
}
@@ -66,3 +65,35 @@ func semconvTextKey(list []any) []any {
}
return out
}
func semconvRepairPart(part map[string]any) map[string]any {
fixed := maps.Clone(part)
switch stringOf(part["type"]) {
case "text", "reasoning":
if part["content"] == nil && part["text"] != nil {
fixed["content"] = fixed["text"]
delete(fixed, "text")
}
case "thinking":
fixed["type"] = "reasoning"
if part["content"] == nil {
fixed["content"] = part["thinking"]
}
delete(fixed, "thinking")
case "tool_call":
if args, ok := part["arguments"].(string); ok {
fixed["arguments"] = decode(args)
}
case "tool_call_response":
if _, ok := part["response"]; !ok {
for _, key := range []string{"result", "output"} {
if value, ok := part[key]; ok {
fixed["response"] = value
delete(fixed, key)
break
}
}
}
}
return fixed
}

View File

@@ -161,3 +161,35 @@ func TestFormat_Semconv(t *testing.T) {
},
})
}
// Cases from a local pydantic-ai 2.55 run against a stub provider, InstrumentationSettings version 5.
func TestFormat_Semconv_Repairs(t *testing.T) {
runFormatCases(t, []formatCase{
{
// pydantic-ai: tool call arguments as a JSON string, the tool result under result with the tool
// name, the instructions as gen_ai.system_instructions and the finish reasons as a list
name: "PydanticAI_ArgumentsDecoded_ResultBecomesResponse_InstructionsPrepended",
input: `[{"role":"user","parts":[{"type":"text","content":"Weather in Paris?"}]},{"role":"assistant","parts":[{"type":"tool_call","id":"call_stub1","name":"get_weather","arguments":"{\"city\": \"Paris\"}"}],"finish_reason":"tool_call"},{"role":"user","parts":[{"type":"tool_call_response","id":"call_stub1","name":"get_weather","result":{"city":"Paris","temp_c":18}}]}]`,
output: `[{"role":"assistant","parts":[{"type":"text","content":"Paris is clear at 18C."}],"finish_reason":"stop"}]`,
attributes: map[string]any{
GenAISystemInstructions: `[{"type":"text","content":"Use the tool."}]`,
GenAIResponseFinishReasons: []any{"stop"},
},
formatter: FormatterSemconv,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "Use the tool."}]},
{"role": "user", "parts": [{"type": "text", "content": "Weather in Paris?"}]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_stub1", "arguments": {"city": "Paris"}}]},
{"role": "user", "parts": [{"type": "tool_call_response", "id": "call_stub1", "response": {"city": "Paris", "temp_c": 18}}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Paris is clear at 18C."}], "finish_reason": "stop"}]`,
},
{
name: "ThinkingPart_BecomesReasoning",
output: `[{"role":"assistant","parts":[{"type":"thinking","thinking":"hmm"},{"type":"tool_call_response","id":"c1","output":"done"},{"type":"text","content":"ok"}]}]`,
formatter: FormatterSemconv,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "reasoning", "content": "hmm"}, {"type": "tool_call_response", "id": "c1", "response": "done"}, {"type": "text", "content": "ok"}]}]`,
},
})
}

View File

@@ -2,16 +2,20 @@ package aiobservabilitytypes
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// formatCase feeds FormatMessages a span document built from attributes, flat dotted keys as the SDK
// set them, plus input and output as gen_ai.input.messages and gen_ai.output.messages.
type formatCase struct {
name string
input any
output any
attributes map[string]any
formatter string
warnings []string
wantInput string
@@ -22,7 +26,17 @@ func runFormatCases(t *testing.T, testCases []formatCase) {
t.Helper()
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
formatted := FormatMessages(testCase.input, testCase.output)
attributes := map[string]any{}
if testCase.input != nil {
attributes[GenAIInputMessages] = testCase.input
}
if testCase.output != nil {
attributes[GenAIOutputMessages] = testCase.output
}
for key, value := range testCase.attributes {
attributes[key] = value
}
formatted := FormatMessages(document(attributes))
assert.Equal(t, testCase.formatter, formatted.Formatter)
assert.Equal(t, append([]string{}, testCase.warnings...), formatted.Warnings)
@@ -36,6 +50,25 @@ func runFormatCases(t *testing.T, testCases []formatCase) {
}
}
// document nests flat dotted keys the way the JSON column stores them.
func document(flat map[string]any) map[string]any {
doc := map[string]any{}
for key, value := range flat {
segments := strings.Split(key, ".")
current := doc
for _, segment := range segments[:len(segments)-1] {
child, ok := current[segment].(map[string]any)
if !ok {
child = map[string]any{}
current[segment] = child
}
current = child
}
current[segments[len(segments)-1]] = value
}
return doc
}
func TestFormat(t *testing.T) {
runFormatCases(t, []formatCase{
{
@@ -44,19 +77,19 @@ func TestFormat(t *testing.T) {
input: `A bat and a ball cost $1.10. The bat costs $1 more than the ball. How much is the ball?`,
output: `Let the cost of the ball be x dollars. Then the bat costs x + $1.00. According to the problem:
  x + (x + 1.00) = 1.10
x + (x + 1.00) = 1.10
Combine like terms:
  2x + 1.00 = 1.10
2x + 1.00 = 1.10
Subtract 1.00 from both sides:
  2x = 0.10
2x = 0.10
Divide by 2:
  x = 0.05
x = 0.05
So, the ball costs 5 cents.`,
formatter: FormatterText,
@@ -78,7 +111,7 @@ So, the ball costs 5 cents.`,
"parts": [
{
"type": "text",
"content": "Let the cost of the ball be x dollars. Then the bat costs x + $1.00. According to the problem:\n\n  x + (x + 1.00) = 1.10\n\nCombine like terms:\n\n  2x + 1.00 = 1.10\n\nSubtract 1.00 from both sides:\n\n  2x = 0.10\n\nDivide by 2:\n\n  x = 0.05\n\nSo, the ball costs 5 cents."
"content": "Let the cost of the ball be x dollars. Then the bat costs x + $1.00. According to the problem:\n\n x + (x + 1.00) = 1.10\n\nCombine like terms:\n\n 2x + 1.00 = 1.10\n\nSubtract 1.00 from both sides:\n\n 2x = 0.10\n\nDivide by 2:\n\n x = 0.05\n\nSo, the ball costs 5 cents."
}
]
}
@@ -93,9 +126,169 @@ So, the ball costs 5 cents.`,
wantOutput: `[]`,
},
{
name: "NoAttributes_EmptyListsNoFormatter",
name: "NoMessageAttributes_EmptyListsNoFormatter",
attributes: map[string]any{GenAIRequestModel: "gpt-4o", GenAIToolName: "get_weather"},
wantInput: `[]`,
wantOutput: `[]`,
},
{
name: "NilDocument_EmptyListsNoFormatter",
wantInput: `[]`,
wantOutput: `[]`,
},
})
}
func TestAttribute(t *testing.T) {
arguments := map[string]any{"city": "Paris", "days": []any{1.0, 2.0}}
testCases := []struct {
name string
doc map[string]any
key string
want any
}{
{name: "Object_ReturnedWhole", doc: document(map[string]any{"gen_ai.tool.call.arguments": arguments}), key: GenAIToolCallArguments, want: arguments},
{name: "MissingPath_Nil", doc: document(map[string]any{"gen_ai.tool.name": "get_weather"}), key: GenAIToolCallArguments},
{name: "ScalarAtPrefix_Nil", doc: document(map[string]any{"gen_ai.tool.call": "x"}), key: GenAIToolCallArguments},
{name: "NilDocument_Nil", key: GenAIToolCallArguments},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, attribute(testCase.doc, testCase.key))
})
}
}
func TestIndexed(t *testing.T) {
testCases := []struct {
name string
doc map[string]any
want []map[string]any
}{
{
name: "NumericKeys_SortedNumerically",
doc: document(map[string]any{"llm.input_messages.10.message.role": "user", "llm.input_messages.2.message.role": "system", "llm.input_messages.x.message.role": "skipped"}),
want: []map[string]any{{"message": map[string]any{"role": "system"}}, {"message": map[string]any{"role": "user"}}},
},
{
name: "StructuredList_KeptInOrder",
doc: map[string]any{"llm": map[string]any{"input_messages": []any{map[string]any{"message": "b"}, map[string]any{"message": "a"}, "not an object"}}},
want: []map[string]any{{"message": "b"}, {"message": "a"}},
},
{name: "Absent_Empty", doc: map[string]any{}, want: []map[string]any{}},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, indexed(testCase.doc, openInferenceInputMessages))
})
}
}
func TestFinishReason(t *testing.T) {
testCases := []struct {
name string
value string
want string
}{
{name: "Enum_Kept", value: "stop", want: "stop"},
{name: "OpenAI_ToolCalls", value: "tool_calls", want: "tool_call"},
{name: "Anthropic_ToolUse", value: "tool_use", want: "tool_call"},
{name: "Anthropic_EndTurn", value: "end_turn", want: "stop"},
{name: "Gemini_UpperCase", value: "MAX_TOKENS", want: "length"},
{name: "Gemini_Safety", value: "SAFETY", want: "content_filter"},
{name: "Responses_Incomplete", value: "incomplete", want: "error"},
{name: "Unknown_KeptLowercase", value: "Something_New", want: "something_new"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
got := finishReason(testCase.value)
require.NotNil(t, got)
assert.Equal(t, testCase.want, got.StringValue())
})
}
assert.Nil(t, finishReason(" "))
}
func TestFormat_SystemInstructions(t *testing.T) {
input := `[{"role":"system","parts":[{"type":"text","content":"be brief"}]},{"role":"user","parts":[{"type":"text","content":"hi"}]}]`
runFormatCases(t, []formatCase{
{
name: "DifferentText_Prepended",
input: input,
attributes: map[string]any{GenAISystemInstructions: `[{"type":"text","content":"never joke"}]`},
formatter: FormatterSemconv,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "never joke"}]},
{"role": "system", "parts": [{"type": "text", "content": "be brief"}]},
{"role": "user", "parts": [{"type": "text", "content": "hi"}]}
]`,
wantOutput: `[]`,
},
{
name: "SameText_Skipped",
input: input,
attributes: map[string]any{GenAISystemInstructions: "be brief"},
formatter: FormatterSemconv,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "be brief"}]},
{"role": "user", "parts": [{"type": "text", "content": "hi"}]}
]`,
wantOutput: `[]`,
},
{
name: "OutputOnly_Prepended",
output: `[{"role":"assistant","parts":[{"type":"text","content":"ok"}]}]`,
attributes: map[string]any{GenAISystemInstructions: "be brief"},
formatter: FormatterSemconv,
wantInput: `[{"role": "system", "parts": [{"type": "text", "content": "be brief"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "ok"}]}]`,
},
{
// a create_agent span carries instructions alone
name: "NoMessages_Nothing",
attributes: map[string]any{GenAISystemInstructions: "be brief"},
wantInput: `[]`,
wantOutput: `[]`,
},
})
}
func TestFormat_FinishReasons(t *testing.T) {
output := `[{"role":"assistant","parts":[{"type":"text","content":"a"}],"finish_reason":"stop"},{"role":"assistant","parts":[{"type":"text","content":"b"}]}]`
runFormatCases(t, []formatCase{
{
name: "List_OverridesMessages",
output: output,
attributes: map[string]any{GenAIResponseFinishReasons: []any{"tool_calls", "length"}},
formatter: FormatterSemconv,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "a"}], "finish_reason": "tool_call"}, {"role": "assistant", "parts": [{"type": "text", "content": "b"}], "finish_reason": "length"}]`,
},
{
name: "JSONString_Parsed",
output: output,
attributes: map[string]any{GenAIResponseFinishReasons: `["stop","length"]`},
formatter: FormatterSemconv,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "a"}], "finish_reason": "stop"}, {"role": "assistant", "parts": [{"type": "text", "content": "b"}], "finish_reason": "length"}]`,
},
{
name: "LengthMismatch_LeavesMessages_Warns",
output: output,
attributes: map[string]any{GenAIResponseFinishReasons: "stop"},
formatter: FormatterSemconv,
warnings: []string{"gen_ai.response.finish_reasons has 1 entries for 2 output messages"},
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "a"}], "finish_reason": "stop"}, {"role": "assistant", "parts": [{"type": "text", "content": "b"}]}]`,
},
{
// vercel capture: a bare string for the single choice, in the provider's spelling
name: "BareString_SingleChoice",
output: `{"choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"hi"}}]}`,
attributes: map[string]any{GenAIResponseFinishReasons: "tool-calls"},
formatter: FormatterOpenAIChat,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "hi"}], "finish_reason": "tool_call"}]`,
},
})
}

View File

@@ -0,0 +1,69 @@
package aiobservabilitytypes
// Format: gen_ai.tool.call.arguments and gen_ai.tool.call.result on an execute_tool span, each an
// object or its JSON string (open-telemetry/semantic-conventions-genai@06ec68e, model/gen-ai/registry.yaml).
// Written by: opentelemetry-instrumentation-langchain, traceloop @tool, OpenInference TOOL spans and
// Vercel ai.toolCall.* through the span mapper.
// Envelopes unwrapped: LangChain's callback payloads, {input_str, tags, metadata, inputs, kwargs} and
// {output, kwargs} (openllmetry callback_handler.py on_tool_start/on_tool_end), and the traceloop
// decorator's {args, kwargs} (traceloop-sdk decorators/base.py _handle_span_input).
// tool renders the call as an assistant tool_call and the result as a tool tool_call_response, both
// carrying gen_ai.tool.call.id so the pair can be matched.
func (f *formatting) tool() {
id := optionalString(f.attr(GenAIToolCallID))
if raw := f.attr(GenAIToolCallArguments); raw != nil {
call := ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(f.attr(GenAIToolName)), ID: id, Arguments: toolArguments(decode(raw))}
f.out.Input = InputMessages{{Role: RoleAssistant, Parts: Parts{part(call)}}}
}
if raw := f.attr(GenAIToolCallResult); raw != nil {
result := ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: id, Response: toolResult(decode(raw))}
f.out.Output = OutputMessages{{Role: RoleTool, Parts: Parts{part(result)}}}
}
f.out.Formatter = FormatterTool
}
func toolArguments(value any) any {
m, ok := value.(map[string]any)
if !ok {
return value
}
if _, isLangChain := m["input_str"]; isLangChain && hasKeys(m, "inputs", "kwargs") {
if inputs := m["inputs"]; inputs != nil {
return inputs
}
return decode(m["input_str"])
}
if len(m) == 2 && hasKeys(m, "args", "kwargs") {
args, _ := m["args"].([]any)
kwargs, _ := m["kwargs"].(map[string]any)
switch {
case len(args) == 0:
return m["kwargs"]
case len(kwargs) == 0:
return args
}
}
return value
}
// toolResult unwraps the LangChain envelope; the traceloop decorator encodes a tool returning
// nothing as the literal null.
func toolResult(value any) any {
if m, ok := value.(map[string]any); ok && len(m) == 2 && hasKeys(m, "output", "kwargs") {
return decode(m["output"])
}
if value == "null" {
return nil
}
return value
}
func hasKeys(m map[string]any, keys ...string) bool {
for _, key := range keys {
if _, ok := m[key]; !ok {
return false
}
}
return true
}

View File

@@ -0,0 +1,115 @@
package aiobservabilitytypes
import "testing"
// Captured cases are the tool span attributes as the collector stored them for the SigNoz/scripts
// static-telemetry-generator captures, after the span mapper filled the gen_ai.tool.call.* keys.
func TestFormat_Tool(t *testing.T) {
runFormatCases(t, []formatCase{
{
// langchain capture, execute_tool get_weather span; opentelemetry-instrumentation-langchain
// callback_handler.py on_tool_start/on_tool_end envelopes
name: "LangChainEnvelopes_InputsAndOutputUnwrapped",
attributes: map[string]any{
GenAIToolName: "get_weather",
GenAIToolCallArguments: `{"input_str": "{'city': 'Bengaluru'}", "tags": [], "metadata": {}, "inputs": {"city": "Bengaluru"}, "kwargs": {"color": "green", "name": null, "tool_call_id": null}}`,
GenAIToolCallResult: `{"output": "{\"city\": \"Bengaluru\", \"temp_c\": 18, \"summary\": \"Clear\"}", "kwargs": {"tags": [], "color": "green", "name": "get_weather"}}`,
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "arguments": {"city": "Bengaluru"}}]}]`,
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "response": {"city": "Bengaluru", "temp_c": 18, "summary": "Clear"}}]}]`,
},
{
// openllmetry capture, get_weather.tool span; traceloop-sdk decorators/base.py
// _handle_span_input {args, kwargs} and the result encoded as a JSON string twice
name: "TraceloopDecorator_KwargsUnwrapped_ResultDecodedTwice",
attributes: map[string]any{
GenAIToolName: "get_weather",
GenAIToolCallArguments: `{"args": [], "kwargs": {"city": "Bengaluru"}}`,
GenAIToolCallResult: `"{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"`,
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "arguments": {"city": "Bengaluru"}}]}]`,
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "response": {"city": "Bengaluru", "temp_c": 28, "summary": "Partly cloudy"}}]}]`,
},
{
name: "TraceloopDecorator_PositionalArgsOnly_ListKept",
attributes: map[string]any{
GenAIToolName: "lookup",
GenAIToolCallArguments: `{"args": ["OpenAI", "What's the weather in Paris?"], "kwargs": {}}`,
GenAIToolCallResult: `null`,
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "lookup", "arguments": ["OpenAI", "What's the weather in Paris?"]}]}]`,
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "response": null}]}]`,
},
{
name: "TraceloopDecorator_BothArgsAndKwargs_EnvelopeKept",
attributes: map[string]any{
GenAIToolName: "lookup",
GenAIToolCallArguments: `{"args": ["Paris"], "kwargs": {"unit": "c"}}`,
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "lookup", "arguments": {"args": ["Paris"], "kwargs": {"unit": "c"}}}]}]`,
wantOutput: `[]`,
},
{
// openinference-openai-agents capture, get_weather TOOL span: input.value and output.value
// copied by the mapper (Arize-ai/openinference@12f2cc03, spec/traces.md)
name: "OpenInferenceToolSpan_PlainJSONAndText",
attributes: map[string]any{
GenAIToolName: "get_weather",
GenAIToolCallArguments: `{"city":"Bangalore"}`,
GenAIToolCallResult: `27C, partly cloudy, light breeze from the south.`,
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "arguments": {"city": "Bangalore"}}]}]`,
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "response": "27C, partly cloudy, light breeze from the south."}]}]`,
},
{
// vercel capture, ai.toolCall span: ai.toolCall.{id,name,args,result} copied by the mapper
// (https://ai-sdk.dev/docs/ai-sdk-core/telemetry)
name: "VercelToolCall_IDOnBothParts",
attributes: map[string]any{
GenAIToolName: "get_current_weather",
GenAIToolCallID: "call_l0ycKcmt1uPCdGWR4SkwoLBv",
GenAIToolCallArguments: `{"city":"Bengaluru","unit":"c"}`,
GenAIToolCallResult: `{"city":"Bengaluru","unit":"c","temperature":27,"condition":"partly cloudy"}`,
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_l0ycKcmt1uPCdGWR4SkwoLBv", "arguments": {"city": "Bengaluru", "unit": "c"}}]}]`,
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_l0ycKcmt1uPCdGWR4SkwoLBv", "response": {"city": "Bengaluru", "unit": "c", "temperature": 27, "condition": "partly cloudy"}}]}]`,
},
{
// the object form the OTel registry asks for, whole in the JSON column
name: "StructuredArguments_ObjectKept",
attributes: map[string]any{
GenAIToolName: "get_weather",
GenAIToolCallID: "call_1",
GenAIToolCallArguments: map[string]any{"city": "Paris", "days": []any{1.0, 2.0}},
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Paris", "days": [1, 2]}}]}]`,
wantOutput: `[]`,
},
{
name: "ResultWithoutArguments_OutputOnly",
attributes: map[string]any{GenAIToolCallResult: "sunny"},
formatter: FormatterTool,
wantInput: `[]`,
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "response": "sunny"}]}]`,
},
{
// the mapper groups never fill both; the tool call wins when an SDK does
name: "MessagesBesideToolCall_ToolCallFormatted",
attributes: map[string]any{
GenAIInputMessages: `[{"role": "user", "parts": [{"type": "text", "content": "hi"}]}]`,
GenAIToolName: "lookup",
GenAIToolCallArguments: `{"city": "Paris"}`,
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "lookup", "arguments": {"city": "Paris"}}]}]`,
wantOutput: `[]`,
},
})
}

View File

@@ -0,0 +1,74 @@
package aiobservabilitytypes
// Format: the flattened gen_ai.prompt.N.* and gen_ai.completion.N.* keys of openllmetry's
// pre-semconv instrumentations (traceloop/openllmetry@94d3f04, opentelemetry-semantic-conventions-ai
// LLM_PROMPTS and LLM_COMPLETIONS; mistralai/__init__.py writes role and content, the openai
// instrumentation before its semconv rewrite also tool_calls.M.{id,name,arguments}, tool_call_id
// and function_call.{name,arguments}).
// Written by: crewai through the openai instrumentation, mistralai, litellm beside its semconv value.
const (
traceloopPrompts = "gen_ai.prompt"
traceloopCompletions = "gen_ai.completion"
)
func (f *formatting) traceloop() bool {
prompts := indexed(f.attrs, traceloopPrompts)
completions := indexed(f.attrs, traceloopCompletions)
if len(prompts) == 0 && len(completions) == 0 {
return false
}
f.out.Input = inputMessages(f.traceloopMessages(prompts))
f.out.Output = f.traceloopMessages(completions)
f.out.Formatter = FormatterTraceloop
return true
}
func (f *formatting) traceloopMessages(buckets []map[string]any) OutputMessages {
out := make(OutputMessages, 0, len(buckets))
for i, m := range buckets {
msg := OutputMessage{Role: role(stringOf(m["role"])), Parts: Parts{}, Name: optionalString(m["name"]), FinishReason: finishReason(stringOf(m["finish_reason"]))}
content, hasContent := m["content"]
if id := stringOf(m["tool_call_id"]); msg.Role == RoleTool && id != "" {
msg.Parts = Parts{part(ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: &id, Response: decode(content)})}
out = append(out, msg)
continue
}
if hasContent {
msg.Parts = append(msg.Parts, traceloopContent(content)...)
}
msg.Parts = append(msg.Parts, traceloopToolCalls(m)...)
if len(msg.Parts) == 0 {
f.warn("message %d has no content", i)
}
out = append(out, msg)
}
return out
}
// traceloopContent reads a content value, a string or the JSON of an OpenAI content block list.
func traceloopContent(content any) Parts {
if blocks, ok := decode(content).([]any); ok {
parts := make(Parts, 0, len(blocks))
for _, block := range blocks {
parts = append(parts, contentPart(block))
}
return parts
}
if text := stringOf(content); text != "" {
return Parts{part(textPart(text))}
}
return nil
}
// traceloopToolCalls reads tool_calls.M.{id,name,arguments} and the legacy function_call object.
func traceloopToolCalls(m map[string]any) Parts {
var parts Parts
for _, call := range indexed(m, "tool_calls") {
parts = append(parts, part(ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(call["name"]), ID: optionalString(call["id"]), Arguments: decode(call["arguments"])}))
}
if call, ok := m["function_call"].(map[string]any); ok {
parts = append(parts, part(ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(call["name"]), Arguments: decode(call["arguments"])}))
}
return parts
}

View File

@@ -0,0 +1,102 @@
package aiobservabilitytypes
import "testing"
// Captured cases are the gen_ai.prompt.N.* and gen_ai.completion.N.* keys as the collector stored
// them for the SigNoz/scripts crewai, mistral and litellm captures.
func TestFormat_Traceloop(t *testing.T) {
runFormatCases(t, []formatCase{
{
// crewai capture, openai.chat span of the tool round (opentelemetry.instrumentation.openai.v1)
name: "ToolRound_ToolCallAndToolMessages",
attributes: map[string]any{
"gen_ai.prompt.0.role": "system",
"gen_ai.prompt.0.content": "You are Weather Reporter. Call get_weather and summarise the result.",
"gen_ai.prompt.1.role": "user",
"gen_ai.prompt.1.content": "Current Task: What's the weather in Bengaluru?",
"gen_ai.prompt.2.role": "assistant",
"gen_ai.prompt.2.tool_calls.0.id": "call_bbVgWPe8AMBol7JlAhDkA6R7",
"gen_ai.prompt.2.tool_calls.0.name": "get_weather",
"gen_ai.prompt.2.tool_calls.0.arguments": `{"city":"Bengaluru"}`,
"gen_ai.prompt.3.role": "tool",
"gen_ai.prompt.3.tool_call_id": "call_bbVgWPe8AMBol7JlAhDkA6R7",
"gen_ai.prompt.3.content": `{"city": "Bengaluru", "temp_c": 18, "summary": "Clear"}`,
"gen_ai.completion.0.role": "assistant",
"gen_ai.completion.0.finish_reason": "stop",
"gen_ai.completion.0.content": "The current weather in Bengaluru is clear with a temperature of 18°C.",
},
formatter: FormatterTraceloop,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are Weather Reporter. Call get_weather and summarise the result."}]},
{"role": "user", "parts": [{"type": "text", "content": "Current Task: What's the weather in Bengaluru?"}]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_bbVgWPe8AMBol7JlAhDkA6R7", "arguments": {"city": "Bengaluru"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_bbVgWPe8AMBol7JlAhDkA6R7", "response": {"city": "Bengaluru", "temp_c": 18, "summary": "Clear"}}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "The current weather in Bengaluru is clear with a temperature of 18°C."}], "finish_reason": "stop"}]`,
},
{
// crewai capture, openai.chat span answering with two tool calls
name: "CompletionToolCalls_ToolCallParts",
attributes: map[string]any{
"gen_ai.prompt.0.role": "user",
"gen_ai.prompt.0.content": "What's the weather in Paris and in London?",
"gen_ai.completion.0.role": "assistant",
"gen_ai.completion.0.finish_reason": "tool_calls",
"gen_ai.completion.0.tool_calls.0.id": "call_1",
"gen_ai.completion.0.tool_calls.0.name": "get_weather",
"gen_ai.completion.0.tool_calls.0.arguments": `{"city":"Paris"}`,
"gen_ai.completion.0.tool_calls.1.id": "call_2",
"gen_ai.completion.0.tool_calls.1.name": "get_weather",
"gen_ai.completion.0.tool_calls.1.arguments": `{"city":"London"}`,
},
formatter: FormatterTraceloop,
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Paris and in London?"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Paris"}},
{"type": "tool_call", "name": "get_weather", "id": "call_2", "arguments": {"city": "London"}}
], "finish_reason": "tool_call"}]`,
},
{
// mistral capture, mistralai.chat span: the instrumentation writes the role without the content
name: "RoleWithoutContent_EmptyMessageWarns",
attributes: map[string]any{
"gen_ai.prompt.0.role": "user",
"gen_ai.completion.0.role": "assistant",
"gen_ai.completion.0.finish_reason": "stop",
"gen_ai.completion.0.content": "Observability is the ability to understand a system's internal state by analyzing its external outputs.",
},
formatter: FormatterTraceloop,
warnings: []string{"message 0 has no content"},
wantInput: `[{"role": "user", "parts": []}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Observability is the ability to understand a system's internal state by analyzing its external outputs."}], "finish_reason": "stop"}]`,
},
{
// a vision prompt: the content is the JSON of the OpenAI content block list
name: "ContentBlockList_TextAndBlobParts",
attributes: map[string]any{
"gen_ai.prompt.0.role": "user",
"gen_ai.prompt.0.content": `[{"type":"text","text":"What animal is this?"},{"type":"image_url","image_url":{"url":"data:image/jpeg;base64,/9j/4AAQ"}}]`,
},
formatter: FormatterTraceloop,
wantInput: `[{"role": "user", "parts": [
{"type": "text", "content": "What animal is this?"},
{"type": "blob", "modality": "image", "mime_type": "image/jpeg", "content": "/9j/4AAQ"}
]}]`,
wantOutput: `[]`,
},
{
// litellm capture, litellm_request span: the semconv output lost the tool call, the flattened
// completion keys beside it still have it
name: "SemconvEmptyPartsWithLegacyFunctionCall_ToolCallFilledIn",
attributes: map[string]any{
GenAIInputMessages: `[{"role": "user", "parts": [{"type": "text", "content": "What is the weather in Bengaluru in celsius?"}]}]`,
GenAIOutputMessages: `[{"role": "assistant", "parts": [], "finish_reason": "tool_calls"}]`,
"gen_ai.completion.0.function_call.name": "get_current_weather",
"gen_ai.completion.0.function_call.arguments": `{"city":"Bengaluru"}`,
},
formatter: FormatterSemconv,
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "What is the weather in Bengaluru in celsius?"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]`,
},
})
}

View File

@@ -0,0 +1,182 @@
package aiobservabilitytypes
import (
"encoding/base64"
"strings"
)
// Format: the Vercel AI SDK telemetry attributes (https://ai-sdk.dev/docs/ai-sdk-core/telemetry):
// ai.prompt.messages, the provider prompt (vercel/ai@6617f11, packages/provider/src/language-model/
// v2/language-model-v2-prompt.ts; v1 spelled tool-call args and tool-result result), ai.prompt on the
// parent span ({system, prompt} or {messages}), ai.response.text and ai.response.toolCalls. The span
// mapper moves ai.prompt.messages and one of the response keys onto gen_ai.*.messages; the other
// response key is read where it is.
// Written by: Vercel AI SDK, Mastra.
// Handled: text, reasoning, tool-call, tool-result (v1 and v2 spellings), image and file parts as
// data URL, URL, bare base64 or a serialised Buffer; ai.response.finishReason.
const (
vercelPrompt = "ai.prompt"
vercelOperation = "ai.operationId"
vercelResponseText = "ai.response.text"
vercelResponseCalls = "ai.response.toolCalls"
vercelResponseFinished = "ai.response.finishReason"
)
func (f *formatting) vercel() bool {
if f.attr(vercelOperation) == nil && f.attr(vercelPrompt) == nil && f.attr("ai.response") == nil {
return false
}
switch input := decode(f.attr(GenAIInputMessages)).(type) {
case []any:
f.out.Input = inputMessages(f.vercelMessages(input))
case nil:
if prompt, ok := decode(f.attr(vercelPrompt)).(map[string]any); ok {
f.out.Input = inputMessages(f.vercelPrompt(prompt))
}
default:
f.out.Input = inputMessages(OutputMessages{genericMessage(input)})
f.warn("input is not a message list, kept as generic")
}
text, calls := f.attr(vercelResponseText), decode(f.attr(vercelResponseCalls))
switch output := decode(f.attr(GenAIOutputMessages)).(type) {
case string:
text = output
case []any:
calls = output
}
msg := OutputMessage{Role: RoleAssistant, Parts: Parts{}, FinishReason: finishReason(stringOf(f.attr(vercelResponseFinished)))}
if s := stringOf(text); s != "" {
msg.Parts = append(msg.Parts, part(textPart(s)))
}
if list, ok := calls.([]any); ok {
for _, item := range list {
if call, ok := item.(map[string]any); ok {
msg.Parts = append(msg.Parts, vercelToolCall(call))
}
}
}
if len(msg.Parts) > 0 {
f.out.Output = OutputMessages{msg}
}
f.out.Formatter = FormatterVercel
return true
}
// vercelPrompt reads the parent span's ai.prompt: the system prompt with either a bare prompt or
// the messages.
func (f *formatting) vercelPrompt(prompt map[string]any) OutputMessages {
var msgs OutputMessages
if system := systemMessage(prompt["system"]); system != nil {
msgs = append(msgs, *system)
}
if messages, ok := prompt["messages"].([]any); ok {
return append(msgs, f.vercelMessages(messages)...)
}
if text := stringOf(prompt["prompt"]); text != "" {
msgs = append(msgs, OutputMessage{Role: RoleUser, Parts: Parts{part(textPart(text))}})
}
return msgs
}
func (f *formatting) vercelMessages(list []any) OutputMessages {
out := make(OutputMessages, 0, len(list))
for _, item := range list {
m, ok := item.(map[string]any)
if !ok {
out = append(out, genericMessage(item))
continue
}
msg := OutputMessage{Role: role(stringOf(m["role"])), Parts: Parts{}}
switch content := m["content"].(type) {
case string:
if content != "" {
msg.Parts = append(msg.Parts, part(textPart(content)))
}
case []any:
for _, item := range content {
msg.Parts = append(msg.Parts, vercelPart(item))
}
}
out = append(out, msg)
}
return out
}
func vercelPart(item any) Part {
p, ok := item.(map[string]any)
if !ok {
return contentPart(item)
}
switch stringOf(p["type"]) {
case "text":
return part(textPart(stringOf(p["text"])))
case "reasoning":
return part(ReasoningPart{Type: PartTypeReasoning, Content: stringOf(p["text"])})
case "redacted-reasoning":
return part(ReasoningPart{Type: PartTypeReasoning})
case "tool-call":
return vercelToolCall(p)
case "tool-result":
response := p["result"]
if output, ok := p["output"].(map[string]any); ok {
response = output["value"]
}
return part(ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: optionalString(p["toolCallId"]), Response: response})
case "image":
mime := stringOf(p["mimeType"])
if mime == "" {
mime = stringOf(p["mediaType"])
}
if media, ok := vercelMedia(p["image"], mime, ModalityImage); ok {
return media
}
case "file":
mime := stringOf(p["mediaType"])
if mime == "" {
mime = stringOf(p["mimeType"])
}
if media, ok := vercelMedia(p["data"], mime, modalityOf(mime)); ok {
return media
}
}
return part(GenericPart(p))
}
// vercelToolCall reads {toolCallId, toolName, input} or the v1 {toolCallId, toolName, args}; the
// arguments may be a JSON string.
func vercelToolCall(call map[string]any) Part {
args := call["input"]
if args == nil {
args = call["args"]
}
return part(ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(call["toolName"]), ID: optionalString(call["toolCallId"]), Arguments: decode(args)})
}
// vercelMedia reads a data URL, a URL, bare base64 or a serialised Node Buffer {type: Buffer,
// data: [bytes]}.
func vercelMedia(value any, mimeType string, modality Modality) (Part, bool) {
switch v := value.(type) {
case string:
if v == "" {
return Part{}, false
}
if strings.Contains(v, "://") || strings.HasPrefix(v, "data:") {
return part(mediaPart(v, modality)), true
}
return part(BlobPart{Type: PartTypeBlob, Modality: modality, Content: v, MimeType: optionalString(mimeType)}), true
case map[string]any:
bytes, ok := v["data"].([]any)
if !ok {
return Part{}, false
}
raw := make([]byte, 0, len(bytes))
for _, b := range bytes {
n, _ := b.(float64)
raw = append(raw, byte(n))
}
return part(BlobPart{Type: PartTypeBlob, Modality: modality, Content: base64.StdEncoding.EncodeToString(raw), MimeType: optionalString(mimeType)}), true
}
return Part{}, false
}

View File

@@ -0,0 +1,159 @@
package aiobservabilitytypes
import "testing"
// Captured cases are the ai.* attributes as the collector stored them for the SigNoz/scripts vercel
// and mastra captures (AI SDK v4) and a local AI SDK v5 run against a stub provider.
func TestFormat_Vercel(t *testing.T) {
runFormatCases(t, []formatCase{
{
// vercel capture, ai.generateText.doGenerate span after a tool round: v1 tool-call args and tool-result result
name: "V4ToolRound_ToolCallAndResultParts",
attributes: map[string]any{
"ai.operationId": "ai.generateText.doGenerate",
"ai.response.finishReason": "stop",
GenAIInputMessages: `[{"role":"user","content":[{"type":"text","text":"What is the weather in Paris and in London, in celsius? Use the tool for each."}]},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_isBPb3o3cqhY8OpugQntLOgy","toolName":"get_current_weather","args":{"city":"Paris","unit":"c"}},{"type":"tool-call","toolCallId":"call_sH9xd5EWFkiSaWmYvg4CeWTl","toolName":"get_current_weather","args":{"city":"London","unit":"c"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_isBPb3o3cqhY8OpugQntLOgy","toolName":"get_current_weather","result":{"city":"Paris","unit":"c","temperature":18,"condition":"clear"}},{"type":"tool-result","toolCallId":"call_sH9xd5EWFkiSaWmYvg4CeWTl","toolName":"get_current_weather","result":{"city":"London","unit":"c","temperature":18,"condition":"clear"}}]}]`,
GenAIOutputMessages: "The current weather is as follows:\n\n- **Paris**: 18°C, clear\n- **London**: 18°C, clear",
},
formatter: FormatterVercel,
wantInput: `[
{"role": "user", "parts": [{"type": "text", "content": "What is the weather in Paris and in London, in celsius? Use the tool for each."}]},
{"role": "assistant", "parts": [
{"type": "tool_call", "name": "get_current_weather", "id": "call_isBPb3o3cqhY8OpugQntLOgy", "arguments": {"city": "Paris", "unit": "c"}},
{"type": "tool_call", "name": "get_current_weather", "id": "call_sH9xd5EWFkiSaWmYvg4CeWTl", "arguments": {"city": "London", "unit": "c"}}
]},
{"role": "tool", "parts": [
{"type": "tool_call_response", "id": "call_isBPb3o3cqhY8OpugQntLOgy", "response": {"city": "Paris", "unit": "c", "temperature": 18, "condition": "clear"}},
{"type": "tool_call_response", "id": "call_sH9xd5EWFkiSaWmYvg4CeWTl", "response": {"city": "London", "unit": "c", "temperature": 18, "condition": "clear"}}
]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "The current weather is as follows:\n\n- **Paris**: 18°C, clear\n- **London**: 18°C, clear"}], "finish_reason": "stop"}]`,
},
{
// vercel capture, ai.streamText.doStream span: the mapper moved ai.response.toolCalls onto the output
name: "V4ToolCalls_ToolCallParts_EmptyTextIgnored",
attributes: map[string]any{
"ai.operationId": "ai.streamText.doStream",
"ai.response.finishReason": "tool-calls",
"ai.response.text": "",
GenAIInputMessages: `[{"role":"user","content":[{"type":"text","text":"What is the weather in Bengaluru in celsius?"}]}]`,
GenAIOutputMessages: `[{"type":"tool-call","toolCallId":"call_l0ycKcmt1uPCdGWR4SkwoLBv","toolName":"get_current_weather","args":{"city":"Bengaluru","unit":"c"}}]`,
},
formatter: FormatterVercel,
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "What is the weather in Bengaluru in celsius?"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_l0ycKcmt1uPCdGWR4SkwoLBv", "arguments": {"city": "Bengaluru", "unit": "c"}}], "finish_reason": "tool_call"}]`,
},
{
// vercel capture, ai.generateText parent span: ai.prompt is unmapped, the answer text was moved
name: "ParentPromptWithSystem_SystemAndUserMessages",
attributes: map[string]any{
"ai.operationId": "ai.generateText",
"ai.prompt": `{"system":"You are concise.","prompt":"Say hello in five words."}`,
"ai.response.finishReason": "stop",
GenAIOutputMessages: "Hello! How are you today?",
},
formatter: FormatterVercel,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are concise."}]},
{"role": "user", "parts": [{"type": "text", "content": "Say hello in five words."}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello! How are you today?"}], "finish_reason": "stop"}]`,
},
{
// vercel capture, ai.generateText parent span of a vision call: the image is a serialised Node Buffer
name: "ParentPromptMessages_BufferImage_BlobPart",
attributes: map[string]any{
"ai.operationId": "ai.generateText",
"ai.prompt": `{"messages":[{"role":"user","content":[{"type":"text","text":"What animal is in this image? One sentence."},{"type":"image","image":{"type":"Buffer","data":[137,80,78,71]},"mediaType":"image/png"}]}]}`,
GenAIOutputMessages: "A duck.",
},
formatter: FormatterVercel,
wantInput: `[{"role": "user", "parts": [
{"type": "text", "content": "What animal is in this image? One sentence."},
{"type": "blob", "modality": "image", "mime_type": "image/png", "content": "iVBORw=="}
]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "A duck."}]}]`,
},
{
// AI SDK v5 (ai@5.0.274) doGenerate after a tool round: tool-call input and tool-result output
// (vercel/ai@6617f11 language-model-v2-prompt.ts)
name: "V5ToolRound_InputAndOutputSpellings",
attributes: map[string]any{
"ai.operationId": "ai.generateText.doGenerate",
"ai.response.finishReason": "stop",
GenAIInputMessages: `[{"role":"system","content":"Use the tool."},{"role":"user","content":[{"type":"text","text":"Weather in Paris?"}]},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_stub1","toolName":"getWeather","input":{"city":"Paris"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_stub1","toolName":"getWeather","output":{"type":"json","value":{"city":"Paris","tempC":18,"summary":"Clear"}}}]}]`,
GenAIOutputMessages: "Paris is clear at 18C.",
},
formatter: FormatterVercel,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "Use the tool."}]},
{"role": "user", "parts": [{"type": "text", "content": "Weather in Paris?"}]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "getWeather", "id": "call_stub1", "arguments": {"city": "Paris"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_stub1", "response": {"city": "Paris", "tempC": 18, "summary": "Clear"}}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Paris is clear at 18C."}], "finish_reason": "stop"}]`,
},
{
// AI SDK v5 ai.response.toolCalls carries the arguments as a JSON string under input; the text
// the mapper left behind in ai.response.text comes first
name: "V5ToolCallsBesideText_TextThenToolCall",
attributes: map[string]any{
"ai.operationId": "ai.streamText.doStream",
"ai.response.finishReason": "tool-calls",
"ai.response.text": "Let me check.",
GenAIInputMessages: `[{"role":"user","content":[{"type":"text","text":"Weather in Paris? Use the tool."}]}]`,
GenAIOutputMessages: `[{"toolCallId":"call_stream1","toolName":"getWeather","input":"{\"city\":\"Paris\"}"}]`,
},
formatter: FormatterVercel,
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "Weather in Paris? Use the tool."}]}]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "text", "content": "Let me check."},
{"type": "tool_call", "name": "getWeather", "id": "call_stream1", "arguments": {"city": "Paris"}}
], "finish_reason": "tool_call"}]`,
},
{
// AI SDK v5 file parts: bare base64 with its media type, a URL, and a tool result given as text
name: "V5FileParts_BlobsByMediaType_URLAsUri",
attributes: map[string]any{
"ai.operationId": "ai.generateText.doGenerate",
GenAIInputMessages: `[{"role":"user","content":[{"type":"text","text":"What is in these?"},{"type":"file","mediaType":"image/png","data":"iVBORw0KGgo="},{"type":"file","mediaType":"application/pdf","filename":"a.pdf","data":"JVBERi0xLjQgc3R1Yg=="},{"type":"file","mediaType":"audio/wav","data":"https://example.com/a.wav"},{"type":"reasoning","text":"thinking"}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_1","toolName":"read","output":{"type":"text","value":"done"}}]}]`,
},
formatter: FormatterVercel,
wantInput: `[
{"role": "user", "parts": [
{"type": "text", "content": "What is in these?"},
{"type": "blob", "modality": "image", "mime_type": "image/png", "content": "iVBORw0KGgo="},
{"type": "blob", "modality": "document", "mime_type": "application/pdf", "content": "JVBERi0xLjQgc3R1Yg=="},
{"type": "uri", "modality": "audio", "uri": "https://example.com/a.wav"},
{"type": "reasoning", "content": "thinking"}
]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_1", "response": "done"}]}
]`,
wantOutput: `[]`,
},
{
// mastra capture, ai.generateText.doGenerate span: the AI SDK v4 shapes under Mastra's own span names
name: "MastraToolCalls_ToolCallPart",
attributes: map[string]any{
"ai.operationId": "ai.generateText.doGenerate",
"ai.response.finishReason": "tool-calls",
GenAIInputMessages: `[{"role":"system","content":"You are a weather assistant. When asked about weather, call get-weather once per city and summarise."},{"role":"user","content":[{"type":"text","text":"What is the weather in Bengaluru?"}]}]`,
GenAIOutputMessages: `[{"toolCallType":"function","toolCallId":"call_526phoykytobsvLouzES805J","toolName":"getWeather","args":"{\"city\":\"Bengaluru\"}"}]`,
},
formatter: FormatterVercel,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are a weather assistant. When asked about weather, call get-weather once per city and summarise."}]},
{"role": "user", "parts": [{"type": "text", "content": "What is the weather in Bengaluru?"}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "getWeather", "id": "call_526phoykytobsvLouzES805J", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]`,
},
{
// vercel capture, ai.generateText span of a failed call
name: "PromptOnlyNoResponse_InputOnly",
attributes: map[string]any{"ai.operationId": "ai.generateText", "ai.prompt": `{"prompt":"this should fail"}`},
formatter: FormatterVercel,
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "this should fail"}]}]`,
wantOutput: `[]`,
},
})
}

View File

@@ -4,7 +4,6 @@ import (
"encoding/base64"
"encoding/json"
"maps"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
@@ -80,10 +79,10 @@ type ThreadSpan struct {
References []OtelSpanRef `json:"references" required:"true" nullable:"false"`
// The formatted fields hold the span's messages in the OTel GenAI shape; Formatter names the
// converter that produced them and FormatterWarnings what it could not resolve.
FormattedInput aiobservabilitytypes.InputMessages `json:"formatted_input" required:"true" nullable:"false"`
FormattedOutput aiobservabilitytypes.OutputMessages `json:"formatted_output" required:"true" nullable:"false"`
FormattedInput aiobservabilitytypes.InputMessages `json:"formattedInput" required:"true" nullable:"false"`
FormattedOutput aiobservabilitytypes.OutputMessages `json:"formattedOutput" required:"true" nullable:"false"`
Formatter string `json:"formatter" required:"true"`
FormatterWarnings []string `json:"formatter_warnings" required:"true" nullable:"false"`
FormatterWarnings []string `json:"formatterWarnings" required:"true" nullable:"false"`
timeUnixNano uint64
}
@@ -180,7 +179,9 @@ func newThreadSpan(traceID string, storable *StorableSpan) *ThreadSpan {
maps.Copy(resources, storable.ResourcesString)
timeUnixNano := uint64(storable.StartTime.UnixNano())
attributes := threadAttributes(storable)
formatted := aiobservabilitytypes.FormatMessages(rawAttribute(storable, aiobservabilitytypes.GenAIInputMessages), rawAttribute(storable, aiobservabilitytypes.GenAIOutputMessages))
// Spans from before the JSON rollout have no document and are not decoded: the legacy maps
// hold an object split into one key per field.
formatted := aiobservabilitytypes.FormatMessages(storable.AttributesJSON)
return &ThreadSpan{
SpanID: storable.SpanID,
TraceID: traceID,
@@ -214,20 +215,3 @@ func threadAttributes(storable *StorableSpan) map[string]any {
storable.AttributesJSON.FlattenInto("", attributes)
return attributes
}
// rawAttribute reads one attribute for decoding from the JSON document, where an object value is
// still whole. The legacy maps hold objects split into one key per field, so spans from before
// the JSON rollout are not decoded. Not handled: a list of JSON strings, which formats as generic,
// and a scalar at a prefix of the path, such as gen_ai.input beside gen_ai.input.messages, which
// ends the walk early.
func rawAttribute(storable *StorableSpan, key string) any {
var current any = map[string]any(storable.AttributesJSON)
for segment := range strings.SplitSeq(key, ".") {
object, ok := current.(map[string]any)
if !ok {
return nil
}
current = object[segment]
}
return current
}

View File

@@ -73,35 +73,3 @@ func TestThreadAttributes(t *testing.T) {
})
}
}
func TestRawAttribute(t *testing.T) {
arguments := map[string]any{"city": "Paris", "days": []any{1.0, 2.0}}
testCases := []struct {
name string
storable StorableSpan
key string
want any
}{
{
name: "JSONColumn_ObjectReturnedWhole",
storable: StorableSpan{AttributesJSON: telemetrystoretypes.JSONValue{"gen_ai": map[string]any{"tool": map[string]any{"call": map[string]any{"arguments": arguments}}}}},
key: "gen_ai.tool.call.arguments",
want: arguments,
},
{
name: "JSONColumn_MissingPath_Nil",
storable: StorableSpan{AttributesJSON: telemetrystoretypes.JSONValue{"gen_ai": map[string]any{"tool": map[string]any{"name": "get_weather"}}}},
key: "gen_ai.tool.call.arguments",
},
{
name: "LegacyMapsOnly_Nil",
storable: StorableSpan{AttributesString: map[string]string{"gen_ai.output.messages": "sunny", "gen_ai.tool.call.arguments.city": "Paris"}},
key: "gen_ai.output.messages",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, rawAttribute(&testCase.storable, testCase.key))
})
}
}

View File

@@ -21,7 +21,7 @@ def test_thread_returns_message_spans_in_order(
seed_attribute_evolution("traces", ATTRIBUTE_JSON_ROLLOUT_TIME)
now = datetime.now(tz=UTC).replace(microsecond=0)
trace_id = TraceIdGenerator.trace_id()
root_id, first_llm_id, tool_id, agent_id, second_llm_id, third_llm_id = (TraceIdGenerator.span_id() for _ in range(6))
root_id, first_llm_id, tool_id, agent_id, second_llm_id, legacy_llm_id, third_llm_id, flattened_llm_id = (TraceIdGenerator.span_id() for _ in range(8))
resources = {"service.name": "tracedetail-thread"}
first_input = json.dumps([{"role": "user", "parts": [{"type": "text", "content": "weather in Bangalore?"}]}])
first_output = json.dumps([{"role": "assistant", "parts": [{"type": "tool_call", "id": "call_1", "name": "get_weather", "arguments": {"city": "Bangalore"}}], "finish_reason": "tool_call"}])
@@ -38,7 +38,9 @@ def test_thread_returns_message_spans_in_order(
),
Traces(timestamp=now - timedelta(seconds=5), trace_id=trace_id, span_id=agent_id, parent_span_id=root_id, name="invoke_agent planner", resources=resources, attributes={"gen_ai.agent.name": "planner"}, attribute_write_mode="json_only"),
Traces(timestamp=now - timedelta(seconds=4), trace_id=trace_id, span_id=second_llm_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": second_input}, attribute_write_mode="json_only"),
Traces(timestamp=now - timedelta(seconds=3), trace_id=trace_id, span_id=legacy_llm_id, parent_span_id=root_id, name="openai.chat", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.prompt.0.role": "user", "gen_ai.prompt.0.content": "and tomorrow?", "gen_ai.completion.0.role": "assistant", "gen_ai.completion.0.content": "Also sunny.", "gen_ai.completion.0.finish_reason": "stop"}, attribute_write_mode="json_only"),
Traces(timestamp=now - timedelta(seconds=2), trace_id=trace_id, span_id=third_llm_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.output.messages": "It is sunny in Bangalore."}, attribute_write_mode="json_only"),
Traces(timestamp=now - timedelta(seconds=1), trace_id=trace_id, span_id=flattened_llm_id, parent_span_id=root_id, name="ChatCompletion", resources=resources, attributes={"llm.model_name": "gpt-4o", "llm.input_messages.0.message.role": "user", "llm.input_messages.0.message.content": "thanks", "llm.output_messages.0.message.role": "assistant", "llm.output_messages.0.message.content": "You're welcome.", "llm.finish_reason": "stop"}, attribute_write_mode="json_only"),
]
)
@@ -47,10 +49,10 @@ def test_thread_returns_message_spans_in_order(
assert response.status_code == HTTPStatus.OK, response.text
thread = response.json()["data"]
assert [span["span_id"] for span in thread["spans"]] == [first_llm_id, tool_id, second_llm_id, third_llm_id]
assert [span["span_id"] for span in thread["spans"]] == [first_llm_id, tool_id, second_llm_id, legacy_llm_id, third_llm_id, flattened_llm_id]
assert "nextCursor" not in thread
first, tool, input_only, output_only = thread["spans"]
first, tool, input_only, legacy, output_only, flattened = thread["spans"]
assert tool["attributes"]["gen_ai.tool.call.arguments"] == json.dumps({"city": "Bangalore"})
assert tool["attributes"]["gen_ai.tool.call.result"] == "sunny"
assert first["time_unix"] == int((now - timedelta(seconds=8)).timestamp() * 1000)
@@ -64,20 +66,26 @@ def test_thread_returns_message_spans_in_order(
tool_call = {"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Bangalore"}}
assert first["formatter"] == "semconv"
assert first["formatted_input"] == [{"role": "user", "parts": [{"type": "text", "content": "weather in Bangalore?"}]}]
assert first["formatted_output"] == [{"role": "assistant", "parts": [tool_call], "finish_reason": "tool_call"}]
assert first["formatter_warnings"] == []
# tool spans carry no messages until their converter lands
assert tool["formatter"] == ""
assert tool["formatted_input"] == []
assert tool["formatted_output"] == []
assert first["formattedInput"] == [{"role": "user", "parts": [{"type": "text", "content": "weather in Bangalore?"}]}]
assert first["formattedOutput"] == [{"role": "assistant", "parts": [tool_call], "finish_reason": "tool_call"}]
assert first["formatterWarnings"] == []
assert tool["formatter"] == "tool"
assert tool["formattedInput"] == [{"role": "assistant", "parts": [tool_call]}]
assert tool["formattedOutput"] == [{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_1", "response": "sunny"}]}]
assert input_only["formatter"] == "openai.chat"
assert input_only["formatted_input"] == [{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_1", "response": "sunny"}]}]
assert input_only["formatted_output"] == []
assert input_only["formattedInput"] == [{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_1", "response": "sunny"}]}]
assert legacy["formatter"] == "traceloop"
assert legacy["attributes"]["gen_ai.prompt.0.content"] == "and tomorrow?"
assert legacy["formattedInput"] == [{"role": "user", "parts": [{"type": "text", "content": "and tomorrow?"}]}]
assert legacy["formattedOutput"] == [{"role": "assistant", "parts": [{"type": "text", "content": "Also sunny."}], "finish_reason": "stop"}]
assert flattened["formatter"] == "openinference"
assert flattened["formattedInput"] == [{"role": "user", "parts": [{"type": "text", "content": "thanks"}]}]
assert flattened["formattedOutput"] == [{"role": "assistant", "parts": [{"type": "text", "content": "You're welcome."}], "finish_reason": "stop"}]
assert input_only["formattedOutput"] == []
assert output_only["formatter"] == "text"
assert output_only["formatted_input"] == []
assert output_only["formatted_output"] == [{"role": "assistant", "parts": [{"type": "text", "content": "It is sunny in Bangalore."}]}]
assert output_only["formatter_warnings"] == ["bare assistant text, role assumed"]
assert output_only["formattedInput"] == []
assert output_only["formattedOutput"] == [{"role": "assistant", "parts": [{"type": "text", "content": "It is sunny in Bangalore."}]}]
assert output_only["formatterWarnings"] == ["bare assistant text, role assumed"]
def test_thread_paginates_with_cursors(
@@ -300,8 +308,8 @@ def test_thread_reads_spans_across_json_rollout(
# messages are decoded from the JSON column only, never from the legacy maps
for span in before_spans:
assert span["formatter"] == ""
assert span["formatted_input"] == []
assert span["formatted_output"] == []
assert span["formattedInput"] == []
assert span["formattedOutput"] == []
straddle = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{straddle_trace_id}/thread"), headers=headers, timeout=10)
assert straddle.status_code == HTTPStatus.OK, straddle.text
@@ -310,9 +318,9 @@ def test_thread_reads_spans_across_json_rollout(
assert straddle_spans[0]["attributes"]["gen_ai.input.messages"] == json.dumps([{"role": "user", "content": "legacy"}])
assert straddle_spans[1]["attributes"]["gen_ai.input.messages"] == json.dumps([{"role": "user", "content": "json"}])
assert straddle_spans[0]["formatter"] == ""
assert straddle_spans[0]["formatted_input"] == []
assert straddle_spans[0]["formattedInput"] == []
assert straddle_spans[1]["formatter"] == "openai.chat"
assert straddle_spans[1]["formatted_input"] == [{"role": "user", "parts": [{"type": "text", "content": "json"}]}]
assert straddle_spans[1]["formattedInput"] == [{"role": "user", "parts": [{"type": "text", "content": "json"}]}]
def test_thread_without_messages_is_empty(