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