mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: record provider_item_id for tool usage (#26856)
## Summary Plumbs the Responses output item id (added as `ToolUsageRecord.ItemID` in #26855) through to the database, captured independently of the `provider_tool_call_id` correlation key. Hosted tools (`web_search_call`, etc.) only have an item id; agentic tools have both. `provider_item_id` is specific to the OpenAI Responses API; it stays empty for chat completions and Anthropic messages, which have no separate item id. ## Changes - Migration `000534`: nullable `provider_item_id` column on `aibridge_tool_usages`. - Proto: `item_id` field 11 on `RecordToolUsageRequest`. - Server handler: persists `provider_item_id` and adds it to structured logging. - Translator: maps `ToolUsageRecord.ItemID` to the proto field. ## Tests - `TestRecordToolUsageProviderItemID`: real-database round-trip asserting `provider_item_id` persists for both hosted and agentic tools, independently of `provider_tool_call_id`. Stacked on #26855. Linear: AIGOV-96 --- _This PR was produced by opencode (agent) using the_ _`anthropic/claude-opus-4-8`_ _model, under human direction and review._
This commit is contained in:
@@ -429,6 +429,7 @@ func (s *Server) RecordToolUsage(ctx context.Context, in *proto.RecordToolUsageR
|
||||
slog.F("interception_id", intcID.String()),
|
||||
slog.F("msg_id", in.GetMsgId()),
|
||||
slog.F("tool_call_id", in.GetToolCallId()),
|
||||
slog.F("item_id", in.GetItemId()),
|
||||
slog.F("tool", in.GetTool()),
|
||||
slog.F("input", in.GetInput()),
|
||||
slog.F("server_url", in.GetServerUrl()),
|
||||
@@ -449,6 +450,7 @@ func (s *Server) RecordToolUsage(ctx context.Context, in *proto.RecordToolUsageR
|
||||
InterceptionID: intcID,
|
||||
ProviderResponseID: in.GetMsgId(),
|
||||
ProviderToolCallID: sql.NullString{String: in.GetToolCallId(), Valid: in.GetToolCallId() != ""},
|
||||
ProviderItemID: sql.NullString{String: in.GetItemId(), Valid: in.GetItemId() != ""},
|
||||
ServerUrl: sql.NullString{String: in.GetServerUrl(), Valid: in.ServerUrl != nil},
|
||||
Tool: in.GetTool(),
|
||||
Input: in.GetInput(),
|
||||
|
||||
@@ -2219,6 +2219,7 @@ func TestRecordToolUsage(t *testing.T) {
|
||||
InterceptionId: uuid.NewString(),
|
||||
MsgId: "msg_123",
|
||||
ToolCallId: "call_xyz",
|
||||
ItemId: "fc_item_xyz",
|
||||
ServerUrl: ptr.Ref("https://api.example.com"),
|
||||
Tool: "read_file",
|
||||
Input: `{"path": "/etc/hosts"}`,
|
||||
@@ -2248,6 +2249,7 @@ func TestRecordToolUsage(t *testing.T) {
|
||||
!assert.Equal(t, interceptionID, p.InterceptionID, "interception ID") ||
|
||||
!assert.Equal(t, req.GetMsgId(), p.ProviderResponseID, "provider response ID") ||
|
||||
!assert.Equal(t, sql.NullString{String: "call_xyz", Valid: true}, p.ProviderToolCallID, "provider tool call ID") ||
|
||||
!assert.Equal(t, sql.NullString{String: "fc_item_xyz", Valid: true}, p.ProviderItemID, "provider item ID") ||
|
||||
!assert.Equal(t, req.GetTool(), p.Tool, "tool") ||
|
||||
!assert.Equal(t, dbServerURL, p.ServerUrl, "server URL") ||
|
||||
!assert.Equal(t, req.GetInput(), p.Input, "input") ||
|
||||
@@ -2847,6 +2849,79 @@ func TestInferredThreadsByToolCalls(t *testing.T) {
|
||||
require.Equal(t, uuid.NullUUID{UUID: aID, Valid: true}, intcC.ThreadRootID)
|
||||
}
|
||||
|
||||
// TestRecordToolUsageProviderItemID exercises the RecordToolUsage RPC against a
|
||||
// real database and confirms that provider_item_id is persisted in its own
|
||||
// column for both shapes of Responses-API tool call. Agentic tools carry both
|
||||
// an item id and a tool_call_id; hosted tools (e.g. web_search_call) carry only
|
||||
// an item id. The hosted case is the important one: it proves the item id is
|
||||
// stored even when tool_call_id is absent, so persistence is not gated on the
|
||||
// tool_call_id being present, and the two ids are written to their own columns.
|
||||
func TestRecordToolUsageProviderItemID(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
|
||||
srv, err := aibridgedserver.NewServer(ctx, db, logger, "/", codersdk.AIBridgeConfig{}, nil, requiredExperiments, agplaiseats.Noop{})
|
||||
require.NoError(t, err)
|
||||
|
||||
intcID := uuid.New()
|
||||
_, err = srv.RecordInterception(ctx, &proto.RecordInterceptionRequest{
|
||||
Id: intcID.String(),
|
||||
ApiKeyId: uuid.NewString(),
|
||||
InitiatorId: user.ID.String(),
|
||||
Provider: "openai",
|
||||
Model: "gpt-5",
|
||||
StartedAt: timestamppb.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Agentic tool: both item_id and tool_call_id are present.
|
||||
_, err = srv.RecordToolUsage(ctx, &proto.RecordToolUsageRequest{
|
||||
InterceptionId: intcID.String(),
|
||||
MsgId: "resp_1",
|
||||
ToolCallId: "call_agentic",
|
||||
ItemId: "fc_item_1",
|
||||
Tool: "function_call",
|
||||
Input: "{}",
|
||||
CreatedAt: timestamppb.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Hosted tool: only item_id is present, tool_call_id is empty.
|
||||
_, err = srv.RecordToolUsage(ctx, &proto.RecordToolUsageRequest{
|
||||
InterceptionId: intcID.String(),
|
||||
MsgId: "resp_1",
|
||||
ItemId: "ws_item_1",
|
||||
Tool: "web_search_call",
|
||||
Input: "{}",
|
||||
CreatedAt: timestamppb.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
usages, err := db.GetAIBridgeToolUsagesByInterceptionID(ctx, intcID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, usages, 2)
|
||||
|
||||
byItemID := make(map[string]database.AIBridgeToolUsage, len(usages))
|
||||
for _, u := range usages {
|
||||
require.True(t, u.ProviderItemID.Valid, "item ID should be persisted for %q", u.Tool)
|
||||
byItemID[u.ProviderItemID.String] = u
|
||||
}
|
||||
|
||||
// Agentic tool: item id and tool_call_id land in their own columns.
|
||||
agentic, ok := byItemID["fc_item_1"]
|
||||
require.True(t, ok, "agentic tool usage persisted by item ID")
|
||||
require.Equal(t, sql.NullString{String: "call_agentic", Valid: true}, agentic.ProviderToolCallID)
|
||||
|
||||
// Hosted tool: item id is persisted even though the tool_call_id is empty.
|
||||
hosted, ok := byItemID["ws_item_1"]
|
||||
require.True(t, ok, "hosted tool usage persisted by item ID")
|
||||
require.Equal(t, sql.NullString{}, hosted.ProviderToolCallID, "hosted tool has no tool_call_id")
|
||||
}
|
||||
|
||||
// TestGetAIProviders exercises the row-to-proto mapping over a real database:
|
||||
// enabled providers carry their keys (and typed Bedrock settings), disabled
|
||||
// providers are included but withhold keys and settings, Copilot (a keyless
|
||||
|
||||
Reference in New Issue
Block a user