From 96bac188c18d36ad6bfa9439da126e61484edcd6 Mon Sep 17 00:00:00 2001 From: Anirudh Sriram Date: Thu, 28 May 2026 11:38:26 -0700 Subject: [PATCH 1/2] feat(run): add --version v2 flag to runs query path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in `--version` flag to `run list`, `run get`, and `run export` that routes through the new SmithDB-backed v2 endpoint (`POST /v2/runs/query`) via `lib/runsv2` in langsmith-go. - Project name is resolved to a session UUID and sent as `project_ids`. - Filter flags are translated to the v2 body (split start/end into `min_start_time` / `max_start_time`, `error` -> `has_error`, etc). - v2 responses are normalized back into the v1 `RunSchema` shape so the existing extract/output pipeline is unchanged. - README adds agent guidance: try `--version v2` first, fall back to v1 if it errors. The CLI surfaces v2 errors as-is — fallback is the caller's (agent's) job, not the CLI's, so the path served is always explicit. Also bumps `langsmith-go` to pick up `lib/runsv2`, which forces a mechanical type fix in `extract.go` / `trace_stats.go` for cost fields that moved from string -> float64 between v0.10.0 and current main. LSEN-149. --- README.md | 7 + go.mod | 14 +- go.sum | 28 ++-- internal/cmd/filters.go | 6 + internal/cmd/helpers.go | 264 +++++++++++++++++++++++++++++++ internal/cmd/run.go | 69 ++++++-- internal/cmd/trace_stats.go | 4 +- internal/extract/extract.go | 15 +- internal/extract/extract_test.go | 6 +- 9 files changed, 362 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index add63ed..2b306da 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,13 @@ langsmith run get --full langsmith run export llm_calls.jsonl --project my-app --run-type llm --full ``` +> **For agents querying runs:** prefer `--version v2` first (SmithDB-backed; faster on tenants that are rolled out). If the call fails with a 4xx (typically 403, 404, or 422), retry the **same command without** `--version` to fall back to v1. Example: +> +> ```bash +> langsmith run list --project my-app --version v2 \ +> || langsmith run list --project my-app +> ``` + ### `thread` — Query conversation threads A thread groups multiple root runs sharing a thread_id (multi-turn conversations). diff --git a/go.mod b/go.mod index b13184c..fcc657d 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,13 @@ go 1.25.0 require ( github.com/google/uuid v1.6.0 github.com/itchyny/gojq v0.12.15 - github.com/langchain-ai/langsmith-go v0.10.0 + github.com/langchain-ai/langsmith-go v0.14.1-0.20260528183050-669ac70d7d2d github.com/olekukonko/tablewriter v0.0.5 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.11.1 github.com/xlab/treeprint v1.2.0 - golang.org/x/net v0.52.0 - golang.org/x/term v0.41.0 + golang.org/x/net v0.54.0 + golang.org/x/term v0.43.0 ) require ( @@ -23,12 +23,12 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/itchyny/timefmt-go v0.1.5 // indirect - github.com/klauspost/compress v1.18.4 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/gjson v1.19.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect @@ -40,8 +40,8 @@ require ( go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/grpc v1.80.0 // indirect diff --git a/go.sum b/go.sum index 0e8a617..2ee07a5 100644 --- a/go.sum +++ b/go.sum @@ -25,14 +25,14 @@ github.com/itchyny/gojq v0.12.15 h1:WC1Nxbx4Ifw5U2oQWACYz32JK8G9qxNtHzrvW4KEcqI= github.com/itchyny/gojq v0.12.15/go.mod h1:uWAHCbCIla1jiNxmeT5/B5mOjSdfkCq6p8vxWg+BM10= github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE= github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/langchain-ai/langsmith-go v0.10.0 h1:crrioqgBBKY+rTYhFW0NujhxBUoq6iARv12bP9xR7mg= -github.com/langchain-ai/langsmith-go v0.10.0/go.mod h1:hUFPP9siu6sGSodcZo/c3uUG5xcXRdqZ8gN899R1OyE= +github.com/langchain-ai/langsmith-go v0.14.1-0.20260528183050-669ac70d7d2d h1:wUWBinpUgAP1u7JyIAePW3RLP2wanHc+VC9RodFti+8= +github.com/langchain-ai/langsmith-go v0.14.1-0.20260528183050-669ac70d7d2d/go.mod h1:ghwFdWr6zS7s3rdMMtEQDU1xWzineFzXa1SEG2XA3XY= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= @@ -55,8 +55,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= @@ -86,14 +86,14 @@ go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpu go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= diff --git a/internal/cmd/filters.go b/internal/cmd/filters.go index a8ae94b..a07a891 100644 --- a/internal/cmd/filters.go +++ b/internal/cmd/filters.go @@ -29,6 +29,7 @@ type FilterFlags struct { Tags string Metadata string RawFilter string + Version string } // addCommonFilterFlags attaches shared filter flags to a command. @@ -55,6 +56,11 @@ func addCommonFilterFlags(cmd *cobra.Command, f *FilterFlags, includeRunType boo } } +// addVersionFlag attaches the --version flag for selecting the runs query backend. +func addVersionFlag(cmd *cobra.Command, f *FilterFlags) { + cmd.Flags().StringVar(&f.Version, "version", "", `Query API version: "" (v1, default) or "v2" (SmithDB)`) +} + // resolveStartTime returns the start time for a query. // Priority: lastNMinutes > since > default (7 days ago). func resolveStartTime(since string, lastNMinutes int) time.Time { diff --git a/internal/cmd/helpers.go b/internal/cmd/helpers.go index 8eccdff..04b31c1 100644 --- a/internal/cmd/helpers.go +++ b/internal/cmd/helpers.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "time" @@ -9,6 +10,7 @@ import ( "github.com/langchain-ai/langsmith-cli/internal/extract" "github.com/langchain-ai/langsmith-cli/internal/output" langsmith "github.com/langchain-ai/langsmith-go" + "github.com/langchain-ai/langsmith-go/lib/runsv2" "github.com/google/uuid" ) @@ -56,6 +58,268 @@ func queryRuns(ctx context.Context, c *client.Client, params langsmith.RunQueryP return allRuns, nil } +// queryRunsV2 queries runs against the v2 (SmithDB) endpoint, resolves the +// project name to a session UUID, paginates on next_cursor, and normalizes +// each v2 Run back into v1's RunSchema so downstream rendering is unchanged. +// minTokens > 0 enables client-side filtering by total_tokens. +func queryRunsV2(ctx context.Context, c *client.Client, body runsv2.QueryRequest, projectName string, limit int, minTokens int) ([]langsmith.RunSchema, error) { + if projectName != "" { + sessionID, err := c.ResolveSessionID(ctx, projectName) + if err != nil { + return nil, err + } + body.ProjectIDs = []string{sessionID} + } + + v2Client := runsv2.NewClient(c.APIURL(), c.APIKey()) + + var allRuns []langsmith.RunSchema + remaining := limit + + for { + resp, err := v2Client.Query(ctx, body) + if err != nil { + return nil, fmt.Errorf("querying runs (v2): %w", err) + } + + for i := range resp.Items { + if remaining <= 0 { + return allRuns, nil + } + run := runV2ToSchema(resp.Items[i]) + if minTokens > 0 && run.TotalTokens < int64(minTokens) { + continue + } + allRuns = append(allRuns, run) + remaining-- + } + + if !resp.HasMore || resp.NextCursor == nil || *resp.NextCursor == "" || remaining <= 0 { + break + } + body.Cursor = resp.NextCursor + } + + return allRuns, nil +} + +// buildRunQueryV2Params translates FilterFlags into a v2 query body. The +// project name is left for queryRunsV2 to resolve into ProjectIDs. +func buildRunQueryV2Params(f *FilterFlags, isRoot bool, defaultLimit int) runsv2.QueryRequest { + body := runsv2.QueryRequest{ + SortOrder: runsv2.Ptr(runsv2.SortOrderDesc), + } + + limit := defaultLimit + if f.Limit > 0 { + limit = f.Limit + } + body.PageSize = runsv2.Ptr(uint64(limit)) + + if isRoot { + body.IsRoot = runsv2.Ptr(true) + } + + body.MinStartTime = runsv2.Ptr(resolveStartTime(f.Since, f.LastNMinutes).Format(time.RFC3339)) + if f.Before != "" { + t, err := time.Parse(time.RFC3339, f.Before) + if err != nil { + t, err = time.Parse("2006-01-02T15:04:05", f.Before) + if err != nil { + ExitErrorf("invalid --before timestamp: %s", f.Before) + } + } + body.MaxStartTime = runsv2.Ptr(t.UTC().Format(time.RFC3339)) + } + + if f.RunType != "" { + body.RunType = runsv2.Ptr(f.RunType) + } + + if f.ErrorFlag { + body.HasError = runsv2.Ptr(true) + } else if f.NoErrorFlag { + body.HasError = runsv2.Ptr(false) + } + + if f.TraceIDs != "" { + ids := splitTrim(f.TraceIDs) + if len(ids) == 1 { + body.TraceID = runsv2.Ptr(ids[0]) + } + } + + if s := buildFilterDSL(f); s != "" { + body.Filter = runsv2.Ptr(s) + } + + return body +} + +// buildRunSelectV2 returns the v2 select-field set covering the same base +// fields used by the downstream RunSchema pipeline plus the optional groups +// requested by the include flags. +func buildRunSelectV2(includeIO, includeFeedback bool) []runsv2.SelectField { + fields := []runsv2.SelectField{ + runsv2.SelectID, + runsv2.SelectTraceID, + runsv2.SelectName, + runsv2.SelectRunType, + runsv2.SelectStatus, + runsv2.SelectStartTime, + runsv2.SelectEndTime, + runsv2.SelectParentRunIDs, + runsv2.SelectProjectID, + runsv2.SelectDottedOrder, + runsv2.SelectIsRoot, + runsv2.SelectExtra, + runsv2.SelectMetadata, + runsv2.SelectTags, + runsv2.SelectPromptTokens, + runsv2.SelectCompletionTokens, + runsv2.SelectTotalTokens, + runsv2.SelectPromptCost, + runsv2.SelectCompletionCost, + runsv2.SelectTotalCost, + runsv2.SelectLatencySeconds, + runsv2.SelectAppPath, + } + if includeIO { + fields = append(fields, runsv2.SelectInputs, runsv2.SelectOutputs, runsv2.SelectError) + } + if includeFeedback { + fields = append(fields, runsv2.SelectFeedbackStats) + } + return fields +} + +// runV2ToSchema converts a v2 Run into the legacy v1 RunSchema shape so the +// existing extract/output pipeline can consume it unchanged. +func runV2ToSchema(r runsv2.Run) langsmith.RunSchema { + out := langsmith.RunSchema{ + Inputs: decodeJSONMap(r.Inputs), + Outputs: decodeJSONMap(r.Outputs), + Extra: decodeJSONMap(r.Extra), + FeedbackStats: decodeFeedbackStats(r.FeedbackStats), + } + if r.ID != nil { + out.ID = *r.ID + } + if r.TraceID != nil { + out.TraceID = *r.TraceID + } + if r.Name != nil { + out.Name = *r.Name + } + if r.RunType != nil { + out.RunType = langsmith.RunTypeEnum(*r.RunType) + } + if r.Status != nil { + out.Status = *r.Status + } + if r.StartTime != nil { + if t, err := time.Parse(time.RFC3339, *r.StartTime); err == nil { + out.StartTime = t + } + } + if r.EndTime != nil { + if t, err := time.Parse(time.RFC3339, *r.EndTime); err == nil { + out.EndTime = t + } + } + if r.FirstTokenTime != nil { + if t, err := time.Parse(time.RFC3339, *r.FirstTokenTime); err == nil { + out.FirstTokenTime = t + } + } + if r.ProjectID != nil { + out.SessionID = *r.ProjectID + } + if len(r.ParentRunIDs) > 0 { + out.ParentRunIDs = r.ParentRunIDs + out.ParentRunID = r.ParentRunIDs[len(r.ParentRunIDs)-1] + } + if r.DottedOrder != nil { + out.DottedOrder = *r.DottedOrder + } + if len(r.Tags) > 0 { + out.Tags = r.Tags + } + if r.ThreadID != nil { + out.ThreadID = *r.ThreadID + } + if r.AppPath != nil { + out.AppPath = *r.AppPath + } + if r.TotalTokens != nil { + out.TotalTokens = *r.TotalTokens + } + if r.PromptTokens != nil { + out.PromptTokens = *r.PromptTokens + } + if r.CompletionTokens != nil { + out.CompletionTokens = *r.CompletionTokens + } + if r.TotalCost != nil { + out.TotalCost = *r.TotalCost + } + if r.PromptCost != nil { + out.PromptCost = *r.PromptCost + } + if r.CompletionCost != nil { + out.CompletionCost = *r.CompletionCost + } + if r.LatencySeconds != nil { + // duration_ms is derived from EndTime-StartTime by the extractor; + // nothing to set on RunSchema for latency directly. + _ = r.LatencySeconds + } + if r.Error != nil { + out.Error = *r.Error + } + if r.InputsPreview != nil { + out.InputsPreview = *r.InputsPreview + } + if r.OutputsPreview != nil { + out.OutputsPreview = *r.OutputsPreview + } + if r.ReferenceExampleID != nil { + out.ReferenceExampleID = *r.ReferenceExampleID + } + if r.ReferenceDatasetID != nil { + out.ReferenceDatasetID = *r.ReferenceDatasetID + } + if r.PriceModelID != nil { + out.PriceModelID = *r.PriceModelID + } + if r.IsInDataset != nil { + out.InDataset = *r.IsInDataset + } + return out +} + +func decodeJSONMap(raw json.RawMessage) map[string]interface{} { + if len(raw) == 0 { + return nil + } + var m map[string]interface{} + if err := json.Unmarshal(raw, &m); err != nil { + return nil + } + return m +} + +func decodeFeedbackStats(raw json.RawMessage) map[string]map[string]interface{} { + if len(raw) == 0 { + return nil + } + var m map[string]map[string]interface{} + if err := json.Unmarshal(raw, &m); err != nil { + return nil + } + return m +} + // buildRunSelect returns the Select fields needed for the given include flags. // Returns nil when neither IO nor feedback is requested, letting the API use its defaults. // When set, includes all base/metadata fields so they aren't stripped from the response. diff --git a/internal/cmd/run.go b/internal/cmd/run.go index defa44a..dd8f8d6 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -3,10 +3,12 @@ package cmd import ( "context" "os" + "time" "github.com/langchain-ai/langsmith-cli/internal/extract" "github.com/langchain-ai/langsmith-cli/internal/output" langsmith "github.com/langchain-ai/langsmith-go" + "github.com/langchain-ai/langsmith-go/lib/runsv2" "github.com/spf13/cobra" ) @@ -65,11 +67,19 @@ func newRunListCmd() *cobra.Command { ExitError("--project is required for run list (or set LANGSMITH_PROJECT)") } - params := BuildRunQueryParams(&ff, false, ff.Limit) - if sel := buildRunSelect(includeIO, includeFeedback); sel != nil { - params.Select = langsmith.F(sel) + var runs []langsmith.RunSchema + var err error + if ff.Version == "v2" { + body := buildRunQueryV2Params(&ff, false, ff.Limit) + body.Selects = buildRunSelectV2(includeIO, includeFeedback) + runs, err = queryRunsV2(ctx, c, body, projectName, ff.Limit, ff.MinTokens) + } else { + params := BuildRunQueryParams(&ff, false, ff.Limit) + if sel := buildRunSelect(includeIO, includeFeedback); sel != nil { + params.Select = langsmith.F(sel) + } + runs, err = queryRuns(ctx, c, params, projectName, ff.Limit, ff.MinTokens) } - runs, err := queryRuns(ctx, c, params, projectName, ff.Limit, ff.MinTokens) if err != nil { ExitErrorf("%v", err) } @@ -87,6 +97,7 @@ func newRunListCmd() *cobra.Command { } addCommonFilterFlags(cmd, &ff, true) + addVersionFlag(cmd, &ff) cmd.Flags().BoolVar(&includeMetadata, "include-metadata", false, "Add status, duration_ms, token_usage, costs, tags, custom_metadata (incl. revision_id)") cmd.Flags().BoolVar(&includeIO, "include-io", false, "Add inputs, outputs, and error fields") cmd.Flags().BoolVar(&includeFeedback, "include-feedback", false, "Add feedback_stats field") @@ -101,6 +112,7 @@ func newRunGetCmd() *cobra.Command { project string since string lastNMinutes int + version string includeMetadata bool includeIO bool includeFeedback bool @@ -128,16 +140,29 @@ func newRunGetCmd() *cobra.Command { ExitError("--project is required for run get (or set LANGSMITH_PROJECT)") } - params := langsmith.RunQueryParams{ - ID: langsmith.F([]string{runID}), - Limit: langsmith.F(int64(1)), - StartTime: langsmith.F(resolveStartTime(since, lastNMinutes)), - } - if sel := buildRunSelect(includeIO, includeFeedback); sel != nil { - params.Select = langsmith.F(sel) + var runs []langsmith.RunSchema + var err error + if version == "v2" { + minStart := resolveStartTime(since, lastNMinutes).UTC().Format(time.RFC3339) + body := runsv2.QueryRequest{ + IDs: []string{runID}, + MinStartTime: &minStart, + PageSize: runsv2.Ptr(uint64(1)), + SortOrder: runsv2.Ptr(runsv2.SortOrderDesc), + Selects: buildRunSelectV2(includeIO, includeFeedback), + } + runs, err = queryRunsV2(ctx, c, body, projectName, 1, 0) + } else { + params := langsmith.RunQueryParams{ + ID: langsmith.F([]string{runID}), + Limit: langsmith.F(int64(1)), + StartTime: langsmith.F(resolveStartTime(since, lastNMinutes)), + } + if sel := buildRunSelect(includeIO, includeFeedback); sel != nil { + params.Select = langsmith.F(sel) + } + runs, err = queryRuns(ctx, c, params, projectName, 1, 0) } - - runs, err := queryRuns(ctx, c, params, projectName, 1, 0) if err != nil { ExitErrorf("fetching run: %v", err) } @@ -159,6 +184,7 @@ func newRunGetCmd() *cobra.Command { cmd.Flags().StringVar(&project, "project", "", "Project name [env: LANGSMITH_PROJECT]") cmd.Flags().StringVar(&since, "since", "", "Only include runs after this timestamp, e.g. 2024-01-15T00:00:00Z (overrides 7-day default)") cmd.Flags().IntVar(&lastNMinutes, "last-n-minutes", 0, "Only include runs from the last N minutes, e.g. 60 (overrides 7-day default)") + cmd.Flags().StringVar(&version, "version", "", `Query API version: "" (v1, default) or "v2" (SmithDB)`) cmd.Flags().BoolVar(&includeMetadata, "include-metadata", false, "Add status, duration_ms, token_usage, costs, tags, custom_metadata (incl. revision_id)") cmd.Flags().BoolVar(&includeIO, "include-io", false, "Add inputs, outputs, and error fields") cmd.Flags().BoolVar(&includeFeedback, "include-feedback", false, "Add feedback_stats field") @@ -201,11 +227,19 @@ func newRunExportCmd() *cobra.Command { ExitError("--project is required for run export (or set LANGSMITH_PROJECT)") } - params := BuildRunQueryParams(&ff, false, ff.Limit) - if sel := buildRunSelect(includeIO, includeFeedback); sel != nil { - params.Select = langsmith.F(sel) + var runs []langsmith.RunSchema + var err error + if ff.Version == "v2" { + body := buildRunQueryV2Params(&ff, false, ff.Limit) + body.Selects = buildRunSelectV2(includeIO, includeFeedback) + runs, err = queryRunsV2(ctx, c, body, projectName, ff.Limit, ff.MinTokens) + } else { + params := BuildRunQueryParams(&ff, false, ff.Limit) + if sel := buildRunSelect(includeIO, includeFeedback); sel != nil { + params.Select = langsmith.F(sel) + } + runs, err = queryRuns(ctx, c, params, projectName, ff.Limit, ff.MinTokens) } - runs, err := queryRuns(ctx, c, params, projectName, ff.Limit, ff.MinTokens) if err != nil { ExitErrorf("%v", err) } @@ -216,6 +250,7 @@ func newRunExportCmd() *cobra.Command { } addCommonFilterFlags(cmd, &ff, true) + addVersionFlag(cmd, &ff) cmd.Flags().BoolVar(&includeMetadata, "include-metadata", false, "Add status, duration_ms, token_usage, costs, tags, custom_metadata (incl. revision_id)") cmd.Flags().BoolVar(&includeIO, "include-io", false, "Add inputs, outputs, and error fields") cmd.Flags().BoolVar(&includeFeedback, "include-feedback", false, "Add feedback_stats field") diff --git a/internal/cmd/trace_stats.go b/internal/cmd/trace_stats.go index 6e4701d..4355360 100644 --- a/internal/cmd/trace_stats.go +++ b/internal/cmd/trace_stats.go @@ -165,13 +165,13 @@ func fetchRunStats(ctx context.Context, c *client.Client, sessionID, since, befo } } -func toRunStats(runCount int64, latencyP50, latencyP99 float64, totalTokens, promptTokens, completionTokens int64, totalCost string, errorRate float64, feedbackStats map[string]interface{}) runStats { +func toRunStats(runCount int64, latencyP50, latencyP99 float64, totalTokens, promptTokens, completionTokens int64, totalCost float64, errorRate float64, feedbackStats map[string]interface{}) runStats { fs := make(map[string]any, len(feedbackStats)) for k, v := range feedbackStats { fs[k] = v } var cost any - if totalCost != "" { + if totalCost > 0 { cost = totalCost } return runStats{ diff --git a/internal/extract/extract.go b/internal/extract/extract.go index b92863d..5e63ed9 100644 --- a/internal/extract/extract.go +++ b/internal/extract/extract.go @@ -2,7 +2,6 @@ package extract import ( "fmt" - "strconv" "time" langsmith "github.com/langchain-ai/langsmith-go" @@ -48,16 +47,16 @@ func ExtractRun(run langsmith.RunSchema, includeMetadata, includeIO, includeFeed } var costs map[string]any - if run.PromptCost != "" || run.CompletionCost != "" || run.TotalCost != "" { + if run.PromptCost > 0 || run.CompletionCost > 0 || run.TotalCost > 0 { costs = map[string]any{} - if f, err := strconv.ParseFloat(run.PromptCost, 64); err == nil && f > 0 { - costs["prompt_cost"] = f + if run.PromptCost > 0 { + costs["prompt_cost"] = run.PromptCost } - if f, err := strconv.ParseFloat(run.CompletionCost, 64); err == nil && f > 0 { - costs["completion_cost"] = f + if run.CompletionCost > 0 { + costs["completion_cost"] = run.CompletionCost } - if f, err := strconv.ParseFloat(run.TotalCost, 64); err == nil && f > 0 { - costs["total_cost"] = f + if run.TotalCost > 0 { + costs["total_cost"] = run.TotalCost } } if len(costs) == 0 { diff --git a/internal/extract/extract_test.go b/internal/extract/extract_test.go index 5399bde..79c3974 100644 --- a/internal/extract/extract_test.go +++ b/internal/extract/extract_test.go @@ -63,9 +63,9 @@ func TestExtractRunWithMetadata(t *testing.T) { PromptTokens: 100, CompletionTokens: 50, TotalTokens: 150, - PromptCost: "0.001", - CompletionCost: "0.0005", - TotalCost: "0.0015", + PromptCost: 0.001, + CompletionCost: 0.0005, + TotalCost: 0.0015, Tags: []string{"production", "v2"}, Extra: map[string]any{"metadata": map[string]any{"model": "gpt-4"}}, } From 0f0af4f571b32bd125a11c05cb5929d754e0f4b2 Mon Sep 17 00:00:00 2001 From: Anirudh Sriram Date: Fri, 29 May 2026 13:39:49 -0700 Subject: [PATCH 2/2] fix(run): preserve custom_metadata and drop empty status in v2 path The v2 (SmithDB) endpoint returns metadata as a separate top-level field, but the extractor reads custom_metadata (incl. revision_id) from Extra["metadata"]. Fold r.Metadata back into Extra so --include-metadata keeps working under --version v2, without clobbering a nested metadata that some response paths already include in extra. SmithDB does not populate status (returns null), so stop selecting and mapping it rather than surfacing a misleading empty value. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/cmd/helpers.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/internal/cmd/helpers.go b/internal/cmd/helpers.go index 04b31c1..6652b5e 100644 --- a/internal/cmd/helpers.go +++ b/internal/cmd/helpers.go @@ -165,7 +165,6 @@ func buildRunSelectV2(includeIO, includeFeedback bool) []runsv2.SelectField { runsv2.SelectTraceID, runsv2.SelectName, runsv2.SelectRunType, - runsv2.SelectStatus, runsv2.SelectStartTime, runsv2.SelectEndTime, runsv2.SelectParentRunIDs, @@ -196,10 +195,20 @@ func buildRunSelectV2(includeIO, includeFeedback bool) []runsv2.SelectField { // runV2ToSchema converts a v2 Run into the legacy v1 RunSchema shape so the // existing extract/output pipeline can consume it unchanged. func runV2ToSchema(r runsv2.Run) langsmith.RunSchema { + extra := decodeJSONMap(r.Extra) + if md := decodeJSONMap(r.Metadata); md != nil { + if extra == nil { + extra = map[string]interface{}{} + } + if _, ok := extra["metadata"]; !ok { + extra["metadata"] = md + } + } + out := langsmith.RunSchema{ Inputs: decodeJSONMap(r.Inputs), Outputs: decodeJSONMap(r.Outputs), - Extra: decodeJSONMap(r.Extra), + Extra: extra, FeedbackStats: decodeFeedbackStats(r.FeedbackStats), } if r.ID != nil { @@ -214,9 +223,6 @@ func runV2ToSchema(r runsv2.Run) langsmith.RunSchema { if r.RunType != nil { out.RunType = langsmith.RunTypeEnum(*r.RunType) } - if r.Status != nil { - out.Status = *r.Status - } if r.StartTime != nil { if t, err := time.Parse(time.RFC3339, *r.StartTime); err == nil { out.StartTime = t