Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,9 @@ docs/plans/
docs/.audit/

/release-action
test-timings.tsv
test-timings.tsv
# Local-only FOCUS export dev/review artifacts (not part of the PR).
/scripts/focusdevserver/
/focus_export_review_prompt.md
/focus_export_review_findings.md
/focus_export_pr_description.md
124 changes: 124 additions & 0 deletions coderd/aibridge/focus/csv.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package focus

import (
"encoding/csv"
"io"
"strconv"
"strings"
)

// Header is the FOCUS export's CSV column order, matching Row's field order.
var Header = []string{
"BilledCost", "BillingAccountId", "BillingAccountName", "BillingAccountType",
"BillingCurrency", "BillingPeriodEnd", "BillingPeriodStart",
"ChargeCategory", "ChargeClass", "ChargeDescription", "ChargeFrequency",
"ChargePeriodEnd", "ChargePeriodStart",
"ConsumedQuantity", "ConsumedUnit",
"ContractedCost", "ContractedUnitPrice", "EffectiveCost", "InvoiceIssuerName",
"ListCost", "ListUnitPrice",
"PricingCategory", "PricingQuantity", "PricingUnit",
"ProviderName", "PublisherName",
"ResourceId", "ResourceName", "ResourceType",
"ServiceCategory", "ServiceName", "ServiceSubcategory",
"SkuId", "SkuPriceId",
"SubAccountId", "SubAccountName", "SubAccountType",
"Tags",
"x_ProviderResponseId", "x_InterceptionId", "x_SessionId", "x_InitiatorId",
"x_CredentialKind", "x_WireProtocol", "x_ProviderInstance", "x_ClientTool",
"x_ErrorType", "x_PricingStatus", "x_TokenType", "x_RowCount",
}

// csvFormulaPrefixes are the leading characters a spreadsheet treats as the
// start of a formula rather than text. Duplicated from
// enterprise/coderd/aibridge.go's unexported escapeCSVCell/csvFormulaPrefixes
// rather than imported: the original lives in the enterprise package, and
// this AGPL package shouldn't depend on it anyway, per the same
// license-boundary principle that keeps the feature gate entirely in
// enterprise/coderd/aibridge.go.
const csvFormulaPrefixes = "=+-@\t\r"

// escapeCSVCell prefixes a leading formula character with a single quote,
// which spreadsheets strip on display, so the value renders as its original
// text instead of being evaluated as a formula.
func escapeCSVCell(value string) string {
if value == "" || !strings.ContainsRune(csvFormulaPrefixes, rune(value[0])) {
return value
}
return "'" + value
}

// escapeCSVCellPtr applies escapeCSVCell to a possibly-nil pointer, returning
// "" for nil.
func escapeCSVCellPtr(value *string) string {
if value == nil {
return ""
}
return escapeCSVCell(*value)
}

// csvCells returns r's values in Header order for one CSV row.
//
// Escaped (spreadsheet formula-injection risk): SubAccountName (a synced IdP
// group name has no server-side character validation), ChargeDescription and
// ResourceName (both embed interceptions.model, already treated as untrusted
// by the existing AI spend export's own escaping), BillingAccountName (embeds
// a username; low risk since usernames are normalized, escaped anyway for
// defense in depth), ProviderName/PublisherName/ServiceName/InvoiceIssuerName
// (safe when resolved from the canonical vendor-label map, but the
// openai-compat fallback is admin-set free text with no validation), and
// x_ProviderResponseId (populated verbatim from the upstream provider's own
// API response, which Coder never validates).
//
// Not escaped: x_ProviderInstance (ai_providers.name has a DB CHECK
// constraint matching a safe pattern) and x_ClientTool (always one of a fixed
// enum of values, never raw user-agent text).
func csvCells(r Row) []string {
return []string{
r.BilledCost, r.BillingAccountID, escapeCSVCell(r.BillingAccountName), r.BillingAccountType,
r.BillingCurrency, r.BillingPeriodEnd.UTC().Format(rfc3339Micro), r.BillingPeriodStart.UTC().Format(rfc3339Micro),
r.ChargeCategory, ptrOrEmpty(r.ChargeClass), escapeCSVCell(r.ChargeDescription), r.ChargeFrequency,
r.ChargePeriodEnd.UTC().Format(rfc3339Micro), r.ChargePeriodStart.UTC().Format(rfc3339Micro),
strconv.FormatInt(r.ConsumedQuantity, 10), r.ConsumedUnit,
r.ContractedCost, ptrOrEmpty(r.ContractedUnitPrice), r.EffectiveCost, escapeCSVCell(r.InvoiceIssuerName),
r.ListCost, ptrOrEmpty(r.ListUnitPrice),
r.PricingCategory, strconv.FormatInt(r.PricingQuantity, 10), r.PricingUnit,
escapeCSVCell(r.ProviderName), escapeCSVCell(r.PublisherName),
ptrOrEmpty(r.ResourceID), escapeCSVCell(r.ResourceName), r.ResourceType,
r.ServiceCategory, escapeCSVCell(r.ServiceName), r.ServiceSubcategory,
r.SkuID, r.SkuPriceID,
ptrOrEmpty(r.SubAccountID), escapeCSVCellPtr(r.SubAccountName), r.SubAccountType,
r.Tags,
escapeCSVCellPtr(r.XProviderResponseID), ptrOrEmpty(r.XInterceptionID), ptrOrEmpty(r.XSessionID), r.XInitiatorID,
r.XCredentialKind, r.XWireProtocol, r.XProviderInstance, r.XClientTool,
ptrOrEmpty(r.XErrorType), r.XPricingStatus, r.XTokenType, strconv.FormatInt(r.XRowCount, 10),
}
}

const rfc3339Micro = "2006-01-02T15:04:05.999999Z07:00"

func ptrOrEmpty(s *string) string {
if s == nil {
return ""
}
return *s
}

// WriteCSV writes rows as a FOCUS CSV document, header first, to w.
// Formula-injection escaping happens only here, applied to a cell just
// before it's written, never on the Row struct itself: WriteParquet and the
// Row values it serializes are never touched, since formula injection is a
// spreadsheet-import risk specific to CSV, and Parquet consumers read typed
// binary data, not evaluated formulas.
func WriteCSV(w io.Writer, rows []Row) error {
cw := csv.NewWriter(w)
if err := cw.Write(Header); err != nil {
return err
}
for _, row := range rows {
if err := cw.Write(csvCells(row)); err != nil {
return err
}
}
cw.Flush()
return cw.Error()
}
108 changes: 108 additions & 0 deletions coderd/aibridge/focus/csv_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package focus_test

import (
"bytes"
"encoding/csv"
"strings"
"testing"

"github.com/coder/coder/v2/coderd/aibridge/focus"
)

// ptr is a small generic helper shared by this package's black-box tests
// (csv_test.go, parquet_test.go) for building a pointer to a literal.
func ptr[T any](v T) *T { return &v }

// TestWriteCSV_EscapesRiskyColumnsOnly confirms the columns the Phase One
// Draft calls out as formula-injection risks are escaped, that the two
// columns explicitly declared safe by construction are left alone, and that
// WriteParquet on the same Row leaves every value untouched.
func TestWriteCSV_EscapesRiskyColumnsOnly(t *testing.T) {
t.Parallel()

formula := "=cmd|' /C calc'!A1"
row := focus.Row{
BillingAccountName: formula,
ChargeDescription: formula,
ResourceName: formula,
ProviderName: formula,
PublisherName: formula,
ServiceName: formula,
InvoiceIssuerName: formula,
SubAccountName: ptr(formula),
XProviderResponseID: ptr(formula),
// Safe by construction: never escaped even though they share the
// same leading character.
XProviderInstance: formula,
XClientTool: formula,

ConsumedUnit: "Tokens",
PricingUnit: "Tokens",
SubAccountType: "Coder Budget Group",
BillingCurrency: "USD",
ChargeCategory: "Usage",
ChargeFrequency: "Usage-Based",
Tags: "{}",
}

var buf bytes.Buffer
if err := focus.WriteCSV(&buf, []focus.Row{row}); err != nil {
t.Fatalf("WriteCSV: %v", err)
}

r := csv.NewReader(&buf)
records, err := r.ReadAll()
if err != nil {
t.Fatalf("parse csv: %v", err)
}
if len(records) != 2 {
t.Fatalf("got %d records, want 2 (header + 1 row)", len(records))
}
if len(records[0]) != len(focus.Header) {
t.Fatalf("header has %d columns, want %d", len(records[0]), len(focus.Header))
}
if len(records[1]) != len(records[0]) {
t.Fatalf("row has %d columns, want %d (matching the header)", len(records[1]), len(records[0]))
}

header := records[0]
values := records[1]
byName := func(name string) string {
for i, h := range header {
if h == name {
return values[i]
}
}
t.Fatalf("column %q not found in header", name)
return ""
}

escapedCols := []string{
"BillingAccountName", "ChargeDescription", "ResourceName",
"ProviderName", "PublisherName", "ServiceName", "InvoiceIssuerName",
"SubAccountName", "x_ProviderResponseId",
}
for _, col := range escapedCols {
got := byName(col)
if !strings.HasPrefix(got, "'") {
t.Errorf("column %s = %q, want a leading single-quote escape", col, got)
}
}

safeCols := []string{"x_ProviderInstance", "x_ClientTool"}
for _, col := range safeCols {
got := byName(col)
if got != formula {
t.Errorf("column %s = %q, want unescaped %q", col, got, formula)
}
}

// The same Row written as Parquet must never see escaping.
var pbuf bytes.Buffer
if err := focus.WriteParquet(&pbuf, []focus.Row{row}); err != nil {
t.Fatalf("WriteParquet: %v", err)
}
if pbuf.Len() == 0 {
t.Fatal("expected non-empty parquet output")
}
}
143 changes: 143 additions & 0 deletions coderd/aibridge/focus/dto.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Package focus maps AI Gateway usage records into FOCUS v1.2-shaped rows for
// the experimental FOCUS export endpoint.
//
// This package is a read-only projection over aibridge_interceptions and
// aibridge_token_usages. It introduces no new source-of-truth table, and
// nothing here changes how usage is recorded or how cost is calculated.
package focus

import (
"time"

"github.com/google/uuid"

"github.com/coder/coder/v2/coderd/database"
)

// TokenType is one of the token categories FOCUS fans a UsageRecord out into.
type TokenType string

const (
TokenTypeInput TokenType = "Input"
TokenTypeOutput TokenType = "Output"
TokenTypeCacheRead TokenType = "CacheRead"
TokenTypeCacheWrite TokenType = "CacheWrite"
)

// Label returns the human-readable token type name used in ChargeDescription,
// e.g. "Input tokens".
func (t TokenType) Label() string {
switch t {
case TokenTypeInput:
return "Input tokens"
case TokenTypeOutput:
return "Output tokens"
case TokenTypeCacheRead:
return "Cache read tokens"
case TokenTypeCacheWrite:
return "Cache write tokens"
default:
return string(t) + " tokens"
}
}

// TokenUsage is one non-zero token-type entry within a UsageRecord. One
// UsageRecord fans out into one FOCUS Row per TokenUsage.
type TokenUsage struct {
Type TokenType
Tokens int64
// UnitPriceMicros is nil when unpriced, never zero-for-unknown.
UnitPriceMicros *int64
}

// BudgetGroup is the resolved sub-account (budget group) a UsageRecord is
// attributed to. Distinct from budget.EffectiveGroup, which carries only a
// group ID and limit and has no Name field.
type BudgetGroup struct {
ID uuid.UUID
Name string
}

// BillingAccount is the resolved account identity for one UsageRecord. It has
// no Coder analog today; both fields are derived by policy from Credential
// and, for BYOK, ProviderInstance and InitiatorID.
type BillingAccount struct {
ID string
Name string
Type string
}

// UsageRecord is one unit of AI Gateway usage with everything the export
// formats need already resolved. It deliberately holds no prompt, response,
// thinking, or tool-call payload, so no output format can leak one.
//
// At raw granularity, one UsageRecord corresponds to exactly one priced
// provider response (one aibridge_token_usages row). At a rolled-up
// granularity, one UsageRecord corresponds to a bucket of provider responses
// sharing every FOCUS dimension except quantity; RowCount then reports how
// many underlying responses were folded together, and the response-level
// identifiers (ResponseID, InterceptionID, SessionID) are empty whenever the
// bucket folded together more than one distinct value for that identifier,
// since no single value would be well-defined.
type UsageRecord struct {
// TokenUsageID becomes ResourceId. At raw granularity this is
// token_usages.id, unique per priced response. At a rolled-up
// granularity there is no natural key, so this is a deterministic
// synthetic UUID derived from the bucket's grouping key, stable for a
// given billing period and mapping version.
TokenUsageID uuid.UUID
// ResponseID becomes x_ProviderResponseId. Empty when not uniquely
// determined (rollup only).
ResponseID string
// InterceptionID becomes x_InterceptionId. Empty when not uniquely
// determined (rollup only).
InterceptionID uuid.UUID
// SessionID becomes x_SessionId. Empty when not uniquely determined
// (rollup only).
SessionID string
ClientTool string // interceptions.client, e.g. Mux
ErrorType *string // interceptions.error_type, nil when the interception succeeded (or, in rollup mode, when every folded response succeeded)

StartedAt time.Time
EndedAt time.Time // already coalesced, never zero

// BillingPeriodAnchor is the instant BillingPeriodStart/End is derived
// from, per the AI Gateway to FOCUS Mapping table: token_usages.created_at
// at raw granularity (i.e. the finest-real-timestamp proxy for "when this
// priced response was recorded"), or the rollup bucket's start at a
// rolled-up granularity. This is deliberately its own field rather than a
// reuse of StartedAt or EndedAt:
// - StartedAt (interception.started_at) is when the request began, not
// when it was recorded/priced; using it can misattribute a request
// that starts in one calendar month but resolves in the next.
// - EndedAt is an exclusive bucket end at rollup granularity, so for the
// final bucket of any month it lands on the first instant of the
// following month, which is the wrong month for BillingPeriodStart/End.
BillingPeriodAnchor time.Time

InitiatorID uuid.UUID
InitiatorUsername string
BudgetGroup *BudgetGroup // nil when unresolved

Model string
// Vendor is the canonical, human-readable vendor label (ProviderName /
// ServiceName / InvoiceIssuerName), resolved from ai_providers.type or the
// wire-protocol fallback label.
Vendor string
// VendorTypeRaw is the short, machine-oriented vendor code used inside
// SkuId/SkuPriceId: ai_providers.type's raw string when a provider row
// resolved, else the wire protocol.
VendorTypeRaw string
Publisher string // derived from the model string
WireProtocol string // interceptions.provider, the upstream API format. Kept distinct from Vendor
ProviderInstance string

Credential database.CredentialKind // centralized or byok. Independent of BillingAccount
BillingAccount BillingAccount // resolved by policy, not switched on Credential's value

Usage []TokenUsage // one entry per token type with non-zero usage

// RowCount is the number of raw aibridge_token_usages rows folded into
// this UsageRecord. Always 1 at raw granularity.
RowCount int64
}
Loading
Loading