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
6 changes: 6 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ tasks:
vars:
source: https://raw.githubusercontent.com/algolia/api-clients-automation/main/specs/bundled/search.yml
destination: ./api/specs/search.yml
download-agent-studio-spec:
desc: Download the latest Agent Studio (RAG API) OpenAPI spec from a live endpoint
cmd: curl -fsSL -o {{ .destination }} {{ .source }}
vars:
source: https://agent-studio.eu.algolia.com/rag-openapi.json
destination: ./api/specs/agent-studio.json
generate:
desc: Generate command flags
internal: true
Expand Down
321 changes: 321 additions & 0 deletions api/agentstudio/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
package agentstudio

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)

// Client provides methods to interact with the Algolia Agent Studio (RAG) API.
type Client struct {
AppID string
APIKey string
BaseURL string

client *http.Client
}

// DefaultBaseURL builds the standard per-app server URL declared by the spec.
func DefaultBaseURL(appID string) string {
return fmt.Sprintf("https://%s.algolia.net/agent-studio/", appID)
}

// NewClient returns a new Agent Studio API client using the default per-app
// server URL.
func NewClient(appID, apiKey string) *Client {
return &Client{
AppID: appID,
APIKey: apiKey,
BaseURL: DefaultBaseURL(appID),
client: http.DefaultClient,
}
}

// NewClientWithHTTPClient returns a new Agent Studio API client with a custom
// HTTP client. Tests use this to inject an httptest server.
func NewClientWithHTTPClient(appID, apiKey string, hc *http.Client) *Client {
return &Client{
AppID: appID,
APIKey: apiKey,
BaseURL: DefaultBaseURL(appID),
client: hc,
}
}

// request sends an HTTP request and unmarshals the response body to res when
// non-nil. Status >= 400 is converted to a formatted error.
func (c *Client) request(
res interface{},
method, path string,
body interface{},
urlParams map[string]string,
) error {
r, err := c.buildRequest(method, path, body, urlParams)
if err != nil {
return err
}

resp, err := c.client.Do(r)
if err != nil {
return err
}

if resp.StatusCode >= 400 {
var errResp ErrResponse
_ = unmarshalTo(resp, &errResp)
return fmt.Errorf("agentstudio: %s %s -> %d %s", method, path, resp.StatusCode, formatErr(errResp))
}

if res != nil {
if err := unmarshalTo(resp, res); err != nil {
return err
}
} else {
_ = resp.Body.Close()
}

return nil
}

func (c *Client) buildRequest(
method, path string,
body interface{},
urlParams map[string]string,
) (*http.Request, error) {
url := strings.TrimRight(c.BaseURL, "/") + "/" + strings.TrimLeft(path, "/")

var reader io.ReadCloser
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = io.NopCloser(bytes.NewReader(b))
}

req, err := http.NewRequest(method, url, reader)
if err != nil {
return nil, err
}

req.Header.Set("X-Algolia-Application-Id", c.AppID)
req.Header.Set("X-Algolia-API-Key", c.APIKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")

if len(urlParams) > 0 {
q := req.URL.Query()
for k, v := range urlParams {
q.Set(k, v)
}
req.URL.RawQuery = q.Encode()
}

return req, nil
}

func unmarshalTo(r *http.Response, v interface{}) error {
defer r.Body.Close()
return json.NewDecoder(r.Body).Decode(v)
}

// formatErr renders an error response for human consumption. Prefers
// `message` (used by /completions), falls back to `detail` (used by CRUD
// endpoints), where detail may be a JSON string, an array of validation
// entries, or arbitrary JSON.
func formatErr(e ErrResponse) string {
if e.Message != "" {
return e.Message
}
if len(e.Detail) == 0 {
return ""
}
var s string
if err := json.Unmarshal(e.Detail, &s); err == nil {
return s
}
var entries []map[string]any
if err := json.Unmarshal(e.Detail, &entries); err == nil {
var parts []string
for _, en := range entries {
parts = append(parts, fmt.Sprintf("%v", en))
}
return strings.Join(parts, "; ")
}
return string(e.Detail)
}

func paginationParams(page, limit int) map[string]string {
params := map[string]string{}
if page > 0 {
params["page"] = strconv.Itoa(page)
}
if limit > 0 {
params["limit"] = strconv.Itoa(limit)
}
return params
}

// Agents -----------------------------------------------------------------

func (c *Client) ListAgents(page, limit int) (*PaginatedAgentsResponse, error) {
var res PaginatedAgentsResponse
if err := c.request(&res, http.MethodGet, "1/agents", nil, paginationParams(page, limit)); err != nil {
return nil, err
}
return &res, nil
}

func (c *Client) GetAgent(id string) (*Agent, error) {
var res Agent
if err := c.request(&res, http.MethodGet, fmt.Sprintf("1/agents/%s", url.PathEscape(id)), nil, nil); err != nil {
return nil, err
}
return &res, nil
}

func (c *Client) CreateAgent(req AgentConfigCreate) (*Agent, error) {
var res Agent
if err := c.request(&res, http.MethodPost, "1/agents", req, nil); err != nil {
return nil, err
}
return &res, nil
}

func (c *Client) UpdateAgent(id string, req AgentConfigUpdate) (*Agent, error) {
var res Agent
if err := c.request(&res, http.MethodPatch, fmt.Sprintf("1/agents/%s", url.PathEscape(id)), req, nil); err != nil {
return nil, err
}
return &res, nil
}

func (c *Client) DeleteAgent(id string) error {
return c.request(nil, http.MethodDelete, fmt.Sprintf("1/agents/%s", url.PathEscape(id)), nil, nil)
}

func (c *Client) DuplicateAgent(id string) (*Agent, error) {
var res Agent
if err := c.request(&res, http.MethodPost, fmt.Sprintf("1/agents/%s/duplicate", url.PathEscape(id)), nil, nil); err != nil {
return nil, err
}
return &res, nil
}

func (c *Client) PublishAgent(id string) (*Agent, error) {
var res Agent
if err := c.request(&res, http.MethodPost, fmt.Sprintf("1/agents/%s/publish", url.PathEscape(id)), nil, nil); err != nil {
return nil, err
}
return &res, nil
}

func (c *Client) UnpublishAgent(id string) (*Agent, error) {
var res Agent
if err := c.request(&res, http.MethodPost, fmt.Sprintf("1/agents/%s/unpublish", url.PathEscape(id)), nil, nil); err != nil {
return nil, err
}
return &res, nil
}

// Completions ------------------------------------------------------------

// CompletionParams carries the query-string options for CreateCompletion.
// CompatibilityMode is required by the API; the rest are optional and default
// to the API's defaults when zero-valued.
type CompletionParams struct {
CompatibilityMode string // "ai-sdk-4" | "ai-sdk-5" (required)
Stream *bool // default true on the server; set false for non-streaming JSON
Cache *bool // default true on the server
}

// CreateCompletion invokes an agent and returns the response body as an
// io.ReadCloser. The caller MUST close the returned reader. When stream=true
// (server default) the body is SSE bytes that arrive incrementally; when
// stream=false it is a single JSON document. Either way the body is streamed
// so we never buffer the full response in memory.
func (c *Client) CreateCompletion(agentID string, req AgentCompletionRequest, params CompletionParams) (io.ReadCloser, error) {
q := map[string]string{
"compatibilityMode": params.CompatibilityMode,
}
if params.Stream != nil {
q["stream"] = strconv.FormatBool(*params.Stream)
}
if params.Cache != nil {
q["cache"] = strconv.FormatBool(*params.Cache)
}

r, err := c.buildRequest(http.MethodPost, fmt.Sprintf("1/agents/%s/completions", url.PathEscape(agentID)), req, q)
if err != nil {
return nil, err
}
resp, err := c.client.Do(r)
if err != nil {
return nil, err
}

if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
var errResp ErrResponse
_ = json.Unmarshal(body, &errResp)
return nil, fmt.Errorf("agentstudio: POST 1/agents/%s/completions -> %d %s", agentID, resp.StatusCode, formatErr(errResp))
}
return resp.Body, nil
}

// Conversations ----------------------------------------------------------

func (c *Client) ListConversations(agentID string, page, limit int) (*PaginatedConversationsResponse, error) {
var res PaginatedConversationsResponse
if err := c.request(&res, http.MethodGet, fmt.Sprintf("1/agents/%s/conversations", url.PathEscape(agentID)), nil, paginationParams(page, limit)); err != nil {
return nil, err
}
return &res, nil
}

func (c *Client) GetConversation(agentID, convID string) (*Conversation, error) {
var res Conversation
if err := c.request(&res, http.MethodGet, fmt.Sprintf("1/agents/%s/conversations/%s", url.PathEscape(agentID), url.PathEscape(convID)), nil, nil); err != nil {
return nil, err
}
return &res, nil
}

func (c *Client) DeleteConversation(agentID, convID string) error {
return c.request(nil, http.MethodDelete, fmt.Sprintf("1/agents/%s/conversations/%s", url.PathEscape(agentID), url.PathEscape(convID)), nil, nil)
}

func (c *Client) DeleteAllConversations(agentID string) error {
return c.request(nil, http.MethodDelete, fmt.Sprintf("1/agents/%s/conversations", url.PathEscape(agentID)), nil, nil)
}

// ExportConversations returns the raw response body as a stream. The caller
// MUST close the returned reader. The spec does not constrain the response
// type (the dashboard call returns an attachment), so the body is streamed
// directly to avoid buffering large exports in memory.
func (c *Client) ExportConversations(agentID string) (io.ReadCloser, error) {
r, err := c.buildRequest(http.MethodGet, fmt.Sprintf("1/agents/%s/conversations/export", url.PathEscape(agentID)), nil, nil)
if err != nil {
return nil, err
}
resp, err := c.client.Do(r)
if err != nil {
return nil, err
}

if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
var errResp ErrResponse
_ = json.Unmarshal(body, &errResp)
return nil, fmt.Errorf("agentstudio: GET conversations/export -> %d %s", resp.StatusCode, formatErr(errResp))
}

return resp.Body, nil
}
Loading
Loading