606 lines
22 KiB
Go
606 lines
22 KiB
Go
package openai
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
)
|
|
|
|
// logicalRequestEndpoint keeps fingerprints from incompatible wire formats
|
|
// distinct even when their JSON payloads happen to look alike.
|
|
type logicalRequestEndpoint string
|
|
|
|
const (
|
|
logicalRequestEndpointChat logicalRequestEndpoint = "chat_completions"
|
|
logicalRequestEndpointAnthropic logicalRequestEndpoint = "anthropic_messages"
|
|
)
|
|
|
|
// logicalRequestLineage is the immutable request prefix and tool contract
|
|
// recorded when a logical request is admitted. It intentionally contains only
|
|
// digests: raw prompts, tool schemas, and tool results never enter the store.
|
|
type logicalRequestLineage struct {
|
|
Endpoint logicalRequestEndpoint
|
|
HistoryDigest string
|
|
ToolsetDigest string
|
|
}
|
|
|
|
type logicalRequestContinuationLineage struct {
|
|
Prefix logicalRequestLineage
|
|
IssuedCallHash string
|
|
ResultIDs []string
|
|
Committed logicalRequestLineage
|
|
}
|
|
|
|
func newChatRequestLineage(raw json.RawMessage) (logicalRequestLineage, error) {
|
|
fields, err := decodeLogicalRequestLineageEnvelope(raw)
|
|
if err != nil {
|
|
return logicalRequestLineage{}, err
|
|
}
|
|
rawMessages, ok := fields["messages"]
|
|
if !ok {
|
|
return logicalRequestLineage{}, fmt.Errorf("chat messages field is required")
|
|
}
|
|
if _, err := validateChatMessages(rawMessages); err != nil {
|
|
return logicalRequestLineage{}, err
|
|
}
|
|
return newLogicalRequestLineageFromRawFields(fields, logicalRequestEndpointChat, []string{"model", "messages"})
|
|
}
|
|
|
|
func newAnthropicRequestLineage(raw json.RawMessage) (logicalRequestLineage, error) {
|
|
fields, err := decodeLogicalRequestLineageEnvelope(raw)
|
|
if err != nil {
|
|
return logicalRequestLineage{}, err
|
|
}
|
|
rawMessages, ok := fields["messages"]
|
|
if !ok {
|
|
return logicalRequestLineage{}, fmt.Errorf("anthropic messages field is required")
|
|
}
|
|
if _, err := validateAnthropicMessages(rawMessages); err != nil {
|
|
return logicalRequestLineage{}, err
|
|
}
|
|
return newLogicalRequestLineageFromRawFields(fields, logicalRequestEndpointAnthropic, []string{"model", "system", "messages"})
|
|
}
|
|
|
|
type chatMessageValidation struct {
|
|
Role string `json:"role"`
|
|
ToolCallID string `json:"tool_call_id"`
|
|
ToolCalls []struct {
|
|
ID string `json:"id"`
|
|
} `json:"tool_calls"`
|
|
}
|
|
|
|
func validateChatMessages(rawMessages json.RawMessage) ([]json.RawMessage, error) {
|
|
if len(rawMessages) == 0 {
|
|
return nil, fmt.Errorf("chat messages field is required")
|
|
}
|
|
var msgList []json.RawMessage
|
|
decoder := json.NewDecoder(bytes.NewReader(rawMessages))
|
|
decoder.UseNumber()
|
|
if err := decoder.Decode(&msgList); err != nil {
|
|
return nil, fmt.Errorf("chat messages must be an array: %w", err)
|
|
}
|
|
if len(msgList) == 0 {
|
|
return nil, fmt.Errorf("chat messages array must not be empty")
|
|
}
|
|
validRoles := map[string]struct{}{
|
|
"system": {},
|
|
"developer": {},
|
|
"user": {},
|
|
"assistant": {},
|
|
"tool": {},
|
|
}
|
|
|
|
globallySeenIssuedIDs := make(map[string]struct{})
|
|
pendingToolCallIDs := make(map[string]struct{})
|
|
|
|
for i, rawMsg := range msgList {
|
|
var m chatMessageValidation
|
|
if err := json.Unmarshal(rawMsg, &m); err != nil {
|
|
return nil, fmt.Errorf("decode chat message at index %d: %w", i, err)
|
|
}
|
|
if _, ok := validRoles[m.Role]; !ok {
|
|
return nil, fmt.Errorf("unknown chat message role %q at index %d", m.Role, i)
|
|
}
|
|
|
|
if m.Role == "tool" {
|
|
if len(pendingToolCallIDs) == 0 {
|
|
return nil, fmt.Errorf("orphan tool result message at index %d", i)
|
|
}
|
|
if m.ToolCallID == "" {
|
|
return nil, fmt.Errorf("tool message at index %d has empty tool_call_id", i)
|
|
}
|
|
if _, ok := pendingToolCallIDs[m.ToolCallID]; !ok {
|
|
return nil, fmt.Errorf("tool message at index %d has unexpected or duplicate tool_call_id %q", i, m.ToolCallID)
|
|
}
|
|
delete(pendingToolCallIDs, m.ToolCallID)
|
|
} else {
|
|
if len(pendingToolCallIDs) > 0 {
|
|
return nil, fmt.Errorf("message at index %d with role %q appeared before all preceding tool_calls were satisfied", i, m.Role)
|
|
}
|
|
|
|
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
|
|
for tcIdx, tc := range m.ToolCalls {
|
|
if tc.ID == "" {
|
|
return nil, fmt.Errorf("assistant message at index %d tool call %d has empty id", i, tcIdx)
|
|
}
|
|
if _, duplicate := globallySeenIssuedIDs[tc.ID]; duplicate {
|
|
return nil, fmt.Errorf("duplicate issued assistant tool call id %q at index %d", tc.ID, i)
|
|
}
|
|
globallySeenIssuedIDs[tc.ID] = struct{}{}
|
|
pendingToolCallIDs[tc.ID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(pendingToolCallIDs) > 0 {
|
|
return nil, fmt.Errorf("message list ended before all assistant tool_calls were satisfied")
|
|
}
|
|
|
|
return msgList, nil
|
|
}
|
|
|
|
func validateAnthropicMessages(rawMessages json.RawMessage) ([]json.RawMessage, error) {
|
|
if len(rawMessages) == 0 {
|
|
return nil, fmt.Errorf("anthropic messages field is required")
|
|
}
|
|
var msgList []json.RawMessage
|
|
decoder := json.NewDecoder(bytes.NewReader(rawMessages))
|
|
decoder.UseNumber()
|
|
if err := decoder.Decode(&msgList); err != nil {
|
|
return nil, fmt.Errorf("anthropic messages must be an array: %w", err)
|
|
}
|
|
if len(msgList) == 0 {
|
|
return nil, fmt.Errorf("anthropic messages array must not be empty")
|
|
}
|
|
|
|
globallySeenToolUseIDs := make(map[string]struct{})
|
|
pendingToolUseIDs := make(map[string]struct{})
|
|
|
|
for i, rawMsg := range msgList {
|
|
var m struct {
|
|
Role string `json:"role"`
|
|
Content json.RawMessage `json:"content"`
|
|
}
|
|
if err := json.Unmarshal(rawMsg, &m); err != nil {
|
|
return nil, fmt.Errorf("decode anthropic message at index %d: %w", i, err)
|
|
}
|
|
if m.Role != "user" && m.Role != "assistant" {
|
|
return nil, fmt.Errorf("invalid anthropic message role %q at index %d", m.Role, i)
|
|
}
|
|
if i == 0 && m.Role != "user" {
|
|
return nil, fmt.Errorf("anthropic messages first message must have role user, got %q", m.Role)
|
|
}
|
|
if i > 0 {
|
|
var prev struct {
|
|
Role string `json:"role"`
|
|
}
|
|
_ = json.Unmarshal(msgList[i-1], &prev)
|
|
if m.Role == prev.Role {
|
|
return nil, fmt.Errorf("anthropic messages roles must alternate, repeated role %q at index %d", m.Role, i)
|
|
}
|
|
}
|
|
|
|
blocks, err := decodeAnthropicContent(m.Content)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("anthropic message %d: %w", i, err)
|
|
}
|
|
|
|
if m.Role == "assistant" {
|
|
for bIdx, block := range blocks {
|
|
if block.Type == "tool_result" || block.Type == "image" {
|
|
return nil, fmt.Errorf("anthropic assistant message %d block %d has invalid type %q", i, bIdx, block.Type)
|
|
}
|
|
if block.Type == "tool_use" {
|
|
if block.ID == "" {
|
|
return nil, fmt.Errorf("anthropic assistant message %d tool_use block %d has empty id", i, bIdx)
|
|
}
|
|
if _, duplicate := globallySeenToolUseIDs[block.ID]; duplicate {
|
|
return nil, fmt.Errorf("duplicate issued assistant tool_use id %q at message %d", block.ID, i)
|
|
}
|
|
globallySeenToolUseIDs[block.ID] = struct{}{}
|
|
pendingToolUseIDs[block.ID] = struct{}{}
|
|
}
|
|
}
|
|
} else if m.Role == "user" {
|
|
if len(pendingToolUseIDs) > 0 {
|
|
if len(blocks) != len(pendingToolUseIDs) {
|
|
return nil, fmt.Errorf("anthropic user message %d tool results count (%d) does not match issued tool_use count (%d)", i, len(blocks), len(pendingToolUseIDs))
|
|
}
|
|
for bIdx, block := range blocks {
|
|
if block.Type != "tool_result" {
|
|
return nil, fmt.Errorf("anthropic user message %d block %d has non-tool_result type %q when responding to tool_use", i, bIdx, block.Type)
|
|
}
|
|
if block.ToolUseID == "" {
|
|
return nil, fmt.Errorf("anthropic user message %d tool_result block %d missing tool_use_id", i, bIdx)
|
|
}
|
|
if _, ok := pendingToolUseIDs[block.ToolUseID]; !ok {
|
|
return nil, fmt.Errorf("anthropic user message %d tool_result tool_use_id %q not in issued tool_use blocks or duplicate", i, block.ToolUseID)
|
|
}
|
|
delete(pendingToolUseIDs, block.ToolUseID)
|
|
}
|
|
} else {
|
|
for bIdx, block := range blocks {
|
|
if block.Type == "tool_use" || block.Type == "thinking" {
|
|
return nil, fmt.Errorf("anthropic user message %d block %d has invalid type %q", i, bIdx, block.Type)
|
|
}
|
|
if block.Type == "tool_result" {
|
|
return nil, fmt.Errorf("orphan tool_result block in anthropic user message at index %d", i)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(pendingToolUseIDs) > 0 {
|
|
return nil, fmt.Errorf("anthropic message list ended before tool_use blocks were satisfied")
|
|
}
|
|
|
|
return msgList, nil
|
|
}
|
|
|
|
func newChatContinuationLineage(raw json.RawMessage) (logicalRequestContinuationLineage, error) {
|
|
fields, err := decodeLogicalRequestLineageEnvelope(raw)
|
|
if err != nil {
|
|
return logicalRequestContinuationLineage{}, err
|
|
}
|
|
rawMessages, ok := fields["messages"]
|
|
if !ok {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("chat continuation messages field is required")
|
|
}
|
|
|
|
msgList, err := validateChatMessages(rawMessages)
|
|
if err != nil {
|
|
return logicalRequestContinuationLineage{}, err
|
|
}
|
|
|
|
var resultIDs []string
|
|
seenResultIDs := make(map[string]struct{})
|
|
resultCount := 0
|
|
|
|
for i := len(msgList) - 1; i >= 0; i-- {
|
|
var msg struct {
|
|
Role string `json:"role"`
|
|
ToolCallID string `json:"tool_call_id"`
|
|
}
|
|
if err := json.Unmarshal(msgList[i], &msg); err != nil {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("decode message at index %d: %w", i, err)
|
|
}
|
|
if msg.Role == "tool" {
|
|
if msg.ToolCallID == "" {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("tool message at index %d has empty tool_call_id", i)
|
|
}
|
|
if _, exists := seenResultIDs[msg.ToolCallID]; exists {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("duplicate tool_call_id %q in frontier", msg.ToolCallID)
|
|
}
|
|
seenResultIDs[msg.ToolCallID] = struct{}{}
|
|
resultIDs = append([]string{msg.ToolCallID}, resultIDs...)
|
|
resultCount++
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
if resultCount == 0 {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("chat continuation must end with at least one tool result message")
|
|
}
|
|
|
|
assistantIndex := len(msgList) - resultCount - 1
|
|
if assistantIndex < 0 {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("chat continuation missing issued assistant message before tool results")
|
|
}
|
|
|
|
var assistantMsg struct {
|
|
Role string `json:"role"`
|
|
ToolCalls []struct {
|
|
ID string `json:"id"`
|
|
} `json:"tool_calls"`
|
|
}
|
|
if err := json.Unmarshal(msgList[assistantIndex], &assistantMsg); err != nil {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("decode assistant message: %w", err)
|
|
}
|
|
if assistantMsg.Role != "assistant" {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("expected assistant message before tool results, got role %q", assistantMsg.Role)
|
|
}
|
|
if len(assistantMsg.ToolCalls) == 0 {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("issued assistant message must contain tool_calls")
|
|
}
|
|
|
|
expectedToolCallIDs := make(map[string]struct{}, len(assistantMsg.ToolCalls))
|
|
for _, tc := range assistantMsg.ToolCalls {
|
|
if tc.ID == "" {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("issued assistant tool call has empty id")
|
|
}
|
|
if _, duplicate := expectedToolCallIDs[tc.ID]; duplicate {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("duplicate issued assistant tool call id %q", tc.ID)
|
|
}
|
|
expectedToolCallIDs[tc.ID] = struct{}{}
|
|
}
|
|
if len(expectedToolCallIDs) != len(seenResultIDs) {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("frontier tool results count (%d) does not match issued assistant tool_calls count (%d)", len(seenResultIDs), len(expectedToolCallIDs))
|
|
}
|
|
for id := range seenResultIDs {
|
|
if _, ok := expectedToolCallIDs[id]; !ok {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("frontier tool_call_id %q not in issued assistant tool_calls", id)
|
|
}
|
|
}
|
|
|
|
issuedCallHash, err := fingerprintCanonicalJSON(logicalRequestEndpointChat, msgList[assistantIndex])
|
|
if err != nil {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("fingerprint issued assistant call: %w", err)
|
|
}
|
|
|
|
prefixMessagesRaw, err := json.Marshal(msgList[:assistantIndex])
|
|
if err != nil {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("marshal prefix messages: %w", err)
|
|
}
|
|
|
|
prefixHistory := map[string]json.RawMessage{
|
|
"model": fields["model"],
|
|
"messages": prefixMessagesRaw,
|
|
}
|
|
prefixHistoryDigest, err := fingerprintCanonicalJSON(logicalRequestEndpointChat, prefixHistory)
|
|
if err != nil {
|
|
return logicalRequestContinuationLineage{}, err
|
|
}
|
|
toolsetDigest, err := fingerprintCanonicalJSON(logicalRequestEndpointChat, fields["tools"])
|
|
if err != nil {
|
|
return logicalRequestContinuationLineage{}, err
|
|
}
|
|
prefixLineage := logicalRequestLineage{
|
|
Endpoint: logicalRequestEndpointChat,
|
|
HistoryDigest: prefixHistoryDigest,
|
|
ToolsetDigest: toolsetDigest,
|
|
}
|
|
|
|
committedHistory := map[string]json.RawMessage{
|
|
"model": fields["model"],
|
|
"messages": fields["messages"],
|
|
}
|
|
committedHistoryDigest, err := fingerprintCanonicalJSON(logicalRequestEndpointChat, committedHistory)
|
|
if err != nil {
|
|
return logicalRequestContinuationLineage{}, err
|
|
}
|
|
committedLineage := logicalRequestLineage{
|
|
Endpoint: logicalRequestEndpointChat,
|
|
HistoryDigest: committedHistoryDigest,
|
|
ToolsetDigest: toolsetDigest,
|
|
}
|
|
|
|
return logicalRequestContinuationLineage{
|
|
Prefix: prefixLineage,
|
|
IssuedCallHash: issuedCallHash,
|
|
ResultIDs: resultIDs,
|
|
Committed: committedLineage,
|
|
}, nil
|
|
}
|
|
|
|
func newAnthropicContinuationLineage(raw json.RawMessage) (logicalRequestContinuationLineage, error) {
|
|
fields, err := decodeLogicalRequestLineageEnvelope(raw)
|
|
if err != nil {
|
|
return logicalRequestContinuationLineage{}, err
|
|
}
|
|
rawMessages, ok := fields["messages"]
|
|
if !ok {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("anthropic continuation messages field is required")
|
|
}
|
|
|
|
msgList, err := validateAnthropicMessages(rawMessages)
|
|
if err != nil {
|
|
return logicalRequestContinuationLineage{}, err
|
|
}
|
|
|
|
lastIndex := len(msgList) - 1
|
|
var lastMsg struct {
|
|
Role string `json:"role"`
|
|
Content json.RawMessage `json:"content"`
|
|
}
|
|
if err := json.Unmarshal(msgList[lastIndex], &lastMsg); err != nil {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("decode last anthropic message: %w", err)
|
|
}
|
|
if lastMsg.Role != "user" {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("anthropic continuation last message must have role user, got %q", lastMsg.Role)
|
|
}
|
|
|
|
var blocks []json.RawMessage
|
|
if err := json.Unmarshal(lastMsg.Content, &blocks); err != nil {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("anthropic continuation user message content must be array of blocks: %w", err)
|
|
}
|
|
|
|
var resultIDs []string
|
|
seenResultIDs := make(map[string]struct{})
|
|
for bIdx, blockRaw := range blocks {
|
|
var block struct {
|
|
Type string `json:"type"`
|
|
ToolUseID string `json:"tool_use_id"`
|
|
}
|
|
if err := json.Unmarshal(blockRaw, &block); err != nil {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("decode content block %d: %w", bIdx, err)
|
|
}
|
|
if block.Type != "tool_result" {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("anthropic continuation trailing user message block %d has non-tool_result type %q", bIdx, block.Type)
|
|
}
|
|
if block.ToolUseID == "" {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("anthropic tool_result block %d missing tool_use_id", bIdx)
|
|
}
|
|
if _, exists := seenResultIDs[block.ToolUseID]; exists {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("duplicate tool_use_id %q in anthropic frontier", block.ToolUseID)
|
|
}
|
|
seenResultIDs[block.ToolUseID] = struct{}{}
|
|
resultIDs = append(resultIDs, block.ToolUseID)
|
|
}
|
|
if len(resultIDs) == 0 {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("anthropic continuation trailing user message contains no tool_result blocks")
|
|
}
|
|
|
|
assistantIndex := lastIndex - 1
|
|
if assistantIndex < 0 {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("anthropic continuation missing issued assistant message before tool results")
|
|
}
|
|
|
|
var assistantMsg struct {
|
|
Role string `json:"role"`
|
|
Content json.RawMessage `json:"content"`
|
|
}
|
|
if err := json.Unmarshal(msgList[assistantIndex], &assistantMsg); err != nil {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("decode assistant message: %w", err)
|
|
}
|
|
if assistantMsg.Role != "assistant" {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("expected assistant message before tool results, got role %q", assistantMsg.Role)
|
|
}
|
|
|
|
var assistantBlocks []json.RawMessage
|
|
if err := json.Unmarshal(assistantMsg.Content, &assistantBlocks); err != nil {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("assistant message content must be array of blocks: %w", err)
|
|
}
|
|
|
|
expectedToolUseIDs := make(map[string]struct{})
|
|
for bIdx, blockRaw := range assistantBlocks {
|
|
var block struct {
|
|
Type string `json:"type"`
|
|
ID string `json:"id"`
|
|
}
|
|
if err := json.Unmarshal(blockRaw, &block); err != nil {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("decode assistant content block %d: %w", bIdx, err)
|
|
}
|
|
if block.Type == "tool_use" {
|
|
if block.ID == "" {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("issued assistant tool_use block has empty id")
|
|
}
|
|
if _, duplicate := expectedToolUseIDs[block.ID]; duplicate {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("duplicate issued assistant tool_use id %q", block.ID)
|
|
}
|
|
expectedToolUseIDs[block.ID] = struct{}{}
|
|
}
|
|
}
|
|
if len(expectedToolUseIDs) == 0 {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("issued assistant message contains no tool_use blocks")
|
|
}
|
|
if len(expectedToolUseIDs) != len(seenResultIDs) {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("anthropic frontier tool results count (%d) does not match issued tool_use count (%d)", len(seenResultIDs), len(expectedToolUseIDs))
|
|
}
|
|
for id := range seenResultIDs {
|
|
if _, ok := expectedToolUseIDs[id]; !ok {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("anthropic tool_result tool_use_id %q not in issued assistant tool_use blocks", id)
|
|
}
|
|
}
|
|
|
|
issuedCallHash, err := fingerprintCanonicalJSON(logicalRequestEndpointAnthropic, msgList[assistantIndex])
|
|
if err != nil {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("fingerprint issued assistant call: %w", err)
|
|
}
|
|
|
|
prefixMessagesRaw, err := json.Marshal(msgList[:assistantIndex])
|
|
if err != nil {
|
|
return logicalRequestContinuationLineage{}, fmt.Errorf("marshal prefix messages: %w", err)
|
|
}
|
|
|
|
prefixHistory := map[string]json.RawMessage{
|
|
"model": fields["model"],
|
|
"system": fields["system"],
|
|
"messages": prefixMessagesRaw,
|
|
}
|
|
prefixHistoryDigest, err := fingerprintCanonicalJSON(logicalRequestEndpointAnthropic, prefixHistory)
|
|
if err != nil {
|
|
return logicalRequestContinuationLineage{}, err
|
|
}
|
|
toolsetDigest, err := fingerprintCanonicalJSON(logicalRequestEndpointAnthropic, fields["tools"])
|
|
if err != nil {
|
|
return logicalRequestContinuationLineage{}, err
|
|
}
|
|
prefixLineage := logicalRequestLineage{
|
|
Endpoint: logicalRequestEndpointAnthropic,
|
|
HistoryDigest: prefixHistoryDigest,
|
|
ToolsetDigest: toolsetDigest,
|
|
}
|
|
|
|
committedHistory := map[string]json.RawMessage{
|
|
"model": fields["model"],
|
|
"system": fields["system"],
|
|
"messages": fields["messages"],
|
|
}
|
|
committedHistoryDigest, err := fingerprintCanonicalJSON(logicalRequestEndpointAnthropic, committedHistory)
|
|
if err != nil {
|
|
return logicalRequestContinuationLineage{}, err
|
|
}
|
|
committedLineage := logicalRequestLineage{
|
|
Endpoint: logicalRequestEndpointAnthropic,
|
|
HistoryDigest: committedHistoryDigest,
|
|
ToolsetDigest: toolsetDigest,
|
|
}
|
|
|
|
return logicalRequestContinuationLineage{
|
|
Prefix: prefixLineage,
|
|
IssuedCallHash: issuedCallHash,
|
|
ResultIDs: resultIDs,
|
|
Committed: committedLineage,
|
|
}, nil
|
|
}
|
|
|
|
func newLogicalRequestLineageFromRaw(raw json.RawMessage, endpoint logicalRequestEndpoint, historyFields []string) (logicalRequestLineage, error) {
|
|
fields, err := decodeLogicalRequestLineageEnvelope(raw)
|
|
if err != nil {
|
|
return logicalRequestLineage{}, err
|
|
}
|
|
return newLogicalRequestLineageFromRawFields(fields, endpoint, historyFields)
|
|
}
|
|
|
|
func newLogicalRequestLineageFromRawFields(fields map[string]json.RawMessage, endpoint logicalRequestEndpoint, historyFields []string) (logicalRequestLineage, error) {
|
|
history := make(map[string]json.RawMessage, len(historyFields))
|
|
for _, field := range historyFields {
|
|
history[field] = fields[field]
|
|
}
|
|
historyDigest, err := fingerprintCanonicalJSON(endpoint, history)
|
|
if err != nil {
|
|
return logicalRequestLineage{}, err
|
|
}
|
|
toolsetDigest, err := fingerprintCanonicalJSON(endpoint, fields["tools"])
|
|
if err != nil {
|
|
return logicalRequestLineage{}, err
|
|
}
|
|
return logicalRequestLineage{Endpoint: endpoint, HistoryDigest: historyDigest, ToolsetDigest: toolsetDigest}, nil
|
|
}
|
|
|
|
func decodeLogicalRequestLineageEnvelope(raw json.RawMessage) (map[string]json.RawMessage, error) {
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.UseNumber()
|
|
var fields map[string]json.RawMessage
|
|
if err := decoder.Decode(&fields); err != nil {
|
|
return nil, fmt.Errorf("decode logical request lineage envelope: %w", err)
|
|
}
|
|
if fields == nil {
|
|
return nil, fmt.Errorf("logical request lineage envelope must be an object")
|
|
}
|
|
var extra any
|
|
if err := decoder.Decode(&extra); err == nil {
|
|
return nil, fmt.Errorf("logical request lineage envelope contains multiple JSON values")
|
|
} else if err != io.EOF {
|
|
return nil, fmt.Errorf("decode logical request lineage envelope: %w", err)
|
|
}
|
|
return fields, nil
|
|
}
|
|
|
|
// fingerprintCanonicalJSON normalizes nested JSON before hashing. Decoding
|
|
// RawMessage values first prevents insignificant formatting differences in a
|
|
// caller's schema or Anthropic content blocks from becoming new lineages.
|
|
func fingerprintCanonicalJSON(endpoint logicalRequestEndpoint, value any) (string, error) {
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal logical request lineage: %w", err)
|
|
}
|
|
var canonical any
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.UseNumber()
|
|
if err := decoder.Decode(&canonical); err != nil {
|
|
return "", fmt.Errorf("decode logical request lineage: %w", err)
|
|
}
|
|
normalized, err := json.Marshal(canonical)
|
|
if err != nil {
|
|
return "", fmt.Errorf("encode logical request lineage: %w", err)
|
|
}
|
|
sum := sha256.Sum256(append(append([]byte(endpoint), '\n'), normalized...))
|
|
return hex.EncodeToString(sum[:]), nil
|
|
}
|