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:
Danny Kopping
2026-07-06 09:29:07 +02:00
committed by GitHub
parent 08a6359cac
commit dd216b96fe
12 changed files with 331 additions and 220 deletions
@@ -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