From ecefc741bc2981786875bdb446b26897c4ede732 Mon Sep 17 00:00:00 2001 From: valentimarco Date: Fri, 25 Sep 2026 22:28:17 +0200 Subject: [PATCH 1/6] feat: mark ai chat writes in item history and meter chat turns --- api/openapi-v2.json | 4 + .../src/lib/features/chat/ChatPanel.svelte | 2 + .../lib/features/chat/ChatToolTrace.svelte | 131 +++++++++++++++--- .../lib/features/items/ItemAgentLog.svelte | 13 +- .../src/lib/features/items/ItemHistory.svelte | 29 +++- .../features/items/activityAttributionData.js | 17 +++ frontend/src/lib/locales/en/workspace.js | 7 + frontend/src/lib/locales/ru/workspace.js | 4 + frontend/src/lib/stores/chatStore.svelte.js | 27 ++-- frontend/src/lib/utils/llmUsage.js | 35 +++++ internal/aitools/comments.go | 3 +- internal/aitools/diagrams.go | 6 +- internal/aitools/env.go | 19 +++ internal/aitools/items.go | 19 +-- internal/database/migrations.go | 8 ++ internal/database/schema/items.sql | 1 + internal/database/schema/items_postgres.sql | 1 + internal/handlers/ai.go | 4 + internal/handlers/ai_dependencies.go | 54 +++++++- internal/handlers/runner_broker.go | 61 ++------ internal/llm/agent.go | 20 ++- internal/llm/manager.go | 60 ++++++++ internal/llm/providers.go | 14 +- internal/llm/types.go | 20 ++- internal/models/agent_conversation.go | 44 ++++-- internal/models/item.go | 5 + .../agent_conversation_repository.go | 27 ++++ internal/repository/diagram_repository.go | 12 +- internal/repository/item_history.go | 13 +- internal/repository/item_repository.go | 8 +- internal/repository/llm_usage_repository.go | 17 +-- internal/services/item_creation.go | 2 +- internal/services/item_diagram_service.go | 6 +- internal/services/item_domain_events.go | 29 ++++ internal/services/item_update_operation.go | 17 ++- internal/services/item_update_service.go | 7 +- internal/services/llm_usage_recorder.go | 106 ++++++++++++++ internal/services/workflow_service.go | 1 + 38 files changed, 699 insertions(+), 154 deletions(-) create mode 100644 frontend/src/lib/utils/llmUsage.js create mode 100644 internal/services/llm_usage_recorder.go diff --git a/api/openapi-v2.json b/api/openapi-v2.json index ece122fc1..18933c49e 100644 --- a/api/openapi-v2.json +++ b/api/openapi-v2.json @@ -19108,6 +19108,10 @@ "nullable": true, "type": "string" }, + "source": { + "description": "The source value.", + "type": "string" + }, "user_email": { "description": "The user email value.", "type": "string" diff --git a/frontend/src/lib/features/chat/ChatPanel.svelte b/frontend/src/lib/features/chat/ChatPanel.svelte index 79a849167..c50f64171 100644 --- a/frontend/src/lib/features/chat/ChatPanel.svelte +++ b/frontend/src/lib/features/chat/ChatPanel.svelte @@ -388,6 +388,8 @@ stopReason={msg.stopReason} needsReview={msg.needsReview} reviewReasons={msg.reviewReasons} + model={msg.model} + usage={msg.usage} /> {/if} diff --git a/frontend/src/lib/features/chat/ChatToolTrace.svelte b/frontend/src/lib/features/chat/ChatToolTrace.svelte index 764adf8f1..5f373c4f9 100644 --- a/frontend/src/lib/features/chat/ChatToolTrace.svelte +++ b/frontend/src/lib/features/chat/ChatToolTrace.svelte @@ -7,9 +7,12 @@ * 2. The agent hitting the iteration ceiling (stopReason === 'max_iterations'). * 3. Empty tool_calls when the agent went straight to a text reply. */ - import { IconChevronRight, IconAlertTriangle, IconCircleCheck, IconCircleX } from '@tabler/icons-svelte-runes'; + import { IconChevronRight, IconAlertTriangle, IconCircleCheck, IconCircleX, IconReceipt } from '@tabler/icons-svelte-runes'; + import { formatCostUSD, hasMeteredUsage } from '../../utils/llmUsage.js'; - /** @type {{ toolCalls?: any[], iterations?: number, maxIterations?: number, stopReason?: string, needsReview?: boolean, reviewReasons?: string[] }} */ + /** + * @type {{ toolCalls?: any[], iterations?: number, maxIterations?: number, stopReason?: string, needsReview?: boolean, reviewReasons?: string[], model?: string, usage?: { prompt_tokens: number, completion_tokens: number, total_tokens: number, cost_usd: number|null, calls: number }|null }} + */ let { toolCalls = [], iterations = 0, @@ -17,12 +20,33 @@ stopReason = '', needsReview = false, reviewReasons = [], + model = '', + usage = null, } = $props(); let expanded = $state(false); let expandedIdx = $state(/** @type {number | null} */ (null)); + // The token/cost reading is behind a click rather than inline: it is + // reference detail most turns never need, and keeping it collapsed stops the + // footer from competing with the answer for attention. + let showMeter = $state(false); const hitLimit = $derived(stopReason === 'max_iterations'); + // Nothing to report when metering never ran for the turn, or when the + // fallback client served it and there is no model to name. + const hasMeter = $derived(hasMeteredUsage(usage)); + const meterLabel = $derived.by(() => { + if (!hasMeter) return ''; + const parts = []; + if (model) parts.push(model); + parts.push(`${usage.total_tokens.toLocaleString()} tokens`); + parts.push( + `(${usage.prompt_tokens.toLocaleString()} in · ${usage.completion_tokens.toLocaleString()} out)` + ); + const cost = formatCostUSD(usage.cost_usd); + parts.push(cost || 'cost unknown'); + return parts.join(' · '); + }); function toolStatus(tc) { // Soft-error convention: tool returned a JSON body with an "error" @@ -71,27 +95,52 @@ {/if} -{#if toolCalls.length > 0 || hitLimit} +{#if toolCalls.length > 0 || hitLimit || hasMeter}
- + {#if toolCalls.length > 0 || hitLimit} + + {/if} + + {#if hasMeter} + + + {/if} {#if expanded}
    @@ -156,6 +205,44 @@ margin-top: 6px; font-size: 11px; color: var(--ds-text-subtle); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + } + .meter { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + border-radius: 4px; + background: var(--ds-surface); + border: 1px solid var(--ds-border); + color: var(--ds-text-subtle); + cursor: pointer; + font: inherit; + } + .meter:hover { + background: var(--ds-background-neutral-hovered); + } + .meter.on { + /* Expanded: the reading is the button's content, so it reads as one + control rather than a label paired with loose text. */ + align-items: baseline; + } + .meter-detail { + color: var(--ds-text); + } + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } .summary { display: inline-flex; diff --git a/frontend/src/lib/features/items/ItemAgentLog.svelte b/frontend/src/lib/features/items/ItemAgentLog.svelte index 989f46b2a..1acfa4a6c 100644 --- a/frontend/src/lib/features/items/ItemAgentLog.svelte +++ b/frontend/src/lib/features/items/ItemAgentLog.svelte @@ -8,6 +8,7 @@ import Lozenge from '../../components/Lozenge.svelte'; import EmptyState from '../../components/EmptyState.svelte'; import { formatAuthenticatedDateTime as formatDateTimeLocale } from '../../utils/authenticatedDateFormatter.js'; + import { formatCostUSD, hasMeteredUsage } from '../../utils/llmUsage.js'; import { t } from '../../stores/i18n.svelte.js'; import { workspacePermissions } from '../../stores'; import { confirm } from '../../composables/useConfirm.js'; @@ -89,12 +90,6 @@ } } - function formatCost(usd) { - if (usd == null) return null; - if (usd > 0 && usd < 0.01) return '<$0.01'; - return `$${usd.toFixed(usd < 1 ? 4 : 2)}`; - } - function statusAppearance(status) { switch (status) { case 'succeeded': return 'success'; @@ -376,12 +371,12 @@ {selectedRun.error}
{/if} - {#if usage && usage.calls > 0} + {#if hasMeteredUsage(usage)}
{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 06e41d8a7..80aaf2b05 100644 --- a/frontend/src/lib/features/items/ItemHistory.svelte +++ b/frontend/src/lib/features/items/ItemHistory.svelte @@ -9,7 +9,7 @@ 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 { agentOwnerName, isAIChatAttributed, loadAttributedItemHistory } from './activityAttributionData.js'; let { itemId } = $props(); @@ -38,6 +38,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 }); @@ -65,13 +71,26 @@ 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: '', changes: [] }; 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; + } + 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, @@ -214,9 +233,9 @@
- {#if group.is_agent} + {#if group.is_agent || isAIChatAttributed(group)} - + {/if} {group.user_name || 'Unknown'} diff --git a/frontend/src/lib/features/items/activityAttributionData.js b/frontend/src/lib/features/items/activityAttributionData.js index ed769077a..aea90af08 100644 --- a/frontend/src/lib/features/items/activityAttributionData.js +++ b/frontend/src/lib/features/items/activityAttributionData.js @@ -11,3 +11,20 @@ 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); +} diff --git a/frontend/src/lib/locales/en/workspace.js b/frontend/src/lib/locales/en/workspace.js index 4827941be..dc5d093fe 100644 --- a/frontend/src/lib/locales/en/workspace.js +++ b/frontend/src/lib/locales/en/workspace.js @@ -486,6 +486,13 @@ 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', + }, + 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 1b595d42d..6fc112d3f 100644 --- a/frontend/src/lib/locales/ru/workspace.js +++ b/frontend/src/lib/locales/ru/workspace.js @@ -39,6 +39,10 @@ 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: 'Изменено через ИИ-чат', + }, + 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..9066db144 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); 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.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) 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..4b76139e0 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" ) @@ -126,3 +128,20 @@ 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" + } + return itemevents.Agent(fmt.Sprintf("user:%d", e.UserID), source) +} 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 1c90d654c..8797f2b41 100644 --- a/internal/database/migrations.go +++ b/internal/database/migrations.go @@ -1685,6 +1685,14 @@ var Catalog = []Migration{ SQLite: slaImportSchema, Postgres: slaImportSchemaPostgres, }, + { + 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", + }, } func applySQLitePersonalLabelsPerUserUnique(db Database) (retErr error) { diff --git a/internal/database/schema/items.sql b/internal/database/schema/items.sql index 3ad745bef..8b98b1cdb 100644 --- a/internal/database/schema/items.sql +++ b/internal/database/schema/items.sql @@ -164,6 +164,7 @@ CREATE TABLE IF NOT EXISTS item_history ( field_name TEXT NOT NULL, old_value TEXT, new_value TEXT, + source TEXT, FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT ); diff --git a/internal/database/schema/items_postgres.sql b/internal/database/schema/items_postgres.sql index 2cfbf2ca3..c251bf0f8 100644 --- a/internal/database/schema/items_postgres.sql +++ b/internal/database/schema/items_postgres.sql @@ -168,6 +168,7 @@ CREATE TABLE IF NOT EXISTS item_history ( field_name TEXT NOT NULL, old_value TEXT, new_value TEXT, + source TEXT, FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT ); 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..f0b109e39 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()) @@ -914,12 +922,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 +971,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 +1009,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/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..4b81987ff 100644 --- a/internal/models/agent_conversation.go +++ b/internal/models/agent_conversation.go @@ -32,13 +32,39 @@ 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 { + 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 1a10f2e78..8b68e7522 100644 --- a/internal/models/item.go +++ b/internal/models/item.go @@ -158,6 +158,11 @@ type ItemHistory struct { IsAgent bool `json:"is_agent"` // Whether the user is an AI agent // 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"` // 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..ce8e5dc55 100644 --- a/internal/repository/diagram_repository.go +++ b/internal/repository/diagram_repository.go @@ -158,12 +158,14 @@ 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. +func (r *DiagramRepository) RecordHistory(itemID, userID int, action string, oldValue *string, newValue, source string) 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) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)`, + itemID, userID, action, oldValue, newValue, nullStringArg(source), ); 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 ac7999afd..e3f472ef2 100644 --- a/internal/repository/item_history.go +++ b/internal/repository/item_history.go @@ -27,6 +27,12 @@ type HistoryEntry struct { OldValueNull bool NewValue string ChangedAt time.Time + // 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 } // RecordHistory records a history entry for an item change @@ -36,9 +42,10 @@ func (r *ItemRepository) RecordHistory(w HistoryWriter, entry HistoryEntry) erro oldValue = nil } _, err := w.ExecWrite(` - INSERT INTO item_history (item_id, user_id, field_name, old_value, new_value, changed_at) - VALUES (?, ?, ?, ?, ?, ?) - `, entry.ItemID, entry.UserID, entry.FieldName, oldValue, entry.NewValue, entry.ChangedAt) + INSERT INTO item_history (item_id, user_id, field_name, old_value, new_value, changed_at, source) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, entry.ItemID, entry.UserID, entry.FieldName, oldValue, entry.NewValue, entry.ChangedAt, + nullStringArg(entry.Source)) 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 0fd9d866d..49c5eb3a6 100644 --- a/internal/repository/item_repository.go +++ b/internal/repository/item_repository.go @@ -1799,7 +1799,8 @@ func (r *ItemRepository) GetHistoryWithApprovals(itemID int, includeAgentOwner b COALESCE(u.first_name || ' ' || u.last_name, u.username, '') as user_name, COALESCE(u.email, '') as user_email, COALESCE(u.is_agent, FALSE) AS is_agent, - COALESCE(NULLIF(TRIM(COALESCE(owner.first_name, '') || ' ' || COALESCE(owner.last_name, '')), ''), owner.username, '') AS agent_owner_name + COALESCE(NULLIF(TRIM(COALESCE(owner.first_name, '') || ' ' || COALESCE(owner.last_name, '')), ''), owner.username, '') AS agent_owner_name, + COALESCE(ih.source, '') AS source 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 @@ -1816,7 +1817,8 @@ func (r *ItemRepository) GetHistoryWithApprovals(itemID int, includeAgentOwner b COALESCE(u.first_name || ' ' || u.last_name, u.username, 'System') AS user_name, COALESCE(u.email, '') AS user_email, COALESCE(u.is_agent, FALSE) AS is_agent, - COALESCE(NULLIF(TRIM(COALESCE(owner.first_name, '') || ' ' || COALESCE(owner.last_name, '')), ''), owner.username, '') AS agent_owner_name + COALESCE(NULLIF(TRIM(COALESCE(owner.first_name, '') || ' ' || COALESCE(owner.last_name, '')), ''), owner.username, '') AS agent_owner_name, + '' AS source 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 @@ -1834,7 +1836,7 @@ func (r *ItemRepository) GetHistoryWithApprovals(itemID int, includeAgentOwner b history := []models.ItemHistory{} for rows.Next() { var entry models.ItemHistory - if err := rows.Scan(&entry.ID, &entry.ItemID, &entry.UserID, &entry.ChangedAt, &entry.FieldName, &entry.OldValue, &entry.NewValue, &entry.UserName, &entry.UserEmail, &entry.IsAgent, &entry.AgentOwnerName); err != nil { + if err := rows.Scan(&entry.ID, &entry.ItemID, &entry.UserID, &entry.ChangedAt, &entry.FieldName, &entry.OldValue, &entry.NewValue, &entry.UserName, &entry.UserEmail, &entry.IsAgent, &entry.AgentOwnerName, &entry.Source); err != nil { return nil, err } if !includeAgentOwner { diff --git a/internal/repository/llm_usage_repository.go b/internal/repository/llm_usage_repository.go index 69d230dc4..ff51b279f 100644 --- a/internal/repository/llm_usage_repository.go +++ b/internal/repository/llm_usage_repository.go @@ -6,6 +6,7 @@ import ( "fmt" "windshift/internal/database" + "windshift/internal/models" ) // LLMUsageRepository persists per-call LLM token usage + cost, metered at the @@ -55,17 +56,11 @@ 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. diff --git a/internal/services/item_creation.go b/internal/services/item_creation.go index 23952f06a..76494ee5c 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..8f7df25b4 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), "") 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), "") 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, "") 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 29432bc50..a5a71e683 100644 --- a/internal/services/item_domain_events.go +++ b/internal/services/item_domain_events.go @@ -97,6 +97,35 @@ 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 +} + +// stampHistorySource applies historySourceForAgent to every entry in a batch. +// History rows are written in one transaction by one actor, so the whole batch +// shares one source. +func stampHistorySource(history []repository.HistoryEntry, metadata itemevents.Metadata) []repository.HistoryEntry { + source := historySourceForAgent(metadata) + if source == "" { + return history + } + for i := range history { + history[i].Source = source + } + 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 345fbeda0..a796c5426 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 5b2697490..37ab0ce30 100644 --- a/internal/services/item_update_service.go +++ b/internal/services/item_update_service.go @@ -265,13 +265,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..a88ab98c5 --- /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, 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 0f3ebf477..15c95fb3e 100644 --- a/internal/services/workflow_service.go +++ b/internal/services/workflow_service.go @@ -628,6 +628,7 @@ func (s *WorkflowService) CommitTransition( OldValue: fmt.Sprintf("%d", oldStatusID), NewValue: fmt.Sprintf("%d", newStatusID), ChangedAt: changedAt, + Source: historySourceForAgent(metadata), }); err != nil { return fmt.Errorf("record transition history: %w", err) } From 00bd54ad53b726d2dac7289d06725f186ae113a7 Mon Sep 17 00:00:00 2001 From: valentimarco Date: Fri, 25 Sep 2026 22:28:17 +0200 Subject: [PATCH 2/6] feat: report the model and cost behind an agent-written history change --- api/openapi-v2.json | 18 ++++ .../src/lib/features/items/ItemHistory.svelte | 82 ++++++++++++++++++- frontend/src/lib/locales/en/workspace.js | 5 ++ frontend/src/lib/locales/ru/workspace.js | 4 + internal/aitools/diagrams.go | 6 +- internal/aitools/env.go | 30 +++++-- internal/database/migrations.go | 8 ++ internal/database/schema/items.sql | 1 + internal/database/schema/items_postgres.sql | 1 + internal/handlers/ai_dependencies.go | 1 + internal/itemevents/itemevents.go | 18 +++- internal/models/item.go | 9 ++ internal/repository/diagram_repository.go | 11 +-- internal/repository/item_history.go | 11 ++- internal/repository/item_repository.go | 39 ++++++++- internal/services/item_diagram_service.go | 6 +- internal/services/item_domain_events.go | 18 +++- internal/services/workflow_service.go | 15 ++-- internal/standardagent/dispatcher.go | 1 + 19 files changed, 248 insertions(+), 36 deletions(-) diff --git a/api/openapi-v2.json b/api/openapi-v2.json index 18933c49e..068f57fd4 100644 --- a/api/openapi-v2.json +++ b/api/openapi-v2.json @@ -19067,11 +19067,21 @@ "description": "The agent owner name value.", "type": "string" }, + "agent_run_id": { + "description": "The agent run id value.", + "nullable": true, + "type": "integer" + }, "changed_at": { "description": "The changed at value.", "format": "date-time", "type": "string" }, + "cost_usd": { + "description": "The cost usd value.", + "nullable": true, + "type": "number" + }, "field_name": { "description": "The field name value.", "type": "string" @@ -19088,6 +19098,10 @@ "description": "The item id value.", "type": "integer" }, + "model": { + "description": "The model value.", + "type": "string" + }, "new_value": { "description": "The new value value.", "nullable": true, @@ -19112,6 +19126,10 @@ "description": "The source value.", "type": "string" }, + "total_tokens": { + "description": "The total tokens value.", + "type": "integer" + }, "user_email": { "description": "The user email value.", "type": "string" diff --git a/frontend/src/lib/features/items/ItemHistory.svelte b/frontend/src/lib/features/items/ItemHistory.svelte index 80aaf2b05..dbf1d0174 100644 --- a/frontend/src/lib/features/items/ItemHistory.svelte +++ b/frontend/src/lib/features/items/ItemHistory.svelte @@ -9,6 +9,7 @@ import EmptyState from '../../components/EmptyState.svelte'; import Tooltip from '../../components/Tooltip.svelte'; import { t } from '../../stores/i18n.svelte.js'; + import { formatCostUSD } from '../../utils/llmUsage.js'; import { agentOwnerName, isAIChatAttributed, loadAttributedItemHistory } from './activityAttributionData.js'; let { itemId } = $props(); @@ -51,6 +52,18 @@ return t('comments.agentAuthored'); } + // The turn that made this change, when the change came from an agent run + // that was metered. Null for a direct edit, for a surface with no run to + // point at (MCP), and for a run with no usage recorded. + function agentRunDetail(entry) { + if (!entry?.agent_run_id) return null; + const model = entry.model || ''; + const tokens = Number(entry.total_tokens) || 0; + const cost = formatCostUSD(entry.cost_usd); + if (!model && !tokens && !cost) return null; + return { model, tokens, cost }; + } + // Group history entries by timestamp (changes made at the same time) let groupedHistory = $derived(groupByTimestamp(history)); @@ -74,6 +87,10 @@ is_agent: false, agent_owner_name: '', source: '', + agent_run_id: null, + model: '', + cost_usd: null, + total_tokens: 0, changes: [] }; groups.push(currentGroup); @@ -87,6 +104,15 @@ if (isAIChatAttributed(entry)) { currentGroup.source = entry.source; } + // Keep the telemetry of whichever row carries it. The run 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; + currentGroup.model = entry.model || ''; + currentGroup.cost_usd = entry.cost_usd ?? null; + currentGroup.total_tokens = entry.total_tokens || 0; + } if (!currentGroup.agent_owner_name && entry.agent_owner_name) { currentGroup.agent_owner_name = entry.agent_owner_name; } @@ -234,9 +260,37 @@
{#if group.is_agent || isAIChatAttributed(group)} - - - + {@const runDetail = agentRunDetail(group)} + {#if runDetail} + + {#snippet tip()} +
+
{t('history.viaAIChat')}
+ {#if runDetail.model} +
+ {t('history.model')} + {runDetail.model} +
+ {/if} + {#if runDetail.tokens} +
+ {t('history.tokens')} + {runDetail.tokens.toLocaleString()} +
+ {/if} +
+ {t('history.cost')} + {runDetail.cost || t('history.costUnknown')} +
+
+ {/snippet} + +
+ {:else} + + + + {/if} {/if} {group.user_name || 'Unknown'} @@ -376,4 +430,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/locales/en/workspace.js b/frontend/src/lib/locales/en/workspace.js index dc5d093fe..3046bb2d6 100644 --- a/frontend/src/lib/locales/en/workspace.js +++ b/frontend/src/lib/locales/en/workspace.js @@ -491,6 +491,11 @@ export default { // 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: { diff --git a/frontend/src/lib/locales/ru/workspace.js b/frontend/src/lib/locales/ru/workspace.js index 6fc112d3f..a9c9cd3c1 100644 --- a/frontend/src/lib/locales/ru/workspace.js +++ b/frontend/src/lib/locales/ru/workspace.js @@ -41,6 +41,10 @@ export default { history: { viaAIChat: 'Изменено через ИИ-чат', + model: 'Модель', + tokens: 'Токены', + cost: 'Стоимость', + costUnknown: 'стоимость неизвестна', }, comments: { diff --git a/internal/aitools/diagrams.go b/internal/aitools/diagrams.go index 9066db144..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), env.Source); 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), env.Source) + 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, env.Source) + _ = 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 4b76139e0..aa3f50028 100644 --- a/internal/aitools/env.go +++ b/internal/aitools/env.go @@ -24,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. @@ -143,5 +148,18 @@ func (e *Env) eventMetadata() itemevents.Metadata { if source == "" { source = "agent" } - return itemevents.Agent(fmt.Sprintf("user:%d", e.UserID), source) + 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/database/migrations.go b/internal/database/migrations.go index 8797f2b41..410a6f9e3 100644 --- a/internal/database/migrations.go +++ b/internal/database/migrations.go @@ -1693,6 +1693,14 @@ var Catalog = []Migration{ 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", + }, } func applySQLitePersonalLabelsPerUserUnique(db Database) (retErr error) { diff --git a/internal/database/schema/items.sql b/internal/database/schema/items.sql index 8b98b1cdb..5d0768599 100644 --- a/internal/database/schema/items.sql +++ b/internal/database/schema/items.sql @@ -165,6 +165,7 @@ CREATE TABLE IF NOT EXISTS item_history ( 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 ); diff --git a/internal/database/schema/items_postgres.sql b/internal/database/schema/items_postgres.sql index c251bf0f8..921f62719 100644 --- a/internal/database/schema/items_postgres.sql +++ b/internal/database/schema/items_postgres.sql @@ -169,6 +169,7 @@ CREATE TABLE IF NOT EXISTS item_history ( 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 ); diff --git a/internal/handlers/ai_dependencies.go b/internal/handlers/ai_dependencies.go index f0b109e39..d8825770a 100644 --- a/internal/handlers/ai_dependencies.go +++ b/internal/handlers/ai_dependencies.go @@ -845,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, 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/models/item.go b/internal/models/item.go index 8b68e7522..e19098b16 100644 --- a/internal/models/item.go +++ b/internal/models/item.go @@ -163,6 +163,15 @@ type ItemHistory struct { // 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, which is what makes Model/CostUSD meaningful rather than decorative. + AgentRunID *int `json:"agent_run_id,omitempty"` + // Model and the metered cost of the turn behind this change. CostUSD is nil + // when the turn was never metered, or when any of its calls could not be + // priced — a partial sum would read as a complete one. + Model string `json:"model,omitempty"` + CostUSD *float64 `json:"cost_usd,omitempty"` + TotalTokens int `json:"total_tokens,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/diagram_repository.go b/internal/repository/diagram_repository.go index ce8e5dc55..1a7842521 100644 --- a/internal/repository/diagram_repository.go +++ b/internal/repository/diagram_repository.go @@ -160,12 +160,13 @@ 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). source // names the acting surface when an agent did this on a user's behalf, and is -// empty for a direct write. -func (r *DiagramRepository) RecordHistory(itemID, userID int, action string, oldValue *string, newValue, source string) error { +// 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, source) - VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)`, - itemID, userID, action, oldValue, newValue, nullStringArg(source), + `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 e3f472ef2..07d487482 100644 --- a/internal/repository/item_history.go +++ b/internal/repository/item_history.go @@ -33,6 +33,11 @@ type HistoryEntry struct { // 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 @@ -42,10 +47,10 @@ func (r *ItemRepository) RecordHistory(w HistoryWriter, entry HistoryEntry) erro oldValue = nil } _, err := w.ExecWrite(` - INSERT INTO item_history (item_id, user_id, field_name, old_value, new_value, changed_at, source) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO item_history (item_id, user_id, field_name, old_value, new_value, changed_at, source, agent_run_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) `, entry.ItemID, entry.UserID, entry.FieldName, oldValue, entry.NewValue, entry.ChangedAt, - nullStringArg(entry.Source)) + 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 49c5eb3a6..1d8080865 100644 --- a/internal/repository/item_repository.go +++ b/internal/repository/item_repository.go @@ -1793,6 +1793,20 @@ func (r *ItemRepository) ClearRelatedWorkItem(itemID int) error { // GetHistoryWithApprovals returns item history plus approval decision events as a single chronological feed. func (r *ItemRepository) GetHistoryWithApprovals(itemID int, includeAgentOwner bool) ([]models.ItemHistory, error) { + // A run can be metered by more than one row (the coding agent bills per + // provider call), so the telemetry is pre-aggregated in a derived table + // rather than joined row-for-row — joining directly would multiply the + // history rows and duplicate every change in the feed. Cost is reported + // only when every call in the run could be priced: summing the known ones + // and calling it the total would understate the spend as a confident + // number. + runTelemetry := ` + SELECT run_id, + MIN(model) AS model, + SUM(total_tokens) AS total_tokens, + CASE WHEN COUNT(cost_usd) = COUNT(*) THEN SUM(cost_usd) ELSE NULL END AS cost_usd + FROM llm_usage GROUP BY run_id + ` query := ` SELECT ih.id, ih.item_id, ih.user_id, ih.changed_at, ih.field_name, ih.old_value, ih.new_value, @@ -1800,10 +1814,15 @@ func (r *ItemRepository) GetHistoryWithApprovals(itemID int, includeAgentOwner b COALESCE(u.email, '') as user_email, COALESCE(u.is_agent, FALSE) AS is_agent, COALESCE(NULLIF(TRIM(COALESCE(owner.first_name, '') || ' ' || COALESCE(owner.last_name, '')), ''), owner.username, '') AS agent_owner_name, - COALESCE(ih.source, '') AS source + COALESCE(ih.source, '') AS source, + ih.agent_run_id, + COALESCE(rt.model, '') AS model, + rt.cost_usd, + COALESCE(rt.total_tokens, 0) AS total_tokens 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 + LEFT JOIN (` + runTelemetry + `) rt ON rt.run_id = ih.agent_run_id WHERE ih.item_id = ? UNION ALL SELECT @@ -1818,7 +1837,11 @@ func (r *ItemRepository) GetHistoryWithApprovals(itemID int, includeAgentOwner b COALESCE(u.email, '') AS user_email, COALESCE(u.is_agent, FALSE) AS is_agent, COALESCE(NULLIF(TRIM(COALESCE(owner.first_name, '') || ' ' || COALESCE(owner.last_name, '')), ''), owner.username, '') AS agent_owner_name, - '' AS source + '' AS source, + NULL AS agent_run_id, + '' AS model, + NULL AS cost_usd, + 0 AS total_tokens 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 @@ -1836,9 +1859,19 @@ func (r *ItemRepository) GetHistoryWithApprovals(itemID int, includeAgentOwner b history := []models.ItemHistory{} for rows.Next() { var entry models.ItemHistory - if err := rows.Scan(&entry.ID, &entry.ItemID, &entry.UserID, &entry.ChangedAt, &entry.FieldName, &entry.OldValue, &entry.NewValue, &entry.UserName, &entry.UserEmail, &entry.IsAgent, &entry.AgentOwnerName, &entry.Source); err != nil { + var runID sql.NullInt64 + var cost sql.NullFloat64 + if err := rows.Scan(&entry.ID, &entry.ItemID, &entry.UserID, &entry.ChangedAt, &entry.FieldName, &entry.OldValue, &entry.NewValue, &entry.UserName, &entry.UserEmail, &entry.IsAgent, &entry.AgentOwnerName, &entry.Source, &runID, &entry.Model, &cost, &entry.TotalTokens); err != nil { return nil, err } + if runID.Valid { + v := int(runID.Int64) + entry.AgentRunID = &v + } + if cost.Valid { + c := cost.Float64 + entry.CostUSD = &c + } if !includeAgentOwner { entry.AgentOwnerName = "" } diff --git a/internal/services/item_diagram_service.go b/internal/services/item_diagram_service.go index 8f7df25b4..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 a5a71e683..c2d541397 100644 --- a/internal/services/item_domain_events.go +++ b/internal/services/item_domain_events.go @@ -112,16 +112,30 @@ func historySourceForAgent(metadata itemevents.Metadata) string { return metadata.SourceKind } -// stampHistorySource applies historySourceForAgent to every entry in a batch. +// 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. +// 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 } diff --git a/internal/services/workflow_service.go b/internal/services/workflow_service.go index 15c95fb3e..b98b27a51 100644 --- a/internal/services/workflow_service.go +++ b/internal/services/workflow_service.go @@ -622,13 +622,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, - Source: historySourceForAgent(metadata), + 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, From 340d6226b77521602d476a891af1dae487ebc925 Mon Sep 17 00:00:00 2001 From: valentimarco Date: Fri, 25 Sep 2026 22:26:11 +0200 Subject: [PATCH 3/6] build: keep host node_modules and dist out of the Docker context --- .dockerignore | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.dockerignore b/.dockerignore index 9c172b7fe..dfd409a41 100644 --- a/.dockerignore +++ b/.dockerignore @@ -36,6 +36,14 @@ cmd/stresstest-cf-index !frontend/scripts/** !frontend/src/ !frontend/src/** +# `!frontend/` above re-includes the whole directory, which otherwise drags the +# developer's node_modules (and their locally built dist) into the context: +# ~900MB on every build, and — worse than slow — `COPY frontend/ ./` lands on +# top of the `npm ci` the frontend-builder stage just ran, so the image would +# build against whatever the host happened to have installed. The stage's own +# node_modules and dist are the only ones that should exist in the image. +frontend/node_modules +frontend/dist !scripts/ !scripts/demo/ !scripts/demo/generate-demo.js From 4c6633d1916867ec1427b7fb999d577c5021d8ee Mon Sep 17 00:00:00 2001 From: Stefan Ernst <24554551+stefan-ernst@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:44:19 +0200 Subject: [PATCH 4/6] perf(history): lazy-load agent-run telemetry instead of joining metering The item history read joined an aggregate of the entire llm_usage table on every request. That subquery is not scoped to the item, so it scanned the whole metering table regardless of which item was opened and degraded linearly with usage volume (always on SQLite, and on Postgres once the planner picks a hash plan over a merge join). Keep the metered detail off the history response and fetch it per run: - Drop model/cost_usd/total_tokens from ItemHistory and the runTelemetry join; the history response is a single-table read carrying only source and agent_run_id. - Add Model to RunUsageTotals (MIN(model) over the run) so the existing indexed /agent-runs/{run_id}/usage endpoint is the one source of model/tokens/cost for both the history tooltip and the agent log. - Fetch that endpoint lazily when the marker is hovered, scoped to the newest 20 runs so a long history cannot fan out into a request per row. --- api/openapi-v2.json | 17 +-- .../src/lib/features/items/ItemHistory.svelte | 112 ++++++++++++------ .../features/items/activityAttributionData.js | 25 ++++ internal/models/agent_conversation.go | 3 + internal/models/item.go | 10 +- internal/repository/item_repository.go | 37 ++---- internal/repository/llm_usage_repository.go | 5 +- 7 files changed, 120 insertions(+), 89 deletions(-) diff --git a/api/openapi-v2.json b/api/openapi-v2.json index 068f57fd4..2d803360a 100644 --- a/api/openapi-v2.json +++ b/api/openapi-v2.json @@ -2238,6 +2238,10 @@ "nullable": true, "type": "number" }, + "model": { + "description": "The model value.", + "type": "string" + }, "prompt_tokens": { "description": "The prompt tokens value.", "type": "integer" @@ -19077,11 +19081,6 @@ "format": "date-time", "type": "string" }, - "cost_usd": { - "description": "The cost usd value.", - "nullable": true, - "type": "number" - }, "field_name": { "description": "The field name value.", "type": "string" @@ -19098,10 +19097,6 @@ "description": "The item id value.", "type": "integer" }, - "model": { - "description": "The model value.", - "type": "string" - }, "new_value": { "description": "The new value value.", "nullable": true, @@ -19126,10 +19121,6 @@ "description": "The source value.", "type": "string" }, - "total_tokens": { - "description": "The total tokens value.", - "type": "integer" - }, "user_email": { "description": "The user email value.", "type": "string" diff --git a/frontend/src/lib/features/items/ItemHistory.svelte b/frontend/src/lib/features/items/ItemHistory.svelte index dbf1d0174..aedb89ca7 100644 --- a/frontend/src/lib/features/items/ItemHistory.svelte +++ b/frontend/src/lib/features/items/ItemHistory.svelte @@ -9,8 +9,14 @@ import EmptyState from '../../components/EmptyState.svelte'; import Tooltip from '../../components/Tooltip.svelte'; import { t } from '../../stores/i18n.svelte.js'; - import { formatCostUSD } from '../../utils/llmUsage.js'; - import { agentOwnerName, isAIChatAttributed, 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(); @@ -18,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)); @@ -52,21 +64,45 @@ return t('comments.agentAuthored'); } - // The turn that made this change, when the change came from an agent run - // that was metered. Null for a direct edit, for a surface with no run to - // point at (MCP), and for a run with no usage recorded. - function agentRunDetail(entry) { - if (!entry?.agent_run_id) return null; - const model = entry.model || ''; - const tokens = Number(entry.total_tokens) || 0; - const cost = formatCostUSD(entry.cost_usd); - if (!model && !tokens && !cost) return null; - return { model, tokens, cost }; - } - // 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 []; @@ -88,9 +124,6 @@ agent_owner_name: '', source: '', agent_run_id: null, - model: '', - cost_usd: null, - total_tokens: 0, changes: [] }; groups.push(currentGroup); @@ -104,14 +137,11 @@ if (isAIChatAttributed(entry)) { currentGroup.source = entry.source; } - // Keep the telemetry of whichever row carries it. The run 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. + // 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; - currentGroup.model = entry.model || ''; - currentGroup.cost_usd = entry.cost_usd ?? null; - currentGroup.total_tokens = entry.total_tokens || 0; } if (!currentGroup.agent_owner_name && entry.agent_owner_name) { currentGroup.agent_owner_name = entry.agent_owner_name; @@ -260,31 +290,35 @@
{#if group.is_agent || isAIChatAttributed(group)} - {@const runDetail = agentRunDetail(group)} - {#if runDetail} + {#if isAIChatAttributed(group) && group.agent_run_id} + {@const runDetail = agentRunDetail(group)} {#snippet tip()}
{t('history.viaAIChat')}
- {#if runDetail.model} + {#if runDetail} + {#if runDetail.model} +
+ {t('history.model')} + {runDetail.model} +
+ {/if} + {#if runDetail.tokens} +
+ {t('history.tokens')} + {runDetail.tokens.toLocaleString()} +
+ {/if}
- {t('history.model')} - {runDetail.model} + {t('history.cost')} + {runDetail.cost || t('history.costUnknown')}
{/if} - {#if runDetail.tokens} -
- {t('history.tokens')} - {runDetail.tokens.toLocaleString()} -
- {/if} -
- {t('history.cost')} - {runDetail.cost || t('history.costUnknown')} -
{/snippet} - + loadRunTelemetry(group.agent_run_id)}> + +
{:else} diff --git a/frontend/src/lib/features/items/activityAttributionData.js b/frontend/src/lib/features/items/activityAttributionData.js index aea90af08..2b806c22f 100644 --- a/frontend/src/lib/features/items/activityAttributionData.js +++ b/frontend/src/lib/features/items/activityAttributionData.js @@ -28,3 +28,28 @@ const ANNOTATED_HISTORY_SOURCES = new Set(['ai_chat']); 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/internal/models/agent_conversation.go b/internal/models/agent_conversation.go index 4b81987ff..d4aeee091 100644 --- a/internal/models/agent_conversation.go +++ b/internal/models/agent_conversation.go @@ -59,6 +59,9 @@ type AgentMessage struct { // 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"` diff --git a/internal/models/item.go b/internal/models/item.go index e19098b16..cf624224e 100644 --- a/internal/models/item.go +++ b/internal/models/item.go @@ -164,14 +164,10 @@ type ItemHistory struct { // 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, which is what makes Model/CostUSD meaningful rather than decorative. + // 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"` - // Model and the metered cost of the turn behind this change. CostUSD is nil - // when the turn was never metered, or when any of its calls could not be - // priced — a partial sum would read as a complete one. - Model string `json:"model,omitempty"` - CostUSD *float64 `json:"cost_usd,omitempty"` - TotalTokens int `json:"total_tokens,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/item_repository.go b/internal/repository/item_repository.go index 1d8080865..c2601af56 100644 --- a/internal/repository/item_repository.go +++ b/internal/repository/item_repository.go @@ -1793,20 +1793,11 @@ func (r *ItemRepository) ClearRelatedWorkItem(itemID int) error { // GetHistoryWithApprovals returns item history plus approval decision events as a single chronological feed. func (r *ItemRepository) GetHistoryWithApprovals(itemID int, includeAgentOwner bool) ([]models.ItemHistory, error) { - // A run can be metered by more than one row (the coding agent bills per - // provider call), so the telemetry is pre-aggregated in a derived table - // rather than joined row-for-row — joining directly would multiply the - // history rows and duplicate every change in the feed. Cost is reported - // only when every call in the run could be priced: summing the known ones - // and calling it the total would understate the spend as a confident - // number. - runTelemetry := ` - SELECT run_id, - MIN(model) AS model, - SUM(total_tokens) AS total_tokens, - CASE WHEN COUNT(cost_usd) = COUNT(*) THEN SUM(cost_usd) ELSE NULL END AS cost_usd - FROM llm_usage GROUP BY run_id - ` + // 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, @@ -1815,14 +1806,10 @@ func (r *ItemRepository) GetHistoryWithApprovals(itemID int, includeAgentOwner b COALESCE(u.is_agent, FALSE) AS is_agent, COALESCE(NULLIF(TRIM(COALESCE(owner.first_name, '') || ' ' || COALESCE(owner.last_name, '')), ''), owner.username, '') AS agent_owner_name, COALESCE(ih.source, '') AS source, - ih.agent_run_id, - COALESCE(rt.model, '') AS model, - rt.cost_usd, - COALESCE(rt.total_tokens, 0) AS total_tokens + 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 - LEFT JOIN (` + runTelemetry + `) rt ON rt.run_id = ih.agent_run_id WHERE ih.item_id = ? UNION ALL SELECT @@ -1838,10 +1825,7 @@ func (r *ItemRepository) GetHistoryWithApprovals(itemID int, includeAgentOwner b COALESCE(u.is_agent, FALSE) AS is_agent, COALESCE(NULLIF(TRIM(COALESCE(owner.first_name, '') || ' ' || COALESCE(owner.last_name, '')), ''), owner.username, '') AS agent_owner_name, '' AS source, - NULL AS agent_run_id, - '' AS model, - NULL AS cost_usd, - 0 AS total_tokens + 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 @@ -1860,18 +1844,13 @@ func (r *ItemRepository) GetHistoryWithApprovals(itemID int, includeAgentOwner b for rows.Next() { var entry models.ItemHistory var runID sql.NullInt64 - var cost sql.NullFloat64 - if err := rows.Scan(&entry.ID, &entry.ItemID, &entry.UserID, &entry.ChangedAt, &entry.FieldName, &entry.OldValue, &entry.NewValue, &entry.UserName, &entry.UserEmail, &entry.IsAgent, &entry.AgentOwnerName, &entry.Source, &runID, &entry.Model, &cost, &entry.TotalTokens); err != nil { + if err := rows.Scan(&entry.ID, &entry.ItemID, &entry.UserID, &entry.ChangedAt, &entry.FieldName, &entry.OldValue, &entry.NewValue, &entry.UserName, &entry.UserEmail, &entry.IsAgent, &entry.AgentOwnerName, &entry.Source, &runID); err != nil { return nil, err } if runID.Valid { v := int(runID.Int64) entry.AgentRunID = &v } - if cost.Valid { - c := cost.Float64 - entry.CostUSD = &c - } if !includeAgentOwner { entry.AgentOwnerName = "" } diff --git a/internal/repository/llm_usage_repository.go b/internal/repository/llm_usage_repository.go index ff51b279f..7bfe77121 100644 --- a/internal/repository/llm_usage_repository.go +++ b/internal/repository/llm_usage_repository.go @@ -66,10 +66,12 @@ type RunUsageTotals = models.RunUsageTotals // 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), @@ -79,10 +81,11 @@ func (r *LLMUsageRepository) TotalsForRun(ctx context.Context, runID int) (RunUs SUM(cost_usd), COUNT(*) 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) From ae35695ad65cc62b90b644269c6a090ba95eca8c Mon Sep 17 00:00:00 2001 From: Stefan Ernst <24554551+stefan-ernst@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:21:55 +0200 Subject: [PATCH 5/6] fix(llm): count provider round-trips in metered usage A chat turn writes one aggregated llm_usage row, so TotalsForRun's COUNT(*) reported one call even when the agent loop made several provider round-trips; the broker's per-call rows reported the real count. Store the count on the row (defaulting to one for existing and per-call rows) and sum it, so the usage endpoint, chat response, and history tooltip agree on the number of calls behind a run. --- internal/database/migrations.go | 8 +++++++ internal/database/schema/llm.sql | 1 + internal/database/schema/llm_postgres.sql | 1 + internal/repository/llm_usage_repository.go | 25 ++++++++++++++------- internal/services/llm_usage_recorder.go | 2 +- 5 files changed, 28 insertions(+), 9 deletions(-) diff --git a/internal/database/migrations.go b/internal/database/migrations.go index 410a6f9e3..c67fda2c9 100644 --- a/internal/database/migrations.go +++ b/internal/database/migrations.go @@ -1701,6 +1701,14 @@ var Catalog = []Migration{ 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", + }, } func applySQLitePersonalLabelsPerUserUnique(db Database) (retErr error) { 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/repository/llm_usage_repository.go b/internal/repository/llm_usage_repository.go index 7bfe77121..817a9b4fd 100644 --- a/internal/repository/llm_usage_repository.go +++ b/internal/repository/llm_usage_repository.go @@ -9,8 +9,10 @@ import ( "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 } @@ -20,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 @@ -40,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, ) @@ -79,7 +88,7 @@ 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(&model, &prompt, &completion, &total, &cacheRead, &cacheWrite, &reasoning, &cost, &calls) if err != nil { diff --git a/internal/services/llm_usage_recorder.go b/internal/services/llm_usage_recorder.go index a88ab98c5..25d630fee 100644 --- a/internal/services/llm_usage_recorder.go +++ b/internal/services/llm_usage_recorder.go @@ -58,7 +58,7 @@ func RecordLLMCall( return repository.RunUsageTotals{}, nil } record := repository.LLMUsageRecord{ - RunID: runID, Model: descriptor.Model, + 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, From 9e738199df77aca481afae83dbff4c3df420af80 Mon Sep 17 00:00:00 2001 From: Stefan Ernst <24554551+stefan-ernst@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:58:19 +0200 Subject: [PATCH 6/6] fix(history): mark the lazy telemetry hover target as presentational --- frontend/src/lib/features/items/ItemHistory.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/features/items/ItemHistory.svelte b/frontend/src/lib/features/items/ItemHistory.svelte index a28bd1292..8803138be 100644 --- a/frontend/src/lib/features/items/ItemHistory.svelte +++ b/frontend/src/lib/features/items/ItemHistory.svelte @@ -334,7 +334,7 @@ {/if}
{/snippet} - loadRunTelemetry(group.agent_run_id)}> + loadRunTelemetry(group.agent_run_id)}>