Skip to content

Commit bc4b439

Browse files
philzexedev-shelley
andcommitted
shelley: record fork provenance to de-duplicate usage data
Prompt: When we fork messages, do we write down where they came from? I'd like to be able to compute usage data based on usage data and these new llm columns. Forking copies a conversation's messages into a new conversation with fresh message_ids and identical usage_data/llm_api_url/model_name. Until now nothing recorded that a message was a copy, so summing usage across all messages (e.g. grouped by the new llm_api_url/model_name columns) double-counts LLM work already incurred in the source conversation. Add a nullable forked_from_message_id column on messages, populated by CopyMessagesForFork with the source message's id (NULL for originally-incurred messages). To compute usage that counts each LLM call exactly once, filter WHERE forked_from_message_id IS NULL; join on it to attribute a fork's copies back to their origin. No FK constraint: the source may be deleted later and the fork should survive independently. Also surface llm_api_url, model_name, and forked_from_message_id through the API message struct and generated TS types so the front-end can do the same attribution/de-duplication. Co-authored-by: Shelley <shelley@exe.dev>
1 parent b507f89 commit bc4b439

8 files changed

Lines changed: 91 additions & 27 deletions

File tree

cmd/go2ts.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ type apiMessageForTS struct {
9090
DisplayData *string `json:"display_data,omitempty"`
9191
Generation int64 `json:"generation"`
9292
EndOfTurn *bool `json:"end_of_turn,omitempty"`
93+
94+
LLMAPIURL *string `json:"llm_api_url,omitempty"`
95+
ModelName *string `json:"model_name,omitempty"`
96+
ForkedFromMessageID *string `json:"forked_from_message_id,omitempty"`
9397
}
9498

9599
type conversationStateForTS struct {

db/generated/messages.sql.go

Lines changed: 23 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

db/generated/models.go

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

db/query/messages.sql

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ LIMIT ? OFFSET ?;
4242
-- are renumbered to generation 1 (the destination starts a fresh generation
4343
-- history), get new message_ids, and preserve content, ordering, and original
4444
-- timestamps. Used to fork a conversation.
45-
INSERT INTO messages (message_id, conversation_id, sequence_id, generation, type, llm_data, user_data, usage_data, display_data, excluded_from_context, llm_api_url, model_name, created_at)
46-
SELECT lower(hex(randomblob(16))), sqlc.arg('dest_conversation_id'), m.sequence_id, 1, m.type, m.llm_data, m.user_data, m.usage_data, m.display_data, m.excluded_from_context, m.llm_api_url, m.model_name, m.created_at
45+
INSERT INTO messages (message_id, conversation_id, sequence_id, generation, type, llm_data, user_data, usage_data, display_data, excluded_from_context, llm_api_url, model_name, forked_from_message_id, created_at)
46+
SELECT lower(hex(randomblob(16))), sqlc.arg('dest_conversation_id'), m.sequence_id, 1, m.type, m.llm_data, m.user_data, m.usage_data, m.display_data, m.excluded_from_context, m.llm_api_url, m.model_name, m.message_id, m.created_at
4747
FROM messages m
4848
WHERE m.conversation_id = sqlc.arg('source_conversation_id')
4949
AND m.sequence_id <= sqlc.arg('cutoff_sequence_id')
@@ -120,7 +120,7 @@ UPDATE messages SET excluded_from_context = ? WHERE message_id = ?;
120120
SELECT m.message_id, m.conversation_id, m.sequence_id, m.type,
121121
m.llm_data, m.user_data, m.usage_data, m.created_at,
122122
m.display_data, m.excluded_from_context, m.generation,
123-
m.llm_api_url, m.model_name
123+
m.llm_api_url, m.model_name, m.forked_from_message_id
124124
FROM messages m
125125
WHERE m.conversation_id = ? AND m.type = 'agent'
126126
AND m.sequence_id > COALESCE(
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- Record fork provenance on copied messages.
2+
--
3+
-- When a conversation is forked, its messages are copied into the new
4+
-- conversation with fresh message_ids (see CopyMessagesForFork). Without a
5+
-- back-reference, summing usage_data / llm_api_url / model_name across all
6+
-- messages double-counts the LLM work that was already incurred in the source
7+
-- conversation, because the forked copies carry identical usage values.
8+
--
9+
-- forked_from_message_id points at the source message a copy was made from.
10+
-- It is NULL for originally-incurred messages. To compute usage that counts
11+
-- each LLM call exactly once, filter WHERE forked_from_message_id IS NULL.
12+
-- It deliberately has no FK constraint: the source message (or its whole
13+
-- conversation) may be deleted later, and the fork should survive as an
14+
-- independent conversation rather than cascade-delete or block.
15+
ALTER TABLE messages ADD COLUMN forked_from_message_id TEXT;
16+
17+
-- Index so attributing a fork's copies back to their origins (or finding all
18+
-- forks of a message) is cheap.
19+
CREATE INDEX idx_messages_forked_from ON messages(forked_from_message_id) WHERE forked_from_message_id IS NOT NULL;

server/fork_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,22 @@ func TestForkConversationCopiesUpToCutoff(t *testing.T) {
8686
if copied[0].MessageID == msgs[0].MessageID {
8787
t.Fatal("copied message should have a new message_id")
8888
}
89+
// Each copy records the source message it came from so usage data can be
90+
// de-duplicated (forked copies carry identical usage values and would
91+
// otherwise double-count). Source messages have no provenance.
92+
for i, c := range copied {
93+
if c.ForkedFromMessageID == nil {
94+
t.Fatalf("copied message %d should record forked_from_message_id", i)
95+
}
96+
if *c.ForkedFromMessageID != msgs[i].MessageID {
97+
t.Fatalf("copied message %d forked_from = %q, want %q", i, *c.ForkedFromMessageID, msgs[i].MessageID)
98+
}
99+
}
100+
for i, m := range srcMsgs {
101+
if m.ForkedFromMessageID != nil {
102+
t.Fatalf("source message %d should have nil forked_from_message_id, got %q", i, *m.ForkedFromMessageID)
103+
}
104+
}
89105
}
90106

91107
// TestHandleForkConversationByMessageID drives the HTTP handler and confirms it

server/server.go

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ type APIMessage struct {
4646
DisplayData *string `json:"display_data,omitempty"`
4747
Generation int64 `json:"generation"`
4848
EndOfTurn *bool `json:"end_of_turn,omitempty"`
49+
// LLMAPIURL and ModelName identify which LLM produced this message's
50+
// usage data; ForkedFromMessageID points at the source message a fork
51+
// copy came from (nil for originally-incurred messages). Together they
52+
// let clients attribute and de-duplicate usage across forks.
53+
LLMAPIURL *string `json:"llm_api_url,omitempty"`
54+
ModelName *string `json:"model_name,omitempty"`
55+
ForkedFromMessageID *string `json:"forked_from_message_id,omitempty"`
4956
}
5057

5158
// ConversationState represents the current state of a conversation.
@@ -155,17 +162,20 @@ func toAPIMessages(messages []generated.Message) []APIMessage {
155162
llmData, endOfTurnPtr := llmDataForAPI(msg.LlmData, msg.Type, msg.MessageID)
156163

157164
apiMsg := APIMessage{
158-
MessageID: msg.MessageID,
159-
ConversationID: msg.ConversationID,
160-
SequenceID: msg.SequenceID,
161-
Type: msg.Type,
162-
LlmData: llmData,
163-
UserData: msg.UserData,
164-
UsageData: msg.UsageData,
165-
CreatedAt: msg.CreatedAt,
166-
DisplayData: msg.DisplayData,
167-
Generation: msg.Generation,
168-
EndOfTurn: endOfTurnPtr,
165+
MessageID: msg.MessageID,
166+
ConversationID: msg.ConversationID,
167+
SequenceID: msg.SequenceID,
168+
Type: msg.Type,
169+
LlmData: llmData,
170+
UserData: msg.UserData,
171+
UsageData: msg.UsageData,
172+
CreatedAt: msg.CreatedAt,
173+
DisplayData: msg.DisplayData,
174+
Generation: msg.Generation,
175+
EndOfTurn: endOfTurnPtr,
176+
LLMAPIURL: msg.LlmApiUrl,
177+
ModelName: msg.ModelName,
178+
ForkedFromMessageID: msg.ForkedFromMessageID,
169179
}
170180
apiMessages[i] = apiMsg
171181
}

ui/src/generated-types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export interface Usage {
2828
output_tokens: number;
2929
cost_usd: number;
3030
model?: string;
31+
url?: string;
3132
start_time?: string | null;
3233
end_time?: string | null;
3334
}
@@ -44,6 +45,9 @@ export interface ApiMessageForTS {
4445
display_data?: string | null;
4546
generation: number;
4647
end_of_turn?: boolean | null;
48+
llm_api_url?: string | null;
49+
model_name?: string | null;
50+
forked_from_message_id?: string | null;
4751
}
4852

4953
export interface ConversationStateForTS {

0 commit comments

Comments
 (0)