mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-15 14:40:41 +01:00
Compare commits
5 Commits
main
...
feat/ai-tr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d70c523277 | ||
|
|
6d1813ab6d | ||
|
|
b41b6a2c16 | ||
|
|
c56924769c | ||
|
|
0c57e7d5e3 |
@@ -17,8 +17,10 @@ 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"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// The message types mirror the OpenTelemetry GenAI message schemas, one type per schema definition:
|
||||
// open-telemetry/semantic-conventions-genai@06ec68e, model/gen-ai/gen-ai-{input,output}-messages.json.
|
||||
// https://github.com/open-telemetry/semantic-conventions-genai/blob/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/model/gen-ai/gen-ai-input-messages.json
|
||||
// https://github.com/open-telemetry/semantic-conventions-genai/blob/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/model/gen-ai/gen-ai-output-messages.json
|
||||
// Keys the schema does not name are dropped; the raw attribute on the span still has them.
|
||||
|
||||
package aiobservabilitytypes
|
||||
|
||||
@@ -4,23 +4,31 @@ 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"
|
||||
FormatterOpenInference = "openinference"
|
||||
FormatterOpenAIChat = "openai.chat"
|
||||
FormatterText = "text"
|
||||
FormatterGeneric = "generic"
|
||||
)
|
||||
|
||||
var dataURL = regexp.MustCompile(`^data:([^;,]+);base64,(.+)$`)
|
||||
|
||||
// Provider spellings of the finish reasons; the enum values themselves pass through.
|
||||
// Provider spellings of the finish reasons; the enum values themselves pass through. tool_calls and
|
||||
// function_call are OpenAI's, end_turn, stop_sequence, max_tokens and tool_use Anthropic's, tool-calls the
|
||||
// Vercel AI SDK's:
|
||||
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L44782
|
||||
// https://platform.claude.com/docs/en/api/messages
|
||||
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/provider/src/language-model/v2/language-model-v2-finish-reason.ts
|
||||
var finishReasonAliases = map[string]FinishReason{
|
||||
"tool_calls": FinishReasonToolCall, "function_call": FinishReasonToolCall,
|
||||
"tool_calls": FinishReasonToolCall, "function_call": FinishReasonToolCall, "tool-calls": FinishReasonToolCall, "tool_use": FinishReasonToolCall,
|
||||
"end_turn": FinishReasonStop, "stop_sequence": FinishReasonStop,
|
||||
"max_tokens": FinishReasonLength,
|
||||
}
|
||||
@@ -35,16 +43,23 @@ type FormattedMessages struct {
|
||||
|
||||
// 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. Each
|
||||
// message value is a JSON string or a structured value. A tool call and its result take precedence over
|
||||
// messages beside them.
|
||||
// message value is a JSON string or a structured value. OpenInference's flattened messages come first,
|
||||
// then a tool call and its result, then the message attributes, then openllmetry's flattened prompts
|
||||
// and completions. 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{attributes: attributes, out: FormattedMessages{Input: InputMessages{}, Output: OutputMessages{}, Warnings: []string{}}}
|
||||
switch {
|
||||
case f.openInference():
|
||||
case attribute(f.attributes, GenAIToolCallArguments) != nil || attribute(f.attributes, GenAIToolCallResult) != nil:
|
||||
f.tool()
|
||||
case attribute(f.attributes, GenAIInputMessages) != nil || attribute(f.attributes, GenAIOutputMessages) != nil:
|
||||
f.messages()
|
||||
case f.traceloop():
|
||||
}
|
||||
f.prependSystemInstructions()
|
||||
f.fillFinishReasons()
|
||||
return f.out
|
||||
}
|
||||
|
||||
@@ -63,18 +78,60 @@ func (f *formatting) messages() {
|
||||
if input := attribute(f.attributes, GenAIInputMessages); 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})
|
||||
}
|
||||
f.out.Input = inputMessages(msgs)
|
||||
}
|
||||
if output := attribute(f.attributes, GenAIOutputMessages); output != nil {
|
||||
f.out.Output, outputLabel = f.side(decode(output), RoleAssistant)
|
||||
f.completionToolCalls()
|
||||
}
|
||||
f.warnMixedFormats(inputLabel, outputLabel)
|
||||
}
|
||||
|
||||
func (f *formatting) warnMixedFormats(inputLabel, outputLabel string) {
|
||||
if inputLabel != "" && outputLabel != "" && outputLabel != inputLabel {
|
||||
f.warn("output formatted as %s, input as %s", outputLabel, inputLabel)
|
||||
}
|
||||
}
|
||||
|
||||
// prependSystemInstructions adds gen_ai.system_instructions, a list of parts, as a leading system message,
|
||||
// unless the input already starts with a system message of the same text: the OpenInference strands
|
||||
// processor keeps the attribute and inserts it as message 0 as well. A span without messages, such as
|
||||
// create_agent, stays without them.
|
||||
// https://github.com/open-telemetry/semantic-conventions-genai/blob/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/model/gen-ai/gen-ai-system-instructions.json
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-strands-agents/src/openinference/instrumentation/strands_agents/processor.py#L152
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-strands-agents/src/openinference/instrumentation/strands_agents/processor.py#L293
|
||||
func (f *formatting) prependSystemInstructions() {
|
||||
raw := attribute(f.attributes, GenAISystemInstructions)
|
||||
if raw == nil || (len(f.out.Input) == 0 && len(f.out.Output) == 0) {
|
||||
return
|
||||
}
|
||||
parts := systemParts(decode(raw))
|
||||
if len(parts) == 0 || (len(f.out.Input) > 0 && f.out.Input[0].Role == RoleSystem && textOf(f.out.Input[0].Parts) == textOf(parts)) {
|
||||
return
|
||||
}
|
||||
f.out.Input = append(InputMessages{{Role: RoleSystem, Parts: parts}}, f.out.Input...)
|
||||
}
|
||||
|
||||
// fillFinishReasons applies gen_ai.response.finish_reasons, one entry per output message, over
|
||||
// whatever the converter found in the payload.
|
||||
// https://github.com/open-telemetry/semantic-conventions-genai/blob/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/model/gen-ai/registry.yaml#L210
|
||||
func (f *formatting) fillFinishReasons() {
|
||||
raw := attribute(f.attributes, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -121,6 +178,44 @@ func attribute(doc map[string]any, key string) any {
|
||||
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
|
||||
@@ -133,6 +228,31 @@ 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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -179,6 +299,23 @@ func finishReason(value string) *FinishReason {
|
||||
return &fr
|
||||
}
|
||||
|
||||
// stringList reads a list of strings, or a bare string as a list of one.
|
||||
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 []string{v}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func optionalString(value any) *string {
|
||||
s := stringOf(value)
|
||||
if s == "" {
|
||||
@@ -195,8 +332,37 @@ 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.
|
||||
// textOf joins the text parts, for comparing a system message with gen_ai.system_instructions.
|
||||
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")
|
||||
}
|
||||
|
||||
// systemParts reads a system prompt given outside the history, a list of parts or a bare string.
|
||||
func systemParts(value any) Parts {
|
||||
switch v := value.(type) {
|
||||
case []any:
|
||||
parts := make(Parts, 0, len(v))
|
||||
for _, item := range v {
|
||||
parts = append(parts, contentPart(item))
|
||||
}
|
||||
return parts
|
||||
case string:
|
||||
if v != "" {
|
||||
return Parts{part(textPart(v))}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// contentPart converts one OpenAI content block, which semconv parts may also carry; a text part
|
||||
// in the semconv spelling has content where OpenAI has text. A block of any other type is kept
|
||||
// as sent.
|
||||
func contentPart(item any) Part {
|
||||
p, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
@@ -207,6 +373,9 @@ func contentPart(item any) Part {
|
||||
}
|
||||
switch stringOf(p["type"]) {
|
||||
case "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"])))
|
||||
|
||||
@@ -167,7 +167,8 @@ func TestFormat_OpenAIChat(t *testing.T) {
|
||||
]`,
|
||||
},
|
||||
{
|
||||
// openinference capture, input.value of the ChatCompletion span
|
||||
// ChatCompletionRequestMessageContentPartFile {file: {file_id}}, as OpenInference records the request in input.value:
|
||||
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L42650
|
||||
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":[]}}]}`,
|
||||
@@ -189,7 +190,8 @@ func TestFormat_OpenAIChat(t *testing.T) {
|
||||
]`,
|
||||
},
|
||||
{
|
||||
// openinference capture, input.value of the ChatCompletion span
|
||||
// ChatCompletionRequestMessageContentPartFile {file: {filename, file_data}}, file_data a data URL:
|
||||
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L42650
|
||||
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}`,
|
||||
wantInput: `[
|
||||
|
||||
137
pkg/types/aiobservabilitytypes/message_format_openinference.go
Normal file
137
pkg/types/aiobservabilitytypes/message_format_openinference.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package aiobservabilitytypes
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/valuer"
|
||||
|
||||
// Format: the flattened llm.input_messages.N.message.* and llm.output_messages.N.message.* keys, the
|
||||
// provider-neutral copy of the conversation the OpenInference instrumentations write on LLM spans. A side
|
||||
// without them falls back to the raw provider payload the mapper moved onto gen_ai.*.messages; pipecat
|
||||
// streams the reply into output.value alone.
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/semantic_conventions.md#L77
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/tool_calling.md#L37
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/multimodal_attributes.md#L35
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/llm_spans.md#L181
|
||||
// Handled: message.content, the ordered message.contents items (text, image, audio, video, reasoning),
|
||||
// message.tool_calls, the legacy message.function_call_*, tool results, llm.finish_reason. A tool_use
|
||||
// content item repeats a call from message.tool_calls and is skipped.
|
||||
// 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.attributes, openInferenceInputMessages)
|
||||
output := indexed(f.attributes, openInferenceOutputMessages)
|
||||
if len(input) == 0 && len(output) == 0 {
|
||||
return false
|
||||
}
|
||||
inputLabel, outputLabel := FormatterOpenInference, FormatterOpenInference
|
||||
if len(input) > 0 {
|
||||
f.out.Input = inputMessages(openInferenceMessages(input))
|
||||
} else if raw := attribute(f.attributes, 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 = openInferenceMessages(output)
|
||||
if fr := finishReason(stringOf(attribute(f.attributes, openInferenceFinishReason))); fr != nil {
|
||||
for i := range f.out.Output {
|
||||
f.out.Output[i].FinishReason = fr
|
||||
}
|
||||
}
|
||||
} else if raw := attribute(f.attributes, GenAIOutputMessages); raw != nil {
|
||||
f.out.Output, outputLabel = f.side(decode(raw), RoleAssistant)
|
||||
}
|
||||
f.warnMixedFormats(inputLabel, outputLabel)
|
||||
return true
|
||||
}
|
||||
|
||||
// openInferenceMessages converts the message buckets and folds runs of assistant messages: the Responses
|
||||
// path writes one message per output item, an empty one for a reasoning item without a summary.
|
||||
func openInferenceMessages(buckets []map[string]any) OutputMessages {
|
||||
msgs := make(OutputMessages, 0, len(buckets))
|
||||
for _, bucket := range buckets {
|
||||
message, _ := bucket["message"].(map[string]any)
|
||||
msgs = append(msgs, openInferenceMessage(message))
|
||||
}
|
||||
return foldAssistant(msgs)
|
||||
}
|
||||
|
||||
// openInferenceMessage converts one message.* object. A message without a role is a tool response when
|
||||
// it has a tool_call_id, an assistant turn when it has tool calls, else a user turn, as the
|
||||
// collector-contrib normalizer decides it:
|
||||
// https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/fc79b95297776dec6c47490cd6f6026b942bf6fb/processor/genainormalizerprocessor/internal/openinference/messages.go#L473
|
||||
func openInferenceMessage(m map[string]any) OutputMessage {
|
||||
msg := OutputMessage{Role: role(stringOf(m["role"])), Parts: Parts{}, Name: optionalString(m["name"])}
|
||||
id := stringOf(m["tool_call_id"])
|
||||
calls := indexed(m, "tool_calls")
|
||||
if msg.Role.IsZero() {
|
||||
switch {
|
||||
case id != "":
|
||||
msg.Role = RoleTool
|
||||
case len(calls) > 0:
|
||||
msg.Role = RoleAssistant
|
||||
default:
|
||||
msg.Role = RoleUser
|
||||
}
|
||||
}
|
||||
if msg.Role == RoleTool && id != "" {
|
||||
msg.Parts = Parts{part(ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: &id, Response: decode(m["content"])})}
|
||||
return msg
|
||||
}
|
||||
if content := stringOf(m["content"]); content != "" {
|
||||
msg.Parts = append(msg.Parts, part(textPart(content)))
|
||||
}
|
||||
msg.Parts = append(msg.Parts, openInferenceContents(m)...)
|
||||
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 text item may come without
|
||||
// message_content.type, as the collector-contrib normalizer reads it:
|
||||
// https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/fc79b95297776dec6c47490cd6f6026b942bf6fb/processor/genainormalizerprocessor/internal/openinference/messages.go#L285
|
||||
func openInferenceContents(m map[string]any) 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"])
|
||||
if typ == "" && content["text"] != nil {
|
||||
typ = "text"
|
||||
}
|
||||
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":
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
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 flattened keys win over the raw request the
|
||||
// mapper moved onto gen_ai.input.messages
|
||||
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}`,
|
||||
},
|
||||
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: message.tool_calls and llm.finish_reason
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/tool_calling.md#L37
|
||||
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",
|
||||
},
|
||||
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: tool role messages carry the result
|
||||
// in message.content with their tool_call_id
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/llm_spans.md#L50
|
||||
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",
|
||||
},
|
||||
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. An image as a data URL:
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/multimodal_attributes.md#L47
|
||||
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",
|
||||
},
|
||||
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: one output message per item, an empty one
|
||||
// for a 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",
|
||||
},
|
||||
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",
|
||||
},
|
||||
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "this should fail"}]}]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
// the strands processor keeps the SDK's gen_ai.system_instructions on the span and inserts the same
|
||||
// prompt as message 0
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-strands-agents/src/openinference/instrumentation/strands_agents/processor.py#L152
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-strands-agents/src/openinference/instrumentation/strands_agents/processor.py#L293
|
||||
name: "StrandsSystemInstructionsRepeated_OneSystemMessage",
|
||||
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": "hi",
|
||||
"llm.output_messages.0.message.role": "assistant",
|
||||
"llm.output_messages.0.message.content": "Hello.",
|
||||
GenAISystemInstructions: `[{"type": "text", "content": "You are concise."}]`,
|
||||
},
|
||||
wantInput: `[
|
||||
{"role": "system", "parts": [{"type": "text", "content": "You are concise."}]},
|
||||
{"role": "user", "parts": [{"type": "text", "content": "hi"}]}
|
||||
]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello."}]}]`,
|
||||
},
|
||||
{
|
||||
// pipecat writes the request as llm.input_messages and the streamed reply as output.value alone,
|
||||
// which the mapper copies onto gen_ai.output.messages
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-pipecat/src/openinference/instrumentation/pipecat/_attributes.py#L430
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-pipecat/src/openinference/instrumentation/pipecat/_observer.py#L628
|
||||
name: "OutputKeysAbsent_RawOutputFormatted_Warns",
|
||||
attributes: map[string]any{
|
||||
"llm.input_messages.0.message.role": "user",
|
||||
"llm.input_messages.0.message.content": "hi",
|
||||
GenAIOutputMessages: "Hello there.",
|
||||
},
|
||||
warnings: []string{"bare assistant text, role assumed", "output formatted as text, input as openinference"},
|
||||
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "hi"}]}]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello there."}]}]`,
|
||||
},
|
||||
{
|
||||
// message.role left unset by some instrumentors: tool_call_id, tool_calls or neither decide it,
|
||||
// as the collector-contrib normalizer does
|
||||
// https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/fc79b95297776dec6c47490cd6f6026b942bf6fb/processor/genainormalizerprocessor/internal/openinference/messages.go#L473
|
||||
name: "MissingRole_InferredFromToolFields",
|
||||
attributes: map[string]any{
|
||||
"llm.input_messages.0.message.content": "Weather in Oslo?",
|
||||
"llm.input_messages.1.message.tool_calls.0.tool_call.id": "call_9",
|
||||
"llm.input_messages.1.message.tool_calls.0.tool_call.function.name": "get_weather",
|
||||
"llm.input_messages.1.message.tool_calls.0.tool_call.function.arguments": `{"city":"Oslo"}`,
|
||||
"llm.input_messages.2.message.tool_call_id": "call_9",
|
||||
"llm.input_messages.2.message.content": "4C, cloudy",
|
||||
},
|
||||
wantInput: `[
|
||||
{"role": "user", "parts": [{"type": "text", "content": "Weather in Oslo?"}]},
|
||||
{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_9", "arguments": {"city": "Oslo"}}]},
|
||||
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_9", "response": "4C, cloudy"}]}
|
||||
]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
// message_content.type left unset on text items by some instrumentors, as the collector-contrib
|
||||
// normalizer reads them
|
||||
// https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/fc79b95297776dec6c47490cd6f6026b942bf6fb/processor/genainormalizerprocessor/internal/openinference/messages.go#L285
|
||||
name: "UntypedContentWithText_TextPart",
|
||||
attributes: map[string]any{
|
||||
"llm.input_messages.0.message.role": "user",
|
||||
"llm.input_messages.0.message.contents.0.message_content.text": "Summarise this.",
|
||||
"llm.input_messages.0.message.contents.1.message_content.text": "Second block.",
|
||||
},
|
||||
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "Summarise this."}, {"type": "text", "content": "Second block."}]}]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
// an image given by URL
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/multimodal_attributes.md#L42
|
||||
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",
|
||||
},
|
||||
wantInput: `[{"role": "user", "parts": [{"type": "uri", "modality": "image", "uri": "https://example.com/duck.jpg"}]}]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
// audio by URL with its transcript, or as the data URI built from a Chat Completions input_audio
|
||||
// block; the transcript has no part to go to
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/multimodal_attributes.md#L55
|
||||
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==",
|
||||
},
|
||||
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: `[]`,
|
||||
},
|
||||
{
|
||||
// reasoning items; id, signature, data and encrypted_content have no field on the reasoning part
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/llm_spans.md#L181
|
||||
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.",
|
||||
},
|
||||
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."}
|
||||
]}]`,
|
||||
},
|
||||
{
|
||||
// a tool call repeated as an ordered tool_use content item; message.tool_calls stays the one
|
||||
// source. llm.finish_reason is Anthropic's stop_reason as the instrumentation passes it through
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/tool_calling.md#L63
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-anthropic/src/openinference/instrumentation/anthropic/_wrappers.py#L938
|
||||
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",
|
||||
},
|
||||
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"}]`,
|
||||
},
|
||||
{
|
||||
// a content type the spec does not name is kept as sent
|
||||
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",
|
||||
},
|
||||
wantInput: `[{"role": "user", "parts": [{"type": "document", "document": {"document": {"id": "file_1"}}}]}]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
// the legacy function_call keys
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/semantic_conventions.md#L111
|
||||
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}`,
|
||||
},
|
||||
wantInput: `[]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "multiply", "arguments": {"x": 2}}]}]`,
|
||||
},
|
||||
{
|
||||
// a tool result with message.name naming the function
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/llm_spans.md#L50
|
||||
name: "ToolResultWithName_NameKept",
|
||||
attributes: map[string]any{
|
||||
"llm.input_messages.0.message.role": "tool",
|
||||
"llm.input_messages.0.message.name": "multiply",
|
||||
"llm.input_messages.0.message.tool_call_id": "call_62136355",
|
||||
"llm.input_messages.0.message.content": "2001",
|
||||
},
|
||||
wantInput: `[{"role": "tool", "name": "multiply", "parts": [{"type": "tool_call_response", "id": "call_62136355", "response": "2001"}]}]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -6,15 +6,20 @@ 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; OpenAI content blocks
|
||||
// left inside parts; tool call arguments as a JSON string and "result" for "response", as pydantic-ai
|
||||
// writes them:
|
||||
// https://github.com/pydantic/pydantic-ai/blob/9ac853bb84ffed7ba0edccb026c5b4f3228c1de7/pydantic_ai_slim/pydantic_ai/messages.py#L3130
|
||||
// https://github.com/pydantic/pydantic-ai/blob/9ac853bb84ffed7ba0edccb026c5b4f3228c1de7/pydantic_ai_slim/pydantic_ai/messages.py#L1724
|
||||
// The choice finish_reason as the provider spells it, as litellm copies it:
|
||||
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2822
|
||||
// Not yet: "thinking" for "reasoning".
|
||||
|
||||
// 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 +33,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 +45,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 +58,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 +69,24 @@ 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 "tool_call":
|
||||
if args, ok := part["arguments"].(string); ok {
|
||||
fixed["arguments"] = decode(args)
|
||||
}
|
||||
case "tool_call_response":
|
||||
if _, ok := part["response"]; !ok && part["result"] != nil {
|
||||
fixed["response"] = part["result"]
|
||||
delete(fixed, "result")
|
||||
}
|
||||
}
|
||||
return fixed
|
||||
}
|
||||
|
||||
@@ -157,3 +157,34 @@ func TestFormat_Semconv(t *testing.T) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormat_Semconv_Repairs(t *testing.T) {
|
||||
runFormatCases(t, []formatCase{
|
||||
{
|
||||
// pydantic-ai keeps string tool call arguments as sent, writes the tool result under result, and the
|
||||
// instructions as a gen_ai.system_instructions text part:
|
||||
// https://github.com/pydantic/pydantic-ai/blob/9ac853bb84ffed7ba0edccb026c5b4f3228c1de7/pydantic_ai_slim/pydantic_ai/messages.py#L3130
|
||||
// https://github.com/pydantic/pydantic-ai/blob/9ac853bb84ffed7ba0edccb026c5b4f3228c1de7/pydantic_ai_slim/pydantic_ai/messages.py#L1724
|
||||
// https://github.com/pydantic/pydantic-ai/blob/9ac853bb84ffed7ba0edccb026c5b4f3228c1de7/pydantic_ai_slim/pydantic_ai/models/instrumented.py#L292
|
||||
name: "PydanticAI_ArgumentsDecoded_ResultBecomesResponse_InstructionsLead",
|
||||
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."}]`},
|
||||
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"}]`,
|
||||
},
|
||||
{
|
||||
// litellm copies the choice finish_reason into the semconv message as OpenAI spells it:
|
||||
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2822
|
||||
name: "LiteLLMFinishReasonSpelling_Normalised",
|
||||
output: `[{"role": "assistant", "parts": [], "finish_reason": "tool_calls"}]`,
|
||||
wantInput: `[]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [], "finish_reason": "tool_call"}]`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -120,3 +120,108 @@ So, the ball costs 5 cents.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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 TestFormat_SystemInstructions(t *testing.T) {
|
||||
input := `[{"role":"user","parts":[{"type":"text","content":"hi"}]}]`
|
||||
runFormatCases(t, []formatCase{
|
||||
{
|
||||
// the langchain instrumentation moves system messages out of the history into gen_ai.system_instructions,
|
||||
// a JSON list of parts:
|
||||
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/span_utils.py#L304
|
||||
name: "ListOfParts_LeadsInput",
|
||||
input: input,
|
||||
attributes: map[string]any{GenAISystemInstructions: `[{"type": "text", "content": "You are concise."}]`},
|
||||
wantInput: `[
|
||||
{"role": "system", "parts": [{"type": "text", "content": "You are concise."}]},
|
||||
{"role": "user", "parts": [{"type": "text", "content": "hi"}]}
|
||||
]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
// the OpenInference strands processor keeps gen_ai.system_instructions on the span and inserts the
|
||||
// same prompt as message 0
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-strands-agents/src/openinference/instrumentation/strands_agents/processor.py#L152
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-strands-agents/src/openinference/instrumentation/strands_agents/processor.py#L293
|
||||
name: "SameTextLeadsInput_Skipped",
|
||||
input: `[{"role":"system","parts":[{"type":"text","content":"You are concise."}]},{"role":"user","parts":[{"type":"text","content":"hi"}]}]`,
|
||||
attributes: map[string]any{GenAISystemInstructions: `[{"type": "text", "content": "You are concise."}]`},
|
||||
wantInput: `[
|
||||
{"role": "system", "parts": [{"type": "text", "content": "You are concise."}]},
|
||||
{"role": "user", "parts": [{"type": "text", "content": "hi"}]}
|
||||
]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
name: "BareString_TextPart",
|
||||
input: input,
|
||||
attributes: map[string]any{GenAISystemInstructions: "be brief"},
|
||||
wantInput: `[
|
||||
{"role": "system", "parts": [{"type": "text", "content": "be brief"}]},
|
||||
{"role": "user", "parts": [{"type": "text", "content": "hi"}]}
|
||||
]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
// 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"}},
|
||||
wantInput: `[]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "a"}], "finish_reason": "tool_call"}, {"role": "assistant", "parts": [{"type": "text", "content": "b"}], "finish_reason": "length"}]`,
|
||||
},
|
||||
{
|
||||
name: "LengthMismatch_LeavesMessages_Warns",
|
||||
output: output,
|
||||
attributes: map[string]any{GenAIResponseFinishReasons: []any{"stop"}},
|
||||
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"}]}]`,
|
||||
},
|
||||
{
|
||||
// llm.finish_reason copied by the mapper as a bare string, in the Vercel AI SDK spelling:
|
||||
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/provider/src/language-model/v2/language-model-v2-finish-reason.ts
|
||||
name: "BareString_SingleChoice",
|
||||
output: `{"choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"hi"}}]}`,
|
||||
attributes: map[string]any{GenAIResponseFinishReasons: "tool-calls"},
|
||||
wantInput: `[]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "hi"}], "finish_reason": "tool_call"}]`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
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).
|
||||
// object or its JSON string:
|
||||
// https://github.com/open-telemetry/semantic-conventions-genai/blob/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/model/gen-ai/registry.yaml#L545
|
||||
// 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).
|
||||
// Envelopes unwrapped: LangChain's on_tool_start and on_tool_end payloads, {input_str, tags, metadata,
|
||||
// inputs, kwargs} and {output, kwargs}:
|
||||
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py#L837
|
||||
// The traceloop decorator's {args, kwargs}:
|
||||
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/traceloop-sdk/traceloop/sdk/decorators/base.py#L169
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -7,8 +7,9 @@ import "testing"
|
||||
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
|
||||
// gen_ai.tool.call.arguments and gen_ai.tool.call.result written by the langchain instrumentation itself,
|
||||
// wrapped in its on_tool_start and on_tool_end payloads:
|
||||
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py#L837
|
||||
name: "LangChainEnvelopes_InputsAndOutputUnwrapped",
|
||||
attributes: map[string]any{
|
||||
GenAIToolName: "get_weather",
|
||||
@@ -19,8 +20,9 @@ func TestFormat_Tool(t *testing.T) {
|
||||
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
|
||||
// traceloop.entity.input and traceloop.entity.output from the @tool decorator, renamed by the mapper;
|
||||
// the tool returned a JSON string, which json.dumps encoded again:
|
||||
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/traceloop-sdk/traceloop/sdk/decorators/base.py#L169
|
||||
name: "TraceloopDecorator_KwargsUnwrapped_ResultDecodedTwice",
|
||||
attributes: map[string]any{
|
||||
GenAIToolName: "get_weather",
|
||||
@@ -31,8 +33,8 @@ func TestFormat_Tool(t *testing.T) {
|
||||
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "response": {"city": "Bengaluru", "temp_c": 28, "summary": "Partly cloudy"}}]}]`,
|
||||
},
|
||||
{
|
||||
// a decorated function returning None is json.dumps(None); the openllmetry capture shows it on
|
||||
// workflow spans, with this positional-args input
|
||||
// traceloop.entity.output of a @tool returning None, json.dumps(None), renamed by the mapper:
|
||||
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/traceloop-sdk/traceloop/sdk/decorators/base.py#L185
|
||||
name: "TraceloopDecorator_PositionalArgsOnly_ListKept",
|
||||
attributes: map[string]any{
|
||||
GenAIToolName: "lookup",
|
||||
@@ -52,8 +54,8 @@ func TestFormat_Tool(t *testing.T) {
|
||||
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)
|
||||
// input.value and output.value of an OpenInference TOOL span, renamed by the mapper:
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/traces.md
|
||||
name: "OpenInferenceToolSpan_PlainJSONAndText",
|
||||
attributes: map[string]any{
|
||||
GenAIToolName: "get_weather",
|
||||
@@ -64,8 +66,8 @@ func TestFormat_Tool(t *testing.T) {
|
||||
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)
|
||||
// ai.toolCall.{id,name,args,result} of a Vercel ai.toolCall span, renamed by the mapper:
|
||||
// https://ai-sdk.dev/docs/ai-sdk-core/telemetry
|
||||
name: "VercelToolCall_IDOnBothParts",
|
||||
attributes: map[string]any{
|
||||
GenAIToolName: "get_current_weather",
|
||||
|
||||
87
pkg/types/aiobservabilitytypes/message_format_traceloop.go
Normal file
87
pkg/types/aiobservabilitytypes/message_format_traceloop.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package aiobservabilitytypes
|
||||
|
||||
// Format: the flattened gen_ai.prompt.N.* and gen_ai.completion.N.* keys of openllmetry's instrumentations
|
||||
// from before their semconv rewrite, role, content, tool_call_id, tool_calls.M.{id,name,arguments} and
|
||||
// finish_reason, with list content as its JSON:
|
||||
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/opentelemetry-semantic-conventions-ai/opentelemetry/semconv_ai/__init__.py#L159
|
||||
// https://github.com/traceloop/openllmetry/blob/af487056a35625a2cff1c17ac83c2c0d90a02bca/packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/shared/chat_wrappers.py#L408
|
||||
// https://github.com/traceloop/openllmetry/blob/af487056a35625a2cff1c17ac83c2c0d90a02bca/packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/shared/chat_wrappers.py#L460
|
||||
// Written by: crewai through the openai instrumentation 0.45.6, mistralai 0.61.0 (https://github.com/traceloop/openllmetry/blob/4643b8826a6e6a4ce1968178d6ac9ca3ce607e2e/packages/opentelemetry-instrumentation-mistralai/opentelemetry/instrumentation/mistralai/__init__.py#L83),
|
||||
// litellm beside its semconv value.
|
||||
|
||||
const (
|
||||
traceloopPrompts = "gen_ai.prompt"
|
||||
traceloopCompletions = "gen_ai.completion"
|
||||
)
|
||||
|
||||
func (f *formatting) traceloop() bool {
|
||||
prompts := indexed(f.attributes, traceloopPrompts)
|
||||
completions := indexed(f.attributes, traceloopCompletions)
|
||||
if len(prompts) == 0 && len(completions) == 0 {
|
||||
return false
|
||||
}
|
||||
f.out.Input = inputMessages(f.traceloopMessages(prompts))
|
||||
f.out.Output = f.traceloopMessages(completions)
|
||||
return true
|
||||
}
|
||||
|
||||
// completionToolCalls fills an output message left without parts from the gen_ai.completion.N keys
|
||||
// litellm writes beside its semconv value, whose parts never carry the tool call:
|
||||
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2365
|
||||
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2770
|
||||
func (f *formatting) completionToolCalls() {
|
||||
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.attributes, traceloopCompletions, i))...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 litellm's 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
|
||||
}
|
||||
106
pkg/types/aiobservabilitytypes/message_format_traceloop_test.go
Normal file
106
pkg/types/aiobservabilitytypes/message_format_traceloop_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
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 a tool round, as the openai instrumentation 0.45.6 wrote
|
||||
// prompts and completions before its semconv rewrite
|
||||
// https://github.com/traceloop/openllmetry/blob/af487056a35625a2cff1c17ac83c2c0d90a02bca/packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/shared/chat_wrappers.py#L408
|
||||
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.",
|
||||
},
|
||||
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
|
||||
// https://github.com/traceloop/openllmetry/blob/af487056a35625a2cff1c17ac83c2c0d90a02bca/packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/shared/chat_wrappers.py#L460
|
||||
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"}`,
|
||||
},
|
||||
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 for every message but
|
||||
// drops a content that is not a plain string
|
||||
// https://github.com/traceloop/openllmetry/blob/4643b8826a6e6a4ce1968178d6ac9ca3ce607e2e/packages/opentelemetry-instrumentation-mistralai/opentelemetry/instrumentation/mistralai/__init__.py#L75
|
||||
// https://github.com/traceloop/openllmetry/blob/4643b8826a6e6a4ce1968178d6ac9ca3ce607e2e/packages/opentelemetry-instrumentation-mistralai/opentelemetry/instrumentation/mistralai/__init__.py#L88
|
||||
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.",
|
||||
},
|
||||
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: list content is written as its JSON, the OpenAI content block list
|
||||
// https://github.com/traceloop/openllmetry/blob/af487056a35625a2cff1c17ac83c2c0d90a02bca/packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/shared/chat_wrappers.py#L431
|
||||
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"}}]`,
|
||||
},
|
||||
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 has no part for the tool call, the
|
||||
// gen_ai.completion.0.function_call keys beside it still carry it
|
||||
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2365
|
||||
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2770
|
||||
name: "SemconvEmptyParts_ToolCallFromCompletionKeys",
|
||||
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"}`,
|
||||
},
|
||||
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"}]`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -21,17 +21,25 @@ def test_thread_returns_gen_ai_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"}])
|
||||
first_output = json.dumps([{"role": "assistant", "parts": [{"type": "tool_call", "id": "call_1", "name": "get_weather", "arguments": {"city": "Bangalore"}}]}])
|
||||
first_instructions = json.dumps([{"type": "text", "content": "Answer with the tool."}])
|
||||
second_input = json.dumps([{"role": "tool", "content": "sunny", "tool_call_id": "call_1"}])
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
Traces(timestamp=now - timedelta(seconds=10), duration=timedelta(seconds=9), trace_id=trace_id, span_id=root_id, name="POST /chat", kind=TracesKind.SPAN_KIND_SERVER, resources=resources, attribute_write_mode="json_only"),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=8), trace_id=trace_id, span_id=first_llm_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": first_input, "gen_ai.output.messages": first_output}, attribute_write_mode="json_only"
|
||||
timestamp=now - timedelta(seconds=8),
|
||||
trace_id=trace_id,
|
||||
span_id=first_llm_id,
|
||||
parent_span_id=root_id,
|
||||
name="chat gpt-4o",
|
||||
resources=resources,
|
||||
attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.system_instructions": first_instructions, "gen_ai.input.messages": first_input, "gen_ai.output.messages": first_output, "gen_ai.response.finish_reasons": json.dumps(["tool_calls"])},
|
||||
attribute_write_mode="json_only",
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=6),
|
||||
@@ -45,7 +53,34 @@ def test_thread_returns_gen_ai_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={
|
||||
"gen_ai.request.model": "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",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -54,10 +89,10 @@ def test_thread_returns_gen_ai_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, agent_id, second_llm_id, third_llm_id]
|
||||
assert [span["span_id"] for span in thread["spans"]] == [first_llm_id, tool_id, agent_id, second_llm_id, legacy_llm_id, third_llm_id, flattened_llm_id]
|
||||
assert "nextCursor" not in thread
|
||||
|
||||
first, tool, agent, input_only, output_only = thread["spans"]
|
||||
first, tool, agent, input_only, legacy, output_only, flattened = thread["spans"]
|
||||
assert agent["attributes"]["gen_ai.agent.name"] == "planner"
|
||||
assert agent["formattedInput"] == []
|
||||
assert agent["formattedOutput"] == []
|
||||
@@ -73,7 +108,7 @@ def test_thread_returns_gen_ai_spans_in_order(
|
||||
assert output_only["attributes"]["gen_ai.output.messages"] == "It is sunny in Bangalore."
|
||||
|
||||
tool_call = {"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Bangalore"}}
|
||||
assert first["formattedInput"] == [{"role": "user", "parts": [{"type": "text", "content": "weather in Bangalore?"}]}]
|
||||
assert first["formattedInput"] == [{"role": "system", "parts": [{"type": "text", "content": "Answer with the tool."}]}, {"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["formattedInput"] == [{"role": "assistant", "parts": [tool_call]}]
|
||||
@@ -83,6 +118,12 @@ def test_thread_returns_gen_ai_spans_in_order(
|
||||
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"]
|
||||
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["attributes"]["llm.input_messages.0.message.content"] == "thanks"
|
||||
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"}]
|
||||
|
||||
|
||||
def test_thread_paginates_with_cursors(
|
||||
|
||||
Reference in New Issue
Block a user