{usage.total_tokens.toLocaleString()} tokens({usage.prompt_tokens.toLocaleString()} in · {usage.completion_tokens.toLocaleString()} out)
- {#if formatCost(usage.cost_usd)}
- {formatCost(usage.cost_usd)}
+ {#if formatCostUSD(usage.cost_usd)}
+ {formatCostUSD(usage.cost_usd)}
{:else}
cost unknown
{/if}
diff --git a/frontend/src/lib/features/items/ItemHistory.svelte b/frontend/src/lib/features/items/ItemHistory.svelte
index 9ed719609..8803138be 100644
--- a/frontend/src/lib/features/items/ItemHistory.svelte
+++ b/frontend/src/lib/features/items/ItemHistory.svelte
@@ -9,7 +9,14 @@
import EmptyState from '../../components/EmptyState.svelte';
import Tooltip from '../../components/Tooltip.svelte';
import { t } from '../../stores/i18n.svelte.js';
- import { agentOwnerName, loadAttributedItemHistory } from './activityAttributionData.js';
+ import { formatCostUSD, hasMeteredUsage } from '../../utils/llmUsage.js';
+ import { agentRuns } from '../../api/agentRuns.js';
+ import {
+ agentOwnerName,
+ historyTelemetryRunIDs,
+ isAIChatAttributed,
+ loadAttributedItemHistory
+ } from './activityAttributionData.js';
let { itemId } = $props();
@@ -17,6 +24,12 @@
let loading = $state(true);
let error = $state('');
+ // Metered telemetry is fetched per run on demand, keyed by run id. A run
+ // that failed or has no usage resolves to null so we do not retry it on
+ // every hover; `undefined` means "not attempted yet".
+ let runTelemetry = $state({});
+ let runTelemetryLoading = $state({});
+
// Get user's timezone
let timezone = $derived(getUserTimezone(authStore.currentUser));
@@ -38,6 +51,12 @@
}
function agentTooltipContent(entry) {
+ // AI-chat attribution is checked first: a chat-driven change is the
+ // more specific fact, and its owner is simply the human whose name is
+ // already on the row, so the connected-agent copy would be misleading.
+ if (isAIChatAttributed(entry)) {
+ return t('history.viaAIChat');
+ }
const owner = agentOwnerName(entry);
if (owner) {
return t('comments.agentOwnedBy', { owner });
@@ -48,6 +67,42 @@
// Group history entries by timestamp (changes made at the same time)
let groupedHistory = $derived(groupByTimestamp(history));
+ // The newest runs this view is allowed to fetch telemetry for. Computed
+ // from the rendered groups so the cap tracks what the user can actually
+ // hover, and evaluated before any request is issued.
+ let telemetryRunIDs = $derived(new Set(historyTelemetryRunIDs(groupedHistory)));
+
+ // Fetch a run's metered model/tokens/cost once, on first hover. Runs beyond
+ // the cap (or already attempted) are ignored so a long history cannot fan
+ // out into a request per agent row.
+ async function loadRunTelemetry(runId) {
+ if (!runId || !telemetryRunIDs.has(runId)) return;
+ if (runTelemetry[runId] !== undefined || runTelemetryLoading[runId]) return;
+ runTelemetryLoading = { ...runTelemetryLoading, [runId]: true };
+ try {
+ const usage = await agentRuns.usage(runId);
+ runTelemetry = { ...runTelemetry, [runId]: usage };
+ } catch {
+ runTelemetry = { ...runTelemetry, [runId]: null };
+ } finally {
+ runTelemetryLoading = { ...runTelemetryLoading, [runId]: false };
+ }
+ }
+
+ // The metered detail behind an AI-chat change, once it has loaded. Null
+ // until then (or when the run recorded no usage), so the tooltip can render
+ // the attribution without waiting on the network.
+ function agentRunDetail(group) {
+ if (!isAIChatAttributed(group) || !group?.agent_run_id) return null;
+ const usage = runTelemetry[group.agent_run_id];
+ if (!usage || !hasMeteredUsage(usage)) return null;
+ return {
+ model: usage.model || '',
+ tokens: Number(usage.total_tokens) || 0,
+ cost: formatCostUSD(usage.cost_usd)
+ };
+ }
+
function groupByTimestamp(entries) {
if (!entries || entries.length === 0) return [];
@@ -65,8 +120,10 @@
user_id: entry.user_id,
user_name: entry.user_name,
user_email: entry.user_email,
- is_agent: entry.is_agent,
- agent_owner_name: entry.agent_owner_name,
+ is_agent: false,
+ agent_owner_name: '',
+ source: '',
+ agent_run_id: null,
actor_kind: entry.actor_kind || 'user',
portal_customer_name: entry.portal_customer_name || '',
portal_customer_email: entry.portal_customer_email || '',
@@ -75,6 +132,24 @@
groups.push(currentGroup);
}
+ // Attribution is OR-ed across the whole group rather than taken from
+ // the first row: one agentic write emits many field rows sharing a
+ // single timestamp, and trusting the first would drop the marker
+ // whenever that row happened to carry no stamp.
+ currentGroup.is_agent = currentGroup.is_agent || !!entry.is_agent;
+ if (isAIChatAttributed(entry)) {
+ currentGroup.source = entry.source;
+ }
+ // Keep the run link from whichever row carries it. The link is on every
+ // row a turn wrote, but a group can also hold rows from a direct edit
+ // made in the same second, and those have none.
+ if (!currentGroup.agent_run_id && entry.agent_run_id) {
+ currentGroup.agent_run_id = entry.agent_run_id;
+ }
+ if (!currentGroup.agent_owner_name && entry.agent_owner_name) {
+ currentGroup.agent_owner_name = entry.agent_owner_name;
+ }
+
currentGroup.changes.push({
field_name: entry.field_name,
old_value: entry.old_value,
@@ -232,10 +307,42 @@
+ {/snippet}
+ loadRunTelemetry(group.agent_run_id)}>
+
+
+
+ {:else}
+
+
+
+ {/if}
{/if}
{groupActorName(group)}
{#if group.actor_kind === 'portal_customer'}
@@ -388,4 +495,26 @@
font-style: italic;
color: var(--ds-text);
}
+
+ /* Hover detail for an agent-authored change: which turn wrote it, and what
+ it cost. Laid out as label/value rows so a long model id cannot push the
+ numbers out of the popover. */
+ :global(.agent-detail) {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 11rem;
+ }
+ :global(.agent-detail-title) {
+ font-weight: 600;
+ margin-bottom: 2px;
+ }
+ :global(.agent-detail-row) {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.75rem;
+ }
+ :global(.agent-detail-value) {
+ font-weight: 500;
+ }
diff --git a/frontend/src/lib/features/items/activityAttributionData.js b/frontend/src/lib/features/items/activityAttributionData.js
index ed769077a..2b806c22f 100644
--- a/frontend/src/lib/features/items/activityAttributionData.js
+++ b/frontend/src/lib/features/items/activityAttributionData.js
@@ -11,3 +11,45 @@ export function loadAttributedItemHistory(apiClient, itemId) {
export function agentOwnerName(entry) {
return typeof entry?.agent_owner_name === 'string' ? entry.agent_owner_name : '';
}
+
+/**
+ * The source an agent acted through, as stamped on item_history.source. Only
+ * the AI Chat is surfaced today; other agent surfaces (mcp, standard_agent)
+ * either act as a real agent user — already covered by the is_agent marker —
+ * or have no in-product history feed to annotate.
+ */
+const ANNOTATED_HISTORY_SOURCES = new Set(['ai_chat']);
+
+/**
+ * Whether a history row was written by the AI Chat on the acting user's
+ * behalf. Distinct from `is_agent`, which means the author *is* an agent
+ * account; this means a human's name is on a change the model made.
+ */
+export function isAIChatAttributed(entry) {
+ return ANNOTATED_HISTORY_SOURCES.has(entry?.source);
+}
+
+/**
+ * Most agent turns a single history view will fetch telemetry for. Hover is
+ * lazy, so this is a fan-out ceiling rather than a prefetch budget: an item
+ * with hundreds of chat-written rows must not be able to turn a scroll into
+ * hundreds of usage requests. Newest runs win, matching the feed order.
+ */
+export const MAX_HISTORY_TELEMETRY_RUNS = 20;
+
+/**
+ * The run ids whose telemetry this history view may load, newest first, capped
+ * at max. Only AI-chat groups qualify: the other agent surfaces have no
+ * per-turn telemetry the history feed can attribute.
+ */
+export function historyTelemetryRunIDs(groups, max = MAX_HISTORY_TELEMETRY_RUNS) {
+ const ids = [];
+ for (const group of groups || []) {
+ const runId = group?.agent_run_id;
+ if (!runId || !isAIChatAttributed(group)) continue;
+ if (ids.includes(runId)) continue;
+ ids.push(runId);
+ if (ids.length >= max) break;
+ }
+ return ids;
+}
diff --git a/frontend/src/lib/locales/en/workspace.js b/frontend/src/lib/locales/en/workspace.js
index ed3f39dff..96f81af6f 100644
--- a/frontend/src/lib/locales/en/workspace.js
+++ b/frontend/src/lib/locales/en/workspace.js
@@ -531,6 +531,18 @@ export default {
selectOrCreateLabels: 'Select or create labels',
},
+ history: {
+ // Tooltip on the bot marker shown when an agent changed the item on this
+ // user's behalf through the AI Chat, as opposed to the connected-agent
+ // markers under comments.agentAuthored / comments.agentOwnedBy.
+ viaAIChat: 'Changed via AI chat',
+ // The agent turn behind that change, revealed by hovering the marker.
+ model: 'Model',
+ tokens: 'Tokens',
+ cost: 'Cost',
+ costUnknown: 'cost unknown',
+ },
+
comments: {
failedToLoad: 'Failed to load comments',
failedToCreate: 'Failed to post comment',
diff --git a/frontend/src/lib/locales/ru/workspace.js b/frontend/src/lib/locales/ru/workspace.js
index e888eb336..0e2f87223 100644
--- a/frontend/src/lib/locales/ru/workspace.js
+++ b/frontend/src/lib/locales/ru/workspace.js
@@ -49,6 +49,14 @@ export default {
setStatus: 'Задать статус', unassigned: 'Не назначено', milestone: 'Этап', iteration: 'Итерация', labels: 'Метки', addLabel: '+ Добавить метку', storyPoints: 'Сторипойнты', storyPointsChildRollup: '{points} пт. из {count} дочерних элементов', project: 'Проект', clickToViewDetails: 'Нажмите, чтобы открыть сведения', itemLinkCopied: 'Ссылка на элемент скопирована', failedToCopyToClipboard: 'Не удалось скопировать в буфер обмена', copyError: 'Ошибка копирования', setField: 'Задать поле «{field}»', selectField: 'Выберите поле «{field}»', enterField: 'Введите «{field}»', notSet: 'Не задано', selectIteration: 'Выберите итерацию', noIteration: 'Без итерации', selectOrCreateLabels: 'Выберите или создайте метки',
},
+ history: {
+ viaAIChat: 'Изменено через ИИ-чат',
+ model: 'Модель',
+ tokens: 'Токены',
+ cost: 'Стоимость',
+ costUnknown: 'стоимость неизвестна',
+ },
+
comments: {
failedToLoad: 'Не удалось загрузить комментарии', failedToCreate: 'Не удалось отправить комментарий', confirmDelete: 'Удалить этот комментарий?', failedToDelete: 'Не удалось удалить комментарий', failedToUpdate: 'Не удалось обновить комментарий', edited: 'изменено', newCommentsAvailable: 'Новых: {count}', editComment: 'Изменить комментарий', deleteComment: 'Удалить комментарий', editPlaceholder: 'Измените комментарий…', writePlaceholder: 'Добавить комментарий…', markdownSupported: 'Поддерживается Markdown', posting: 'Отправка…', comment: 'Комментарий', noComments: 'Комментариев пока нет', beFirstToComment: 'Оставьте первый комментарий к этому элементу.', internalNote: 'Внутренняя заметка', internalNoteHint: 'Не видна на портале', internalNoteHintGeneral: 'Видна только участникам команды', internal: 'Внутренний', oldestFirst: 'Сначала старые', newestFirst: 'Сначала новые', agentAuthored: 'Автор — ИИ-агент', agentOwnedBy: 'Владелец ИИ-агента: {owner}',
},
diff --git a/frontend/src/lib/stores/chatStore.svelte.js b/frontend/src/lib/stores/chatStore.svelte.js
index a514934cc..1491d1d4d 100644
--- a/frontend/src/lib/stores/chatStore.svelte.js
+++ b/frontend/src/lib/stores/chatStore.svelte.js
@@ -33,12 +33,25 @@ function applyGeneralSession(session, storedMessages, afterMessageId = 0) {
sessionType = 'general';
sessionWorkspaceId = 0;
agentProfileId = 0;
- messages = (Array.isArray(storedMessages) ? storedMessages : []).map((message) => ({
+ messages = (Array.isArray(storedMessages) ? storedMessages : []).map(toChatMessage);
+ historyAfterMessageId = afterMessageId;
+}
+
+/**
+ * Normalize a stored or live message into the shape the panel renders. Model
+ * and usage are carried through so a turn's metering survives a reload — the
+ * fields are absent on user turns, which is why this maps defensively rather
+ * than spreading.
+ */
+function toChatMessage(message) {
+ const normalized = {
id: message.id,
role: message.role,
content: message.content,
- }));
- historyAfterMessageId = afterMessageId;
+ };
+ if (message.model) normalized.model = message.model;
+ if (message.usage) normalized.usage = message.usage;
+ return normalized;
}
async function loadGeneralHistory() {
@@ -91,11 +104,7 @@ async function loadSession(session) {
sessionWorkspaceId = session.workspace_id || 0;
agentProfileId = session.agent_profile_id || 0;
historyAfterMessageId = 0;
- messages = (Array.isArray(stored) ? stored : []).map((message) => ({
- id: message.id,
- role: message.role,
- content: message.content,
- }));
+ messages = (Array.isArray(stored) ? stored : []).map(toChatMessage);
error = '';
itemKeyMap = {};
historyLoaded = true;
@@ -261,6 +270,8 @@ async function sendMessage(text, context) {
stopReason: result.stop_reason || '',
needsReview: result.needs_review || false,
reviewReasons: result.review_reasons || [],
+ model: result.model || '',
+ usage: result.usage || null,
};
messages = [...messages, assistantMsg];
extractItemKeys(assistantMsg.toolCalls);
diff --git a/frontend/src/lib/utils/llmUsage.js b/frontend/src/lib/utils/llmUsage.js
new file mode 100644
index 000000000..b88070c36
--- /dev/null
+++ b/frontend/src/lib/utils/llmUsage.js
@@ -0,0 +1,35 @@
+/**
+ * Formatting for metered LLM token/cost readings.
+ *
+ * Shared by every surface that shows a run's spend (the item agent log, the
+ * AI chat transcript) so the same run never renders two different prices — a
+ * discrepancy there reads as a billing bug even when both numbers are right.
+ */
+
+/**
+ * Render a USD amount, or null when the cost is unknown.
+ *
+ * A null cost is a real state, not zero: tokens are metered even when the
+ * provider billed nothing we can read and the catalog has no rate for a token
+ * class the call used. Callers must show that as "unknown" rather than $0.00.
+ *
+ * Sub-cent amounts are shown as "<$0.01" because rounding them to $0.00 would
+ * read as free, and four decimals below a dollar keeps small but real spends
+ * visible.
+ */
+export function formatCostUSD(usd) {
+ if (usd == null || Number.isNaN(Number(usd))) return null;
+ const value = Number(usd);
+ if (value === 0) return '$0.00';
+ if (value > 0 && value < 0.01) return '<$0.01';
+ return `$${value.toFixed(value < 1 ? 4 : 2)}`;
+}
+
+/**
+ * Whether a usage reading has anything worth showing. A reading with no calls
+ * means metering never ran for the turn, which is different from a turn that
+ * genuinely cost nothing.
+ */
+export function hasMeteredUsage(usage) {
+ return !!usage && Number(usage.calls) > 0;
+}
diff --git a/internal/aitools/comments.go b/internal/aitools/comments.go
index d1239ae12..d076cfda3 100644
--- a/internal/aitools/comments.go
+++ b/internal/aitools/comments.go
@@ -7,7 +7,6 @@ import (
"time"
"windshift/internal/auth"
- "windshift/internal/itemevents"
"windshift/internal/logger"
"windshift/internal/models"
"windshift/internal/services"
@@ -121,7 +120,7 @@ func init() {
AuthorID: env.UserID,
Content: args.Content,
ActorUserID: env.UserID,
- EventMetadata: itemevents.Agent(fmt.Sprintf("user:%d", env.UserID), "agent"),
+ EventMetadata: env.eventMetadata(),
})
if err != nil {
return nil, err
diff --git a/internal/aitools/diagrams.go b/internal/aitools/diagrams.go
index 78c885af2..dad3dbbd8 100644
--- a/internal/aitools/diagrams.go
+++ b/internal/aitools/diagrams.go
@@ -223,7 +223,7 @@ func init() {
return nil, err
}
if herr := repo.RecordHistory(itemID, env.UserID, "diagram_created", nil,
- fmt.Sprintf("diagram:%d:%s", id, name)); herr != nil {
+ fmt.Sprintf("diagram:%d:%s", id, name), env.Source, env.historyRunID()); herr != nil {
// History is best-effort, mirroring the handler.
_ = herr
}
@@ -310,7 +310,7 @@ func init() {
oldName = &old
}
_ = repo.RecordHistory(existing.ItemID, env.UserID, "diagram_updated", oldName,
- fmt.Sprintf("diagram:%d:%s", args.ID, newName))
+ fmt.Sprintf("diagram:%d:%s", args.ID, newName), env.Source, env.historyRunID())
env.AuditWrite(resourceDiagram, args.ID, "update_diagram", newName)
return diagramSummaryDTO{
@@ -358,7 +358,7 @@ func init() {
if !canEdit {
return map[string]string{"error": "permission denied"}, nil
}
- _ = repo.RecordHistory(itemID, env.UserID, "diagram_deleted", &name, name)
+ _ = repo.RecordHistory(itemID, env.UserID, "diagram_deleted", &name, name, env.Source, env.historyRunID())
if err := repo.Delete(args.ID); err != nil {
if errors.Is(err, repository.ErrNotFound) {
return map[string]string{"error": "diagram not found"}, nil
diff --git a/internal/aitools/env.go b/internal/aitools/env.go
index d1629b95c..aa3f50028 100644
--- a/internal/aitools/env.go
+++ b/internal/aitools/env.go
@@ -3,9 +3,11 @@
package aitools
import (
+ "fmt"
"log/slog"
"windshift/internal/database"
+ "windshift/internal/itemevents"
"windshift/internal/logger"
"windshift/internal/services"
)
@@ -22,11 +24,16 @@ const (
// Env provides tools their caller, services, and readable workspaces. Tools
// must gate workspace data through AccessibleWorkspaceIDs regardless of adapter.
type Env struct {
- DB database.Database
- UserID int
- Username string // Cached at Env-construction time for audit logs
- Timezone string // Validated IANA timezone for the acting user
- Source string // SourceAIChat | SourceMCP — for audit trail
+ DB database.Database
+ UserID int
+ Username string // Cached at Env-construction time for audit logs
+ Timezone string // Validated IANA timezone for the acting user
+ Source string // SourceAIChat | SourceMCP — for audit trail
+ // RunID is the agent_runs row this invocation belongs to, when the caller
+ // has one (the chat, the coding agent). It is what lets a resulting change
+ // name the turn that caused it — the model and cost behind the edit. Zero
+ // for surfaces with no run to point at, such as MCP.
+ RunID int
AccessibleWorkspaceIDs []int
// AuditDetails contains adapter-supplied correlation identifiers only.
// Raw tool arguments and results must never be placed here.
@@ -126,3 +133,33 @@ func (e *Env) HasWorkspaceAccess(workspaceID int) bool {
}
return false
}
+
+// eventMetadata describes this adapter's writes as agent-actor provenance, so
+// the durable event store and the item-history feed can both answer "an agent
+// did this on the user's behalf, through which surface?". ActorRef carries the
+// human the agent acts as — the identity every tool's permission check already
+// used, so the audit trail attributes the change to the same principal.
+//
+// Source falls back to "agent" when an embedder left it unset: writing
+// "application" would be a lie (this is not a direct UI click) and would make
+// the history feed's source column non-empty but unmatchable.
+func (e *Env) eventMetadata() itemevents.Metadata {
+ source := e.Source
+ if source == "" {
+ source = "agent"
+ }
+ metadata := itemevents.Agent(fmt.Sprintf("user:%d", e.UserID), source)
+ metadata.AgentRunID = e.RunID
+ return metadata
+}
+
+// historyRunID is the agent run to attribute a history row to, or nil when this
+// surface has no run to point at (MCP) — a row must not claim a turn that does
+// not exist.
+func (e *Env) historyRunID() *int {
+ if e.RunID <= 0 {
+ return nil
+ }
+ runID := e.RunID
+ return &runID
+}
diff --git a/internal/aitools/items.go b/internal/aitools/items.go
index ff68df9d5..13b33f71b 100644
--- a/internal/aitools/items.go
+++ b/internal/aitools/items.go
@@ -11,7 +11,6 @@ import (
"windshift/internal/auth"
"windshift/internal/cql"
"windshift/internal/database"
- "windshift/internal/itemevents"
"windshift/internal/logger"
"windshift/internal/models"
"windshift/internal/repository"
@@ -357,7 +356,7 @@ func init() {
CreatorID: &env.UserID,
ValidatingUserID: env.UserID,
PermService: env.PermService,
- EventMetadata: itemevents.Agent(fmt.Sprintf("user:%d", env.UserID), "agent"),
+ EventMetadata: env.eventMetadata(),
})
if err != nil {
return map[string]string{"error": fmt.Sprintf("create failed: %s", err.Error())}, nil //nolint:nilerr // surface as a tool error in JSON, not as a protocol error
@@ -411,9 +410,10 @@ func init() {
result, err := services.NewItemUpdateService(env.DB).
WithPermissionService(env.PermService).
UpdateItem(services.UpdateItemRequest{
- ItemID: itemID,
- UpdateData: updateData,
- UserID: env.UserID,
+ ItemID: itemID,
+ UpdateData: updateData,
+ UserID: env.UserID,
+ EventMetadata: env.eventMetadata(),
})
if err != nil {
return map[string]string{"error": fmt.Sprintf("update failed: %s", err.Error())}, nil //nolint:nilerr // surface as a tool error in JSON, not as a protocol error
@@ -554,10 +554,11 @@ func init() {
conditionSvc := services.NewConditionService(env.DB, env.PermService, services.NewScriptEngine())
approvalSvc := services.NewApprovalService(env.DB, repository.NewLeaveRepository(env.DB), workflowSvc)
result, err := workflowSvc.PerformTransition(ctx, services.PerformTransitionRequest{
- ItemID: itemID,
- ToStatusID: toStatusID,
- ActorUserID: env.UserID,
- Modes: []string{"validator", "condition"},
+ ItemID: itemID,
+ ToStatusID: toStatusID,
+ ActorUserID: env.UserID,
+ EventMetadata: env.eventMetadata(),
+ Modes: []string{"validator", "condition"},
}, repository.NewItemRepository(env.DB), conditionSvc, approvalSvc)
if err != nil {
if rej := services.IsTransitionRejection(err); rej != nil {
diff --git a/internal/database/migrations.go b/internal/database/migrations.go
index 37e5a6b08..85b0c6d73 100644
--- a/internal/database/migrations.go
+++ b/internal/database/migrations.go
@@ -2468,6 +2468,30 @@ var Catalog = []Migration{
CREATE INDEX IF NOT EXISTS idx_zammad_ticket_changes_link_observed ON zammad_ticket_changes(ticket_link_id, observed_at DESC);
`,
},
+ {
+ Version: "20260931_item_history_source",
+ Name: "Record the acting surface on item history rows",
+ CheckSQLite: sqliteColumnCheck("item_history", "source"),
+ CheckPostgres: pgColumnCheck("item_history", "source"),
+ SQLite: "ALTER TABLE item_history ADD COLUMN source TEXT",
+ Postgres: "ALTER TABLE item_history ADD COLUMN IF NOT EXISTS source TEXT",
+ },
+ {
+ Version: "20260931_item_history_agent_run",
+ Name: "Link agent-written item history rows to the run that made them",
+ CheckSQLite: sqliteColumnCheck("item_history", "agent_run_id"),
+ CheckPostgres: pgColumnCheck("item_history", "agent_run_id"),
+ SQLite: "ALTER TABLE item_history ADD COLUMN agent_run_id INTEGER",
+ Postgres: "ALTER TABLE item_history ADD COLUMN IF NOT EXISTS agent_run_id INTEGER",
+ },
+ {
+ Version: "20260931_llm_usage_calls",
+ Name: "Count provider round-trips on metered LLM usage rows",
+ CheckSQLite: sqliteColumnCheck("llm_usage", "calls"),
+ CheckPostgres: pgColumnCheck("llm_usage", "calls"),
+ SQLite: "ALTER TABLE llm_usage ADD COLUMN calls INTEGER NOT NULL DEFAULT 1",
+ Postgres: "ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS calls INTEGER NOT NULL DEFAULT 1",
+ },
}
// viewSettingsToolsBackfillIDs lists the workspace tools ids as they existed
diff --git a/internal/database/schema/items.sql b/internal/database/schema/items.sql
index 8088c7dd2..420bda41b 100644
--- a/internal/database/schema/items.sql
+++ b/internal/database/schema/items.sql
@@ -173,6 +173,8 @@ CREATE TABLE IF NOT EXISTS item_history (
field_name TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
+ source TEXT,
+ agent_run_id INTEGER,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT,
FOREIGN KEY (actor_portal_customer_id) REFERENCES portal_customers(id) ON DELETE SET NULL
diff --git a/internal/database/schema/items_postgres.sql b/internal/database/schema/items_postgres.sql
index c6b790df3..c2e353190 100644
--- a/internal/database/schema/items_postgres.sql
+++ b/internal/database/schema/items_postgres.sql
@@ -178,6 +178,8 @@ CREATE TABLE IF NOT EXISTS item_history (
field_name TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
+ source TEXT,
+ agent_run_id INTEGER,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT,
FOREIGN KEY (actor_portal_customer_id) REFERENCES portal_customers(id) ON DELETE SET NULL
diff --git a/internal/database/schema/llm.sql b/internal/database/schema/llm.sql
index 315af2235..91239fc57 100644
--- a/internal/database/schema/llm.sql
+++ b/internal/database/schema/llm.sql
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS llm_provider_model_cache (
CREATE TABLE IF NOT EXISTS llm_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL,
+ calls INTEGER NOT NULL DEFAULT 1,
model TEXT NOT NULL,
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
diff --git a/internal/database/schema/llm_postgres.sql b/internal/database/schema/llm_postgres.sql
index e453615b3..5906d341e 100644
--- a/internal/database/schema/llm_postgres.sql
+++ b/internal/database/schema/llm_postgres.sql
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS llm_provider_model_cache (
CREATE TABLE IF NOT EXISTS llm_usage (
id SERIAL PRIMARY KEY,
run_id INTEGER NOT NULL,
+ calls INTEGER NOT NULL DEFAULT 1,
model TEXT NOT NULL,
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
diff --git a/internal/handlers/ai.go b/internal/handlers/ai.go
index 884c1ef52..a8deeef42 100644
--- a/internal/handlers/ai.go
+++ b/internal/handlers/ai.go
@@ -35,6 +35,9 @@ type AIHandler struct {
commentService *services.CommentService
approvalService *services.ApprovalService
chatLLMs chatLLMResolver
+ // llmUsage meters each chat turn's token/cost against its own agent_runs
+ // row, so the model's identity and the turn's spend outlive the response.
+ llmUsage *repository.LLMUsageRepository
}
// NewAIHandler creates a new AI handler.
@@ -62,6 +65,7 @@ func NewAIHandler(
conversations: repository.NewAgentConversationRepository(db),
agentBindings: repository.NewWorkspaceAgentBindingRepository(db),
chatLLMs: llmManager,
+ llmUsage: repository.NewLLMUsageRepository(db),
}
}
diff --git a/internal/handlers/ai_dependencies.go b/internal/handlers/ai_dependencies.go
index a07bd7e96..d8825770a 100644
--- a/internal/handlers/ai_dependencies.go
+++ b/internal/handlers/ai_dependencies.go
@@ -651,6 +651,14 @@ type ChatResponse struct {
// not a claim that the answer is wrong. ReviewReasons explains why.
NeedsReview bool `json:"needs_review,omitempty"`
ReviewReasons []string `json:"review_reasons,omitempty"`
+ // Model is the model id that actually answered, which is not always the one
+ // the connection currently points at. Empty for the env-var fallback client,
+ // which has no configured model to name.
+ Model string `json:"model,omitempty"`
+ // Usage is the metered token/cost total for this turn. Nil when metering
+ // failed or nothing was recorded, so the UI omits the meter rather than
+ // showing a confident zero.
+ Usage *repository.RunUsageTotals `json:"usage,omitempty"`
}
// reviewVerdictForToolCalls computes the recovery-aware review flag for a chat
@@ -807,7 +815,7 @@ func (h *AIHandler) Chat(w http.ResponseWriter, r *http.Request) {
history = append(history, llm.Message{Role: message.Role, Content: content})
}
- llmClient, err := mode.resolveLLM(h.chatLLMs, req.ConnectionID)
+ llmClient, llmDescriptor, err := mode.resolveLLM(h.chatLLMs, req.ConnectionID)
if err != nil {
_ = runRepo.Finalize(r.Context(), begun.RunID, models.AgentRunStatusFailed,
"Configured LLM is unavailable", time.Now().UTC())
@@ -837,6 +845,7 @@ func (h *AIHandler) Chat(w http.ResponseWriter, r *http.Request) {
Username: mode.actingName,
Timezone: actingTimezone,
Source: mode.source,
+ RunID: begun.RunID,
AccessibleWorkspaceIDs: mode.accessibleWorkspaceIDs,
AuditDetails: map[string]any{
"agent_session_id": session.ID,
@@ -914,12 +923,30 @@ func (h *AIHandler) Chat(w http.ResponseWriter, r *http.Request) {
_ = runRepo.AppendEvent(context.Background(), begun.RunID, "tool",
marshalChatMetadata(map[string]any{"name": call.Name, "status": status}))
}
+ // Meter the turn against its own agent_runs row so the model's identity and
+ // spend survive a reload. Best-effort: the answer is already produced, and a
+ // bookkeeping miss must not fail a turn the user is waiting on.
+ usage, meterErr := services.RecordLLMCall(r.Context(), h.llmUsage, begun.RunID, llmDescriptor, services.MeteredCall{
+ Usage: result.Usage,
+ Images: result.Images,
+ Calls: result.Calls,
+ })
+ if meterErr != nil {
+ slog.WarnContext(context.Background(), "meter ai chat turn",
+ slog.Int("run_id", begun.RunID),
+ slog.String("model", llmDescriptor.Model),
+ slog.Any("error", meterErr),
+ )
+ }
+
metadata := marshalChatMetadata(map[string]any{
"stop_reason": result.StopReason,
"iterations": result.Iterations,
"tool_summaries": toolSummaries,
"needs_review": verdict.Flagged,
"review_reasons": verdict.Reasons,
+ "model": llmDescriptor.Model,
+ "usage": usage,
})
assistantMessageID, err := h.conversations.CompleteTurn(
context.Background(), session.ID, begun.RunID, mode.actingUserID,
@@ -945,9 +972,21 @@ func (h *AIHandler) Chat(w http.ResponseWriter, r *http.Request) {
StopReason: string(result.StopReason),
NeedsReview: verdict.Flagged,
ReviewReasons: verdict.Reasons,
+ Model: llmDescriptor.Model,
+ Usage: chatUsageOrNil(usage, meterErr),
})
}
+// chatUsageOrNil reports the metered totals, or nil when metering failed or
+// nothing was recorded. A nil Usage tells the UI to omit the meter rather than
+// render a confident zero.
+func chatUsageOrNil(usage repository.RunUsageTotals, err error) *repository.RunUsageTotals {
+ if err != nil || usage.Calls == 0 {
+ return nil
+ }
+ return &usage
+}
+
var errChatPermissionDenied = errors.New("agent chat permission denied")
type chatExecutionMode struct {
@@ -971,13 +1010,21 @@ type chatExecutionMode struct {
type chatLLMResolver interface {
Resolve(connectionID int) (llm.Client, error)
ResolveForFeatureWithOverride(featureKey string, userOverrideConnectionID int) (llm.Client, error)
+ // ResolveWithIdentity / ResolveForFeatureWithIdentity return the descriptor
+ // for the connection the matching Resolve would have picked. The chat needs
+ // the model name and catalog pricing to report and meter the turn, and it
+ // must come from the same resolution the client did — resolving twice could
+ // straddle an admin changing the default connection and attribute one
+ // model's cost to another.
+ ResolveWithIdentity(connectionID int) (llm.Client, llm.ConnectionDescriptor, error)
+ ResolveForFeatureWithIdentity(featureKey string, userOverrideConnectionID int) (llm.Client, llm.ConnectionDescriptor, error)
}
-func (m chatExecutionMode) resolveLLM(manager chatLLMResolver, overrideID int) (llm.Client, error) {
+func (m chatExecutionMode) resolveLLM(manager chatLLMResolver, overrideID int) (llm.Client, llm.ConnectionDescriptor, error) {
if m.standard {
- return manager.Resolve(m.llmConnectionID)
+ return manager.ResolveWithIdentity(m.llmConnectionID)
}
- return manager.ResolveForFeatureWithOverride("ai_chat", overrideID)
+ return manager.ResolveForFeatureWithIdentity("ai_chat", overrideID)
}
func (h *AIHandler) resolveChatSession(ctx context.Context, userID, requestedSessionID int) (*models.AgentSession, error) {
diff --git a/internal/handlers/runner_broker.go b/internal/handlers/runner_broker.go
index fa9ec815c..c9d207240 100644
--- a/internal/handlers/runner_broker.go
+++ b/internal/handlers/runner_broker.go
@@ -234,7 +234,7 @@ func (h *RunnerBrokerHandler) ProxyLLM(w http.ResponseWriter, r *http.Request) {
response.Choices[i].Message.ProviderBinding = binding
}
}
- h.persistLLMUsage(runID, cfg, request, response.Usage)
+ h.persistLLMUsage(r.Context(), runID, cfg, request, response.Usage)
w.Header().Set("Cache-Control", "no-store")
respondJSON(w, http.StatusOK, response)
}
@@ -244,58 +244,27 @@ func llmBindingFingerprint(connectionID int, providerType, model string) string
return fmt.Sprintf("sha256:%x", sum[:])
}
-func (h *RunnerBrokerHandler) persistLLMUsage(runID int, cfg *llm.ConnectionRuntimeConfig, request llm.CompletionRequest, usage llm.Usage) {
+func (h *RunnerBrokerHandler) persistLLMUsage(ctx context.Context, runID int, cfg *llm.ConnectionRuntimeConfig, request llm.CompletionRequest, usage llm.Usage) {
if h.usage == nil {
return
}
- record := repository.LLMUsageRecord{
- RunID: runID, Model: cfg.Model, PromptTokens: usage.PromptTokens,
- CompletionTokens: usage.CompletionTokens, TotalTokens: usage.TotalTokens,
- CacheReadTokens: usage.CacheReadTokens, CacheWriteTokens: usage.CacheWriteTokens,
- ReasoningTokens: usage.ReasoningTokens,
- }
- pricing := h.llmConns.ModelPricing(llm.ProviderType(cfg.ProviderType), cfg.Model)
- switch {
- case usage.ProviderCostUSD != nil:
- // The provider billed a number; it beats any rate we could apply.
- record.CostUSD = usage.ProviderCostUSD
- record.CostSource = "provider"
- case pricing != nil && pricing.CanPriceUsage(usage):
- cost := pricing.CostUSD(usage, completionRequestImageCount(request))
- record.CostUSD = &cost
- record.CostSource = "computed"
- case pricing != nil:
- // Rates exist but not for every class this call actually used. Pricing
- // it anyway would bill a cache write at the base input rate, so the row
- // stays costless — but it is recorded as unpriced rather than left
- // indistinguishable from a model with no configured rates at all.
- record.CostSource = "unpriced"
- slog.Warn("llm usage not priced: model pricing is missing a rate for a token class this call used",
- slog.Int("run_id", runID),
- slog.String("model", cfg.Model),
- slog.Int("cache_read_tokens", usage.CacheReadTokens),
- slog.Int("cache_write_tokens", usage.CacheWriteTokens),
- )
- }
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- if err := h.usage.Insert(ctx, record); err != nil {
+ // The descriptor carries only non-secret identity plus catalog pricing; the
+ // client holding the decrypted key stays inside the manager.
+ descriptor := llm.ConnectionDescriptor{
+ ProviderType: llm.ProviderType(cfg.ProviderType),
+ Model: cfg.Model,
+ Pricing: h.llmConns.ModelPricing(llm.ProviderType(cfg.ProviderType), cfg.Model),
+ }
+ _, err := services.RecordLLMCall(ctx, h.usage, runID, descriptor, services.MeteredCall{
+ Usage: usage,
+ Images: llm.CompletionRequestImageCount(request),
+ Calls: 1,
+ })
+ if err != nil {
slog.Warn("persist llm usage", slog.Int("run_id", runID), slog.Any("error", err))
}
}
-func completionRequestImageCount(request llm.CompletionRequest) int {
- count := 0
- for _, message := range request.Messages {
- for _, attachment := range message.Attachments {
- if strings.HasPrefix(strings.ToLower(attachment.MimeType), "image/") {
- count++
- }
- }
- }
- return count
-}
-
// allowedGitProxyPath reports whether the {gitpath...} tail is one of the three
// endpoints a git smart-HTTP client uses. Anything else (notably a "../"
// traversal tail) is rejected so the proxied upstream path cannot escape the
diff --git a/internal/itemevents/itemevents.go b/internal/itemevents/itemevents.go
index 5bb75f77b..b6d0893bf 100644
--- a/internal/itemevents/itemevents.go
+++ b/internal/itemevents/itemevents.go
@@ -43,6 +43,14 @@ type Metadata struct {
CorrelationID string
CausationEventKey string
Automation *AutomationContext
+ // AgentRunID is the agent_runs row that produced this write, when an agent
+ // made it. It is the only precise link from an agent's effect back to the
+ // turn that caused it — a history row stamped with just "an agent did this"
+ // cannot say which turn, and therefore cannot report that turn's model or
+ // cost without guessing from timestamps. Adapters set it; the item-history
+ // feed persists it. newEvent surfaces it as SourceRef so the durable event
+ // store carries the same link.
+ AgentRunID int
}
// AutomationContext preserves action cascade information across restarts.
@@ -706,6 +714,14 @@ func newEvent(eventType string, workspaceID, itemID int, metadata Metadata, payl
if metadata.SourceKind == "" {
metadata.SourceKind = "application"
}
+ // Surface the agent run as the event's source reference so the durable
+ // stream carries the same turn-level link the history feed persists. An
+ // automation's own SourceRef (the triggering application) wins: it
+ // describes *why* the write happened, which is the more specific fact.
+ sourceRef := metadata.SourceRef
+ if sourceRef == "" && metadata.AgentRunID > 0 {
+ sourceRef = "agent_run:" + strconv.Itoa(metadata.AgentRunID)
+ }
workspace := workspaceID
return events.NewEvent{
WorkspaceID: &workspace,
@@ -717,7 +733,7 @@ func newEvent(eventType string, workspaceID, itemID int, metadata Metadata, payl
ActorKind: metadata.ActorKind,
ActorRef: metadata.ActorRef,
SourceKind: metadata.SourceKind,
- SourceRef: metadata.SourceRef,
+ SourceRef: sourceRef,
CorrelationID: metadata.CorrelationID,
CausationEventKey: metadata.CausationEventKey,
Payload: encoded,
diff --git a/internal/llm/agent.go b/internal/llm/agent.go
index d49ee27d0..efb94f738 100644
--- a/internal/llm/agent.go
+++ b/internal/llm/agent.go
@@ -57,6 +57,13 @@ type AgentResult struct {
MaxIter int `json:"max_iterations"`
StopReason StopReason `json:"stop_reason"`
Usage Usage `json:"usage"`
+ // Calls is the number of provider round-trips the loop actually made, and
+ // Images the total image parts sent across them. Neither is derivable from
+ // Usage, but both change the price: a flat per-request rate is charged once
+ // per call and images carry their own per-part rate. Callers that price the
+ // run need them to avoid under-reporting the spend.
+ Calls int `json:"calls"`
+ Images int `json:"images"`
}
// RunAgent runs an agentic loop: sends the user message to the LLM with tools,
@@ -83,6 +90,7 @@ func RunAgent(ctx context.Context, client Client, cfg AgentConfig, userMessage s
var allToolCalls []ToolCallRecord
var totalUsage Usage
+ var totalImages int
terminalResults := map[string]string{}
forceFinalWithoutTools := false
@@ -94,13 +102,14 @@ func RunAgent(ctx context.Context, client Client, cfg AgentConfig, userMessage s
toolChoice = nil
}
- resp, err := client.Complete(ctx, CompletionRequest{
+ request := CompletionRequest{
Messages: messages,
Tools: tools,
ToolChoice: toolChoice,
MaxTokens: cfg.MaxTokens,
Temperature: cfg.Temperature,
- })
+ }
+ resp, err := client.Complete(ctx, request)
if err != nil {
return nil, fmt.Errorf("LLM request failed (iteration %d): %w", i+1, err)
}
@@ -111,6 +120,7 @@ func RunAgent(ctx context.Context, client Client, cfg AgentConfig, userMessage s
totalUsage.CacheReadTokens += resp.Usage.CacheReadTokens
totalUsage.CacheWriteTokens += resp.Usage.CacheWriteTokens
totalUsage.ReasoningTokens += resp.Usage.ReasoningTokens
+ totalImages += CompletionRequestImageCount(request)
if resp.Usage.ProviderCostUSD != nil {
runCost := *resp.Usage.ProviderCostUSD
if totalUsage.ProviderCostUSD != nil {
@@ -141,6 +151,8 @@ func RunAgent(ctx context.Context, client Client, cfg AgentConfig, userMessage s
MaxIter: maxIter,
StopReason: StopReasonDone,
Usage: totalUsage,
+ Calls: i + 1,
+ Images: totalImages,
}, nil
}
@@ -241,6 +253,8 @@ func RunAgent(ctx context.Context, client Client, cfg AgentConfig, userMessage s
MaxIter: maxIter,
StopReason: StopReasonDone,
Usage: totalUsage,
+ Calls: maxIter,
+ Images: totalImages,
}, nil
}
@@ -260,6 +274,8 @@ func RunAgent(ctx context.Context, client Client, cfg AgentConfig, userMessage s
MaxIter: maxIter,
StopReason: StopReasonMaxIterations,
Usage: totalUsage,
+ Calls: maxIter,
+ Images: totalImages,
}, nil
}
diff --git a/internal/llm/manager.go b/internal/llm/manager.go
index 95965682d..baa902318 100644
--- a/internal/llm/manager.go
+++ b/internal/llm/manager.go
@@ -200,6 +200,49 @@ func (m *ConnectionManager) resolve(connectionID int) (*resolvedConnection, erro
}, nil
}
+// ConnectionDescriptor is the non-secret identity of a resolved LLM connection:
+// enough for a caller to report which model answered a request and to price the
+// usage it produced, without holding a client or touching the API key.
+type ConnectionDescriptor struct {
+ ConnectionID int
+ ProviderType ProviderType
+ Model string
+ // UsedFallback marks the env-var client, which has no database row — its
+ // provider and model are genuinely unknown, so callers must not guess them.
+ UsedFallback bool
+ // Pricing is the catalog's USD rates for Model, or nil when the catalog
+ // carries none. A nil Pricing means cost stays unknown, never estimated.
+ Pricing *Pricing
+}
+
+// ResolveWithIdentity returns a Client for the given connection ID together
+// with the descriptor for the same resolution. Callers that need to report or
+// price a call use this rather than resolving a second time: a second resolve
+// could land on a different connection if the admin changed the default
+// mid-request, which would attribute one model's cost to another.
+func (m *ConnectionManager) ResolveWithIdentity(connectionID int) (Client, ConnectionDescriptor, error) {
+ rc, err := m.resolve(connectionID)
+ if err != nil {
+ return nil, ConnectionDescriptor{}, err
+ }
+ return rc.client, m.descriptorFor(rc), nil
+}
+
+// descriptorFor projects a resolved connection onto its public identity,
+// attaching catalog pricing when one exists for the model.
+func (m *ConnectionManager) descriptorFor(rc *resolvedConnection) ConnectionDescriptor {
+ desc := ConnectionDescriptor{
+ ConnectionID: rc.connectionID,
+ ProviderType: rc.providerType,
+ Model: rc.model,
+ UsedFallback: rc.usedFallback,
+ }
+ if desc.Model != "" {
+ desc.Pricing = m.ModelPricing(rc.providerType, rc.model)
+ }
+ return desc
+}
+
// ConnectionRuntimeConfig contains the decrypted runtime fields needed to
// resolve the admin-selected provider for a coding-agent run (the model id for
// the agent container; the key + base URL stay server-side in the llm-proxy).
@@ -784,6 +827,23 @@ func (m *ConnectionManager) ResolveForFeatureWithOverride(featureKey string, use
return m.Resolve(decision.connectionID)
}
+// ResolveForFeatureWithIdentity applies the same per-feature policy as
+// ResolveForFeatureWithOverride and returns the descriptor for the connection
+// that policy selected. Both must stay on decideFeatureResolution: a chat that
+// reports the wrong model because it re-derived the policy is worse than one
+// that reports nothing.
+func (m *ConnectionManager) ResolveForFeatureWithIdentity(featureKey string, userOverrideConnectionID int) (Client, ConnectionDescriptor, error) {
+ cfg, err := LoadAIFeaturesConfig(m.db)
+ if err != nil {
+ return nil, ConnectionDescriptor{}, err
+ }
+ decision := decideFeatureResolution(cfg[featureKey], userOverrideConnectionID)
+ if decision.disabled {
+ return nil, ConnectionDescriptor{}, ErrFeatureDisabled
+ }
+ return m.ResolveWithIdentity(decision.connectionID)
+}
+
// featureResolution is the outcome of applying the feature policy: either the
// feature is disabled, or we know which connection_id to pass to Resolve.
type featureResolution struct {
diff --git a/internal/llm/providers.go b/internal/llm/providers.go
index 2401762b0..eeb85e408 100644
--- a/internal/llm/providers.go
+++ b/internal/llm/providers.go
@@ -55,15 +55,27 @@ type Pricing struct {
// Reasoning tokens are already included in CompletionTokens and are reported
// separately for visibility, so charging them again would double bill them.
func (p *Pricing) CostUSD(usage Usage, images int) float64 {
+ return p.CostUSDForCalls(usage, images, 1)
+}
+
+// CostUSDForCalls extends CostUSD to an agentic turn that made several provider
+// round-trips. Per-token and per-image rates scale with the aggregated usage
+// and image count, but the flat per-request rate applies once per call — an
+// agent loop that took six steps paid the request fee six times, and pricing it
+// once would under-report the spend.
+func (p *Pricing) CostUSDForCalls(usage Usage, images, calls int) float64 {
if p == nil {
return 0
}
+ if calls < 1 {
+ calls = 1
+ }
return float64(usage.PromptTokens)*p.PromptUSD +
float64(usage.CacheReadTokens)*p.CacheReadUSD +
float64(usage.CacheWriteTokens)*p.CacheWriteUSD +
float64(usage.CompletionTokens)*p.CompletionUSD +
float64(images)*p.ImageUSD +
- p.RequestUSD
+ float64(calls)*p.RequestUSD
}
// HasCompleteCacheRates reports whether cache placement can be enabled without
diff --git a/internal/llm/types.go b/internal/llm/types.go
index 830d507c8..b8e88ae7d 100644
--- a/internal/llm/types.go
+++ b/internal/llm/types.go
@@ -1,6 +1,9 @@
package llm
-import "encoding/json"
+import (
+ "encoding/json"
+ "strings"
+)
// unmarshalExtras parses data into a raw map and removes the specified known keys,
// returning only the unknown fields.
@@ -142,6 +145,21 @@ type CompletionRequest struct {
// CompletionRequest.
type ChatCompletionRequest = CompletionRequest
+// CompletionRequestImageCount reports how many image parts a request sends.
+// Images carry their own per-part rate, so metering needs this alongside the
+// normalized token usage to price a call correctly.
+func CompletionRequestImageCount(request CompletionRequest) int {
+ count := 0
+ for _, message := range request.Messages {
+ for _, attachment := range message.Attachments {
+ if strings.HasPrefix(strings.ToLower(attachment.MimeType), "image/") {
+ count++
+ }
+ }
+ }
+ return count
+}
+
// CompletionResponse is Windshift's normalized generation result.
type CompletionResponse struct {
ID string `json:"id"`
diff --git a/internal/models/agent_conversation.go b/internal/models/agent_conversation.go
index 3ca42892f..d4aeee091 100644
--- a/internal/models/agent_conversation.go
+++ b/internal/models/agent_conversation.go
@@ -32,13 +32,42 @@ type AgentSessionParticipant struct {
}
type AgentMessage struct {
- ID int `json:"id"`
- SessionID int `json:"session_id"`
- Role string `json:"role"`
- AuthorUserID *int `json:"author_user_id,omitempty"`
- AgentRunID *int `json:"agent_run_id,omitempty"`
- Content string `json:"content"`
- ContextJSON string `json:"-"`
- MetadataJSON string `json:"-"`
- CreatedAt time.Time `json:"created_at"`
+ ID int `json:"id"`
+ SessionID int `json:"session_id"`
+ Role string `json:"role"`
+ AuthorUserID *int `json:"author_user_id,omitempty"`
+ AgentRunID *int `json:"agent_run_id,omitempty"`
+ Content string `json:"content"`
+ ContextJSON string `json:"-"`
+ MetadataJSON string `json:"-"`
+ // Model is the model that produced an assistant turn, recovered from
+ // MetadataJSON. Empty for user turns and for turns the env-var fallback
+ // client served, which has no configured model to name.
+ Model string `json:"model,omitempty"`
+ // Usage is the metered token/cost total for the turn. Nil when the turn was
+ // not metered, so a client can omit the reading rather than render a
+ // confident zero.
+ Usage *RunUsageTotals `json:"usage,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// RunUsageTotals is the token + cost spend for one metered turn. CostUSD is nil
+// when no catalog rate or provider figure covered the call; tokens are always
+// reported, because tokens are metered even when their price is unknown.
+//
+// Every field is always present on the wire — no omitempty — so a client can
+// rely on required-ness instead of guarding each read, and so this stays
+// shape-compatible with the agent-run usage response serving the same numbers.
+type RunUsageTotals struct {
+ // Model is the model that served the run. Empty when no metered call named
+ // one; omitted from responses in that case.
+ Model string `json:"model,omitempty"`
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ TotalTokens int `json:"total_tokens"`
+ CacheReadTokens int `json:"cache_read_tokens"`
+ CacheWriteTokens int `json:"cache_write_tokens"`
+ ReasoningTokens int `json:"reasoning_tokens"`
+ CostUSD *float64 `json:"cost_usd"` // nil when no call carried a known cost
+ Calls int `json:"calls"`
}
diff --git a/internal/models/item.go b/internal/models/item.go
index 6dbaa9f29..43856950c 100644
--- a/internal/models/item.go
+++ b/internal/models/item.go
@@ -171,6 +171,16 @@ type ItemHistory struct {
PortalCustomerEmail string `json:"portal_customer_email,omitempty"`
// AgentOwnerName is permission-filtered by the item-history handler.
AgentOwnerName string `json:"agent_owner_name,omitempty"`
+ // Source names the acting surface when an agent made the change on the
+ // user's behalf ("ai_chat", "mcp", "standard_agent"). Empty for a direct
+ // write. The UI marks these rows so a viewer can tell a change the user
+ // typed from one the AI made for them under their name.
+ Source string `json:"source,omitempty"`
+ // AgentRunID links an agent-authored change back to the turn that caused
+ // it. It is a link, not the telemetry itself: the history response stays a
+ // single-table read, and the model/tokens/cost behind the turn are fetched
+ // on demand from the run's usage endpoint.
+ AgentRunID *int `json:"agent_run_id,omitempty"`
// Resolved values for display (when value is an ID)
ResolvedOldValue *string `json:"resolved_old_value,omitempty"` // Human-readable version of old_value
ResolvedNewValue *string `json:"resolved_new_value,omitempty"` // Human-readable version of new_value
diff --git a/internal/repository/agent_conversation_repository.go b/internal/repository/agent_conversation_repository.go
index 76c438f7c..6a739ad6a 100644
--- a/internal/repository/agent_conversation_repository.go
+++ b/internal/repository/agent_conversation_repository.go
@@ -307,6 +307,7 @@ func (r *AgentConversationRepository) listMessages(ctx context.Context, sessionI
}
message.ContextJSON = contextJSON.String
message.MetadataJSON = metadataJSON.String
+ applyAgentMessageTelemetry(&message)
reversed = append(reversed, message)
}
if err := rows.Err(); err != nil {
@@ -319,6 +320,32 @@ func (r *AgentConversationRepository) listMessages(ctx context.Context, sessionI
return out, nil
}
+// agentMessageTelemetry is the allow-listed projection of a stored message's
+// metadata_json that is safe to serve. agent_messages.metadata_json is json:"-"
+// on purpose: it is an internal scratchpad whose other keys (stop reason, tool
+// summaries, review reasons) are an implementation detail of the run loop.
+// Telemetry is different — a user asking "what did that cost, and which model
+// answered?" needs it after a reload, and it carries no prompt or response text.
+type agentMessageTelemetry struct {
+ Model string `json:"model"`
+ Usage *models.RunUsageTotals `json:"usage"`
+}
+
+// applyAgentMessageTelemetry lifts the model and usage out of a message's stored
+// metadata. A malformed or absent blob leaves both fields zero: telemetry is
+// supplementary, so a corrupt value must not cost the caller the message.
+func applyAgentMessageTelemetry(message *models.AgentMessage) {
+ if message.MetadataJSON == "" {
+ return
+ }
+ var telemetry agentMessageTelemetry
+ if err := json.Unmarshal([]byte(message.MetadataJSON), &telemetry); err != nil {
+ return
+ }
+ message.Model = telemetry.Model
+ message.Usage = telemetry.Usage
+}
+
type BeginAgentTurnInput struct {
SessionID int
SenderUserID int
diff --git a/internal/repository/diagram_repository.go b/internal/repository/diagram_repository.go
index d2e66e23e..1a7842521 100644
--- a/internal/repository/diagram_repository.go
+++ b/internal/repository/diagram_repository.go
@@ -158,12 +158,15 @@ func (r *DiagramRepository) GetNameAndItemID(id int) (name string, itemID int, e
}
// RecordHistory appends an item_history row for a diagram-related action.
-// userID is required (the handler skips the call entirely when nil).
-func (r *DiagramRepository) RecordHistory(itemID, userID int, action string, oldValue *string, newValue string) error {
+// userID is required (the handler skips the call entirely when nil). source
+// names the acting surface when an agent did this on a user's behalf, and is
+// empty for a direct write. agentRunID links the row to the agent run that made
+// it, or nil.
+func (r *DiagramRepository) RecordHistory(itemID, userID int, action string, oldValue *string, newValue, source string, agentRunID *int) error {
if _, err := r.db.ExecWrite(
- `INSERT INTO item_history (item_id, user_id, field_name, old_value, new_value, changed_at)
- VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
- itemID, userID, action, oldValue, newValue,
+ `INSERT INTO item_history (item_id, user_id, field_name, old_value, new_value, changed_at, source, agent_run_id)
+ VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?, ?)`,
+ itemID, userID, action, oldValue, newValue, nullStringArg(source), nullIntArg(agentRunID),
); err != nil {
return fmt.Errorf("record diagram history for item %d: %w", itemID, err)
}
diff --git a/internal/repository/item_history.go b/internal/repository/item_history.go
index a45479f89..d5c8542ff 100644
--- a/internal/repository/item_history.go
+++ b/internal/repository/item_history.go
@@ -47,6 +47,17 @@ type HistoryEntry struct {
UserEmail string
PortalCustomerName string
PortalCustomerEmail string
+ // Source names the surface that produced the change when an agent acted on
+ // a human's behalf (aitools.SourceAIChat, SourceMCP, SourceStandardAgent).
+ // Empty for a direct cookie-auth write, which needs no explanation. The
+ // column answers "did the AI do this?", so it is deliberately sparse
+ // rather than re-encoding provenance every UI click already implies.
+ Source string
+ // AgentRunID is the agent_runs row that produced this change, when an agent
+ // made it. It is what lets the history feed report that turn's model and
+ // cost instead of leaving the change unattributable. Always nil for a
+ // direct write.
+ AgentRunID *int
}
// RecordHistory records a history entry for an item change
@@ -76,9 +87,10 @@ func (r *ItemRepository) RecordHistory(w HistoryWriter, entry HistoryEntry) erro
return fmt.Errorf("unknown item history actor kind %q", actorKind)
}
_, err := w.ExecWrite(`
- INSERT INTO item_history (item_id, user_id, actor_kind, actor_portal_customer_id, field_name, old_value, new_value, changed_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- `, entry.ItemID, userID, actorKind, actorCustomerID, entry.FieldName, oldValue, entry.NewValue, entry.ChangedAt)
+ INSERT INTO item_history (item_id, user_id, actor_kind, actor_portal_customer_id, field_name, old_value, new_value, changed_at, source, agent_run_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `, entry.ItemID, userID, actorKind, actorCustomerID, entry.FieldName, oldValue, entry.NewValue, entry.ChangedAt,
+ nullStringArg(entry.Source), nullIntArg(entry.AgentRunID))
if err != nil {
return fmt.Errorf("failed to record history: %w", err)
}
diff --git a/internal/repository/item_repository.go b/internal/repository/item_repository.go
index 4826c5683..7f6834495 100644
--- a/internal/repository/item_repository.go
+++ b/internal/repository/item_repository.go
@@ -1920,6 +1920,11 @@ func (r *ItemRepository) ClearRelatedWorkItem(itemID int) error {
// Portal-customer actors resolve to their customer name; system rows have no
// user and display through the empty-name fallback the frontend applies.
func (r *ItemRepository) GetHistoryWithApprovals(itemID int, includeAgentOwner bool) ([]models.ItemHistory, error) {
+ // The metered model/tokens/cost for an agent-written change are deliberately
+ // NOT joined here: aggregating llm_usage is not scoped to the item, so doing
+ // it inline would scan the whole metering table on every history load, which
+ // is a hot path. The response carries only agent_run_id; the client fetches
+ // that run's usage on demand from the indexed per-run endpoint.
query := `
SELECT
ih.id, ih.item_id, ih.user_id, ih.changed_at, ih.field_name, ih.old_value, ih.new_value,
@@ -1929,7 +1934,9 @@ func (r *ItemRepository) GetHistoryWithApprovals(itemID int, includeAgentOwner b
COALESCE(NULLIF(TRIM(COALESCE(owner.first_name, '') || ' ' || COALESCE(owner.last_name, '')), ''), owner.username, '') AS agent_owner_name,
ih.actor_kind,
ih.actor_portal_customer_id,
- COALESCE(NULLIF(TRIM(pc.name), ''), pc.email, '') AS portal_customer_name
+ COALESCE(NULLIF(TRIM(pc.name), ''), pc.email, '') AS portal_customer_name,
+ COALESCE(ih.source, '') AS source,
+ ih.agent_run_id
FROM item_history ih
LEFT JOIN users u ON ih.user_id = u.id
LEFT JOIN users owner ON owner.id = u.agent_owner_user_id
@@ -1950,7 +1957,9 @@ func (r *ItemRepository) GetHistoryWithApprovals(itemID int, includeAgentOwner b
COALESCE(NULLIF(TRIM(COALESCE(owner.first_name, '') || ' ' || COALESCE(owner.last_name, '')), ''), owner.username, '') AS agent_owner_name,
CASE WHEN d.actor_portal_customer_id IS NOT NULL THEN 'portal_customer' ELSE 'user' END AS actor_kind,
d.actor_portal_customer_id,
- COALESCE(NULLIF(TRIM(pc.name), ''), pc.email, '') AS portal_customer_name
+ COALESCE(NULLIF(TRIM(pc.name), ''), pc.email, '') AS portal_customer_name,
+ '' AS source,
+ NULL AS agent_run_id
FROM approval_decisions d
JOIN approval_requests ar ON ar.id = d.approval_request_id
LEFT JOIN users u ON u.id = d.actor_user_id
@@ -1969,9 +1978,9 @@ func (r *ItemRepository) GetHistoryWithApprovals(itemID int, includeAgentOwner b
history := []models.ItemHistory{}
for rows.Next() {
var entry models.ItemHistory
- var userID, portalCustomerID sql.NullInt64
+ var userID, portalCustomerID, runID sql.NullInt64
var actorKind sql.NullString
- if err := rows.Scan(&entry.ID, &entry.ItemID, &userID, &entry.ChangedAt, &entry.FieldName, &entry.OldValue, &entry.NewValue, &entry.UserName, &entry.UserEmail, &entry.IsAgent, &entry.AgentOwnerName, &actorKind, &portalCustomerID, &entry.PortalCustomerName); err != nil {
+ if err := rows.Scan(&entry.ID, &entry.ItemID, &userID, &entry.ChangedAt, &entry.FieldName, &entry.OldValue, &entry.NewValue, &entry.UserName, &entry.UserEmail, &entry.IsAgent, &entry.AgentOwnerName, &actorKind, &portalCustomerID, &entry.PortalCustomerName, &entry.Source, &runID); err != nil {
return nil, err
}
if userID.Valid {
@@ -1982,6 +1991,10 @@ func (r *ItemRepository) GetHistoryWithApprovals(itemID int, includeAgentOwner b
id := int(portalCustomerID.Int64)
entry.PortalCustomerID = &id
}
+ if runID.Valid {
+ v := int(runID.Int64)
+ entry.AgentRunID = &v
+ }
if !includeAgentOwner {
entry.AgentOwnerName = ""
}
diff --git a/internal/repository/llm_usage_repository.go b/internal/repository/llm_usage_repository.go
index 69d230dc4..817a9b4fd 100644
--- a/internal/repository/llm_usage_repository.go
+++ b/internal/repository/llm_usage_repository.go
@@ -6,10 +6,13 @@ import (
"fmt"
"windshift/internal/database"
+ "windshift/internal/models"
)
-// LLMUsageRepository persists per-call LLM token usage + cost, metered at the
-// broker. One row per chat-completion; per-run totals are aggregated on read.
+// LLMUsageRepository persists metered LLM token usage + cost. The broker writes
+// one row per provider call, while a chat turn writes one aggregated row whose
+// Calls records how many round-trips it took; per-run totals are aggregated on
+// read.
type LLMUsageRepository struct {
db database.Database
}
@@ -19,10 +22,13 @@ func NewLLMUsageRepository(db database.Database) *LLMUsageRepository {
return &LLMUsageRepository{db: db}
}
-// LLMUsageRecord is one metered chat-completion call. CostUSD is nil when the
-// provider catalog carries no pricing (tokens metered, cost unknown).
+// LLMUsageRecord is one metered unit of provider work. CostUSD is nil when the
+// provider catalog carries no pricing (tokens metered, cost unknown). Calls is
+// how many provider round-trips the row accounts for; it defaults to one so a
+// broker's per-call rows and a chat turn's aggregated row aggregate alike.
type LLMUsageRecord struct {
RunID int
+ Calls int
Model string
PromptTokens int
CompletionTokens int
@@ -39,13 +45,17 @@ type LLMUsageRecord struct {
// Insert records one metered call.
func (r *LLMUsageRepository) Insert(ctx context.Context, rec LLMUsageRecord) error {
+ calls := rec.Calls
+ if calls < 1 {
+ calls = 1
+ }
_, err := r.db.ExecWriteContext(ctx, `
INSERT INTO llm_usage
- (run_id, model, prompt_tokens, completion_tokens, total_tokens,
+ (run_id, calls, model, prompt_tokens, completion_tokens, total_tokens,
cache_read_tokens, cache_write_tokens, reasoning_tokens, cost_usd, cost_source)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
- rec.RunID, rec.Model, rec.PromptTokens, rec.CompletionTokens, rec.TotalTokens,
+ rec.RunID, calls, rec.Model, rec.PromptTokens, rec.CompletionTokens, rec.TotalTokens,
rec.CacheReadTokens, rec.CacheWriteTokens, rec.ReasoningTokens,
nullFloatArg(rec.CostUSD), rec.CostSource,
)
@@ -55,26 +65,22 @@ func (r *LLMUsageRepository) Insert(ctx context.Context, rec LLMUsageRecord) err
return nil
}
-// RunUsageTotals is the aggregated token + cost spend for a single run.
-type RunUsageTotals struct {
- PromptTokens int `json:"prompt_tokens"`
- CompletionTokens int `json:"completion_tokens"`
- TotalTokens int `json:"total_tokens"`
- CacheReadTokens int `json:"cache_read_tokens"`
- CacheWriteTokens int `json:"cache_write_tokens"`
- ReasoningTokens int `json:"reasoning_tokens"`
- CostUSD *float64 `json:"cost_usd"` // nil when no call carried a known cost
- Calls int `json:"calls"`
-}
+// RunUsageTotals is the aggregated token + cost spend for a single run. It
+// aliases the model because the same numbers are read here, written by the
+// metering services, and served on agent-message and agent-run responses — one
+// definition, so a field added for one surface cannot be missing on another.
+type RunUsageTotals = models.RunUsageTotals
// TotalsForRun aggregates all metered calls for a run. CostUSD is the sum of
// the calls whose cost was known; it is nil when none were.
func (r *LLMUsageRepository) TotalsForRun(ctx context.Context, runID int) (RunUsageTotals, error) {
var t RunUsageTotals
+ var model sql.NullString
var prompt, completion, total, cacheRead, cacheWrite, reasoning, calls sql.NullInt64
var cost sql.NullFloat64
err := r.db.QueryRowContext(ctx, `
SELECT
+ COALESCE(MIN(model), ''),
COALESCE(SUM(prompt_tokens), 0),
COALESCE(SUM(completion_tokens), 0),
COALESCE(SUM(total_tokens), 0),
@@ -82,12 +88,13 @@ func (r *LLMUsageRepository) TotalsForRun(ctx context.Context, runID int) (RunUs
COALESCE(SUM(cache_write_tokens), 0),
COALESCE(SUM(reasoning_tokens), 0),
SUM(cost_usd),
- COUNT(*)
+ COALESCE(SUM(calls), 0)
FROM llm_usage WHERE run_id = ?
- `, runID).Scan(&prompt, &completion, &total, &cacheRead, &cacheWrite, &reasoning, &cost, &calls)
+ `, runID).Scan(&model, &prompt, &completion, &total, &cacheRead, &cacheWrite, &reasoning, &cost, &calls)
if err != nil {
return t, fmt.Errorf("aggregate llm_usage for run %d: %w", runID, err)
}
+ t.Model = model.String
t.PromptTokens = int(prompt.Int64)
t.CompletionTokens = int(completion.Int64)
t.TotalTokens = int(total.Int64)
diff --git a/internal/services/item_creation.go b/internal/services/item_creation.go
index 2f2b1d885..f4b6705f5 100644
--- a/internal/services/item_creation.go
+++ b/internal/services/item_creation.go
@@ -321,7 +321,7 @@ func (c *itemCreation) recordCreation(tx database.Tx, itemID, itemNumber int) er
}
metadata := itemCreateEventMetadata(c.params, c.createdAt)
if c.params.CreatorID != nil {
- history := creationHistoryEntries(*item, *c.params.CreatorID, metadata.OccurredAt)
+ history := stampHistorySource(creationHistoryEntries(*item, *c.params.CreatorID, metadata.OccurredAt), metadata)
if err := repository.NewItemRepository(c.db).RecordHistoryBatch(tx, history); err != nil {
return fmt.Errorf("record item creation history: %w", err)
}
diff --git a/internal/services/item_diagram_service.go b/internal/services/item_diagram_service.go
index cce6d7632..82b58ae8a 100644
--- a/internal/services/item_diagram_service.go
+++ b/internal/services/item_diagram_service.go
@@ -51,7 +51,7 @@ func (s *ItemDiagramService) Create(itemID int, name, data string, actorID int)
if err != nil {
return nil, err
}
- _ = s.repo.RecordHistory(itemID, actorID, "diagram_created", nil, fmt.Sprintf("diagram:%d:%s", id, name))
+ _ = s.repo.RecordHistory(itemID, actorID, "diagram_created", nil, fmt.Sprintf("diagram:%d:%s", id, name), "", nil)
return s.repo.GetByID(int(id))
}
@@ -71,7 +71,7 @@ func (s *ItemDiagramService) Update(id int, name, data string, actorID int) (*mo
if current.Name != name {
oldName = ¤t.Name
}
- _ = s.repo.RecordHistory(current.ItemID, actorID, "diagram_updated", oldName, fmt.Sprintf("diagram:%d:%s", id, name))
+ _ = s.repo.RecordHistory(current.ItemID, actorID, "diagram_updated", oldName, fmt.Sprintf("diagram:%d:%s", id, name), "", nil)
return s.repo.GetByID(id)
}
@@ -95,7 +95,7 @@ func (s *ItemDiagramService) Delete(id, actorID int) (*models.ItemDiagram, error
if err != nil {
return nil, err
}
- _ = s.repo.RecordHistory(diagram.ItemID, actorID, "diagram_deleted", &diagram.Name, diagram.Name)
+ _ = s.repo.RecordHistory(diagram.ItemID, actorID, "diagram_deleted", &diagram.Name, diagram.Name, "", nil)
if err := s.repo.Delete(id); err != nil {
return nil, err
}
diff --git a/internal/services/item_domain_events.go b/internal/services/item_domain_events.go
index e91cb01e3..ecd3acc06 100644
--- a/internal/services/item_domain_events.go
+++ b/internal/services/item_domain_events.go
@@ -97,6 +97,49 @@ func itemCreateEventMetadata(params ItemCreationParams, occurredAt time.Time) it
return mergeItemEventMetadata(params.EventMetadata, fallback)
}
+// historySourceForAgent reports the surface that produced an item-history row
+// when an agent acted on a human's behalf, and "" otherwise. Only agent-actor
+// writes are stamped: the item_history.source column exists to answer "did the
+// AI do this?" in the history feed, not to re-encode provenance that a direct
+// cookie-auth click already makes obvious.
+//
+// Callers must pass the *merged* metadata (mergeItemEventMetadata) so an adapter
+// that supplies a partial Metadata still resolves its SourceKind.
+func historySourceForAgent(metadata itemevents.Metadata) string {
+ if metadata.ActorKind != "agent" {
+ return ""
+ }
+ return metadata.SourceKind
+}
+
+// historyRunForAgent reports the agent run behind an agent-authored history row,
+// or nil when the surface has no run to point at (MCP) or the write was direct.
+// Without it a history row can say an agent acted but not which turn, and the
+// feed could not report that turn's model or cost.
+func historyRunForAgent(metadata itemevents.Metadata) *int {
+ if metadata.ActorKind != "agent" || metadata.AgentRunID <= 0 {
+ return nil
+ }
+ runID := metadata.AgentRunID
+ return &runID
+}
+
+// stampHistorySource applies the agent provenance to every entry in a batch.
+// History rows are written in one transaction by one actor, so the whole batch
+// shares one source and one originating run.
+func stampHistorySource(history []repository.HistoryEntry, metadata itemevents.Metadata) []repository.HistoryEntry {
+ source := historySourceForAgent(metadata)
+ if source == "" {
+ return history
+ }
+ runID := historyRunForAgent(metadata)
+ for i := range history {
+ history[i].Source = source
+ history[i].AgentRunID = runID
+ }
+ return history
+}
+
func itemHistoryEventChanges(history []repository.HistoryEntry) []itemevents.FieldChange {
changes := make([]itemevents.FieldChange, 0, len(history))
for _, entry := range history {
diff --git a/internal/services/item_update_operation.go b/internal/services/item_update_operation.go
index 31853a99c..831e0dc19 100644
--- a/internal/services/item_update_operation.go
+++ b/internal/services/item_update_operation.go
@@ -157,6 +157,17 @@ func readItemMilestoneIDsForHistory(tx database.Tx, itemID int) ([]int, error) {
return ids, nil
}
+// eventMetadata resolves the merged provenance for this update. Hoisted into a
+// method so the history rows and the domain event are stamped from one value —
+// item_history.source must never disagree with the event's actor_kind/source_kind.
+func (u *itemUpdateOperation) eventMetadata() itemevents.Metadata {
+ metadata := mergeItemEventMetadata(u.req.EventMetadata, itemEventMetadata(u.req.UserID, "application", nil))
+ if metadata.OccurredAt.IsZero() {
+ metadata.OccurredAt = u.now
+ }
+ return metadata
+}
+
func (u *itemUpdateOperation) recordHistoryAndEvent() error {
eventHistory := u.service.compareAndGenerateHistory(u.original, &u.updated, u.req.UserID)
if u.opts.recordHistory {
@@ -174,6 +185,7 @@ func (u *itemUpdateOperation) recordHistoryAndEvent() error {
}
}
if u.opts.recordHistory {
+ stampHistorySource(u.history, u.eventMetadata())
if err := u.service.recordItemHistory(u.tx, u.history); err != nil {
return fmt.Errorf("failed to record history: %w", err)
}
@@ -185,10 +197,7 @@ func (u *itemUpdateOperation) recordHistoryAndEvent() error {
}
func (u *itemUpdateOperation) recordEvent() error {
- metadata := mergeItemEventMetadata(u.req.EventMetadata, itemEventMetadata(u.req.UserID, "application", nil))
- if metadata.OccurredAt.IsZero() {
- metadata.OccurredAt = u.now
- }
+ metadata := u.eventMetadata()
changes := itemevents.Changes(u.original, &u.updated)
if u.hasMilestoneIDs && u.milestonesChanged {
changes = append(changes, itemevents.FieldChange{
diff --git a/internal/services/item_update_service.go b/internal/services/item_update_service.go
index b721b6abd..d9e6ecd5e 100644
--- a/internal/services/item_update_service.go
+++ b/internal/services/item_update_service.go
@@ -271,13 +271,14 @@ func (s *ItemUpdateService) recordMilestoneAddition(
NewValue: joinIntsCSV(newIDs),
ChangedAt: now,
}}
- if err := s.recordItemHistory(tx, history); err != nil {
- return nil, fmt.Errorf("failed to record history: %w", err)
- }
metadata := mergeItemEventMetadata(req.EventMetadata, itemEventMetadata(req.UserID, "application", nil))
if metadata.OccurredAt.IsZero() {
metadata.OccurredAt = now
}
+ stampHistorySource(history, metadata)
+ if err := s.recordItemHistory(tx, history); err != nil {
+ return nil, fmt.Errorf("failed to record history: %w", err)
+ }
if _, err := itemevents.NewRecorder(s.db).Updated(
context.Background(), tx, originalItem, itemHistoryEventChanges(history), metadata,
); err != nil {
diff --git a/internal/services/llm_usage_recorder.go b/internal/services/llm_usage_recorder.go
new file mode 100644
index 000000000..25d630fee
--- /dev/null
+++ b/internal/services/llm_usage_recorder.go
@@ -0,0 +1,106 @@
+package services
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "windshift/internal/llm"
+ "windshift/internal/repository"
+)
+
+// MeteredCall is one metered unit of provider work: the normalized usage a
+// provider reported plus the two dimensions that usage alone cannot express.
+//
+// A single API call needs neither — but an agentic turn is several calls, and
+// both the flat per-request rate and the per-image rate scale with them, so
+// pricing a multi-call turn from Usage alone would under-report the spend.
+type MeteredCall struct {
+ Usage llm.Usage
+ // Images is the total number of image parts sent across Calls.
+ Images int
+ // Calls is the number of provider round-trips. The flat per-request rate
+ // applies once per call.
+ Calls int
+}
+
+// usageInsertTimeout bounds the metering write. Metering is bookkeeping that
+// must never hold up the request it is recording for, and must not inherit a
+// caller's canceled context.
+const usageInsertTimeout = 5 * time.Second
+
+// RecordLLMCall resolves the cost of one metered unit of provider work and
+// persists it to llm_usage under runID, returning the run's totals.
+//
+// Cost resolution lives here, as the single implementation, because getting it
+// wrong is a billing error rather than a cosmetic one:
+//
+// - A provider-reported figure wins outright. It already accounts for
+// discounts and routing we cannot see.
+// - Otherwise catalog rates apply, but only when they cover every class the
+// call actually used. Pricing a cache write at the base input rate, or an
+// image at zero, would silently under-bill, so those rows are recorded
+// unpriced instead.
+// - "unpriced" is recorded explicitly so a row is never indistinguishable
+// from a model the catalog knows nothing about.
+//
+// The returned totals are the authoritative numbers for the run; callers should
+// prefer them over re-deriving a sum.
+func RecordLLMCall(
+ ctx context.Context,
+ repo *repository.LLMUsageRepository,
+ runID int,
+ descriptor llm.ConnectionDescriptor,
+ metered MeteredCall,
+) (repository.RunUsageTotals, error) {
+ if repo == nil {
+ return repository.RunUsageTotals{}, nil
+ }
+ record := repository.LLMUsageRecord{
+ RunID: runID, Calls: metered.Calls, Model: descriptor.Model,
+ PromptTokens: metered.Usage.PromptTokens, CompletionTokens: metered.Usage.CompletionTokens,
+ TotalTokens: metered.Usage.TotalTokens, CacheReadTokens: metered.Usage.CacheReadTokens,
+ CacheWriteTokens: metered.Usage.CacheWriteTokens, ReasoningTokens: metered.Usage.ReasoningTokens,
+ }
+ pricing := descriptor.Pricing
+ switch {
+ case metered.Usage.ProviderCostUSD != nil:
+ record.CostUSD = metered.Usage.ProviderCostUSD
+ record.CostSource = "provider"
+ case canPriceCall(pricing, metered):
+ cost := pricing.CostUSDForCalls(metered.Usage, metered.Images, metered.Calls)
+ record.CostUSD = &cost
+ record.CostSource = "computed"
+ case pricing != nil:
+ record.CostSource = "unpriced"
+ slog.WarnContext(ctx, "llm usage not priced: model pricing is missing a rate for a class this call used",
+ slog.Int("run_id", runID),
+ slog.String("model", descriptor.Model),
+ slog.Int("cache_read_tokens", metered.Usage.CacheReadTokens),
+ slog.Int("cache_write_tokens", metered.Usage.CacheWriteTokens),
+ slog.Int("images", metered.Images),
+ )
+ }
+
+ insertCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), usageInsertTimeout)
+ defer cancel()
+ if err := repo.Insert(insertCtx, record); err != nil {
+ return repository.RunUsageTotals{}, fmt.Errorf("insert llm_usage for run %d: %w", runID, err)
+ }
+ return repo.TotalsForRun(insertCtx, runID)
+}
+
+// canPriceCall reports whether catalog rates cover every class the call
+// actually used. Token classes are CanPriceUsage's job; the image rate is
+// checked here because a call that sent images with no image rate would
+// otherwise price those images at zero and report a confidently wrong total.
+func canPriceCall(pricing *llm.Pricing, metered MeteredCall) bool {
+ if pricing == nil {
+ return false
+ }
+ if !pricing.CanPriceUsage(metered.Usage) {
+ return false
+ }
+ return metered.Images == 0 || pricing.ImageUSD > 0
+}
diff --git a/internal/services/workflow_service.go b/internal/services/workflow_service.go
index 5778998bb..6c11bb5a7 100644
--- a/internal/services/workflow_service.go
+++ b/internal/services/workflow_service.go
@@ -623,12 +623,14 @@ func (s *WorkflowService) CommitTransition(
}
if err := itemRepo.RecordHistory(tx, repository.HistoryEntry{
- ItemID: itemID,
- UserID: actorUserID,
- FieldName: "status_id",
- OldValue: fmt.Sprintf("%d", oldStatusID),
- NewValue: fmt.Sprintf("%d", newStatusID),
- ChangedAt: changedAt,
+ ItemID: itemID,
+ UserID: actorUserID,
+ FieldName: "status_id",
+ OldValue: fmt.Sprintf("%d", oldStatusID),
+ NewValue: fmt.Sprintf("%d", newStatusID),
+ ChangedAt: changedAt,
+ Source: historySourceForAgent(metadata),
+ AgentRunID: historyRunForAgent(metadata),
}); err != nil {
return fmt.Errorf("record transition history: %w", err)
}
diff --git a/internal/standardagent/dispatcher.go b/internal/standardagent/dispatcher.go
index eac7b0c45..a3afe4b89 100644
--- a/internal/standardagent/dispatcher.go
+++ b/internal/standardagent/dispatcher.go
@@ -437,6 +437,7 @@ func (d *Dispatcher) execute(run *models.AgentRun) {
Username: snapshot.ActingName,
Timezone: timezone,
Source: aitools.SourceStandardAgent,
+ RunID: run.ID,
AccessibleWorkspaceIDs: []int{run.WorkspaceID},
AuditDetails: map[string]any{
"agent_run_id": run.ID,