Skip to content
Closed
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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/langchain-ai/langsmith-cli
go 1.25.0

require (
github.com/BurntSushi/toml v1.6.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/yamux v0.1.2
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
Expand Down
44 changes: 44 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,50 @@ func (c *Client) RawDelete(ctx context.Context, path string, result any) error {
return c.rawRequest(ctx, http.MethodDelete, path, nil, result)
}

// RawDo performs an arbitrary HTTP request and returns the raw response.
// Unlike RawGet/RawPost/RawDelete, it does not unmarshal the response and
// does not treat 4xx/5xx as errors — callers decide how to handle status codes.
// body may be nil. extraHeaders are merged on top of the default auth headers.
func (c *Client) RawDo(ctx context.Context, method, path string, body io.Reader, extraHeaders http.Header) (statusCode int, respHeaders http.Header, respBody []byte, err error) {
url := c.apiURL + path

req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return 0, nil, nil, fmt.Errorf("creating request: %w", err)
}

req.Header.Set("x-api-key", c.apiKey)
req.Header.Set("Content-Type", "application/json")
if wsID := os.Getenv("LANGSMITH_WORKSPACE_ID"); wsID != "" {
req.Header.Set("x-tenant-id", wsID)
}
for k, vals := range extraHeaders {
for _, v := range vals {
req.Header.Set(k, v)
}
}

httpClient := &http.Client{Timeout: 30 * time.Second}
resp, err := httpClient.Do(req)
if err != nil {
return 0, nil, nil, fmt.Errorf("HTTP %s %s: %w", method, path, err)
}
defer resp.Body.Close()

respBody, err = io.ReadAll(resp.Body)
if err != nil {
return 0, nil, nil, fmt.Errorf("reading response: %w", err)
}

return resp.StatusCode, resp.Header, respBody, nil
}

// APIKey returns the client's API key.
func (c *Client) APIKey() string { return c.apiKey }

// APIURL returns the client's normalized API URL.
func (c *Client) APIURL() string { return c.apiURL }

func (c *Client) rawRequest(ctx context.Context, method, path string, body any, result any) error {
url := c.apiURL + path

Expand Down
119 changes: 115 additions & 4 deletions internal/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package client
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
Expand Down Expand Up @@ -223,8 +224,6 @@ func TestRawRequest_SetsAPIKeyHeader(t *testing.T) {
}

func TestRawRequest_SetsWorkspaceHeader(t *testing.T) {
t.Setenv("LANGSMITH_WORKSPACE_ID", "ws-123")

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("x-tenant-id"); got != "ws-123" {
t.Errorf("expected x-tenant-id=ws-123, got %q", got)
Expand All @@ -234,13 +233,12 @@ func TestRawRequest_SetsWorkspaceHeader(t *testing.T) {
}))
defer ts.Close()

t.Setenv("LANGSMITH_WORKSPACE_ID", "ws-123")
c := New("key", ts.URL)
_ = c.RawGet(context.Background(), "/test", nil)
}

func TestRawRequest_NoWorkspaceHeaderWhenUnset(t *testing.T) {
t.Setenv("LANGSMITH_WORKSPACE_ID", "")

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("x-tenant-id"); got != "" {
t.Errorf("expected empty x-tenant-id, got %q", got)
Comment on lines 241 to 244
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Test TestRawRequest_NoWorkspaceHeaderWhenUnset is environment-dependent after removing t.Setenv

The removal of t.Setenv("LANGSMITH_WORKSPACE_ID", "") from TestRawRequest_NoWorkspaceHeaderWhenUnset makes the test fail if the developer or CI environment has LANGSMITH_WORKSPACE_ID set to a non-empty value. The client.New() function (internal/client/client.go:50) and rawRequest() (internal/client/client.go:162) both read this env var and will set the x-tenant-id header, causing the assertion at line 243 to fail. The previous code explicitly cleared this variable, ensuring the test was self-contained regardless of the environment.

(Refers to lines 241-253)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Expand Down Expand Up @@ -315,3 +313,116 @@ func TestRawRequest_500Error(t *testing.T) {
t.Errorf("expected 500 in error, got %q", err.Error())
}
}

// ---------- RawDo ----------

func TestRawDo_ReturnsStatusAndBody(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "PATCH" {
t.Errorf("expected PATCH, got %s", r.Method)
}
if r.URL.Path != "/api/v1/sessions" {
t.Errorf("expected /api/v1/sessions, got %s", r.URL.Path)
}
if r.Header.Get("x-api-key") != "my-key" {
t.Errorf("expected x-api-key=my-key, got %q", r.Header.Get("x-api-key"))
}
w.Header().Set("X-Request-Id", "req-abc")
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"id":"123"}`))
}))
defer ts.Close()

c := New("my-key", ts.URL)
status, hdr, body, err := c.RawDo(context.Background(), "PATCH", "/api/v1/sessions", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status != 200 {
t.Errorf("expected status 200, got %d", status)
}
if hdr.Get("X-Request-Id") != "req-abc" {
t.Errorf("expected X-Request-Id=req-abc, got %q", hdr.Get("X-Request-Id"))
}
if string(body) != `{"id":"123"}` {
t.Errorf("expected body {\"id\":\"123\"}, got %q", string(body))
}
}

func TestRawDo_WithBodyReader(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data, _ := io.ReadAll(r.Body)
w.WriteHeader(201)
_, _ = w.Write(data)
}))
defer ts.Close()

c := New("key", ts.URL)
status, _, body, err := c.RawDo(context.Background(), "POST", "/create", strings.NewReader(`{"name":"test"}`), nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status != 201 {
t.Errorf("expected 201, got %d", status)
}
if string(body) != `{"name":"test"}` {
t.Errorf("unexpected body: %s", body)
}
}

func TestRawDo_ExtraHeaders(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Custom") != "hello" {
t.Errorf("expected X-Custom=hello, got %q", r.Header.Get("X-Custom"))
}
if r.Header.Get("x-api-key") != "key" {
t.Errorf("expected x-api-key=key, got %q", r.Header.Get("x-api-key"))
}
w.WriteHeader(200)
_, _ = w.Write([]byte("{}"))
}))
defer ts.Close()

c := New("key", ts.URL)
extra := http.Header{"X-Custom": []string{"hello"}}
_, _, _, err := c.RawDo(context.Background(), "GET", "/test", nil, extra)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

func TestRawDo_Returns4xxWithoutError(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(422)
_, _ = w.Write([]byte(`{"detail":"invalid"}`))
}))
defer ts.Close()

c := New("key", ts.URL)
status, _, body, err := c.RawDo(context.Background(), "GET", "/test", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status != 422 {
t.Errorf("expected 422, got %d", status)
}
if string(body) != `{"detail":"invalid"}` {
t.Errorf("unexpected body: %s", body)
}
}

// ---------- Accessors ----------

func TestAPIKey(t *testing.T) {
c := New("secret", "http://localhost")
if c.APIKey() != "secret" {
t.Errorf("expected secret, got %q", c.APIKey())
}
}

func TestAPIURL(t *testing.T) {
c := New("key", "http://localhost:1234")
if c.APIURL() != "http://localhost:1234" {
t.Errorf("expected http://localhost:1234, got %q", c.APIURL())
}
}
84 changes: 84 additions & 0 deletions internal/cmd/api/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package api

import (
"fmt"
"os"
"strings"

"github.com/spf13/cobra"
)

// NewCmd creates the top-level `langsmith api` command.
func NewCmd() *cobra.Command {
var (
body string
headers []string
include bool
)

cmd := &cobra.Command{
Use: "api",
Short: "Browse API endpoints and make authenticated requests",
Long: `Browse LangSmith API endpoints and make authenticated HTTP requests.

Browse endpoints:
langsmith api ls List all endpoints
langsmith api ls --tag datasets Filter by tag
langsmith api ls --search "create" Search endpoints
langsmith api info GET sessions Show endpoint details

Make requests:
langsmith api GET sessions?limit=5
langsmith api POST runs/query --body '{"session_id":"abc"}'
langsmith api DELETE sessions/abc-123
langsmith api POST datasets --body @body.json
echo '{"name":"x"}' | langsmith api POST sessions --body @-
langsmith api GET sessions --include`,
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) < 2 {
return cmd.Help()
}

method := strings.ToUpper(args[0])
if !isHTTPMethod(method) {
return fmt.Errorf("unknown subcommand or HTTP method: %q\nRun 'langsmith api --help' for usage", args[0])
}

path := args[1]

apiKey := resolveAPIKey(cmd)
if apiKey == "" {
return fmt.Errorf("LANGSMITH_API_KEY not set")
}
apiURL := resolveAPIURL(cmd)

w := cmd.OutOrStdout()
statusCode, err := runRequest(apiURL, apiKey, method, path, body, headers, include, w)
if err != nil {
return err
}
if statusCode >= 400 {
os.Exit(1)
}
return nil
},
}

// Flags for request mode
cmd.Flags().StringVar(&body, "body", "", `Request body (JSON string, @file, or @- for stdin)`)
cmd.Flags().StringArrayVarP(&headers, "header", "H", nil, "Additional headers (Key:Value, repeatable)")
cmd.Flags().BoolVarP(&include, "include", "i", false, "Include HTTP response headers in output")

// These persistent flags mirror root's so the api command works standalone
// in tests. When registered under root, cobra's flag inheritance means
// the root's values take precedence when set via CLI.
cmd.PersistentFlags().String("api-key", "", "LangSmith API key [env: LANGSMITH_API_KEY]")
cmd.PersistentFlags().String("api-url", "", "LangSmith API URL [env: LANGSMITH_ENDPOINT]")
cmd.PersistentFlags().String("format", "json", "Output format: json or pretty")

cmd.AddCommand(newLsCmd())
cmd.AddCommand(newInfoCmd())

return cmd
}
Loading