Skip to content
Merged
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
22 changes: 11 additions & 11 deletions cmd/lk/analytics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1481,7 +1481,7 @@ func logsAPI(t *testing.T, status int, body string) (*public.Client, *url.Values
// asked for and, when a page is empty, says why: no records at those levels,
// none at all, or no more.
func TestFetchSessionLogs(t *testing.T) {
const page = `{"records":[` +
const page = `{"items":[` +
`{"id":"log_1","level":"LOG_LEVEL_WARN","logger":"app","message":"slow tts"},` +
`{"id":"log_2","level":"LOG_LEVEL_ERROR","logger":"app","message":"tts failed"}],` +
`"pageInfo":{"nextCursor":"c2","hasMore":true}}`
Expand All @@ -1503,7 +1503,7 @@ func TestFetchSessionLogs(t *testing.T) {
},
{
name: "no records at those levels",
body: `{"records":[]}`,
body: `{"items":[]}`,
opts: public.LogOptions{Levels: []string{"fatal"}},
wantQuery: url.Values{"logLevels": {"LOG_LEVEL_FATAL"}},
wantStatus: []string{"Session RM_1 has no agent log records at the levels asked for (fatal)"},
Expand All @@ -1516,7 +1516,7 @@ func TestFetchSessionLogs(t *testing.T) {
},
{
name: "last page",
body: `{"records":[]}`,
body: `{"items":[]}`,
opts: public.LogOptions{PageOptions: public.PageOptions{Cursor: "c2"}},
wantQuery: url.Values{"page.cursor": {"c2"}},
wantStatus: []string{"No more log records"},
Expand Down Expand Up @@ -1546,7 +1546,7 @@ func TestFetchSessionLogs(t *testing.T) {
// TestFetchSessionLogsJSON checks --json prints the records and still
// explains an empty page on stderr.
func TestFetchSessionLogsJSON(t *testing.T) {
client, _ := logsAPI(t, http.StatusOK, `{"records":[]}`)
client, _ := logsAPI(t, http.StatusOK, `{"items":[]}`)
stdout, stderr := captureOut(t)

require.NoError(t, fetchSessionLogs(context.Background(), client, "p1", "RM_1", public.LogOptions{}, true))
Expand Down Expand Up @@ -1702,11 +1702,11 @@ func tracesAPI(t *testing.T, status int, pages map[string]string) (*public.Clien
// tracePages is a session's trace across two pages, the second starting with
// the children of a span on the first.
var tracePages = map[string]string{
"": `{"spans":[` +
"": `{"items":[` +
`{"spanId":"a1","name":"agent_session"},` +
`{"spanId":"b2","parentSpanId":"a1","name":"agent_turn"}],` +
`"pageInfo":{"nextCursor":"c2","hasMore":true}}`,
"c2": `{"spans":[` +
"c2": `{"items":[` +
`{"spanId":"c3","parentSpanId":"b2","name":"llm_request"},` +
`{"spanId":"d4","parentSpanId":"b2","name":"tts_request"}]}`,
}
Expand Down Expand Up @@ -1792,7 +1792,7 @@ func TestFetchSessionTracesEmpty(t *testing.T) {
{wantStatus: "Session RM_1 has no trace spans"},
{cursor: "c9", wantStatus: "No more spans"},
} {
client, _ := tracesAPI(t, http.StatusOK, map[string]string{"": `{}`, "c9": `{"spans":[]}`})
client, _ := tracesAPI(t, http.StatusOK, map[string]string{"": `{}`, "c9": `{"items":[]}`})
stdout, stderr := captureOut(t)

require.NoError(t, fetchSessionTraces(context.Background(), client, "p1", "RM_1", public.PageOptions{Limit: 10, Cursor: tt.cursor}, false))
Expand Down Expand Up @@ -1987,7 +1987,7 @@ func metricsAPI(t *testing.T, status int, body string) (*public.Client, *url.Val
// asked for and, when a page is empty, says why: no points for those names,
// none at all, or no more.
func TestFetchSessionMetrics(t *testing.T) {
const page = `{"points":[` +
const page = `{"items":[` +
`{"name":"lk.agents.turn.e2e_latency","unit":"s","kind":"METRIC_KIND_HISTOGRAM","endTime":"2026-10-07T11:00:30Z",` +
`"histogram":{"count":"3","sum":2.4,"min":0.6,"max":1.1}},` +
`{"name":"lk.agents.usage.llm_input_tokens","unit":"{token}","kind":"METRIC_KIND_SUM","endTime":"2026-10-07T11:00:30Z","value":1520}],` +
Expand All @@ -2013,7 +2013,7 @@ func TestFetchSessionMetrics(t *testing.T) {
},
{
name: "no points for those names",
body: `{"points":[]}`,
body: `{"items":[]}`,
opts: public.MetricOptions{Names: []string{"lk.agents.nope"}},
wantQuery: url.Values{"names": {"lk.agents.nope"}},
wantStatus: []string{"Session RM_1 has no points for the metrics asked for (lk.agents.nope)"},
Expand All @@ -2026,7 +2026,7 @@ func TestFetchSessionMetrics(t *testing.T) {
},
{
name: "last page",
body: `{"points":[]}`,
body: `{"items":[]}`,
opts: public.MetricOptions{PageOptions: public.PageOptions{Cursor: "c2"}},
wantQuery: url.Values{"page.cursor": {"c2"}},
wantStatus: []string{"No more metric points"},
Expand Down Expand Up @@ -2056,7 +2056,7 @@ func TestFetchSessionMetrics(t *testing.T) {
// TestFetchSessionMetricsJSON checks --json prints the points and still
// explains an empty page on stderr.
func TestFetchSessionMetricsJSON(t *testing.T) {
client, _ := metricsAPI(t, http.StatusOK, `{"points":[]}`)
client, _ := metricsAPI(t, http.StatusOK, `{"items":[]}`)
stdout, stderr := captureOut(t)

require.NoError(t, fetchSessionMetrics(context.Background(), client, "p1", "RM_1", public.MetricOptions{}, true))
Expand Down
2 changes: 1 addition & 1 deletion cmd/lk/credential_property_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ func TestCredentialResolution(t *testing.T) {
respond("POST http://"+name+".test/twirp/livekit.Egress/ListEgress", http.StatusOK, "")
}
for _, id := range []string{"p_1", "p_2", "two", "gone"} {
respond("GET "+publicAPI+"/v0/projects/"+id+"/keys", http.StatusOK, `{"keys":[]}`)
respond("GET "+publicAPI+"/v0/projects/"+id+"/keys", http.StatusOK, `{"items":[]}`)
}
// Every key's secret is known, so every token's signature is checked:
// configured projects', --dev's, and the inline key paired with the
Expand Down
15 changes: 11 additions & 4 deletions cmd/lk/project_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (

"github.com/urfave/cli/v3"

"github.com/livekit/livekit-cli/v2/pkg/public"
"github.com/livekit/livekit-cli/v2/pkg/public/render"
"github.com/livekit/livekit-cli/v2/pkg/util"
)
Expand Down Expand Up @@ -81,17 +82,23 @@ func listProjectKeys(ctx context.Context, cmd *cli.Command) error {
if err != nil {
return err
}
filter := public.KeyFilter{IncludeAgentKeys: cmd.Bool("include-agent-keys")}
if cmd.Bool("mine") {
if cmd.Bool("include-agent-keys") {
if filter.IncludeAgentKeys {
return errors.New("--mine and --include-agent-keys cannot be combined")
}
personal, _, err := client.ListScopedKeys(ctx, projectID)
// The Public API's id for the signed-in user, which the stored session
// may not record.
me, err := client.GetCurrentUser(ctx)
if err != nil {
return cloudAPIError(err)
}
return render.AccessKeys(out, cmd.Bool("json"), personal)
// An empty owner would list everyone's keys.
if filter.OwnerID = util.Deref(me.Id); filter.OwnerID == "" {
return errors.New("unexpected response from server: missing user id")
}
}
keys, err := client.ListKeys(ctx, projectID, cmd.Bool("include-agent-keys"))
keys, err := client.ListKeys(ctx, projectID, filter)
if err != nil {
return cloudAPIError(err)
}
Expand Down
1 change: 1 addition & 0 deletions cmd/lk/script_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ var publicAPIRoutes = []struct {
{http.MethodGet, regexp.MustCompile(`^/v0/projects/[^/]+/members$`), func() any { return new(oapi.LivekitPublicapiProjectsV1ListMembersResponse) }},
{http.MethodDelete, regexp.MustCompile(`^/v0/projects/[^/]+/members/[^/]+$`), func() any { return new(oapi.LivekitPublicapiProjectsV1RemoveMemberResponse) }},
{http.MethodDelete, regexp.MustCompile(`^/v0/workspaces/[^/]+/members/[^/]+$`), func() any { return new(oapi.LivekitPublicapiWorkspacesV1DeleteMemberResponse) }},
{http.MethodGet, regexp.MustCompile(`^/v0/users/me$`), func() any { return new(oapi.LivekitPublicapiUsersV1GetCurrentUserResponse) }},
}

// middlewareError is the body the Public API's auth middleware and panic
Expand Down
2 changes: 1 addition & 1 deletion cmd/lk/testdata/script/key-commands-as-admin.txtar
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# A project ADMIN lists, creates and deletes the project's API keys.
respond GET http://publicapi.test/v0/projects/p_1/keys 200 '{"keys":[{"apiKey":"APIold","description":"ci"}]}'
respond GET http://publicapi.test/v0/projects/p_1/keys 200 '{"items":[{"apiKey":"APIold","description":"ci"}]}'
exec lk --user-auth project key list --project one
cmp stdout want-list-stdout

Expand Down
34 changes: 34 additions & 0 deletions cmd/lk/testdata/script/key-list-mine.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# --mine lists the keys the signed-in user owns: lk asks the Public API who
# the user is and filters the key list by that owner. The fake answers only
# the filtered list, so a request without the owner fails.
respond GET http://publicapi.test/v0/users/me 200 '{"user":{"id":"u_admin","email":"admin@example.com"}}'
respond GET http://publicapi.test/v0/projects/p_1/keys?includeAgentKeys=false&ownerId=u_admin 200 '{"items":[{"apiKey":"APImine","description":"laptop","ownerId":"u_admin","ownerEmail":"admin@example.com"}],"pageInfo":{"hasMore":false}}'
exec lk --user-auth project key list --mine --project one
cmp stdout want-list-stdout

! exec lk --user-auth project key list --mine --include-agent-keys --project one
stderr '--mine and --include-agent-keys cannot be combined'

requests
cmp stdout want-requests

-- home/.livekit/cli-config.yaml --
default_user: u_admin
users:
- id: u_admin
email: admin@example.com
session_token: st_admin
api_url: http://publicapi.test
projects:
- project_id: p_1
name: one
alias: one
-- want-list-stdout --
┌─────────┬─────────────┬───────────────────┬─────────┐
│ API Key │ Description │ Owner │ Created │
├─────────┼─────────────┼───────────────────┼─────────┤
│ APImine │ laptop │ admin@example.com │ - │
└─────────┴─────────────┴───────────────────┴─────────┘
-- want-requests --
GET http://publicapi.test/v0/users/me bearer=st_admin
GET http://publicapi.test/v0/projects/p_1/keys bearer=st_admin project=p_1
31 changes: 31 additions & 0 deletions cmd/lk/testdata/script/key-list-pages.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# A key list longer than a page shows every key: lk passes each page's
# cursor back until the Public API says there are no more.
respond GET http://publicapi.test/v0/projects/p_1/keys?includeAgentKeys=false 200 '{"items":[{"apiKey":"APIone","description":"first page"}],"pageInfo":{"nextCursor":"c1","hasMore":true}}'
respond GET http://publicapi.test/v0/projects/p_1/keys?includeAgentKeys=false&page.cursor=c1 200 '{"items":[{"apiKey":"APItwo","description":"second page"}],"pageInfo":{"nextCursor":"","hasMore":false}}'
exec lk --user-auth project key list --project one
cmp stdout want-list-stdout

requests
cmp stdout want-requests

-- home/.livekit/cli-config.yaml --
default_user: u_admin
users:
- id: u_admin
email: admin@example.com
session_token: st_admin
api_url: http://publicapi.test
projects:
- project_id: p_1
name: one
alias: one
-- want-list-stdout --
┌─────────┬─────────────┬───────────────────┬─────────┐
│ API Key │ Description │ Owner │ Created │
├─────────┼─────────────┼───────────────────┼─────────┤
│ APIone │ first page │ (service account) │ - │
│ APItwo │ second page │ (service account) │ - │
└─────────┴─────────────┴───────────────────┴─────────┘
-- want-requests --
GET http://publicapi.test/v0/projects/p_1/keys bearer=st_admin project=p_1
GET http://publicapi.test/v0/projects/p_1/keys bearer=st_admin project=p_1
27 changes: 27 additions & 0 deletions cmd/lk/testdata/script/project-list-pages.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# A user with more projects than fit on a page sees them all: lk passes each
# page's cursor back until the Public API says there are no more.
respond GET http://publicapi.test/v0/projects 200 '{"items":[{"id":"p_1","name":"one"}],"pageInfo":{"nextCursor":"c1","hasMore":true}}'
respond GET http://publicapi.test/v0/projects?page.cursor=c1 200 '{"items":[{"id":"p_2","name":"two"}],"pageInfo":{"nextCursor":"","hasMore":false}}'
exec lk --user-auth project list
cmp stdout want-stdout

requests
cmp stdout want-requests

-- home/.livekit/cli-config.yaml --
default_user: u_1
users:
- id: u_1
email: dev@example.com
session_token: st_1
api_url: http://publicapi.test
-- want-stdout --
┌───────┬──────┬────────────┐
│ Alias │ Name │ Project ID │
├───────┼──────┼────────────┤
│ one │ one │ p_1 │
│ two │ two │ p_2 │
└───────┴──────┴────────────┘
-- want-requests --
GET http://publicapi.test/v0/projects bearer=st_1
GET http://publicapi.test/v0/projects bearer=st_1
3 changes: 1 addition & 2 deletions cmd/lk/user_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -420,8 +420,7 @@ func fetchUserWorkspaces(ctx context.Context, sessionToken string) ([]config.Use
if err != nil {
return nil, err
}
// Best-effort cache of the first (large) page; cache resolution refreshes on miss.
workspaces, _, err := client.ListWorkspaces(ctx, 200, "")
workspaces, err := client.ListAllWorkspaces(ctx)
if err != nil {
return nil, err
}
Expand Down
48 changes: 35 additions & 13 deletions pkg/public/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,21 +68,19 @@ func bearerAuth(token string) oapi.RequestEditorFn {
}
}

// ListProjects returns the projects the authenticated user can access.
//
// NOTE: this returns only the first page; wire up PageInfo/cursor paging when a
// command needs the full set.
// ListProjects returns every project the authenticated user can access,
// reading each page.
func (c *Client) ListProjects(ctx context.Context) ([]oapi.LivekitPublicapiProjectsV1Project, error) {
resp, err := c.gen.ProjectServiceListProjectsWithResponse(ctx, &oapi.ProjectServiceListProjectsParams{
PagePageSize: ptr(int32(100)),
return allPages(func(cursor *string) ([]oapi.LivekitPublicapiProjectsV1Project, *oapi.LivekitPublicapiCommonV1PageInfo, error) {
resp, err := c.gen.ProjectServiceListProjectsWithResponse(ctx, &oapi.ProjectServiceListProjectsParams{PageCursor: cursor})
if err != nil {
return nil, nil, err
}
if resp.JSON200 == nil {
return nil, nil, responseError(resp.StatusCode(), resp.Body)
}
return items(resp.JSON200.Items), resp.JSON200.PageInfo, nil
})
if err != nil {
return nil, err
}
if resp.JSON200 == nil {
return nil, responseError(resp.StatusCode(), resp.Body)
}
return items(resp.JSON200.Items), nil
}

// GetProject returns a single project by id.
Expand Down Expand Up @@ -257,6 +255,30 @@ func pageCursor(pi *oapi.LivekitPublicapiCommonV1PageInfo) string {
return *pi.NextCursor
}

// allPages reads a whole list, one page at a time from the first: it passes
// fetch a nil cursor, then each page's nextCursor, until a page says it is
// the last. fetch reads the page at cursor.
func allPages[T any](fetch func(cursor *string) ([]T, *oapi.LivekitPublicapiCommonV1PageInfo, error)) ([]T, error) {
var all []T
var cursor *string
for {
page, info, err := fetch(cursor)
if err != nil {
return nil, err
}
all = append(all, page...)
next := pageCursor(info)
if info == nil || info.HasMore == nil || !*info.HasMore || next == "" {
return all, nil
}
// A cursor that didn't move would read the same page forever.
if cursor != nil && *cursor == next {
return nil, fmt.Errorf("unexpected response from server: page cursor %q repeats", next)
}
cursor = &next
}
}

// requirePayload guards a nested payload pointer from an otherwise-successful
// (200) response: the outer JSON200 can be non-nil while a nested field is nil,
// because a 200 with a missing/blank body still decodes (Go leaves absent fields
Expand Down
Loading
Loading