From daf5a31c8bc965fe0f7d2d3da12c1369180446f0 Mon Sep 17 00:00:00 2001 From: anirudh-makuluri Date: Fri, 22 May 2026 00:57:30 -0700 Subject: [PATCH 01/12] Add initial cloud-first Go SDK scaffold --- sdks/go/README.md | 23 +++ sdks/go/sdk/go.mod | 3 + sdks/go/sdk/moss/client.go | 116 +++++++++++ sdks/go/sdk/moss/client_test.go | 259 ++++++++++++++++++++++++ sdks/go/sdk/moss/conversion.go | 67 ++++++ sdks/go/sdk/moss/errors.go | 31 +++ sdks/go/sdk/moss/internal/httpclient.go | 83 ++++++++ sdks/go/sdk/moss/internal/manage_api.go | 104 ++++++++++ sdks/go/sdk/moss/internal/query_api.go | 60 ++++++ sdks/go/sdk/moss/models.go | 135 ++++++++++++ sdks/go/sdk/moss/options.go | 32 +++ sdks/go/sdk/moss/query.go | 31 +++ sdks/go/sdk/moss/read.go | 69 +++++++ 13 files changed, 1013 insertions(+) create mode 100644 sdks/go/README.md create mode 100644 sdks/go/sdk/go.mod create mode 100644 sdks/go/sdk/moss/client.go create mode 100644 sdks/go/sdk/moss/client_test.go create mode 100644 sdks/go/sdk/moss/conversion.go create mode 100644 sdks/go/sdk/moss/errors.go create mode 100644 sdks/go/sdk/moss/internal/httpclient.go create mode 100644 sdks/go/sdk/moss/internal/manage_api.go create mode 100644 sdks/go/sdk/moss/internal/query_api.go create mode 100644 sdks/go/sdk/moss/models.go create mode 100644 sdks/go/sdk/moss/options.go create mode 100644 sdks/go/sdk/moss/query.go create mode 100644 sdks/go/sdk/moss/read.go diff --git a/sdks/go/README.md b/sdks/go/README.md new file mode 100644 index 00000000..4478779a --- /dev/null +++ b/sdks/go/README.md @@ -0,0 +1,23 @@ +# Moss Go SDK + +This directory contains the in-progress Go SDK for Moss. + +The first implementation track is intentionally: + +- pure Go +- cloud-first +- compatible with the public repository + +Current scope: + +- typed client construction +- cloud index metadata reads +- cloud document reads +- cloud query + +Deferred follow-up work: + +- mutation flows (`CreateIndex`, `AddDocs`, `DeleteDocs`, `GetJobStatus`) +- examples +- integration tests +- local runtime loading/query parity diff --git a/sdks/go/sdk/go.mod b/sdks/go/sdk/go.mod new file mode 100644 index 00000000..646e00ed --- /dev/null +++ b/sdks/go/sdk/go.mod @@ -0,0 +1,3 @@ +module github.com/usemoss/moss/sdks/go/sdk + +go 1.22.2 diff --git a/sdks/go/sdk/moss/client.go b/sdks/go/sdk/moss/client.go new file mode 100644 index 00000000..9561abd3 --- /dev/null +++ b/sdks/go/sdk/moss/client.go @@ -0,0 +1,116 @@ +package moss + +import ( + "net/http" + "os" + "strings" + "time" + + "github.com/usemoss/moss/sdks/go/sdk/moss/internal" +) + +const ( + DefaultManageURL = "https://service.usemoss.dev/v1/manage" + defaultTimeout = 60 * time.Second +) + +type clientConfig struct { + manageURL string + queryURL string + httpClient *http.Client +} + +// Client is the cloud-first Moss Go SDK client. +type Client struct { + projectID string + projectKey string + manageURL string + queryURL string + httpClient *http.Client + manageAPI *internal.ManageAPI + queryAPI *internal.QueryAPI +} + +// NewClient constructs a new Moss client with optional overrides. +func NewClient(projectID, projectKey string, opts ...Option) *Client { + cfg := clientConfig{ + manageURL: defaultManageURL(), + httpClient: &http.Client{Timeout: defaultTimeout}, + } + cfg.queryURL = defaultQueryURL(cfg.manageURL) + + for _, opt := range opts { + if opt != nil { + opt(&cfg) + } + } + + if cfg.queryURL == "" { + cfg.queryURL = defaultQueryURL(cfg.manageURL) + } + + jsonClient := internal.NewJSONHTTPClient(cfg.httpClient) + + return &Client{ + projectID: strings.TrimSpace(projectID), + projectKey: strings.TrimSpace(projectKey), + manageURL: strings.TrimSpace(cfg.manageURL), + queryURL: strings.TrimSpace(cfg.queryURL), + httpClient: cfg.httpClient, + manageAPI: internal.NewManageAPI(jsonClient), + queryAPI: internal.NewQueryAPI(jsonClient), + } +} + +func defaultManageURL() string { + if value := strings.TrimSpace(os.Getenv("MOSS_CLOUD_API_MANAGE_URL")); value != "" { + return value + } + return DefaultManageURL +} + +func defaultQueryURL(manageURL string) string { + if value := strings.TrimSpace(os.Getenv("MOSS_CLOUD_QUERY_URL")); value != "" { + return value + } + if manageURL == "" { + return "" + } + return strings.Replace(manageURL, "/v1/manage", "/query", 1) +} + +func (c *Client) validateManageRequest(indexName string) error { + if err := validateCredentials(c.projectID, c.projectKey); err != nil { + return err + } + if strings.TrimSpace(c.manageURL) == "" { + return ErrMissingManageURL + } + if strings.TrimSpace(indexName) == "" { + return ErrEmptyIndexName + } + return nil +} + +func (c *Client) validateQueryRequest(indexName string) error { + if err := validateCredentials(c.projectID, c.projectKey); err != nil { + return err + } + if strings.TrimSpace(c.queryURL) == "" { + return ErrMissingQueryURL + } + if strings.TrimSpace(indexName) == "" { + return ErrEmptyIndexName + } + return nil +} + +func validateCredentials(projectID, projectKey string) error { + if strings.TrimSpace(projectID) == "" { + return ErrMissingProjectID + } + if strings.TrimSpace(projectKey) == "" { + return ErrMissingProjectKey + } + return nil +} diff --git a/sdks/go/sdk/moss/client_test.go b/sdks/go/sdk/moss/client_test.go new file mode 100644 index 00000000..820ffe49 --- /dev/null +++ b/sdks/go/sdk/moss/client_test.go @@ -0,0 +1,259 @@ +package moss + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNewClientUsesDocumentedDefaults(t *testing.T) { + t.Setenv("MOSS_CLOUD_API_MANAGE_URL", "") + t.Setenv("MOSS_CLOUD_QUERY_URL", "") + + client := NewClient("project-id", "project-key") + + if client.manageURL != "https://service.usemoss.dev/v1/manage" { + t.Fatalf("unexpected manage URL: %s", client.manageURL) + } + if client.queryURL != "https://service.usemoss.dev/query" { + t.Fatalf("unexpected query URL: %s", client.queryURL) + } +} + +func TestNewClientHonorsExplicitURLs(t *testing.T) { + client := NewClient( + "project-id", + "project-key", + WithManageURL("https://custom.example.com/v1/manage"), + WithQueryURL("https://query.example.com/search"), + ) + + if client.manageURL != "https://custom.example.com/v1/manage" { + t.Fatalf("unexpected manage URL: %s", client.manageURL) + } + if client.queryURL != "https://query.example.com/search" { + t.Fatalf("unexpected query URL: %s", client.queryURL) + } +} + +func TestGetIndexSendsManageRequestShape(t *testing.T) { + var gotHeader string + var gotBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Get("X-Project-Key") + defer r.Body.Close() + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"idx-1", + "name":"support-docs", + "version":"1.0.0", + "status":"Ready", + "docCount":124, + "createdAt":"2026-05-21T00:00:00Z", + "updatedAt":"2026-05-21T01:00:00Z", + "model":{"id":"moss-minilm","version":"1.0.0"} + }`)) + })) + defer server.Close() + + client := NewClient( + "project-123", + "project-key-123", + WithManageURL(server.URL), + ) + + info, err := client.GetIndex(context.Background(), "support-docs") + if err != nil { + t.Fatalf("GetIndex returned error: %v", err) + } + + if gotHeader != "project-key-123" { + t.Fatalf("unexpected project key header: %q", gotHeader) + } + if gotBody["action"] != "getIndex" { + t.Fatalf("unexpected action: %#v", gotBody["action"]) + } + if gotBody["projectId"] != "project-123" { + t.Fatalf("unexpected projectId: %#v", gotBody["projectId"]) + } + if gotBody["indexName"] != "support-docs" { + t.Fatalf("unexpected indexName: %#v", gotBody["indexName"]) + } + if info.Name != "support-docs" || info.DocCount != 124 || info.Model.ID != "moss-minilm" { + t.Fatalf("unexpected index info: %#v", info) + } +} + +func TestListIndexesDecodesResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"id":"1","name":"alpha","status":"Ready","docCount":2,"model":{"id":"moss-minilm"}}, + {"id":"2","name":"beta","status":"Building","docCount":3,"model":{"id":"custom"}} + ]`)) + })) + defer server.Close() + + client := NewClient("project-id", "project-key", WithManageURL(server.URL)) + + indexes, err := client.ListIndexes(context.Background()) + if err != nil { + t.Fatalf("ListIndexes returned error: %v", err) + } + if len(indexes) != 2 { + t.Fatalf("unexpected index count: %d", len(indexes)) + } + if indexes[0].Name != "alpha" || indexes[1].Model.ID != "custom" { + t.Fatalf("unexpected indexes: %#v", indexes) + } +} + +func TestDeleteIndexSendsExpectedAction(t *testing.T) { + var gotBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`true`)) + })) + defer server.Close() + + client := NewClient("project-id", "project-key", WithManageURL(server.URL)) + + ok, err := client.DeleteIndex(context.Background(), "old-index") + if err != nil { + t.Fatalf("DeleteIndex returned error: %v", err) + } + if !ok { + t.Fatal("expected delete result to be true") + } + if gotBody["action"] != "deleteIndex" { + t.Fatalf("unexpected action: %#v", gotBody["action"]) + } +} + +func TestGetDocsPassesDocIDs(t *testing.T) { + var gotBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"id":"doc-1","text":"hello","metadata":{"topic":"refunds"}} + ]`)) + })) + defer server.Close() + + client := NewClient("project-id", "project-key", WithManageURL(server.URL)) + + docs, err := client.GetDocs(context.Background(), "support-docs", &GetDocumentsOptions{ + DocIDs: []string{"doc-1"}, + }) + if err != nil { + t.Fatalf("GetDocs returned error: %v", err) + } + if len(docs) != 1 || docs[0].Metadata["topic"] != "refunds" { + t.Fatalf("unexpected docs: %#v", docs) + } + + docIDs, ok := gotBody["docIds"].([]any) + if !ok || len(docIDs) != 1 || docIDs[0] != "doc-1" { + t.Fatalf("unexpected docIds payload: %#v", gotBody["docIds"]) + } +} + +func TestQuerySendsExpectedCloudPayload(t *testing.T) { + var gotBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "docs":[{"id":"doc-1","text":"Refunds take 5-7 days","score":0.91,"metadata":{"topic":"refunds"}}], + "query":"refund policy", + "indexName":"support-docs", + "timeTakenMs":17 + }`)) + })) + defer server.Close() + + client := NewClient( + "project-id", + "project-key", + WithQueryURL(server.URL), + ) + + result, err := client.Query(context.Background(), "support-docs", "refund policy", &QueryOptions{ + TopK: 7, + Embedding: []float32{0.1, 0.2, 0.3}, + }) + if err != nil { + t.Fatalf("Query returned error: %v", err) + } + + if gotBody["projectKey"] != "project-key" { + t.Fatalf("unexpected projectKey: %#v", gotBody["projectKey"]) + } + if gotBody["topK"] != float64(7) { + t.Fatalf("unexpected topK: %#v", gotBody["topK"]) + } + if _, ok := gotBody["queryEmbedding"]; !ok { + t.Fatalf("queryEmbedding missing from payload: %#v", gotBody) + } + if len(result.Docs) != 1 || result.Docs[0].Score != 0.91 { + t.Fatalf("unexpected query result: %#v", result) + } + if result.TimeTakenMs == nil || *result.TimeTakenMs != 17 { + t.Fatalf("unexpected timeTakenMs: %#v", result.TimeTakenMs) + } +} + +func TestQueryRejectsUnsupportedFilter(t *testing.T) { + client := NewClient("project-id", "project-key") + + _, err := client.Query(context.Background(), "support-docs", "refund policy", &QueryOptions{ + Filter: map[string]any{"field": "topic"}, + }) + if !errors.Is(err, ErrUnsupportedQueryFilter) { + t.Fatalf("expected ErrUnsupportedQueryFilter, got %v", err) + } +} + +func TestManageHTTPErrorIsWrapped(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer server.Close() + + client := NewClient("project-id", "project-key", WithManageURL(server.URL)) + + _, err := client.GetIndex(context.Background(), "support-docs") + if err == nil { + t.Fatal("expected GetIndex to fail") + } + + var httpErr *HTTPError + if !errors.As(err, &httpErr) { + t.Fatalf("expected HTTPError, got %T", err) + } + if httpErr.StatusCode != http.StatusInternalServerError { + t.Fatalf("unexpected status code: %d", httpErr.StatusCode) + } +} diff --git a/sdks/go/sdk/moss/conversion.go b/sdks/go/sdk/moss/conversion.go new file mode 100644 index 00000000..f46ea03f --- /dev/null +++ b/sdks/go/sdk/moss/conversion.go @@ -0,0 +1,67 @@ +package moss + +import ( + "errors" + + "github.com/usemoss/moss/sdks/go/sdk/moss/internal" +) + +func toIndexInfo(value internal.IndexInfoResponse) IndexInfo { + return IndexInfo{ + ID: value.ID, + Name: value.Name, + Version: value.Version, + Status: IndexStatus(value.Status), + DocCount: value.DocCount, + CreatedAt: value.CreatedAt, + UpdatedAt: value.UpdatedAt, + Model: ModelRef{ + ID: value.Model.ID, + Version: value.Model.Version, + }, + } +} + +func toDocumentInfo(value internal.DocumentInfoResponse) DocumentInfo { + return DocumentInfo{ + ID: value.ID, + Text: value.Text, + Metadata: value.Metadata, + Embedding: value.Embedding, + } +} + +func toSearchResult(value internal.SearchResultResponse) SearchResult { + docs := make([]QueryResultDocumentInfo, 0, len(value.Docs)) + for _, item := range value.Docs { + docs = append(docs, QueryResultDocumentInfo{ + ID: item.ID, + Text: item.Text, + Metadata: item.Metadata, + Score: item.Score, + }) + } + + return SearchResult{ + Docs: docs, + Query: value.Query, + IndexName: value.IndexName, + TimeTakenMs: value.TimeTakenMs, + } +} + +func normalizeError(err error) error { + if err == nil { + return nil + } + + var httpErr *internal.HTTPError + if !errors.As(err, &httpErr) { + return err + } + + return &HTTPError{ + StatusCode: httpErr.StatusCode, + Body: httpErr.Body, + } +} diff --git a/sdks/go/sdk/moss/errors.go b/sdks/go/sdk/moss/errors.go new file mode 100644 index 00000000..e44b6fac --- /dev/null +++ b/sdks/go/sdk/moss/errors.go @@ -0,0 +1,31 @@ +package moss + +import ( + "errors" + "fmt" +) + +var ( + ErrMissingProjectID = errors.New("moss: missing project ID") + ErrMissingProjectKey = errors.New("moss: missing project key") + ErrMissingManageURL = errors.New("moss: manage URL is not configured") + ErrMissingQueryURL = errors.New("moss: query URL is not configured") + ErrEmptyIndexName = errors.New("moss: index name must not be empty") + ErrUnsupportedQueryFilter = errors.New("moss: query filters are not supported in the cloud-only Go SDK yet") +) + +// HTTPError wraps non-2xx responses from Moss services. +type HTTPError struct { + StatusCode int + Body string +} + +func (e *HTTPError) Error() string { + if e == nil { + return "" + } + if e.Body == "" { + return fmt.Sprintf("moss: http request failed with status %d", e.StatusCode) + } + return fmt.Sprintf("moss: http request failed with status %d: %s", e.StatusCode, e.Body) +} diff --git a/sdks/go/sdk/moss/internal/httpclient.go b/sdks/go/sdk/moss/internal/httpclient.go new file mode 100644 index 00000000..46080970 --- /dev/null +++ b/sdks/go/sdk/moss/internal/httpclient.go @@ -0,0 +1,83 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" +) + +type JSONHTTPClient struct { + httpClient *http.Client +} + +func NewJSONHTTPClient(httpClient *http.Client) *JSONHTTPClient { + return &JSONHTTPClient{httpClient: httpClient} +} + +func (c *JSONHTTPClient) PostJSON( + ctx context.Context, + url string, + headers map[string]string, + payload any, + dest any, +) error { + var body io.Reader + if payload != nil { + buf := new(bytes.Buffer) + if err := json.NewEncoder(buf).Encode(payload); err != nil { + return err + } + body = buf + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) + if err != nil { + return err + } + + for key, value := range headers { + req.Header.Set(key, value) + } + if payload != nil && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) + return &HTTPError{ + StatusCode: resp.StatusCode, + Body: strings.TrimSpace(string(body)), + } + } + + if dest == nil { + io.Copy(io.Discard, resp.Body) + return nil + } + + return json.NewDecoder(resp.Body).Decode(dest) +} + +type HTTPError struct { + StatusCode int + Body string +} + +func (e *HTTPError) Error() string { + if e == nil { + return "" + } + if e.Body == "" { + return "http request failed" + } + return e.Body +} diff --git a/sdks/go/sdk/moss/internal/manage_api.go b/sdks/go/sdk/moss/internal/manage_api.go new file mode 100644 index 00000000..6607a498 --- /dev/null +++ b/sdks/go/sdk/moss/internal/manage_api.go @@ -0,0 +1,104 @@ +package internal + +import "context" + +type ManageAPI struct { + httpClient *JSONHTTPClient +} + +func NewManageAPI(httpClient *JSONHTTPClient) *ManageAPI { + return &ManageAPI{httpClient: httpClient} +} + +type manageRequest struct { + Action string `json:"action"` + ProjectID string `json:"projectId"` + IndexName string `json:"indexName,omitempty"` + DocIDs []string `json:"docIds,omitempty"` +} + +type ModelRefResponse struct { + ID string `json:"id"` + Version *string `json:"version,omitempty"` +} + +type IndexInfoResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Version *string `json:"version,omitempty"` + Status string `json:"status"` + DocCount int `json:"docCount"` + CreatedAt *string `json:"createdAt,omitempty"` + UpdatedAt *string `json:"updatedAt,omitempty"` + Model ModelRefResponse `json:"model"` +} + +type DocumentInfoResponse struct { + ID string `json:"id"` + Text string `json:"text"` + Metadata map[string]string `json:"metadata,omitempty"` + Embedding []float32 `json:"embedding,omitempty"` +} + +func (a *ManageAPI) GetIndex(ctx context.Context, manageURL, projectID, projectKey, indexName string) (IndexInfoResponse, error) { + var response IndexInfoResponse + if err := a.do(ctx, manageURL, projectID, projectKey, manageRequest{ + Action: "getIndex", + ProjectID: projectID, + IndexName: indexName, + }, &response); err != nil { + return IndexInfoResponse{}, err + } + return response, nil +} + +func (a *ManageAPI) ListIndexes(ctx context.Context, manageURL, projectID, projectKey string) ([]IndexInfoResponse, error) { + var response []IndexInfoResponse + if err := a.do(ctx, manageURL, projectID, projectKey, manageRequest{ + Action: "listIndexes", + ProjectID: projectID, + }, &response); err != nil { + return nil, err + } + return response, nil +} + +func (a *ManageAPI) DeleteIndex(ctx context.Context, manageURL, projectID, projectKey, indexName string) (bool, error) { + var response bool + if err := a.do(ctx, manageURL, projectID, projectKey, manageRequest{ + Action: "deleteIndex", + ProjectID: projectID, + IndexName: indexName, + }, &response); err != nil { + return false, err + } + return response, nil +} + +func (a *ManageAPI) GetDocs( + ctx context.Context, + manageURL, projectID, projectKey, indexName string, + docIDs []string, +) ([]DocumentInfoResponse, error) { + request := manageRequest{ + Action: "getDocs", + ProjectID: projectID, + IndexName: indexName, + } + if len(docIDs) > 0 { + request.DocIDs = docIDs + } + + var response []DocumentInfoResponse + if err := a.do(ctx, manageURL, projectID, projectKey, request, &response); err != nil { + return nil, err + } + return response, nil +} + +func (a *ManageAPI) do(ctx context.Context, manageURL, projectID, projectKey string, payload any, dest any) error { + return a.httpClient.PostJSON(ctx, manageURL, map[string]string{ + "Content-Type": "application/json", + "X-Project-Key": projectKey, + }, payload, dest) +} diff --git a/sdks/go/sdk/moss/internal/query_api.go b/sdks/go/sdk/moss/internal/query_api.go new file mode 100644 index 00000000..1076a73b --- /dev/null +++ b/sdks/go/sdk/moss/internal/query_api.go @@ -0,0 +1,60 @@ +package internal + +import "context" + +type QueryAPI struct { + httpClient *JSONHTTPClient +} + +func NewQueryAPI(httpClient *JSONHTTPClient) *QueryAPI { + return &QueryAPI{httpClient: httpClient} +} + +type queryRequest struct { + Query string `json:"query"` + IndexName string `json:"indexName"` + ProjectID string `json:"projectId"` + ProjectKey string `json:"projectKey"` + TopK int `json:"topK"` + QueryEmbedding []float32 `json:"queryEmbedding,omitempty"` +} + +type SearchResultResponse struct { + Docs []QueryResultDocumentInfoResponse `json:"docs"` + Query string `json:"query"` + IndexName *string `json:"indexName,omitempty"` + TimeTakenMs *int `json:"timeTakenMs,omitempty"` +} + +type QueryResultDocumentInfoResponse struct { + ID string `json:"id"` + Text string `json:"text"` + Metadata map[string]string `json:"metadata,omitempty"` + Score float64 `json:"score"` +} + +func (a *QueryAPI) Query( + ctx context.Context, + queryURL, projectID, projectKey, indexName, query string, + topK int, + queryEmbedding []float32, +) (SearchResultResponse, error) { + request := queryRequest{ + Query: query, + IndexName: indexName, + ProjectID: projectID, + ProjectKey: projectKey, + TopK: topK, + } + if len(queryEmbedding) > 0 { + request.QueryEmbedding = queryEmbedding + } + + var response SearchResultResponse + if err := a.httpClient.PostJSON(ctx, queryURL, map[string]string{ + "Content-Type": "application/json", + }, request, &response); err != nil { + return SearchResultResponse{}, err + } + return response, nil +} diff --git a/sdks/go/sdk/moss/models.go b/sdks/go/sdk/moss/models.go new file mode 100644 index 00000000..a3604be2 --- /dev/null +++ b/sdks/go/sdk/moss/models.go @@ -0,0 +1,135 @@ +package moss + +// MossModel identifies the embedding model backing an index. +type MossModel string + +const ( + ModelMossMiniLM MossModel = "moss-minilm" + ModelMossMediumLM MossModel = "moss-mediumlm" + ModelCustom MossModel = "custom" +) + +// IndexStatus describes the current lifecycle state of an index. +type IndexStatus string + +const ( + IndexStatusNotStarted IndexStatus = "NotStarted" + IndexStatusBuilding IndexStatus = "Building" + IndexStatusReady IndexStatus = "Ready" + IndexStatusFailed IndexStatus = "Failed" +) + +// JobStatus describes the current lifecycle state of a mutation job. +type JobStatus string + +const ( + JobStatusPendingUpload JobStatus = "pending_upload" + JobStatusUploading JobStatus = "uploading" + JobStatusBuilding JobStatus = "building" + JobStatusCompleted JobStatus = "completed" + JobStatusFailed JobStatus = "failed" +) + +// JobPhase describes the current phase of a mutation job. +type JobPhase string + +const ( + JobPhaseDownloading JobPhase = "downloading" + JobPhaseDeserializing JobPhase = "deserializing" + JobPhaseGeneratingEmbeddings JobPhase = "generating_embeddings" + JobPhaseBuildingIndex JobPhase = "building_index" + JobPhaseUploading JobPhase = "uploading" + JobPhaseCleanup JobPhase = "cleanup" +) + +// ModelRef points at the model used by an index. +type ModelRef struct { + ID string `json:"id"` + Version *string `json:"version,omitempty"` +} + +// IndexInfo describes persisted index metadata. +type IndexInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Version *string `json:"version,omitempty"` + Status IndexStatus `json:"status"` + DocCount int `json:"docCount"` + CreatedAt *string `json:"createdAt,omitempty"` + UpdatedAt *string `json:"updatedAt,omitempty"` + Model ModelRef `json:"model"` +} + +// DocumentInfo is the canonical index document representation. +type DocumentInfo struct { + ID string `json:"id"` + Text string `json:"text"` + Metadata map[string]string `json:"metadata,omitempty"` + Embedding []float32 `json:"embedding,omitempty"` +} + +// QueryResultDocumentInfo is a document returned from a query with a score. +type QueryResultDocumentInfo struct { + ID string `json:"id"` + Text string `json:"text"` + Metadata map[string]string `json:"metadata,omitempty"` + Score float64 `json:"score"` +} + +// SearchResult is the response returned by query operations. +type SearchResult struct { + Docs []QueryResultDocumentInfo `json:"docs"` + Query string `json:"query"` + IndexName *string `json:"indexName,omitempty"` + TimeTakenMs *int `json:"timeTakenMs,omitempty"` +} + +// QueryOptions customizes cloud query behavior. +type QueryOptions struct { + Embedding []float32 `json:"embedding,omitempty"` + TopK int `json:"topK,omitempty"` + Alpha *float64 `json:"alpha,omitempty"` + Filter map[string]any `json:"filter,omitempty"` +} + +// GetDocumentsOptions optionally narrows document retrieval by ID. +type GetDocumentsOptions struct { + DocIDs []string `json:"docIds,omitempty"` +} + +// CreateIndexOptions customizes index creation behavior. +type CreateIndexOptions struct { + ModelID MossModel `json:"modelId,omitempty"` +} + +// MutationOptions customizes add/update/delete document behavior. +type MutationOptions struct { + Upsert *bool `json:"upsert,omitempty"` +} + +// MutationResult is returned when a mutation job completes. +type MutationResult struct { + JobID string `json:"jobId"` + IndexName string `json:"indexName"` + DocCount int `json:"docCount"` +} + +// JobProgress is emitted while a mutation job is running. +type JobProgress struct { + JobID string `json:"jobId"` + Status JobStatus `json:"status"` + Progress float64 `json:"progress"` + CurrentPhase *JobPhase `json:"currentPhase,omitempty"` +} + +// JobStatusResponse is the persisted status view for a mutation job. +type JobStatusResponse struct { + JobID string `json:"jobId"` + Status JobStatus `json:"status"` + Progress float64 `json:"progress"` + CurrentPhase *JobPhase `json:"currentPhase,omitempty"` + Error *string `json:"error,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + CompletedAt *string `json:"completedAt,omitempty"` +} diff --git a/sdks/go/sdk/moss/options.go b/sdks/go/sdk/moss/options.go new file mode 100644 index 00000000..495ced1a --- /dev/null +++ b/sdks/go/sdk/moss/options.go @@ -0,0 +1,32 @@ +package moss + +import "net/http" + +// Option customizes client construction. +type Option func(*clientConfig) + +// WithManageURL overrides the default manage endpoint. +func WithManageURL(url string) Option { + return func(cfg *clientConfig) { + cfg.manageURL = url + if cfg.queryURL == "" { + cfg.queryURL = defaultQueryURL(url) + } + } +} + +// WithQueryURL overrides the default query endpoint. +func WithQueryURL(url string) Option { + return func(cfg *clientConfig) { + cfg.queryURL = url + } +} + +// WithHTTPClient injects a custom HTTP client. +func WithHTTPClient(httpClient *http.Client) Option { + return func(cfg *clientConfig) { + if httpClient != nil { + cfg.httpClient = httpClient + } + } +} diff --git a/sdks/go/sdk/moss/query.go b/sdks/go/sdk/moss/query.go new file mode 100644 index 00000000..05f9bcd4 --- /dev/null +++ b/sdks/go/sdk/moss/query.go @@ -0,0 +1,31 @@ +package moss + +import "context" + +const defaultTopK = 5 + +// Query executes a cloud query against the configured index. +func (c *Client) Query(ctx context.Context, indexName, query string, options *QueryOptions) (SearchResult, error) { + if err := c.validateQueryRequest(indexName); err != nil { + return SearchResult{}, err + } + if options != nil && options.Filter != nil { + return SearchResult{}, ErrUnsupportedQueryFilter + } + + topK := defaultTopK + if options != nil && options.TopK > 0 { + topK = options.TopK + } + + var embedding []float32 + if options != nil && len(options.Embedding) > 0 { + embedding = options.Embedding + } + + response, err := c.queryAPI.Query(ctx, c.queryURL, c.projectID, c.projectKey, indexName, query, topK, embedding) + if err != nil { + return SearchResult{}, normalizeError(err) + } + return toSearchResult(response), nil +} diff --git a/sdks/go/sdk/moss/read.go b/sdks/go/sdk/moss/read.go new file mode 100644 index 00000000..e4d668ff --- /dev/null +++ b/sdks/go/sdk/moss/read.go @@ -0,0 +1,69 @@ +package moss + +import "context" + +// GetIndex fetches metadata for a single index. +func (c *Client) GetIndex(ctx context.Context, indexName string) (IndexInfo, error) { + if err := c.validateManageRequest(indexName); err != nil { + return IndexInfo{}, err + } + response, err := c.manageAPI.GetIndex(ctx, c.manageURL, c.projectID, c.projectKey, indexName) + if err != nil { + return IndexInfo{}, normalizeError(err) + } + return toIndexInfo(response), nil +} + +// ListIndexes returns all indexes for the configured project. +func (c *Client) ListIndexes(ctx context.Context) ([]IndexInfo, error) { + if err := validateCredentials(c.projectID, c.projectKey); err != nil { + return nil, err + } + if c.manageURL == "" { + return nil, ErrMissingManageURL + } + response, err := c.manageAPI.ListIndexes(ctx, c.manageURL, c.projectID, c.projectKey) + if err != nil { + return nil, normalizeError(err) + } + + out := make([]IndexInfo, 0, len(response)) + for _, item := range response { + out = append(out, toIndexInfo(item)) + } + return out, nil +} + +// DeleteIndex removes an index from the configured project. +func (c *Client) DeleteIndex(ctx context.Context, indexName string) (bool, error) { + if err := c.validateManageRequest(indexName); err != nil { + return false, err + } + ok, err := c.manageAPI.DeleteIndex(ctx, c.manageURL, c.projectID, c.projectKey, indexName) + if err != nil { + return false, normalizeError(err) + } + return ok, nil +} + +// GetDocs retrieves all documents for an index or a selected subset by ID. +func (c *Client) GetDocs(ctx context.Context, indexName string, options *GetDocumentsOptions) ([]DocumentInfo, error) { + if err := c.validateManageRequest(indexName); err != nil { + return nil, err + } + var docIDs []string + if options != nil { + docIDs = options.DocIDs + } + + response, err := c.manageAPI.GetDocs(ctx, c.manageURL, c.projectID, c.projectKey, indexName, docIDs) + if err != nil { + return nil, normalizeError(err) + } + + out := make([]DocumentInfo, 0, len(response)) + for _, item := range response { + out = append(out, toDocumentInfo(item)) + } + return out, nil +} From fb3891163adb2ad3d46175fb843ace9cc49b2681 Mon Sep 17 00:00:00 2001 From: anirudh-makuluri Date: Fri, 22 May 2026 13:06:34 -0700 Subject: [PATCH 02/12] Expand Go SDK with mutations, examples, and tests --- sdks/go/README.md | 28 +- sdks/go/sdk/README.md | 145 ++++++++ sdks/go/sdk/examples/basic/main.go | 90 +++++ .../go/sdk/examples/custom-embeddings/main.go | 64 ++++ sdks/go/sdk/moss/conversion.go | 32 ++ sdks/go/sdk/moss/errors.go | 3 + sdks/go/sdk/moss/integration_test.go | 120 +++++++ sdks/go/sdk/moss/internal/manage_api.go | 137 +++++++- sdks/go/sdk/moss/models.go | 6 +- sdks/go/sdk/moss/mutation.go | 319 ++++++++++++++++++ sdks/go/sdk/moss/mutation_test.go | 215 ++++++++++++ 11 files changed, 1139 insertions(+), 20 deletions(-) create mode 100644 sdks/go/sdk/README.md create mode 100644 sdks/go/sdk/examples/basic/main.go create mode 100644 sdks/go/sdk/examples/custom-embeddings/main.go create mode 100644 sdks/go/sdk/moss/integration_test.go create mode 100644 sdks/go/sdk/moss/mutation.go create mode 100644 sdks/go/sdk/moss/mutation_test.go diff --git a/sdks/go/README.md b/sdks/go/README.md index 4478779a..c50b8f09 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -1,23 +1,19 @@ # Moss Go SDK -This directory contains the in-progress Go SDK for Moss. +The Go SDK is currently implemented as a cloud-first, pure-Go client. -The first implementation track is intentionally: +Current status: -- pure Go -- cloud-first -- compatible with the public repository +- typed client and models +- cloud reads (`GetIndex`, `ListIndexes`, `GetDocs`, `DeleteIndex`) +- cloud query (`Query`) +- cloud mutations (`CreateIndex`, `AddDocs`, `DeleteDocs`, `GetJobStatus`) +- unit tests -Current scope: +Current limitations: -- typed client construction -- cloud index metadata reads -- cloud document reads -- cloud query +- no local `LoadIndex` / `UnloadIndex` +- no in-memory query runtime +- no local metadata-filtered query parity -Deferred follow-up work: - -- mutation flows (`CreateIndex`, `AddDocs`, `DeleteDocs`, `GetJobStatus`) -- examples -- integration tests -- local runtime loading/query parity +The Go module itself lives under [`sdks/go/sdk/`](./sdk/). diff --git a/sdks/go/sdk/README.md b/sdks/go/sdk/README.md new file mode 100644 index 00000000..0700d3e4 --- /dev/null +++ b/sdks/go/sdk/README.md @@ -0,0 +1,145 @@ +# Moss client library for Go + +`moss` provides a typed Go client for Moss cloud-backed semantic search workflows. + +This first Go release is intentionally: + +- pure Go +- cloud-first +- buildable from the public repository + +## Features + +- typed Go client and models +- cloud index creation and document mutation +- cloud index metadata and document reads +- cloud query with optional caller-provided embeddings +- env-gated live integration tests + +## Current limitations + +- no local `LoadIndex` / `UnloadIndex` +- no in-memory query runtime +- no local metadata filtering support + +If you pass `QueryOptions.Filter`, the Go SDK returns an explicit error because +cloud-only query does not yet provide the same behavior as the local runtimes. + +## Installation + +From this repository, use the module at: + +```go +github.com/usemoss/moss/sdks/go/sdk/moss +``` + +## Quick start + +```go +package main + +import ( + "context" + "fmt" + "log" + + "github.com/usemoss/moss/sdks/go/sdk/moss" +) + +func main() { + ctx := context.Background() + + client := moss.NewClient("your-project-id", "your-project-key") + + docs := []moss.DocumentInfo{ + { + ID: "doc-1", + Text: "Refunds are processed within five to seven business days.", + Metadata: map[string]string{ + "topic": "refunds", + }, + }, + { + ID: "doc-2", + Text: "Orders can be tracked from the account dashboard.", + Metadata: map[string]string{ + "topic": "shipping", + }, + }, + } + + result, err := client.CreateIndex(ctx, "support-docs", docs, nil) + if err != nil { + log.Fatal(err) + } + + fmt.Println("created job:", result.JobID) + + search, err := client.Query(ctx, "support-docs", "how long do refunds take?", &moss.QueryOptions{ + TopK: 3, + }) + if err != nil { + log.Fatal(err) + } + + for _, doc := range search.Docs { + fmt.Printf("%s %.3f\n", doc.ID, doc.Score) + } +} +``` + +## Custom embeddings + +If your documents already have embeddings, omit `ModelID` and the SDK will +default to `"custom"` automatically: + +```go +docs := []moss.DocumentInfo{ + { + ID: "doc-1", + Text: "Attach a caller-provided embedding.", + Embedding: []float32{1, 0, 0, 0}, + }, + { + ID: "doc-2", + Text: "This index uses custom vectors.", + Embedding: []float32{0, 1, 0, 0}, + }, +} + +_, err := client.CreateIndex(ctx, "custom-embeddings", docs, nil) +if err != nil { + log.Fatal(err) +} + +results, err := client.Query(ctx, "custom-embeddings", "", &moss.QueryOptions{ + Embedding: []float32{1, 0, 0, 0}, + TopK: 5, +}) +``` + +All documents must either provide embeddings or omit them entirely in the same +batch. + +## Examples + +Runnable examples live here: + +- [`examples/basic/main.go`](./examples/basic/main.go) +- [`examples/custom-embeddings/main.go`](./examples/custom-embeddings/main.go) + +## Integration tests + +Live tests are skipped unless both of these are set: + +```bash +export MOSS_TEST_PROJECT_ID=... +export MOSS_TEST_PROJECT_KEY=... +``` + +Then run: + +```bash +cd sdks/go/sdk +go test ./... +``` diff --git a/sdks/go/sdk/examples/basic/main.go b/sdks/go/sdk/examples/basic/main.go new file mode 100644 index 00000000..7cb38bd9 --- /dev/null +++ b/sdks/go/sdk/examples/basic/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "time" + + "github.com/usemoss/moss/sdks/go/sdk/moss" +) + +func main() { + projectID := os.Getenv("MOSS_PROJECT_ID") + projectKey := os.Getenv("MOSS_PROJECT_KEY") + if projectID == "" || projectKey == "" { + log.Fatal("set MOSS_PROJECT_ID and MOSS_PROJECT_KEY") + } + + ctx := context.Background() + client := moss.NewClient(projectID, projectKey) + indexName := fmt.Sprintf("go-basic-%d", time.Now().Unix()) + + docs := []moss.DocumentInfo{ + { + ID: "doc-1", + Text: "Refunds are processed within five to seven business days.", + Metadata: map[string]string{ + "topic": "refunds", + }, + }, + { + ID: "doc-2", + Text: "Orders can be tracked from the account dashboard.", + Metadata: map[string]string{ + "topic": "shipping", + }, + }, + } + + result, err := client.CreateIndex(ctx, indexName, docs, nil) + if err != nil { + log.Fatal(err) + } + fmt.Println("create job:", result.JobID) + + search, err := queryWithRetry(ctx, client, indexName, "how long do refunds take?") + if err != nil { + log.Fatal(err) + } + + fmt.Println("query:", search.Query) + for _, doc := range search.Docs { + fmt.Printf("%s %.3f %s\n", doc.ID, doc.Score, doc.Text) + } + + if err := cleanup(ctx, client, indexName); err != nil { + log.Printf("cleanup warning: %v", err) + } +} + +func cleanup(ctx context.Context, client *moss.Client, indexName string) error { + _, err := client.DeleteIndex(ctx, indexName) + return err +} + +func queryWithRetry(ctx context.Context, client *moss.Client, indexName, query string) (moss.SearchResult, error) { + const attempts = 6 + + for attempt := 1; attempt <= attempts; attempt++ { + result, err := client.Query(ctx, indexName, query, &moss.QueryOptions{ + TopK: 3, + }) + if err == nil { + return result, nil + } + + var httpErr *moss.HTTPError + if !errors.As(err, &httpErr) || httpErr.StatusCode != 503 || attempt == attempts { + return moss.SearchResult{}, err + } + + delay := time.Duration(attempt) * 2 * time.Second + log.Printf("query returned 503, retrying in %s (%d/%d)", delay, attempt, attempts) + time.Sleep(delay) + } + + return moss.SearchResult{}, fmt.Errorf("query retries exhausted") +} diff --git a/sdks/go/sdk/examples/custom-embeddings/main.go b/sdks/go/sdk/examples/custom-embeddings/main.go new file mode 100644 index 00000000..d251fb2d --- /dev/null +++ b/sdks/go/sdk/examples/custom-embeddings/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "github.com/usemoss/moss/sdks/go/sdk/moss" +) + +func main() { + projectID := os.Getenv("MOSS_PROJECT_ID") + projectKey := os.Getenv("MOSS_PROJECT_KEY") + if projectID == "" || projectKey == "" { + log.Fatal("set MOSS_PROJECT_ID and MOSS_PROJECT_KEY") + } + + ctx := context.Background() + client := moss.NewClient(projectID, projectKey) + indexName := fmt.Sprintf("go-custom-%d", time.Now().Unix()) + + docs := []moss.DocumentInfo{ + { + ID: "refunds", + Text: "Refunds are processed within five business days.", + Embedding: []float32{1, 0, 0, 0}, + }, + { + ID: "shipping", + Text: "Track your order from the shipping dashboard.", + Embedding: []float32{0, 1, 0, 0}, + }, + } + + result, err := client.CreateIndex(ctx, indexName, docs, nil) + if err != nil { + log.Fatal(err) + } + fmt.Println("create job:", result.JobID) + + query := []float32{1, 0, 0, 0} + search, err := client.Query(ctx, indexName, "", &moss.QueryOptions{ + Embedding: query, + TopK: 2, + }) + if err != nil { + log.Fatal(err) + } + + for _, doc := range search.Docs { + fmt.Printf("%s %.3f\n", doc.ID, doc.Score) + } + + if err := cleanup(ctx, client, indexName); err != nil { + log.Printf("cleanup warning: %v", err) + } +} + +func cleanup(ctx context.Context, client *moss.Client, indexName string) error { + _, err := client.DeleteIndex(ctx, indexName) + return err +} diff --git a/sdks/go/sdk/moss/conversion.go b/sdks/go/sdk/moss/conversion.go index f46ea03f..0bb4b000 100644 --- a/sdks/go/sdk/moss/conversion.go +++ b/sdks/go/sdk/moss/conversion.go @@ -31,6 +31,19 @@ func toDocumentInfo(value internal.DocumentInfoResponse) DocumentInfo { } } +func toDocumentInfoResponses(values []DocumentInfo) []internal.DocumentInfoResponse { + out := make([]internal.DocumentInfoResponse, 0, len(values)) + for _, value := range values { + out = append(out, internal.DocumentInfoResponse{ + ID: value.ID, + Text: value.Text, + Metadata: value.Metadata, + Embedding: value.Embedding, + }) + } + return out +} + func toSearchResult(value internal.SearchResultResponse) SearchResult { docs := make([]QueryResultDocumentInfo, 0, len(value.Docs)) for _, item := range value.Docs { @@ -50,6 +63,25 @@ func toSearchResult(value internal.SearchResultResponse) SearchResult { } } +func toJobStatusResponse(value internal.JobStatusResponse) JobStatusResponse { + var currentPhase *JobPhase + if value.CurrentPhase != nil { + phase := JobPhase(*value.CurrentPhase) + currentPhase = &phase + } + + return JobStatusResponse{ + JobID: value.JobID, + Status: JobStatus(value.Status), + Progress: value.Progress, + CurrentPhase: currentPhase, + Error: value.Error, + CreatedAt: value.CreatedAt, + UpdatedAt: value.UpdatedAt, + CompletedAt: value.CompletedAt, + } +} + func normalizeError(err error) error { if err == nil { return nil diff --git a/sdks/go/sdk/moss/errors.go b/sdks/go/sdk/moss/errors.go index e44b6fac..bea0c9df 100644 --- a/sdks/go/sdk/moss/errors.go +++ b/sdks/go/sdk/moss/errors.go @@ -11,6 +11,9 @@ var ( ErrMissingManageURL = errors.New("moss: manage URL is not configured") ErrMissingQueryURL = errors.New("moss: query URL is not configured") ErrEmptyIndexName = errors.New("moss: index name must not be empty") + ErrEmptyJobID = errors.New("moss: job ID must not be empty") + ErrEmptyDocuments = errors.New("moss: documents must not be empty") + ErrEmptyDocumentIDs = errors.New("moss: document IDs must not be empty") ErrUnsupportedQueryFilter = errors.New("moss: query filters are not supported in the cloud-only Go SDK yet") ) diff --git a/sdks/go/sdk/moss/integration_test.go b/sdks/go/sdk/moss/integration_test.go new file mode 100644 index 00000000..0c7b6c07 --- /dev/null +++ b/sdks/go/sdk/moss/integration_test.go @@ -0,0 +1,120 @@ +package moss + +import ( + "context" + "fmt" + "os" + "testing" + "time" +) + +func TestCloudLifecycleIntegration(t *testing.T) { + projectID := os.Getenv("MOSS_TEST_PROJECT_ID") + projectKey := os.Getenv("MOSS_TEST_PROJECT_KEY") + if projectID == "" || projectKey == "" { + t.Skip("Skipping cloud integration test: set MOSS_TEST_PROJECT_ID and MOSS_TEST_PROJECT_KEY") + } + + client := NewClient(projectID, projectKey) + ctx := context.Background() + indexName := fmt.Sprintf("go-sdk-int-%d", time.Now().UnixNano()) + + docs := []DocumentInfo{ + { + ID: "doc-1", + Text: "Refunds are processed within five business days.", + Embedding: []float32{1, 0, 0, 0}, + }, + { + ID: "doc-2", + Text: "Orders can be tracked from the dashboard.", + Embedding: []float32{0, 1, 0, 0}, + }, + } + + t.Cleanup(func() { + _, _ = client.DeleteIndex(context.Background(), indexName) + }) + + createResult, err := client.CreateIndex(ctx, indexName, docs, nil) + if err != nil { + t.Fatalf("CreateIndex failed: %v", err) + } + if createResult.JobID == "" || createResult.IndexName != indexName || createResult.DocCount != 2 { + t.Fatalf("unexpected create result: %#v", createResult) + } + + status, err := client.GetJobStatus(ctx, createResult.JobID) + if err != nil { + t.Fatalf("GetJobStatus failed: %v", err) + } + if status.JobID != createResult.JobID || status.Status != JobStatusCompleted { + t.Fatalf("unexpected job status: %#v", status) + } + + info, err := client.GetIndex(ctx, indexName) + if err != nil { + t.Fatalf("GetIndex failed: %v", err) + } + if info.Name != indexName || info.DocCount != 2 || info.Model.ID != string(ModelCustom) { + t.Fatalf("unexpected index info: %#v", info) + } + + gotDocs, err := client.GetDocs(ctx, indexName, nil) + if err != nil { + t.Fatalf("GetDocs failed: %v", err) + } + if len(gotDocs) != 2 { + t.Fatalf("unexpected doc count: %d", len(gotDocs)) + } + + search, err := client.Query(ctx, indexName, "", &QueryOptions{ + Embedding: []float32{1, 0, 0, 0}, + TopK: 2, + }) + if err != nil { + t.Fatalf("Query failed: %v", err) + } + if len(search.Docs) == 0 || search.Docs[0].ID != "doc-1" { + t.Fatalf("unexpected query result: %#v", search) + } + + upsert := true + addResult, err := client.AddDocs(ctx, indexName, []DocumentInfo{ + { + ID: "doc-3", + Text: "Customers can update shipping addresses before fulfillment.", + Embedding: []float32{0, 0, 1, 0}, + }, + }, &MutationOptions{Upsert: &upsert}) + if err != nil { + t.Fatalf("AddDocs failed: %v", err) + } + if addResult.DocCount != 1 { + t.Fatalf("unexpected add result: %#v", addResult) + } + + info, err = client.GetIndex(ctx, indexName) + if err != nil { + t.Fatalf("GetIndex after AddDocs failed: %v", err) + } + if info.DocCount != 3 { + t.Fatalf("unexpected doc count after add: %d", info.DocCount) + } + + deleteResult, err := client.DeleteDocs(ctx, indexName, []string{"doc-2"}, nil) + if err != nil { + t.Fatalf("DeleteDocs failed: %v", err) + } + if deleteResult.DocCount != 1 { + t.Fatalf("unexpected delete result: %#v", deleteResult) + } + + info, err = client.GetIndex(ctx, indexName) + if err != nil { + t.Fatalf("GetIndex after DeleteDocs failed: %v", err) + } + if info.DocCount != 2 { + t.Fatalf("unexpected doc count after delete: %d", info.DocCount) + } +} diff --git a/sdks/go/sdk/moss/internal/manage_api.go b/sdks/go/sdk/moss/internal/manage_api.go index 6607a498..daa31503 100644 --- a/sdks/go/sdk/moss/internal/manage_api.go +++ b/sdks/go/sdk/moss/internal/manage_api.go @@ -17,6 +17,33 @@ type manageRequest struct { DocIDs []string `json:"docIds,omitempty"` } +type initUploadRequest struct { + Action string `json:"action"` + ProjectID string `json:"projectId"` + IndexName string `json:"indexName"` + ModelID string `json:"modelId"` + DocCount int `json:"docCount"` + Dimension int `json:"dimension"` +} + +type addDocsRequest struct { + Action string `json:"action"` + ProjectID string `json:"projectId"` + IndexName string `json:"indexName"` + Docs []DocumentInfoResponse `json:"docs"` + Options *addDocsOptions `json:"options,omitempty"` +} + +type addDocsOptions struct { + Upsert *bool `json:"upsert,omitempty"` +} + +type jobRequest struct { + Action string `json:"action"` + ProjectID string `json:"projectId"` + JobID string `json:"jobId"` +} + type ModelRefResponse struct { ID string `json:"id"` Version *string `json:"version,omitempty"` @@ -40,6 +67,28 @@ type DocumentInfoResponse struct { Embedding []float32 `json:"embedding,omitempty"` } +type InitUploadResponse struct { + JobID string `json:"jobId"` + UploadURL string `json:"uploadUrl"` + ExpiresIn int `json:"expiresIn"` +} + +type MutationResponse struct { + JobID string `json:"jobId"` + Status string `json:"status"` +} + +type JobStatusResponse struct { + JobID string `json:"jobId"` + Status string `json:"status"` + Progress float64 `json:"progress"` + CurrentPhase *string `json:"currentPhase"` + Error *string `json:"error"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + CompletedAt *string `json:"completedAt"` +} + func (a *ManageAPI) GetIndex(ctx context.Context, manageURL, projectID, projectKey, indexName string) (IndexInfoResponse, error) { var response IndexInfoResponse if err := a.do(ctx, manageURL, projectID, projectKey, manageRequest{ @@ -75,6 +124,89 @@ func (a *ManageAPI) DeleteIndex(ctx context.Context, manageURL, projectID, proje return response, nil } +func (a *ManageAPI) InitUpload( + ctx context.Context, + manageURL, projectID, projectKey, indexName, modelID string, + docCount, dimension int, +) (InitUploadResponse, error) { + var response InitUploadResponse + if err := a.do(ctx, manageURL, projectID, projectKey, initUploadRequest{ + Action: "initUpload", + ProjectID: projectID, + IndexName: indexName, + ModelID: modelID, + DocCount: docCount, + Dimension: dimension, + }, &response); err != nil { + return InitUploadResponse{}, err + } + return response, nil +} + +func (a *ManageAPI) StartBuild(ctx context.Context, manageURL, projectID, projectKey, jobID string) (MutationResponse, error) { + var response MutationResponse + if err := a.do(ctx, manageURL, projectID, projectKey, jobRequest{ + Action: "startBuild", + ProjectID: projectID, + JobID: jobID, + }, &response); err != nil { + return MutationResponse{}, err + } + return response, nil +} + +func (a *ManageAPI) AddDocs( + ctx context.Context, + manageURL, projectID, projectKey, indexName string, + docs []DocumentInfoResponse, + upsert *bool, +) (MutationResponse, error) { + request := addDocsRequest{ + Action: "addDocs", + ProjectID: projectID, + IndexName: indexName, + Docs: docs, + } + if upsert != nil { + request.Options = &addDocsOptions{Upsert: upsert} + } + + var response MutationResponse + if err := a.do(ctx, manageURL, projectID, projectKey, request, &response); err != nil { + return MutationResponse{}, err + } + return response, nil +} + +func (a *ManageAPI) DeleteDocs( + ctx context.Context, + manageURL, projectID, projectKey, indexName string, + docIDs []string, +) (MutationResponse, error) { + var response MutationResponse + if err := a.do(ctx, manageURL, projectID, projectKey, manageRequest{ + Action: "deleteDocs", + ProjectID: projectID, + IndexName: indexName, + DocIDs: docIDs, + }, &response); err != nil { + return MutationResponse{}, err + } + return response, nil +} + +func (a *ManageAPI) GetJobStatus(ctx context.Context, manageURL, projectID, projectKey, jobID string) (JobStatusResponse, error) { + var response JobStatusResponse + if err := a.do(ctx, manageURL, projectID, projectKey, jobRequest{ + Action: "getJobStatus", + ProjectID: projectID, + JobID: jobID, + }, &response); err != nil { + return JobStatusResponse{}, err + } + return response, nil +} + func (a *ManageAPI) GetDocs( ctx context.Context, manageURL, projectID, projectKey, indexName string, @@ -98,7 +230,8 @@ func (a *ManageAPI) GetDocs( func (a *ManageAPI) do(ctx context.Context, manageURL, projectID, projectKey string, payload any, dest any) error { return a.httpClient.PostJSON(ctx, manageURL, map[string]string{ - "Content-Type": "application/json", - "X-Project-Key": projectKey, + "Content-Type": "application/json", + "X-Project-Key": projectKey, + "X-Service-Version": "v1", }, payload, dest) } diff --git a/sdks/go/sdk/moss/models.go b/sdks/go/sdk/moss/models.go index a3604be2..520e5215 100644 --- a/sdks/go/sdk/moss/models.go +++ b/sdks/go/sdk/moss/models.go @@ -99,12 +99,14 @@ type GetDocumentsOptions struct { // CreateIndexOptions customizes index creation behavior. type CreateIndexOptions struct { - ModelID MossModel `json:"modelId,omitempty"` + ModelID MossModel `json:"modelId,omitempty"` + OnProgress func(JobProgress) `json:"-"` } // MutationOptions customizes add/update/delete document behavior. type MutationOptions struct { - Upsert *bool `json:"upsert,omitempty"` + Upsert *bool `json:"upsert,omitempty"` + OnProgress func(JobProgress) `json:"-"` } // MutationResult is returned when a mutation job completes. diff --git a/sdks/go/sdk/moss/mutation.go b/sdks/go/sdk/moss/mutation.go new file mode 100644 index 00000000..878fc014 --- /dev/null +++ b/sdks/go/sdk/moss/mutation.go @@ -0,0 +1,319 @@ +package moss + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "strings" + "time" +) + +const ( + defaultPollInterval = 2 * time.Second + defaultMutationTimeout = 30 * time.Minute + maxConsecutivePollErrors = 3 + maxUploadRetries = 3 + baseUploadRetryDelay = 1 * time.Second +) + +// CreateIndex initializes an upload, sends the bulk payload, starts the build, and polls until completion. +func (c *Client) CreateIndex(ctx context.Context, indexName string, docs []DocumentInfo, options *CreateIndexOptions) (MutationResult, error) { + if err := c.validateManageRequest(indexName); err != nil { + return MutationResult{}, err + } + if len(docs) == 0 { + return MutationResult{}, ErrEmptyDocuments + } + + modelID := resolveModelID(docs, options) + dimension, err := resolveEmbeddingDimension(docs, modelID) + if err != nil { + return MutationResult{}, err + } + + initResponse, err := c.manageAPI.InitUpload(ctx, c.manageURL, c.projectID, c.projectKey, indexName, string(modelID), len(docs), dimension) + if err != nil { + return MutationResult{}, normalizeError(err) + } + + payload, err := serializeBulkPayload(docs, dimension) + if err != nil { + return MutationResult{}, err + } + + if err := c.uploadBulkPayload(ctx, initResponse.UploadURL, payload); err != nil { + return MutationResult{}, err + } + + if _, err := c.manageAPI.StartBuild(ctx, c.manageURL, c.projectID, c.projectKey, initResponse.JobID); err != nil { + return MutationResult{}, normalizeError(err) + } + + var onProgress func(JobProgress) + if options != nil { + onProgress = options.OnProgress + } + + return c.pollJobUntilComplete(ctx, initResponse.JobID, indexName, len(docs), onProgress) +} + +// AddDocs appends or upserts documents and polls the async job until completion. +func (c *Client) AddDocs(ctx context.Context, indexName string, docs []DocumentInfo, options *MutationOptions) (MutationResult, error) { + if err := c.validateManageRequest(indexName); err != nil { + return MutationResult{}, err + } + if len(docs) == 0 { + return MutationResult{}, ErrEmptyDocuments + } + + var upsert *bool + var onProgress func(JobProgress) + if options != nil { + upsert = options.Upsert + onProgress = options.OnProgress + } + + response, err := c.manageAPI.AddDocs(ctx, c.manageURL, c.projectID, c.projectKey, indexName, toDocumentInfoResponses(docs), upsert) + if err != nil { + return MutationResult{}, normalizeError(err) + } + + return c.pollJobUntilComplete(ctx, response.JobID, indexName, len(docs), onProgress) +} + +// DeleteDocs removes documents by ID and polls the async job until completion. +func (c *Client) DeleteDocs(ctx context.Context, indexName string, docIDs []string, options *MutationOptions) (MutationResult, error) { + if err := c.validateManageRequest(indexName); err != nil { + return MutationResult{}, err + } + if len(docIDs) == 0 { + return MutationResult{}, ErrEmptyDocumentIDs + } + + var onProgress func(JobProgress) + if options != nil { + onProgress = options.OnProgress + } + + response, err := c.manageAPI.DeleteDocs(ctx, c.manageURL, c.projectID, c.projectKey, indexName, docIDs) + if err != nil { + return MutationResult{}, normalizeError(err) + } + + return c.pollJobUntilComplete(ctx, response.JobID, indexName, len(docIDs), onProgress) +} + +// GetJobStatus fetches the current status of an async mutation job. +func (c *Client) GetJobStatus(ctx context.Context, jobID string) (JobStatusResponse, error) { + if err := validateCredentials(c.projectID, c.projectKey); err != nil { + return JobStatusResponse{}, err + } + if strings.TrimSpace(c.manageURL) == "" { + return JobStatusResponse{}, ErrMissingManageURL + } + if strings.TrimSpace(jobID) == "" { + return JobStatusResponse{}, ErrEmptyJobID + } + + response, err := c.manageAPI.GetJobStatus(ctx, c.manageURL, c.projectID, c.projectKey, jobID) + if err != nil { + return JobStatusResponse{}, normalizeError(err) + } + return toJobStatusResponse(response), nil +} + +func resolveModelID(docs []DocumentInfo, options *CreateIndexOptions) MossModel { + if options != nil && options.ModelID != "" { + return options.ModelID + } + + for _, doc := range docs { + if len(doc.Embedding) > 0 { + return ModelCustom + } + } + + return ModelMossMiniLM +} + +func resolveEmbeddingDimension(docs []DocumentInfo, modelID MossModel) (int, error) { + withEmbeddings := 0 + for _, doc := range docs { + if len(doc.Embedding) > 0 { + withEmbeddings++ + } + } + + withoutEmbeddings := len(docs) - withEmbeddings + if withEmbeddings > 0 && withoutEmbeddings > 0 { + return 0, fmt.Errorf("moss: all documents must either all have embeddings or none should have embeddings") + } + + if withEmbeddings == 0 { + if modelID == ModelCustom { + return 0, fmt.Errorf("moss: cannot use model %q without pre-computed embeddings", ModelCustom) + } + return 0, nil + } + + dimension := len(docs[0].Embedding) + for _, doc := range docs[1:] { + if len(doc.Embedding) != dimension { + return 0, fmt.Errorf("moss: document %q has mismatched embedding dimension (expected %d, got %d)", doc.ID, dimension, len(doc.Embedding)) + } + } + + return dimension, nil +} + +func serializeBulkPayload(docs []DocumentInfo, dimension int) ([]byte, error) { + metadataDocs := make([]map[string]any, 0, len(docs)) + for _, doc := range docs { + item := map[string]any{ + "id": doc.ID, + "text": doc.Text, + } + if len(doc.Metadata) > 0 { + item["metadata"] = doc.Metadata + } + metadataDocs = append(metadataDocs, item) + } + + metadataBytes, err := json.Marshal(metadataDocs) + if err != nil { + return nil, err + } + + const headerSize = 20 + embeddingsSize := 0 + if dimension > 0 { + embeddingsSize = len(docs) * dimension * 4 + } + + buf := bytes.NewBuffer(make([]byte, 0, headerSize+len(metadataBytes)+embeddingsSize)) + buf.Write([]byte{'M', 'O', 'S', 'S'}) + for _, value := range []uint32{1, uint32(len(docs)), uint32(dimension), uint32(len(metadataBytes))} { + if err := binary.Write(buf, binary.LittleEndian, value); err != nil { + return nil, err + } + } + buf.Write(metadataBytes) + + for _, doc := range docs { + for _, value := range doc.Embedding { + if err := binary.Write(buf, binary.LittleEndian, value); err != nil { + return nil, err + } + } + } + + return buf.Bytes(), nil +} + +func (c *Client) uploadBulkPayload(ctx context.Context, uploadURL string, payload []byte) error { + var lastErr error + + for attempt := 0; attempt < maxUploadRetries; attempt++ { + req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/octet-stream") + + resp, err := c.httpClient.Do(req) + if err != nil { + lastErr = err + } else { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) + resp.Body.Close() + + if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { + return nil + } + + lastErr = &HTTPError{ + StatusCode: resp.StatusCode, + Body: strings.TrimSpace(string(body)), + } + + if resp.StatusCode < http.StatusInternalServerError { + return lastErr + } + } + + if attempt == maxUploadRetries-1 { + break + } + + delay := time.Duration(math.Pow(2, float64(attempt))) * baseUploadRetryDelay + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } + + return lastErr +} + +func (c *Client) pollJobUntilComplete( + ctx context.Context, + jobID, indexName string, + docCount int, + onProgress func(JobProgress), +) (MutationResult, error) { + timeoutCtx, cancel := context.WithTimeout(ctx, defaultMutationTimeout) + defer cancel() + + ticker := time.NewTicker(defaultPollInterval) + defer ticker.Stop() + + consecutiveErrors := 0 + + for { + status, err := c.GetJobStatus(timeoutCtx, jobID) + if err != nil { + consecutiveErrors++ + if consecutiveErrors >= maxConsecutivePollErrors { + return MutationResult{}, fmt.Errorf("moss: job status polling failed after %d consecutive errors: %w", maxConsecutivePollErrors, err) + } + } else { + consecutiveErrors = 0 + if onProgress != nil { + onProgress(JobProgress{ + JobID: status.JobID, + Status: status.Status, + Progress: status.Progress, + CurrentPhase: status.CurrentPhase, + }) + } + + switch status.Status { + case JobStatusCompleted: + return MutationResult{ + JobID: jobID, + IndexName: indexName, + DocCount: docCount, + }, nil + case JobStatusFailed: + if status.Error != nil && *status.Error != "" { + return MutationResult{}, fmt.Errorf("moss: job failed: %s", *status.Error) + } + return MutationResult{}, fmt.Errorf("moss: job failed") + } + } + + select { + case <-timeoutCtx.Done(): + return MutationResult{}, timeoutCtx.Err() + case <-ticker.C: + } + } +} diff --git a/sdks/go/sdk/moss/mutation_test.go b/sdks/go/sdk/moss/mutation_test.go new file mode 100644 index 00000000..9aa06ef2 --- /dev/null +++ b/sdks/go/sdk/moss/mutation_test.go @@ -0,0 +1,215 @@ +package moss + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func TestCreateIndexRunsInitUploadUploadStartBuildAndPoll(t *testing.T) { + var initSeen, startSeen bool + var uploaded []byte + var pollCount atomic.Int32 + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + defer server.Close() + + mux.HandleFunc("/manage", func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request body: %v", err) + } + + switch body["action"] { + case "initUpload": + initSeen = true + if body["modelId"] != "moss-minilm" { + t.Fatalf("unexpected modelId: %#v", body["modelId"]) + } + if body["dimension"] != float64(0) { + t.Fatalf("unexpected dimension: %#v", body["dimension"]) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"jobId":"job-create","uploadUrl":"` + server.URL + `/upload","expiresIn":3600}`)) + case "startBuild": + startSeen = true + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"jobId":"job-create","status":"building"}`)) + case "getJobStatus": + w.Header().Set("Content-Type", "application/json") + if pollCount.Add(1) == 1 { + _, _ = w.Write([]byte(`{"jobId":"job-create","status":"building","progress":42,"currentPhase":"building_index","error":null,"createdAt":"2026-05-22T00:00:00Z","updatedAt":"2026-05-22T00:00:01Z","completedAt":null}`)) + return + } + _, _ = w.Write([]byte(`{"jobId":"job-create","status":"completed","progress":100,"currentPhase":null,"error":null,"createdAt":"2026-05-22T00:00:00Z","updatedAt":"2026-05-22T00:00:02Z","completedAt":"2026-05-22T00:00:02Z"}`)) + default: + t.Fatalf("unexpected action: %#v", body["action"]) + } + }) + + mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + data, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read upload body: %v", err) + } + uploaded = data + w.WriteHeader(http.StatusOK) + }) + + client := NewClient("project-id", "project-key", WithManageURL(server.URL+"/manage")) + progresses := []JobProgress{} + + result, err := client.CreateIndex(context.Background(), "support-docs", []DocumentInfo{ + {ID: "doc-1", Text: "hello"}, + {ID: "doc-2", Text: "world"}, + }, &CreateIndexOptions{ + OnProgress: func(progress JobProgress) { + progresses = append(progresses, progress) + }, + }) + if err != nil { + t.Fatalf("CreateIndex returned error: %v", err) + } + + if !initSeen || !startSeen { + t.Fatalf("expected initUpload and startBuild to both run") + } + if len(uploaded) == 0 { + t.Fatal("expected upload payload to be sent") + } + if string(uploaded[:4]) != "MOSS" { + t.Fatalf("unexpected upload header: %q", string(uploaded[:4])) + } + if result.JobID != "job-create" || result.IndexName != "support-docs" || result.DocCount != 2 { + t.Fatalf("unexpected mutation result: %#v", result) + } + if len(progresses) != 2 || progresses[len(progresses)-1].Status != JobStatusCompleted { + t.Fatalf("unexpected progress updates: %#v", progresses) + } +} + +func TestCreateIndexRejectsMixedEmbeddings(t *testing.T) { + client := NewClient("project-id", "project-key") + + _, err := client.CreateIndex(context.Background(), "support-docs", []DocumentInfo{ + {ID: "doc-1", Text: "a", Embedding: []float32{1, 2}}, + {ID: "doc-2", Text: "b"}, + }, nil) + if err == nil || !strings.Contains(err.Error(), "all have embeddings or none") { + t.Fatalf("expected mixed embeddings error, got %v", err) + } +} + +func TestAddDocsSendsJSONMutationAndPolls(t *testing.T) { + var gotBody map[string]any + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + defer server.Close() + + mux.HandleFunc("/manage", func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + switch gotBody["action"] { + case "addDocs": + _, _ = w.Write([]byte(`{"jobId":"job-add","status":"building"}`)) + case "getJobStatus": + _, _ = w.Write([]byte(`{"jobId":"job-add","status":"completed","progress":100,"currentPhase":null,"error":null,"createdAt":"2026-05-22T00:00:00Z","updatedAt":"2026-05-22T00:00:01Z","completedAt":"2026-05-22T00:00:01Z"}`)) + default: + t.Fatalf("unexpected action: %#v", gotBody["action"]) + } + }) + + upsert := true + client := NewClient("project-id", "project-key", WithManageURL(server.URL+"/manage")) + result, err := client.AddDocs(context.Background(), "support-docs", []DocumentInfo{ + {ID: "doc-3", Text: "new"}, + }, &MutationOptions{Upsert: &upsert}) + if err != nil { + t.Fatalf("AddDocs returned error: %v", err) + } + + if result.JobID != "job-add" || result.DocCount != 1 { + t.Fatalf("unexpected add result: %#v", result) + } + if gotBody["action"] != "getJobStatus" { + t.Fatalf("expected final request to be getJobStatus, got %#v", gotBody["action"]) + } +} + +func TestDeleteDocsSendsExpectedAction(t *testing.T) { + var firstAction string + var seenDelete bool + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + defer server.Close() + + mux.HandleFunc("/manage", func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request body: %v", err) + } + action := body["action"].(string) + if firstAction == "" { + firstAction = action + } + if action == "deleteDocs" { + seenDelete = true + } + + w.Header().Set("Content-Type", "application/json") + if action == "deleteDocs" { + _, _ = w.Write([]byte(`{"jobId":"job-del","status":"building"}`)) + return + } + _, _ = w.Write([]byte(`{"jobId":"job-del","status":"completed","progress":100,"currentPhase":null,"error":null,"createdAt":"2026-05-22T00:00:00Z","updatedAt":"2026-05-22T00:00:01Z","completedAt":"2026-05-22T00:00:01Z"}`)) + }) + + client := NewClient("project-id", "project-key", WithManageURL(server.URL+"/manage")) + result, err := client.DeleteDocs(context.Background(), "support-docs", []string{"doc-1", "doc-2"}, nil) + if err != nil { + t.Fatalf("DeleteDocs returned error: %v", err) + } + + if !seenDelete || firstAction != "deleteDocs" { + t.Fatalf("expected first action to be deleteDocs, got %q", firstAction) + } + if result.DocCount != 2 { + t.Fatalf("unexpected delete result: %#v", result) + } +} + +func TestGetJobStatusDecodesResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"jobId":"job-123","status":"building","progress":55,"currentPhase":"uploading","error":null,"createdAt":"2026-05-22T00:00:00Z","updatedAt":"2026-05-22T00:00:01Z","completedAt":null}`)) + })) + defer server.Close() + + client := NewClient("project-id", "project-key", WithManageURL(server.URL)) + status, err := client.GetJobStatus(context.Background(), "job-123") + if err != nil { + t.Fatalf("GetJobStatus returned error: %v", err) + } + + if status.JobID != "job-123" || status.Status != JobStatusBuilding || status.Progress != 55 { + t.Fatalf("unexpected job status: %#v", status) + } + if status.CurrentPhase == nil || *status.CurrentPhase != JobPhaseUploading { + t.Fatalf("unexpected current phase: %#v", status.CurrentPhase) + } +} From 58db501701c46b34d7af5096b9c31e556b65a8bb Mon Sep 17 00:00:00 2001 From: anirudh-makuluri Date: Fri, 22 May 2026 23:22:06 -0700 Subject: [PATCH 03/12] Add bindings-backed Go SDK implementation --- sdks/go/README.md | 23 +- sdks/go/bindings/README.md | 41 ++ sdks/go/bindings/errors.go | 5 + sdks/go/bindings/go.mod | 3 + sdks/go/bindings/libmoss.go | 596 ++++++++++++++++++ sdks/go/bindings/models.go | 71 +++ sdks/go/bindings/stub.go | 83 +++ sdks/go/sdk/README.md | 50 +- sdks/go/sdk/examples/basic/main.go | 38 +- .../go/sdk/examples/custom-embeddings/main.go | 9 + sdks/go/sdk/go.mod | 4 + sdks/go/sdk/moss/client.go | 161 +++-- sdks/go/sdk/moss/client_test.go | 444 ++++++++----- sdks/go/sdk/moss/conversion.go | 63 +- sdks/go/sdk/moss/errors.go | 19 +- sdks/go/sdk/moss/integration_test.go | 16 + sdks/go/sdk/moss/internal/httpclient.go | 83 --- sdks/go/sdk/moss/internal/manage_api.go | 237 ------- sdks/go/sdk/moss/internal/query_api.go | 60 -- sdks/go/sdk/moss/local.go | 90 +++ sdks/go/sdk/moss/models.go | 17 +- sdks/go/sdk/moss/mutation.go | 187 ++---- sdks/go/sdk/moss/mutation_test.go | 243 ++++--- sdks/go/sdk/moss/options.go | 20 +- sdks/go/sdk/moss/query.go | 61 +- sdks/go/sdk/moss/read.go | 51 +- 26 files changed, 1655 insertions(+), 1020 deletions(-) create mode 100644 sdks/go/bindings/README.md create mode 100644 sdks/go/bindings/errors.go create mode 100644 sdks/go/bindings/go.mod create mode 100644 sdks/go/bindings/libmoss.go create mode 100644 sdks/go/bindings/models.go create mode 100644 sdks/go/bindings/stub.go delete mode 100644 sdks/go/sdk/moss/internal/httpclient.go delete mode 100644 sdks/go/sdk/moss/internal/manage_api.go delete mode 100644 sdks/go/sdk/moss/internal/query_api.go create mode 100644 sdks/go/sdk/moss/local.go diff --git a/sdks/go/README.md b/sdks/go/README.md index c50b8f09..5241313e 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -1,19 +1,20 @@ # Moss Go SDK -The Go SDK is currently implemented as a cloud-first, pure-Go client. +The Go work now has the same two-layer direction as the other Moss SDKs: + +- `sdks/go/sdk/` contains the public Go SDK +- `sdks/go/bindings/` wraps the native `libmoss` runtime via CGO Current status: -- typed client and models -- cloud reads (`GetIndex`, `ListIndexes`, `GetDocs`, `DeleteIndex`) -- cloud query (`Query`) -- cloud mutations (`CreateIndex`, `AddDocs`, `DeleteDocs`, `GetJobStatus`) -- unit tests +- bindings-backed manage operations for mutations and metadata reads +- local `LoadIndex` / `UnloadIndex` / local `Query` via `libmoss` +- examples and unit tests +- env-gated integration test scaffold -Current limitations: +Important note: -- no local `LoadIndex` / `UnloadIndex` -- no in-memory query runtime -- no local metadata-filtered query parity +- all runtime operations require the `libmoss` C SDK plus `-tags libmoss` -The Go module itself lives under [`sdks/go/sdk/`](./sdk/). +The public SDK module lives under [`sdks/go/sdk/`](./sdk/), and the native +bindings module lives under [`sdks/go/bindings/`](./bindings/). diff --git a/sdks/go/bindings/README.md b/sdks/go/bindings/README.md new file mode 100644 index 00000000..18ebb016 --- /dev/null +++ b/sdks/go/bindings/README.md @@ -0,0 +1,41 @@ +# Moss Go Bindings + +This package wraps the native `libmoss` runtime for Go via CGO. + +It mirrors the role of the other language bindings packages in this repository: + +- native runtime access +- local index loading +- local query execution +- cloud-backed manage operations exposed through the native client + +## Status + +The real bindings implementation is compiled only with the `libmoss` build tag. +Without that tag, this package builds a stub that returns a clear +`ErrBindingsUnavailable` error. + +## Local build workflow + +Download the matching `libmoss` C SDK release archive for your platform from: + +- + +For Linux `x86_64`, extract the archive somewhere local so you have: + +```text +/ +├── include/libmoss.h +└── lib/libmoss.so +``` + +Then build with: + +```bash +export CGO_CFLAGS="-I/include" +export CGO_LDFLAGS="-L/lib" +export LD_LIBRARY_PATH="/lib" +go test -tags libmoss ./... +``` + +The Go SDK module can then be built with the same flags and tag. diff --git a/sdks/go/bindings/errors.go b/sdks/go/bindings/errors.go new file mode 100644 index 00000000..8c0e91ac --- /dev/null +++ b/sdks/go/bindings/errors.go @@ -0,0 +1,5 @@ +package mosscore + +import "errors" + +var ErrBindingsUnavailable = errors.New("mosscore: libmoss bindings are unavailable; build with -tags libmoss and configure the libmoss C SDK") diff --git a/sdks/go/bindings/go.mod b/sdks/go/bindings/go.mod new file mode 100644 index 00000000..d34cdccc --- /dev/null +++ b/sdks/go/bindings/go.mod @@ -0,0 +1,3 @@ +module github.com/usemoss/moss/sdks/go/bindings + +go 1.22.2 diff --git a/sdks/go/bindings/libmoss.go b/sdks/go/bindings/libmoss.go new file mode 100644 index 00000000..62e3dccd --- /dev/null +++ b/sdks/go/bindings/libmoss.go @@ -0,0 +1,596 @@ +//go:build libmoss + +package mosscore + +/* +#cgo linux LDFLAGS: -lmoss -ldl -lm -lpthread +#cgo darwin LDFLAGS: -lmoss -lc++ +#cgo windows LDFLAGS: -lmoss +#include +#include +*/ +import "C" + +import ( + "fmt" + "runtime" + "sync" + "unsafe" +) + +type ManageClient struct { + ptr *C.MossClient +} + +type IndexManager struct { + ptr *C.MossClient + mu sync.RWMutex + loaded map[string]struct{} +} + +func NewManageClient(projectID, projectKey string) (*ManageClient, error) { + ptr, err := newCClient(projectID, projectKey) + if err != nil { + return nil, err + } + client := &ManageClient{ptr: ptr} + runtime.SetFinalizer(client, func(c *ManageClient) { _ = c.Close() }) + return client, nil +} + +func (c *ManageClient) Close() error { + if c == nil || c.ptr == nil { + return nil + } + C.moss_client_free(c.ptr) + c.ptr = nil + return nil +} + +func (c *ManageClient) CreateIndex(name string, docs []DocumentInfo, modelID string) (MutationResult, error) { + input, err := newCDocumentInput(docs) + if err != nil { + return MutationResult{}, err + } + defer input.free() + + cName := C.CString(name) + defer C.free(unsafe.Pointer(cName)) + + var cModelID *C.char + if modelID != "" { + cModelID = C.CString(modelID) + defer C.free(unsafe.Pointer(cModelID)) + } + + var out *C.MossMutationResult + result := C.moss_client_create_index(c.ptr, cName, input.ptr(), C.uintptr_t(len(docs)), cModelID, &out) + if err := checkResult(result); err != nil { + return MutationResult{}, err + } + defer C.moss_free_mutation_result(out) + + return MutationResult{ + JobID: goString(out.job_id), + IndexName: goString(out.index_name), + DocCount: int(out.doc_count), + }, nil +} + +func (c *ManageClient) AddDocs(name string, docs []DocumentInfo, options *MutationOptions) (MutationResult, error) { + input, err := newCDocumentInput(docs) + if err != nil { + return MutationResult{}, err + } + defer input.free() + + cName := C.CString(name) + defer C.free(unsafe.Pointer(cName)) + + var out *C.MossMutationResult + var cOpts *C.MossMutationOptions + if options != nil && options.Upsert != nil { + cOpts = &C.MossMutationOptions{upsert: C.bool(*options.Upsert)} + } + + result := C.moss_client_add_docs(c.ptr, cName, input.ptr(), C.uintptr_t(len(docs)), cOpts, &out) + if err := checkResult(result); err != nil { + return MutationResult{}, err + } + defer C.moss_free_mutation_result(out) + + return MutationResult{ + JobID: goString(out.job_id), + IndexName: goString(out.index_name), + DocCount: int(out.doc_count), + }, nil +} + +func (c *ManageClient) DeleteDocs(name string, docIDs []string) (MutationResult, error) { + cName := C.CString(name) + defer C.free(unsafe.Pointer(cName)) + + ids := newCStringArray(docIDs) + defer ids.free() + + var out *C.MossMutationResult + result := C.moss_client_delete_docs(c.ptr, cName, ids.ptr(), C.uintptr_t(len(docIDs)), &out) + if err := checkResult(result); err != nil { + return MutationResult{}, err + } + defer C.moss_free_mutation_result(out) + + return MutationResult{ + JobID: goString(out.job_id), + IndexName: goString(out.index_name), + DocCount: int(out.doc_count), + }, nil +} + +func (c *ManageClient) GetJobStatus(jobID string) (JobStatusResponse, error) { + cJobID := C.CString(jobID) + defer C.free(unsafe.Pointer(cJobID)) + + var out *C.MossJobStatusResponse + result := C.moss_client_get_job_status(c.ptr, cJobID, &out) + if err := checkResult(result); err != nil { + return JobStatusResponse{}, err + } + defer C.moss_free_job_status_response(out) + + return JobStatusResponse{ + JobID: goString(out.job_id), + Status: goString(out.status), + Progress: float64(out.progress), + CurrentPhase: goOptionalString(out.current_phase), + Error: goOptionalString(out.error), + CreatedAt: goString(out.created_at), + UpdatedAt: goString(out.updated_at), + CompletedAt: goOptionalString(out.completed_at), + }, nil +} + +func (c *ManageClient) GetIndex(name string) (IndexInfo, error) { + cName := C.CString(name) + defer C.free(unsafe.Pointer(cName)) + + var out *C.MossIndexInfo + result := C.moss_client_get_index(c.ptr, cName, &out) + if err := checkResult(result); err != nil { + return IndexInfo{}, err + } + defer C.moss_free_index_info(out) + + return convertIndexInfo(out), nil +} + +func (c *ManageClient) ListIndexes() ([]IndexInfo, error) { + var out *C.MossIndexInfo + var count C.uintptr_t + result := C.moss_client_list_indexes(c.ptr, &out, &count) + if err := checkResult(result); err != nil { + return nil, err + } + defer C.moss_free_index_info_list(out, count) + + items := unsafe.Slice(out, int(count)) + response := make([]IndexInfo, 0, len(items)) + for i := range items { + response = append(response, convertIndexInfo(&items[i])) + } + return response, nil +} + +func (c *ManageClient) DeleteIndex(name string) (bool, error) { + cName := C.CString(name) + defer C.free(unsafe.Pointer(cName)) + + var deleted C.bool + result := C.moss_client_delete_index(c.ptr, cName, &deleted) + if err := checkResult(result); err != nil { + return false, err + } + return bool(deleted), nil +} + +func (c *ManageClient) GetDocs(name string, docIDs []string) ([]DocumentInfo, error) { + cName := C.CString(name) + defer C.free(unsafe.Pointer(cName)) + + ids := newCStringArray(docIDs) + defer ids.free() + + var out *C.MossDocumentInfo + var count C.uintptr_t + result := C.moss_client_get_docs(c.ptr, cName, ids.ptr(), C.uintptr_t(len(docIDs)), &out, &count) + if err := checkResult(result); err != nil { + return nil, err + } + defer C.moss_free_documents(out, count) + + return convertDocuments(out, count), nil +} + +func NewIndexManager(projectID, projectKey string) (*IndexManager, error) { + ptr, err := newCClient(projectID, projectKey) + if err != nil { + return nil, err + } + manager := &IndexManager{ + ptr: ptr, + loaded: map[string]struct{}{}, + } + runtime.SetFinalizer(manager, func(m *IndexManager) { _ = m.Close() }) + return manager, nil +} + +func (m *IndexManager) Close() error { + if m == nil || m.ptr == nil { + return nil + } + C.moss_client_free(m.ptr) + m.ptr = nil + return nil +} + +func (m *IndexManager) LoadIndex(indexName string, options *LoadIndexOptions) (IndexInfo, error) { + cName := C.CString(indexName) + defer C.free(unsafe.Pointer(cName)) + + var out *C.MossIndexInfo + var cOpts *C.MossLoadIndexOptions + if options != nil { + cOpts = &C.MossLoadIndexOptions{ + auto_refresh: C.bool(options.AutoRefresh), + polling_interval_secs: C.uint64_t(options.PollingIntervalInSeconds), + } + } + + result := C.moss_client_load_index(m.ptr, cName, cOpts, &out) + if err := checkResult(result); err != nil { + return IndexInfo{}, err + } + defer C.moss_free_index_info(out) + + m.mu.Lock() + m.loaded[indexName] = struct{}{} + m.mu.Unlock() + return convertIndexInfo(out), nil +} + +func (m *IndexManager) UnloadIndex(indexName string) error { + cName := C.CString(indexName) + defer C.free(unsafe.Pointer(cName)) + + result := C.moss_client_unload_index(m.ptr, cName) + if err := checkResult(result); err != nil { + return err + } + m.mu.Lock() + delete(m.loaded, indexName) + m.mu.Unlock() + return nil +} + +func (m *IndexManager) HasIndex(indexName string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + _, ok := m.loaded[indexName] + return ok +} + +func (m *IndexManager) Query(indexName, query string, queryEmbedding []float32, topK int, alpha float32, filterJSON *string) (SearchResult, error) { + return m.query(indexName, query, queryEmbedding, topK, alpha, filterJSON) +} + +func (m *IndexManager) QueryText(indexName, query string, topK int, alpha float32, filterJSON *string) (SearchResult, error) { + return m.query(indexName, query, nil, topK, alpha, filterJSON) +} + +func (m *IndexManager) LoadQueryModel(indexName string) error { + return nil +} + +func (m *IndexManager) RefreshIndex(indexName string) (RefreshResult, error) { + cName := C.CString(indexName) + defer C.free(unsafe.Pointer(cName)) + + var out *C.MossRefreshResult + result := C.moss_client_refresh_index(m.ptr, cName, &out) + if err := checkResult(result); err != nil { + return RefreshResult{}, err + } + defer C.moss_free_refresh_result(out) + + return RefreshResult{ + IndexName: goString(out.index_name), + PreviousUpdatedAt: goString(out.previous_updated_at), + NewUpdatedAt: goString(out.new_updated_at), + WasUpdated: bool(out.was_updated), + }, nil +} + +func (m *IndexManager) GetIndexInfo(indexName string) (IndexInfo, error) { + cName := C.CString(indexName) + defer C.free(unsafe.Pointer(cName)) + + var out *C.MossIndexInfo + result := C.moss_client_get_index(m.ptr, cName, &out) + if err := checkResult(result); err != nil { + return IndexInfo{}, err + } + defer C.moss_free_index_info(out) + + return convertIndexInfo(out), nil +} + +func (m *IndexManager) query(indexName, query string, queryEmbedding []float32, topK int, alpha float32, filterJSON *string) (SearchResult, error) { + cName := C.CString(indexName) + defer C.free(unsafe.Pointer(cName)) + cQuery := C.CString(query) + defer C.free(unsafe.Pointer(cQuery)) + + var cFilter *C.char + if filterJSON != nil { + cFilter = C.CString(*filterJSON) + defer C.free(unsafe.Pointer(cFilter)) + } + + var embeddingPtr *C.float + var embeddingMem unsafe.Pointer + if len(queryEmbedding) > 0 { + embeddingMem = C.malloc(C.size_t(len(queryEmbedding)) * C.size_t(unsafe.Sizeof(C.float(0)))) + defer C.free(embeddingMem) + embeddingSlice := unsafe.Slice((*C.float)(embeddingMem), len(queryEmbedding)) + for i, value := range queryEmbedding { + embeddingSlice[i] = C.float(value) + } + embeddingPtr = (*C.float)(embeddingMem) + } + + optsMem := C.malloc(C.size_t(unsafe.Sizeof(C.MossQueryOptions{}))) + defer C.free(optsMem) + opts := (*C.MossQueryOptions)(optsMem) + *opts = C.MossQueryOptions{ + top_k: C.uintptr_t(topK), + alpha: C.float(alpha), + filter_json: cFilter, + embedding: embeddingPtr, + embedding_dim: C.uintptr_t(len(queryEmbedding)), + } + + var out *C.MossSearchResult + result := C.moss_client_query(m.ptr, cName, cQuery, opts, &out) + if err := checkResult(result); err != nil { + return SearchResult{}, err + } + defer C.moss_free_search_result(out) + + docs := make([]QueryResultDocumentInfo, 0, int(out.doc_count)) + items := unsafe.Slice(out.docs, int(out.doc_count)) + for i := range items { + item := items[i] + docs = append(docs, QueryResultDocumentInfo{ + ID: goString(item.id), + Text: goString(item.text), + Metadata: convertMetadata(item.metadata, item.metadata_count), + Score: float64(item.score), + }) + } + + return SearchResult{ + Docs: docs, + Query: goString(out.query), + IndexName: goOptionalString(out.index_name), + TimeTakenMs: int(out.time_taken_ms), + }, nil +} + +func newCClient(projectID, projectKey string) (*C.MossClient, error) { + cProjectID := C.CString(projectID) + defer C.free(unsafe.Pointer(cProjectID)) + cProjectKey := C.CString(projectKey) + defer C.free(unsafe.Pointer(cProjectKey)) + + var out *C.MossClient + result := C.moss_client_new(cProjectID, cProjectKey, &out) + if err := checkResult(result); err != nil { + return nil, err + } + return out, nil +} + +func checkResult(result C.MossResult) error { + if result == C.OK { + return nil + } + + message := "libmoss call failed" + if value := C.moss_last_error(); value != nil { + message = C.GoString(value) + } + + return fmt.Errorf("mosscore: %s (code %d)", message, int32(result)) +} + +func convertIndexInfo(info *C.MossIndexInfo) IndexInfo { + return IndexInfo{ + ID: goString(info.id), + Name: goString(info.name), + Version: goOptionalString(info.version), + Status: goString(info.status), + DocCount: int(info.doc_count), + CreatedAt: goOptionalString(info.created_at), + UpdatedAt: goOptionalString(info.updated_at), + Model: ModelRef{ + ID: goString(info.model.id), + Version: goOptionalString(info.model.version), + }, + } +} + +func convertDocuments(out *C.MossDocumentInfo, count C.uintptr_t) []DocumentInfo { + items := unsafe.Slice(out, int(count)) + response := make([]DocumentInfo, 0, len(items)) + for i := range items { + item := items[i] + embedding := make([]float32, int(item.embedding_dim)) + if item.embedding != nil && item.embedding_dim > 0 { + values := unsafe.Slice(item.embedding, int(item.embedding_dim)) + for j := range values { + embedding[j] = float32(values[j]) + } + } + response = append(response, DocumentInfo{ + ID: goString(item.id), + Text: goString(item.text), + Metadata: convertMetadata(item.metadata, item.metadata_count), + Embedding: embedding, + }) + } + return response +} + +func convertMetadata(entries *C.MossMetadataEntry, count C.uintptr_t) map[string]string { + if entries == nil || count == 0 { + return nil + } + items := unsafe.Slice(entries, int(count)) + response := make(map[string]string, len(items)) + for i := range items { + response[goString(items[i].key)] = goString(items[i].value) + } + return response +} + +func goString(value *C.char) string { + if value == nil { + return "" + } + return C.GoString(value) +} + +func goOptionalString(value *C.char) *string { + if value == nil { + return nil + } + v := C.GoString(value) + return &v +} + +type cDocumentInput struct { + docs *C.MossDocumentInfo + count int + allocations []unsafe.Pointer + strings []*C.char +} + +func newCDocumentInput(docs []DocumentInfo) (*cDocumentInput, error) { + input := &cDocumentInput{ + count: len(docs), + } + if len(docs) == 0 { + return input, nil + } + + docsMem := C.malloc(C.size_t(len(docs)) * C.size_t(unsafe.Sizeof(C.MossDocumentInfo{}))) + input.allocations = append(input.allocations, docsMem) + input.docs = (*C.MossDocumentInfo)(docsMem) + docSlice := unsafe.Slice(input.docs, len(docs)) + + for i, doc := range docs { + cID := C.CString(doc.ID) + cText := C.CString(doc.Text) + input.strings = append(input.strings, cID, cText) + + var metadataPtr *C.MossMetadataEntry + if len(doc.Metadata) > 0 { + metaMem := C.malloc(C.size_t(len(doc.Metadata)) * C.size_t(unsafe.Sizeof(C.MossMetadataEntry{}))) + input.allocations = append(input.allocations, metaMem) + metaSlice := unsafe.Slice((*C.MossMetadataEntry)(metaMem), len(doc.Metadata)) + metaIndex := 0 + for key, value := range doc.Metadata { + cKey := C.CString(key) + cValue := C.CString(value) + input.strings = append(input.strings, cKey, cValue) + metaSlice[metaIndex] = C.MossMetadataEntry{ + key: cKey, + value: cValue, + } + metaIndex++ + } + metadataPtr = (*C.MossMetadataEntry)(metaMem) + } + + var embeddingPtr *C.float + if len(doc.Embedding) > 0 { + embeddingMem := C.malloc(C.size_t(len(doc.Embedding)) * C.size_t(unsafe.Sizeof(C.float(0)))) + input.allocations = append(input.allocations, embeddingMem) + embeddingSlice := unsafe.Slice((*C.float)(embeddingMem), len(doc.Embedding)) + for j, value := range doc.Embedding { + embeddingSlice[j] = C.float(value) + } + embeddingPtr = (*C.float)(embeddingMem) + } + + docSlice[i] = C.MossDocumentInfo{ + id: cID, + text: cText, + metadata: metadataPtr, + metadata_count: C.uintptr_t(len(doc.Metadata)), + embedding: embeddingPtr, + embedding_dim: C.uintptr_t(len(doc.Embedding)), + } + } + + return input, nil +} + +func (i *cDocumentInput) ptr() *C.MossDocumentInfo { + return i.docs +} + +func (i *cDocumentInput) free() { + for _, ptr := range i.allocations { + C.free(ptr) + } + for _, value := range i.strings { + C.free(unsafe.Pointer(value)) + } +} + +type cStringArray struct { + valuesPtr **C.char + count int + strings []*C.char + mem unsafe.Pointer +} + +func newCStringArray(values []string) *cStringArray { + array := &cStringArray{count: len(values)} + if len(values) == 0 { + return array + } + array.mem = C.malloc(C.size_t(len(values)) * C.size_t(unsafe.Sizeof((*C.char)(nil)))) + array.valuesPtr = (**C.char)(array.mem) + items := unsafe.Slice(array.valuesPtr, len(values)) + for i, value := range values { + cValue := C.CString(value) + array.strings = append(array.strings, cValue) + items[i] = cValue + } + return array +} + +func (a *cStringArray) ptr() **C.char { + return a.valuesPtr +} + +func (a *cStringArray) free() { + if a.mem != nil { + C.free(a.mem) + } + for _, value := range a.strings { + C.free(unsafe.Pointer(value)) + } +} diff --git a/sdks/go/bindings/models.go b/sdks/go/bindings/models.go new file mode 100644 index 00000000..33667092 --- /dev/null +++ b/sdks/go/bindings/models.go @@ -0,0 +1,71 @@ +package mosscore + +type DocumentInfo struct { + ID string + Text string + Metadata map[string]string + Embedding []float32 +} + +type MutationOptions struct { + Upsert *bool +} + +type MutationResult struct { + JobID string + IndexName string + DocCount int +} + +type ModelRef struct { + ID string + Version *string +} + +type IndexInfo struct { + ID string + Name string + Version *string + Status string + DocCount int + CreatedAt *string + UpdatedAt *string + Model ModelRef +} + +type JobStatusResponse struct { + JobID string + Status string + Progress float64 + CurrentPhase *string + Error *string + CreatedAt string + UpdatedAt string + CompletedAt *string +} + +type LoadIndexOptions struct { + AutoRefresh bool + PollingIntervalInSeconds uint64 +} + +type QueryResultDocumentInfo struct { + ID string + Text string + Metadata map[string]string + Score float64 +} + +type SearchResult struct { + Docs []QueryResultDocumentInfo + Query string + IndexName *string + TimeTakenMs int +} + +type RefreshResult struct { + IndexName string + PreviousUpdatedAt string + NewUpdatedAt string + WasUpdated bool +} diff --git a/sdks/go/bindings/stub.go b/sdks/go/bindings/stub.go new file mode 100644 index 00000000..dc32277e --- /dev/null +++ b/sdks/go/bindings/stub.go @@ -0,0 +1,83 @@ +//go:build !libmoss + +package mosscore + +type ManageClient struct{} + +func NewManageClient(projectID, projectKey string) (*ManageClient, error) { + return nil, ErrBindingsUnavailable +} + +func (c *ManageClient) Close() error { return nil } + +func (c *ManageClient) CreateIndex(name string, docs []DocumentInfo, modelID string) (MutationResult, error) { + return MutationResult{}, ErrBindingsUnavailable +} + +func (c *ManageClient) AddDocs(name string, docs []DocumentInfo, options *MutationOptions) (MutationResult, error) { + return MutationResult{}, ErrBindingsUnavailable +} + +func (c *ManageClient) DeleteDocs(name string, docIDs []string) (MutationResult, error) { + return MutationResult{}, ErrBindingsUnavailable +} + +func (c *ManageClient) GetJobStatus(jobID string) (JobStatusResponse, error) { + return JobStatusResponse{}, ErrBindingsUnavailable +} + +func (c *ManageClient) GetIndex(name string) (IndexInfo, error) { + return IndexInfo{}, ErrBindingsUnavailable +} + +func (c *ManageClient) ListIndexes() ([]IndexInfo, error) { + return nil, ErrBindingsUnavailable +} + +func (c *ManageClient) DeleteIndex(name string) (bool, error) { + return false, ErrBindingsUnavailable +} + +func (c *ManageClient) GetDocs(name string, docIDs []string) ([]DocumentInfo, error) { + return nil, ErrBindingsUnavailable +} + +type IndexManager struct{} + +func NewIndexManager(projectID, projectKey string) (*IndexManager, error) { + return nil, ErrBindingsUnavailable +} + +func (m *IndexManager) Close() error { return nil } + +func (m *IndexManager) LoadIndex(indexName string, options *LoadIndexOptions) (IndexInfo, error) { + return IndexInfo{}, ErrBindingsUnavailable +} + +func (m *IndexManager) UnloadIndex(indexName string) error { + return ErrBindingsUnavailable +} + +func (m *IndexManager) HasIndex(indexName string) bool { + return false +} + +func (m *IndexManager) Query(indexName, query string, queryEmbedding []float32, topK int, alpha float32, filterJSON *string) (SearchResult, error) { + return SearchResult{}, ErrBindingsUnavailable +} + +func (m *IndexManager) QueryText(indexName, query string, topK int, alpha float32, filterJSON *string) (SearchResult, error) { + return SearchResult{}, ErrBindingsUnavailable +} + +func (m *IndexManager) LoadQueryModel(indexName string) error { + return ErrBindingsUnavailable +} + +func (m *IndexManager) RefreshIndex(indexName string) (RefreshResult, error) { + return RefreshResult{}, ErrBindingsUnavailable +} + +func (m *IndexManager) GetIndexInfo(indexName string) (IndexInfo, error) { + return IndexInfo{}, ErrBindingsUnavailable +} diff --git a/sdks/go/sdk/README.md b/sdks/go/sdk/README.md index 0700d3e4..7b3e7b20 100644 --- a/sdks/go/sdk/README.md +++ b/sdks/go/sdk/README.md @@ -1,29 +1,25 @@ # Moss client library for Go -`moss` provides a typed Go client for Moss cloud-backed semantic search workflows. +`moss` provides a typed Go client for Moss semantic search workflows. -This first Go release is intentionally: +The Go SDK now has two layers: -- pure Go -- cloud-first -- buildable from the public repository +- a public SDK in `sdks/go/sdk` +- native `libmoss` bindings in `sdks/go/bindings` ## Features - typed Go client and models -- cloud index creation and document mutation -- cloud index metadata and document reads -- cloud query with optional caller-provided embeddings +- bindings-backed index creation and document mutation +- bindings-backed index metadata and document reads +- local index loading and query via native bindings +- optional caller-provided embeddings for custom indexes - env-gated live integration tests ## Current limitations -- no local `LoadIndex` / `UnloadIndex` -- no in-memory query runtime -- no local metadata filtering support - -If you pass `QueryOptions.Filter`, the Go SDK returns an explicit error because -cloud-only query does not yet provide the same behavior as the local runtimes. +- the SDK requires the `libmoss` C SDK and the `libmoss` build tag for real runtime operations +- `LoadIndexOptions.CachePath` is not exposed by the current `libmoss` C API yet ## Installation @@ -33,6 +29,10 @@ From this repository, use the module at: github.com/usemoss/moss/sdks/go/sdk/moss ``` +Download the `libmoss` C SDK release and build with `-tags libmoss`. The +bindings setup is documented in +[`../bindings/README.md`](../bindings/README.md). + ## Quick start ```go @@ -50,6 +50,7 @@ func main() { ctx := context.Background() client := moss.NewClient("your-project-id", "your-project-key") + defer client.Close() docs := []moss.DocumentInfo{ { @@ -75,6 +76,10 @@ func main() { fmt.Println("created job:", result.JobID) + if _, err := client.LoadIndex(ctx, "support-docs", &moss.LoadIndexOptions{}); err != nil { + log.Fatal(err) + } + search, err := client.Query(ctx, "support-docs", "how long do refunds take?", &moss.QueryOptions{ TopK: 3, }) @@ -112,6 +117,10 @@ if err != nil { log.Fatal(err) } +if _, err := client.LoadIndex(ctx, "custom-embeddings", &moss.LoadIndexOptions{}); err != nil { + log.Fatal(err) +} + results, err := client.Query(ctx, "custom-embeddings", "", &moss.QueryOptions{ Embedding: []float32{1, 0, 0, 0}, TopK: 5, @@ -128,6 +137,15 @@ Runnable examples live here: - [`examples/basic/main.go`](./examples/basic/main.go) - [`examples/custom-embeddings/main.go`](./examples/custom-embeddings/main.go) +Run them with native bindings enabled: + +```bash +export CGO_CFLAGS="-I/include" +export CGO_LDFLAGS="-L/lib" +export LD_LIBRARY_PATH="/lib" +go run -tags libmoss ./examples/basic +``` + ## Integration tests Live tests are skipped unless both of these are set: @@ -142,4 +160,8 @@ Then run: ```bash cd sdks/go/sdk go test ./... +CGO_CFLAGS="-I/include" \ +CGO_LDFLAGS="-L/lib" \ +LD_LIBRARY_PATH="/lib" \ +go test -tags libmoss ./... ``` diff --git a/sdks/go/sdk/examples/basic/main.go b/sdks/go/sdk/examples/basic/main.go index 7cb38bd9..533c2b00 100644 --- a/sdks/go/sdk/examples/basic/main.go +++ b/sdks/go/sdk/examples/basic/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "errors" "fmt" "log" "os" @@ -20,6 +19,11 @@ func main() { ctx := context.Background() client := moss.NewClient(projectID, projectKey) + defer func() { + if err := client.Close(); err != nil { + log.Printf("close warning: %v", err) + } + }() indexName := fmt.Sprintf("go-basic-%d", time.Now().Unix()) docs := []moss.DocumentInfo{ @@ -45,7 +49,13 @@ func main() { } fmt.Println("create job:", result.JobID) - search, err := queryWithRetry(ctx, client, indexName, "how long do refunds take?") + if _, err := client.LoadIndex(ctx, indexName, &moss.LoadIndexOptions{}); err != nil { + log.Fatal(err) + } + + search, err := client.Query(ctx, indexName, "how long do refunds take?", &moss.QueryOptions{ + TopK: 3, + }) if err != nil { log.Fatal(err) } @@ -64,27 +74,3 @@ func cleanup(ctx context.Context, client *moss.Client, indexName string) error { _, err := client.DeleteIndex(ctx, indexName) return err } - -func queryWithRetry(ctx context.Context, client *moss.Client, indexName, query string) (moss.SearchResult, error) { - const attempts = 6 - - for attempt := 1; attempt <= attempts; attempt++ { - result, err := client.Query(ctx, indexName, query, &moss.QueryOptions{ - TopK: 3, - }) - if err == nil { - return result, nil - } - - var httpErr *moss.HTTPError - if !errors.As(err, &httpErr) || httpErr.StatusCode != 503 || attempt == attempts { - return moss.SearchResult{}, err - } - - delay := time.Duration(attempt) * 2 * time.Second - log.Printf("query returned 503, retrying in %s (%d/%d)", delay, attempt, attempts) - time.Sleep(delay) - } - - return moss.SearchResult{}, fmt.Errorf("query retries exhausted") -} diff --git a/sdks/go/sdk/examples/custom-embeddings/main.go b/sdks/go/sdk/examples/custom-embeddings/main.go index d251fb2d..e1c7aa50 100644 --- a/sdks/go/sdk/examples/custom-embeddings/main.go +++ b/sdks/go/sdk/examples/custom-embeddings/main.go @@ -19,6 +19,11 @@ func main() { ctx := context.Background() client := moss.NewClient(projectID, projectKey) + defer func() { + if err := client.Close(); err != nil { + log.Printf("close warning: %v", err) + } + }() indexName := fmt.Sprintf("go-custom-%d", time.Now().Unix()) docs := []moss.DocumentInfo{ @@ -40,6 +45,10 @@ func main() { } fmt.Println("create job:", result.JobID) + if _, err := client.LoadIndex(ctx, indexName, &moss.LoadIndexOptions{}); err != nil { + log.Fatal(err) + } + query := []float32{1, 0, 0, 0} search, err := client.Query(ctx, indexName, "", &moss.QueryOptions{ Embedding: query, diff --git a/sdks/go/sdk/go.mod b/sdks/go/sdk/go.mod index 646e00ed..fe443db1 100644 --- a/sdks/go/sdk/go.mod +++ b/sdks/go/sdk/go.mod @@ -1,3 +1,7 @@ module github.com/usemoss/moss/sdks/go/sdk go 1.22.2 + +require github.com/usemoss/moss/sdks/go/bindings v0.0.0 + +replace github.com/usemoss/moss/sdks/go/bindings => ../bindings diff --git a/sdks/go/sdk/moss/client.go b/sdks/go/sdk/moss/client.go index 9561abd3..5182ba26 100644 --- a/sdks/go/sdk/moss/client.go +++ b/sdks/go/sdk/moss/client.go @@ -1,43 +1,58 @@ package moss import ( - "net/http" - "os" "strings" - "time" + "sync" - "github.com/usemoss/moss/sdks/go/sdk/moss/internal" -) - -const ( - DefaultManageURL = "https://service.usemoss.dev/v1/manage" - defaultTimeout = 60 * time.Second + mosscore "github.com/usemoss/moss/sdks/go/bindings" ) type clientConfig struct { - manageURL string - queryURL string - httpClient *http.Client + manageURL string + queryURL string +} + +type manageRuntime interface { + Close() error + CreateIndex(name string, docs []mosscore.DocumentInfo, modelID string) (mosscore.MutationResult, error) + AddDocs(name string, docs []mosscore.DocumentInfo, options *mosscore.MutationOptions) (mosscore.MutationResult, error) + DeleteDocs(name string, docIDs []string) (mosscore.MutationResult, error) + GetJobStatus(jobID string) (mosscore.JobStatusResponse, error) + GetIndex(name string) (mosscore.IndexInfo, error) + ListIndexes() ([]mosscore.IndexInfo, error) + DeleteIndex(name string) (bool, error) + GetDocs(name string, docIDs []string) ([]mosscore.DocumentInfo, error) +} + +type indexRuntime interface { + Close() error + LoadIndex(indexName string, options *mosscore.LoadIndexOptions) (mosscore.IndexInfo, error) + UnloadIndex(indexName string) error + HasIndex(indexName string) bool + Query(indexName, query string, queryEmbedding []float32, topK int, alpha float32, filterJSON *string) (mosscore.SearchResult, error) + QueryText(indexName, query string, topK int, alpha float32, filterJSON *string) (mosscore.SearchResult, error) + LoadQueryModel(indexName string) error + RefreshIndex(indexName string) (mosscore.RefreshResult, error) + GetIndexInfo(indexName string) (mosscore.IndexInfo, error) } -// Client is the cloud-first Moss Go SDK client. +// Client is the bindings-backed Moss Go SDK client. type Client struct { - projectID string - projectKey string - manageURL string - queryURL string - httpClient *http.Client - manageAPI *internal.ManageAPI - queryAPI *internal.QueryAPI + projectID string + projectKey string + manageURL string + queryURL string + manageMu sync.Mutex + manageClient manageRuntime + indexMu sync.Mutex + indexMgr indexRuntime + manageFactory func(projectID, projectKey string) (manageRuntime, error) + indexFactory func(projectID, projectKey string) (indexRuntime, error) } // NewClient constructs a new Moss client with optional overrides. func NewClient(projectID, projectKey string, opts ...Option) *Client { - cfg := clientConfig{ - manageURL: defaultManageURL(), - httpClient: &http.Client{Timeout: defaultTimeout}, - } - cfg.queryURL = defaultQueryURL(cfg.manageURL) + cfg := clientConfig{} for _, opt := range opts { if opt != nil { @@ -45,47 +60,24 @@ func NewClient(projectID, projectKey string, opts ...Option) *Client { } } - if cfg.queryURL == "" { - cfg.queryURL = defaultQueryURL(cfg.manageURL) - } - - jsonClient := internal.NewJSONHTTPClient(cfg.httpClient) - return &Client{ projectID: strings.TrimSpace(projectID), projectKey: strings.TrimSpace(projectKey), manageURL: strings.TrimSpace(cfg.manageURL), queryURL: strings.TrimSpace(cfg.queryURL), - httpClient: cfg.httpClient, - manageAPI: internal.NewManageAPI(jsonClient), - queryAPI: internal.NewQueryAPI(jsonClient), + manageFactory: func(projectID, projectKey string) (manageRuntime, error) { + return mosscore.NewManageClient(projectID, projectKey) + }, + indexFactory: func(projectID, projectKey string) (indexRuntime, error) { + return mosscore.NewIndexManager(projectID, projectKey) + }, } } -func defaultManageURL() string { - if value := strings.TrimSpace(os.Getenv("MOSS_CLOUD_API_MANAGE_URL")); value != "" { - return value - } - return DefaultManageURL -} - -func defaultQueryURL(manageURL string) string { - if value := strings.TrimSpace(os.Getenv("MOSS_CLOUD_QUERY_URL")); value != "" { - return value - } - if manageURL == "" { - return "" - } - return strings.Replace(manageURL, "/v1/manage", "/query", 1) -} - func (c *Client) validateManageRequest(indexName string) error { if err := validateCredentials(c.projectID, c.projectKey); err != nil { return err } - if strings.TrimSpace(c.manageURL) == "" { - return ErrMissingManageURL - } if strings.TrimSpace(indexName) == "" { return ErrEmptyIndexName } @@ -96,9 +88,6 @@ func (c *Client) validateQueryRequest(indexName string) error { if err := validateCredentials(c.projectID, c.projectKey); err != nil { return err } - if strings.TrimSpace(c.queryURL) == "" { - return ErrMissingQueryURL - } if strings.TrimSpace(indexName) == "" { return ErrEmptyIndexName } @@ -114,3 +103,61 @@ func validateCredentials(projectID, projectKey string) error { } return nil } + +func (c *Client) ensureManageClient() (manageRuntime, error) { + c.manageMu.Lock() + defer c.manageMu.Unlock() + + if c.manageClient != nil { + return c.manageClient, nil + } + + client, err := c.manageFactory(c.projectID, c.projectKey) + if err != nil { + return nil, err + } + c.manageClient = client + return client, nil +} + +func (c *Client) ensureIndexManager() (indexRuntime, error) { + c.indexMu.Lock() + defer c.indexMu.Unlock() + + if c.indexMgr != nil { + return c.indexMgr, nil + } + + manager, err := c.indexFactory(c.projectID, c.projectKey) + if err != nil { + return nil, err + } + c.indexMgr = manager + return manager, nil +} + +// Close releases any lazily initialized native runtime handles owned by the client. +func (c *Client) Close() error { + c.manageMu.Lock() + manage := c.manageClient + c.manageClient = nil + c.manageMu.Unlock() + + c.indexMu.Lock() + index := c.indexMgr + c.indexMgr = nil + c.indexMu.Unlock() + + var firstErr error + if manage != nil { + if err := manage.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + if index != nil { + if err := index.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} diff --git a/sdks/go/sdk/moss/client_test.go b/sdks/go/sdk/moss/client_test.go index 820ffe49..865dc236 100644 --- a/sdks/go/sdk/moss/client_test.go +++ b/sdks/go/sdk/moss/client_test.go @@ -4,161 +4,229 @@ import ( "context" "encoding/json" "errors" - "net/http" - "net/http/httptest" "testing" + + mosscore "github.com/usemoss/moss/sdks/go/bindings" ) -func TestNewClientUsesDocumentedDefaults(t *testing.T) { - t.Setenv("MOSS_CLOUD_API_MANAGE_URL", "") - t.Setenv("MOSS_CLOUD_QUERY_URL", "") +type fakeManageRuntime struct { + closeCalled bool + createIndexFn func(name string, docs []mosscore.DocumentInfo, modelID string) (mosscore.MutationResult, error) + addDocsFn func(name string, docs []mosscore.DocumentInfo, options *mosscore.MutationOptions) (mosscore.MutationResult, error) + deleteDocsFn func(name string, docIDs []string) (mosscore.MutationResult, error) + getJobStatusFn func(jobID string) (mosscore.JobStatusResponse, error) + getIndexFn func(name string) (mosscore.IndexInfo, error) + listIndexesFn func() ([]mosscore.IndexInfo, error) + deleteIndexFn func(name string) (bool, error) + getDocsFn func(name string, docIDs []string) ([]mosscore.DocumentInfo, error) +} - client := NewClient("project-id", "project-key") +func (f *fakeManageRuntime) Close() error { + f.closeCalled = true + return nil +} - if client.manageURL != "https://service.usemoss.dev/v1/manage" { - t.Fatalf("unexpected manage URL: %s", client.manageURL) - } - if client.queryURL != "https://service.usemoss.dev/query" { - t.Fatalf("unexpected query URL: %s", client.queryURL) +func (f *fakeManageRuntime) CreateIndex(name string, docs []mosscore.DocumentInfo, modelID string) (mosscore.MutationResult, error) { + if f.createIndexFn == nil { + return mosscore.MutationResult{}, nil } + return f.createIndexFn(name, docs, modelID) } -func TestNewClientHonorsExplicitURLs(t *testing.T) { - client := NewClient( - "project-id", - "project-key", - WithManageURL("https://custom.example.com/v1/manage"), - WithQueryURL("https://query.example.com/search"), - ) - - if client.manageURL != "https://custom.example.com/v1/manage" { - t.Fatalf("unexpected manage URL: %s", client.manageURL) - } - if client.queryURL != "https://query.example.com/search" { - t.Fatalf("unexpected query URL: %s", client.queryURL) +func (f *fakeManageRuntime) AddDocs(name string, docs []mosscore.DocumentInfo, options *mosscore.MutationOptions) (mosscore.MutationResult, error) { + if f.addDocsFn == nil { + return mosscore.MutationResult{}, nil } + return f.addDocsFn(name, docs, options) } -func TestGetIndexSendsManageRequestShape(t *testing.T) { - var gotHeader string - var gotBody map[string]any +func (f *fakeManageRuntime) DeleteDocs(name string, docIDs []string) (mosscore.MutationResult, error) { + if f.deleteDocsFn == nil { + return mosscore.MutationResult{}, nil + } + return f.deleteDocsFn(name, docIDs) +} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotHeader = r.Header.Get("X-Project-Key") - defer r.Body.Close() - if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { - t.Fatalf("decode request body: %v", err) - } +func (f *fakeManageRuntime) GetJobStatus(jobID string) (mosscore.JobStatusResponse, error) { + if f.getJobStatusFn == nil { + return mosscore.JobStatusResponse{}, nil + } + return f.getJobStatusFn(jobID) +} - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "id":"idx-1", - "name":"support-docs", - "version":"1.0.0", - "status":"Ready", - "docCount":124, - "createdAt":"2026-05-21T00:00:00Z", - "updatedAt":"2026-05-21T01:00:00Z", - "model":{"id":"moss-minilm","version":"1.0.0"} - }`)) - })) - defer server.Close() - - client := NewClient( - "project-123", - "project-key-123", - WithManageURL(server.URL), - ) +func (f *fakeManageRuntime) GetIndex(name string) (mosscore.IndexInfo, error) { + if f.getIndexFn == nil { + return mosscore.IndexInfo{}, nil + } + return f.getIndexFn(name) +} - info, err := client.GetIndex(context.Background(), "support-docs") - if err != nil { - t.Fatalf("GetIndex returned error: %v", err) +func (f *fakeManageRuntime) ListIndexes() ([]mosscore.IndexInfo, error) { + if f.listIndexesFn == nil { + return nil, nil } + return f.listIndexesFn() +} - if gotHeader != "project-key-123" { - t.Fatalf("unexpected project key header: %q", gotHeader) +func (f *fakeManageRuntime) DeleteIndex(name string) (bool, error) { + if f.deleteIndexFn == nil { + return false, nil } - if gotBody["action"] != "getIndex" { - t.Fatalf("unexpected action: %#v", gotBody["action"]) + return f.deleteIndexFn(name) +} + +func (f *fakeManageRuntime) GetDocs(name string, docIDs []string) ([]mosscore.DocumentInfo, error) { + if f.getDocsFn == nil { + return nil, nil } - if gotBody["projectId"] != "project-123" { - t.Fatalf("unexpected projectId: %#v", gotBody["projectId"]) + return f.getDocsFn(name, docIDs) +} + +type fakeIndexRuntime struct { + closeCalled bool + loaded map[string]bool + loadIndexFn func(indexName string, options *mosscore.LoadIndexOptions) (mosscore.IndexInfo, error) + unloadIndexFn func(indexName string) error + queryFn func(indexName, query string, queryEmbedding []float32, topK int, alpha float32, filterJSON *string) (mosscore.SearchResult, error) + queryTextFn func(indexName, query string, topK int, alpha float32, filterJSON *string) (mosscore.SearchResult, error) + loadQueryModelFn func(indexName string) error + refreshIndexFn func(indexName string) (mosscore.RefreshResult, error) + getIndexInfoFn func(indexName string) (mosscore.IndexInfo, error) +} + +func (f *fakeIndexRuntime) Close() error { + f.closeCalled = true + return nil +} + +func (f *fakeIndexRuntime) LoadIndex(indexName string, options *mosscore.LoadIndexOptions) (mosscore.IndexInfo, error) { + if f.loadIndexFn == nil { + if f.loaded == nil { + f.loaded = map[string]bool{} + } + f.loaded[indexName] = true + return mosscore.IndexInfo{Name: indexName, Model: mosscore.ModelRef{ID: string(ModelMossMiniLM)}}, nil } - if gotBody["indexName"] != "support-docs" { - t.Fatalf("unexpected indexName: %#v", gotBody["indexName"]) + return f.loadIndexFn(indexName, options) +} + +func (f *fakeIndexRuntime) UnloadIndex(indexName string) error { + if f.unloadIndexFn != nil { + return f.unloadIndexFn(indexName) } - if info.Name != "support-docs" || info.DocCount != 124 || info.Model.ID != "moss-minilm" { - t.Fatalf("unexpected index info: %#v", info) + if f.loaded != nil { + delete(f.loaded, indexName) } + return nil } -func TestListIndexesDecodesResponse(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[ - {"id":"1","name":"alpha","status":"Ready","docCount":2,"model":{"id":"moss-minilm"}}, - {"id":"2","name":"beta","status":"Building","docCount":3,"model":{"id":"custom"}} - ]`)) - })) - defer server.Close() +func (f *fakeIndexRuntime) HasIndex(indexName string) bool { + return f.loaded != nil && f.loaded[indexName] +} - client := NewClient("project-id", "project-key", WithManageURL(server.URL)) +func (f *fakeIndexRuntime) Query(indexName, query string, queryEmbedding []float32, topK int, alpha float32, filterJSON *string) (mosscore.SearchResult, error) { + if f.queryFn == nil { + return mosscore.SearchResult{}, nil + } + return f.queryFn(indexName, query, queryEmbedding, topK, alpha, filterJSON) +} - indexes, err := client.ListIndexes(context.Background()) - if err != nil { - t.Fatalf("ListIndexes returned error: %v", err) +func (f *fakeIndexRuntime) QueryText(indexName, query string, topK int, alpha float32, filterJSON *string) (mosscore.SearchResult, error) { + if f.queryTextFn == nil { + return mosscore.SearchResult{}, nil } - if len(indexes) != 2 { - t.Fatalf("unexpected index count: %d", len(indexes)) + return f.queryTextFn(indexName, query, topK, alpha, filterJSON) +} + +func (f *fakeIndexRuntime) LoadQueryModel(indexName string) error { + if f.loadQueryModelFn == nil { + return nil } - if indexes[0].Name != "alpha" || indexes[1].Model.ID != "custom" { - t.Fatalf("unexpected indexes: %#v", indexes) + return f.loadQueryModelFn(indexName) +} + +func (f *fakeIndexRuntime) RefreshIndex(indexName string) (mosscore.RefreshResult, error) { + if f.refreshIndexFn == nil { + return mosscore.RefreshResult{}, nil } + return f.refreshIndexFn(indexName) } -func TestDeleteIndexSendsExpectedAction(t *testing.T) { - var gotBody map[string]any +func (f *fakeIndexRuntime) GetIndexInfo(indexName string) (mosscore.IndexInfo, error) { + if f.getIndexInfoFn == nil { + return mosscore.IndexInfo{}, nil + } + return f.getIndexInfoFn(indexName) +} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { - t.Fatalf("decode request body: %v", err) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`true`)) - })) - defer server.Close() +func newTestClient(manage manageRuntime, index indexRuntime) *Client { + client := NewClient("project-id", "project-key") + client.manageClient = manage + client.indexMgr = index + return client +} - client := NewClient("project-id", "project-key", WithManageURL(server.URL)) +func TestGetIndexUsesBindingsRuntime(t *testing.T) { + client := newTestClient(&fakeManageRuntime{ + getIndexFn: func(name string) (mosscore.IndexInfo, error) { + if name != "support-docs" { + t.Fatalf("unexpected index name: %q", name) + } + return mosscore.IndexInfo{ + ID: "idx-1", + Name: "support-docs", + Status: "Ready", + DocCount: 124, + Model: mosscore.ModelRef{ID: string(ModelMossMiniLM)}, + }, nil + }, + }, nil) - ok, err := client.DeleteIndex(context.Background(), "old-index") + info, err := client.GetIndex(context.Background(), "support-docs") if err != nil { - t.Fatalf("DeleteIndex returned error: %v", err) - } - if !ok { - t.Fatal("expected delete result to be true") + t.Fatalf("GetIndex returned error: %v", err) } - if gotBody["action"] != "deleteIndex" { - t.Fatalf("unexpected action: %#v", gotBody["action"]) + if info.Name != "support-docs" || info.DocCount != 124 || info.Model.ID != string(ModelMossMiniLM) { + t.Fatalf("unexpected index info: %#v", info) } } -func TestGetDocsPassesDocIDs(t *testing.T) { - var gotBody map[string]any +func TestListIndexesUsesBindingsRuntime(t *testing.T) { + client := newTestClient(&fakeManageRuntime{ + listIndexesFn: func() ([]mosscore.IndexInfo, error) { + return []mosscore.IndexInfo{ + {Name: "alpha", Status: "Ready", DocCount: 2, Model: mosscore.ModelRef{ID: string(ModelMossMiniLM)}}, + {Name: "beta", Status: "Building", DocCount: 3, Model: mosscore.ModelRef{ID: string(ModelCustom)}}, + }, nil + }, + }, nil) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { - t.Fatalf("decode request body: %v", err) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[ - {"id":"doc-1","text":"hello","metadata":{"topic":"refunds"}} - ]`)) - })) - defer server.Close() + indexes, err := client.ListIndexes(context.Background()) + if err != nil { + t.Fatalf("ListIndexes returned error: %v", err) + } + if len(indexes) != 2 { + t.Fatalf("unexpected index count: %d", len(indexes)) + } + if indexes[0].Name != "alpha" || indexes[1].Model.ID != string(ModelCustom) { + t.Fatalf("unexpected indexes: %#v", indexes) + } +} - client := NewClient("project-id", "project-key", WithManageURL(server.URL)) +func TestGetDocsPassesDocIDsToBindings(t *testing.T) { + client := newTestClient(&fakeManageRuntime{ + getDocsFn: func(name string, docIDs []string) ([]mosscore.DocumentInfo, error) { + if name != "support-docs" { + t.Fatalf("unexpected index name: %q", name) + } + if len(docIDs) != 1 || docIDs[0] != "doc-1" { + t.Fatalf("unexpected doc IDs: %#v", docIDs) + } + return []mosscore.DocumentInfo{ + {ID: "doc-1", Text: "hello", Metadata: map[string]string{"topic": "refunds"}}, + }, nil + }, + }, nil) docs, err := client.GetDocs(context.Background(), "support-docs", &GetDocumentsOptions{ DocIDs: []string{"doc-1"}, @@ -169,54 +237,64 @@ func TestGetDocsPassesDocIDs(t *testing.T) { if len(docs) != 1 || docs[0].Metadata["topic"] != "refunds" { t.Fatalf("unexpected docs: %#v", docs) } +} + +func TestQueryRequiresLoadedIndex(t *testing.T) { + client := newTestClient(nil, &fakeIndexRuntime{loaded: map[string]bool{}}) - docIDs, ok := gotBody["docIds"].([]any) - if !ok || len(docIDs) != 1 || docIDs[0] != "doc-1" { - t.Fatalf("unexpected docIds payload: %#v", gotBody["docIds"]) + _, err := client.Query(context.Background(), "support-docs", "refund policy", nil) + if !errors.Is(err, ErrIndexNotLoaded) { + t.Fatalf("expected ErrIndexNotLoaded, got %v", err) } } -func TestQuerySendsExpectedCloudPayload(t *testing.T) { - var gotBody map[string]any - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { - t.Fatalf("decode request body: %v", err) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "docs":[{"id":"doc-1","text":"Refunds take 5-7 days","score":0.91,"metadata":{"topic":"refunds"}}], - "query":"refund policy", - "indexName":"support-docs", - "timeTakenMs":17 - }`)) - })) - defer server.Close() - - client := NewClient( - "project-id", - "project-key", - WithQueryURL(server.URL), - ) +func TestQueryUsesLocalBindingsAndSupportsFilters(t *testing.T) { + client := newTestClient(nil, &fakeIndexRuntime{ + loaded: map[string]bool{"support-docs": true}, + queryTextFn: func(indexName, query string, topK int, alpha float32, filterJSON *string) (mosscore.SearchResult, error) { + if indexName != "support-docs" { + t.Fatalf("unexpected index name: %q", indexName) + } + if query != "refund policy" { + t.Fatalf("unexpected query: %q", query) + } + if topK != 7 { + t.Fatalf("unexpected topK: %d", topK) + } + if alpha != 0.6 { + t.Fatalf("unexpected alpha: %f", alpha) + } + if filterJSON == nil { + t.Fatal("expected filter JSON to be passed") + } + + var decoded map[string]any + if err := json.Unmarshal([]byte(*filterJSON), &decoded); err != nil { + t.Fatalf("decode filter: %v", err) + } + if decoded["field"] != "topic" { + t.Fatalf("unexpected filter payload: %#v", decoded) + } + + timeTaken := 17 + return mosscore.SearchResult{ + Docs: []mosscore.QueryResultDocumentInfo{{ID: "doc-1", Text: "Refunds take 5-7 days", Score: 0.91, Metadata: map[string]string{"topic": "refunds"}}}, + Query: query, + IndexName: &indexName, + TimeTakenMs: timeTaken, + }, nil + }, + }) + alpha := 0.6 result, err := client.Query(context.Background(), "support-docs", "refund policy", &QueryOptions{ - TopK: 7, - Embedding: []float32{0.1, 0.2, 0.3}, + TopK: 7, + Alpha: &alpha, + Filter: map[string]any{"field": "topic"}, }) if err != nil { t.Fatalf("Query returned error: %v", err) } - - if gotBody["projectKey"] != "project-key" { - t.Fatalf("unexpected projectKey: %#v", gotBody["projectKey"]) - } - if gotBody["topK"] != float64(7) { - t.Fatalf("unexpected topK: %#v", gotBody["topK"]) - } - if _, ok := gotBody["queryEmbedding"]; !ok { - t.Fatalf("queryEmbedding missing from payload: %#v", gotBody) - } if len(result.Docs) != 1 || result.Docs[0].Score != 0.91 { t.Fatalf("unexpected query result: %#v", result) } @@ -225,35 +303,51 @@ func TestQuerySendsExpectedCloudPayload(t *testing.T) { } } -func TestQueryRejectsUnsupportedFilter(t *testing.T) { - client := NewClient("project-id", "project-key") - - _, err := client.Query(context.Background(), "support-docs", "refund policy", &QueryOptions{ - Filter: map[string]any{"field": "topic"}, +func TestLoadIndexSkipsQueryModelForCustomEmbeddings(t *testing.T) { + loadQueryModelCalled := false + client := newTestClient(nil, &fakeIndexRuntime{ + loadIndexFn: func(indexName string, options *mosscore.LoadIndexOptions) (mosscore.IndexInfo, error) { + return mosscore.IndexInfo{ + Name: indexName, + Model: mosscore.ModelRef{ID: string(ModelCustom)}, + }, nil + }, + loadQueryModelFn: func(indexName string) error { + loadQueryModelCalled = true + return nil + }, }) - if !errors.Is(err, ErrUnsupportedQueryFilter) { - t.Fatalf("expected ErrUnsupportedQueryFilter, got %v", err) + + name, err := client.LoadIndex(context.Background(), "custom-index", &LoadIndexOptions{}) + if err != nil { + t.Fatalf("LoadIndex returned error: %v", err) + } + if name != "custom-index" { + t.Fatalf("unexpected loaded index name: %q", name) + } + if loadQueryModelCalled { + t.Fatal("expected custom embedding index to skip query model loading") } } -func TestManageHTTPErrorIsWrapped(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "boom", http.StatusInternalServerError) - })) - defer server.Close() - - client := NewClient("project-id", "project-key", WithManageURL(server.URL)) +func TestLoadIndexRejectsUnsupportedCachePath(t *testing.T) { + client := newTestClient(nil, &fakeIndexRuntime{}) - _, err := client.GetIndex(context.Background(), "support-docs") - if err == nil { - t.Fatal("expected GetIndex to fail") + _, err := client.LoadIndex(context.Background(), "support-docs", &LoadIndexOptions{CachePath: "/tmp/cache"}) + if !errors.Is(err, ErrUnsupportedCachePath) { + t.Fatalf("expected ErrUnsupportedCachePath, got %v", err) } +} + +func TestCloseReleasesInitializedBindings(t *testing.T) { + manage := &fakeManageRuntime{} + index := &fakeIndexRuntime{} + client := newTestClient(manage, index) - var httpErr *HTTPError - if !errors.As(err, &httpErr) { - t.Fatalf("expected HTTPError, got %T", err) + if err := client.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) } - if httpErr.StatusCode != http.StatusInternalServerError { - t.Fatalf("unexpected status code: %d", httpErr.StatusCode) + if !manage.closeCalled || !index.closeCalled { + t.Fatalf("expected runtimes to be closed: manage=%v index=%v", manage.closeCalled, index.closeCalled) } } diff --git a/sdks/go/sdk/moss/conversion.go b/sdks/go/sdk/moss/conversion.go index 0bb4b000..7946437d 100644 --- a/sdks/go/sdk/moss/conversion.go +++ b/sdks/go/sdk/moss/conversion.go @@ -1,12 +1,25 @@ package moss -import ( - "errors" +import mosscore "github.com/usemoss/moss/sdks/go/bindings" - "github.com/usemoss/moss/sdks/go/sdk/moss/internal" -) +func toCoreDocumentInfo(value DocumentInfo) mosscore.DocumentInfo { + return mosscore.DocumentInfo{ + ID: value.ID, + Text: value.Text, + Metadata: value.Metadata, + Embedding: value.Embedding, + } +} + +func toCoreDocumentInfos(values []DocumentInfo) []mosscore.DocumentInfo { + out := make([]mosscore.DocumentInfo, 0, len(values)) + for _, value := range values { + out = append(out, toCoreDocumentInfo(value)) + } + return out +} -func toIndexInfo(value internal.IndexInfoResponse) IndexInfo { +func fromCoreIndexInfo(value mosscore.IndexInfo) IndexInfo { return IndexInfo{ ID: value.ID, Name: value.Name, @@ -22,7 +35,7 @@ func toIndexInfo(value internal.IndexInfoResponse) IndexInfo { } } -func toDocumentInfo(value internal.DocumentInfoResponse) DocumentInfo { +func fromCoreDocumentInfo(value mosscore.DocumentInfo) DocumentInfo { return DocumentInfo{ ID: value.ID, Text: value.Text, @@ -31,20 +44,15 @@ func toDocumentInfo(value internal.DocumentInfoResponse) DocumentInfo { } } -func toDocumentInfoResponses(values []DocumentInfo) []internal.DocumentInfoResponse { - out := make([]internal.DocumentInfoResponse, 0, len(values)) - for _, value := range values { - out = append(out, internal.DocumentInfoResponse{ - ID: value.ID, - Text: value.Text, - Metadata: value.Metadata, - Embedding: value.Embedding, - }) +func fromCoreMutationResult(value mosscore.MutationResult) MutationResult { + return MutationResult{ + JobID: value.JobID, + IndexName: value.IndexName, + DocCount: value.DocCount, } - return out } -func toSearchResult(value internal.SearchResultResponse) SearchResult { +func fromCoreSearchResult(value mosscore.SearchResult) SearchResult { docs := make([]QueryResultDocumentInfo, 0, len(value.Docs)) for _, item := range value.Docs { docs = append(docs, QueryResultDocumentInfo{ @@ -55,15 +63,16 @@ func toSearchResult(value internal.SearchResultResponse) SearchResult { }) } + timeTaken := value.TimeTakenMs return SearchResult{ Docs: docs, Query: value.Query, IndexName: value.IndexName, - TimeTakenMs: value.TimeTakenMs, + TimeTakenMs: &timeTaken, } } -func toJobStatusResponse(value internal.JobStatusResponse) JobStatusResponse { +func fromCoreJobStatusResponse(value mosscore.JobStatusResponse) JobStatusResponse { var currentPhase *JobPhase if value.CurrentPhase != nil { phase := JobPhase(*value.CurrentPhase) @@ -81,19 +90,3 @@ func toJobStatusResponse(value internal.JobStatusResponse) JobStatusResponse { CompletedAt: value.CompletedAt, } } - -func normalizeError(err error) error { - if err == nil { - return nil - } - - var httpErr *internal.HTTPError - if !errors.As(err, &httpErr) { - return err - } - - return &HTTPError{ - StatusCode: httpErr.StatusCode, - Body: httpErr.Body, - } -} diff --git a/sdks/go/sdk/moss/errors.go b/sdks/go/sdk/moss/errors.go index bea0c9df..1638300c 100644 --- a/sdks/go/sdk/moss/errors.go +++ b/sdks/go/sdk/moss/errors.go @@ -6,18 +6,17 @@ import ( ) var ( - ErrMissingProjectID = errors.New("moss: missing project ID") - ErrMissingProjectKey = errors.New("moss: missing project key") - ErrMissingManageURL = errors.New("moss: manage URL is not configured") - ErrMissingQueryURL = errors.New("moss: query URL is not configured") - ErrEmptyIndexName = errors.New("moss: index name must not be empty") - ErrEmptyJobID = errors.New("moss: job ID must not be empty") - ErrEmptyDocuments = errors.New("moss: documents must not be empty") - ErrEmptyDocumentIDs = errors.New("moss: document IDs must not be empty") - ErrUnsupportedQueryFilter = errors.New("moss: query filters are not supported in the cloud-only Go SDK yet") + ErrMissingProjectID = errors.New("moss: missing project ID") + ErrMissingProjectKey = errors.New("moss: missing project key") + ErrEmptyIndexName = errors.New("moss: index name must not be empty") + ErrEmptyJobID = errors.New("moss: job ID must not be empty") + ErrEmptyDocuments = errors.New("moss: documents must not be empty") + ErrEmptyDocumentIDs = errors.New("moss: document IDs must not be empty") + ErrIndexNotLoaded = errors.New("moss: index is not loaded locally; call LoadIndex first") + ErrUnsupportedCachePath = errors.New("moss: LoadIndexOptions.CachePath is not supported by the current libmoss bindings") ) -// HTTPError wraps non-2xx responses from Moss services. +// HTTPError is retained for compatibility with earlier SDK scaffolding. type HTTPError struct { StatusCode int Body string diff --git a/sdks/go/sdk/moss/integration_test.go b/sdks/go/sdk/moss/integration_test.go index 0c7b6c07..0eeb415d 100644 --- a/sdks/go/sdk/moss/integration_test.go +++ b/sdks/go/sdk/moss/integration_test.go @@ -2,10 +2,13 @@ package moss import ( "context" + "errors" "fmt" "os" "testing" "time" + + mosscore "github.com/usemoss/moss/sdks/go/bindings" ) func TestCloudLifecycleIntegration(t *testing.T) { @@ -16,6 +19,9 @@ func TestCloudLifecycleIntegration(t *testing.T) { } client := NewClient(projectID, projectKey) + t.Cleanup(func() { + _ = client.Close() + }) ctx := context.Background() indexName := fmt.Sprintf("go-sdk-int-%d", time.Now().UnixNano()) @@ -38,6 +44,9 @@ func TestCloudLifecycleIntegration(t *testing.T) { createResult, err := client.CreateIndex(ctx, indexName, docs, nil) if err != nil { + if errors.Is(err, mosscore.ErrBindingsUnavailable) { + t.Skip("Skipping Go bindings integration test: libmoss bindings are unavailable in this build") + } t.Fatalf("CreateIndex failed: %v", err) } if createResult.JobID == "" || createResult.IndexName != indexName || createResult.DocCount != 2 { @@ -68,6 +77,13 @@ func TestCloudLifecycleIntegration(t *testing.T) { t.Fatalf("unexpected doc count: %d", len(gotDocs)) } + if _, err := client.LoadIndex(ctx, indexName, &LoadIndexOptions{}); err != nil { + if errors.Is(err, mosscore.ErrBindingsUnavailable) { + t.Skip("Skipping local query integration: libmoss bindings are unavailable in this build") + } + t.Fatalf("LoadIndex failed: %v", err) + } + search, err := client.Query(ctx, indexName, "", &QueryOptions{ Embedding: []float32{1, 0, 0, 0}, TopK: 2, diff --git a/sdks/go/sdk/moss/internal/httpclient.go b/sdks/go/sdk/moss/internal/httpclient.go deleted file mode 100644 index 46080970..00000000 --- a/sdks/go/sdk/moss/internal/httpclient.go +++ /dev/null @@ -1,83 +0,0 @@ -package internal - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "strings" -) - -type JSONHTTPClient struct { - httpClient *http.Client -} - -func NewJSONHTTPClient(httpClient *http.Client) *JSONHTTPClient { - return &JSONHTTPClient{httpClient: httpClient} -} - -func (c *JSONHTTPClient) PostJSON( - ctx context.Context, - url string, - headers map[string]string, - payload any, - dest any, -) error { - var body io.Reader - if payload != nil { - buf := new(bytes.Buffer) - if err := json.NewEncoder(buf).Encode(payload); err != nil { - return err - } - body = buf - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) - if err != nil { - return err - } - - for key, value := range headers { - req.Header.Set(key, value) - } - if payload != nil && req.Header.Get("Content-Type") == "" { - req.Header.Set("Content-Type", "application/json") - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) - return &HTTPError{ - StatusCode: resp.StatusCode, - Body: strings.TrimSpace(string(body)), - } - } - - if dest == nil { - io.Copy(io.Discard, resp.Body) - return nil - } - - return json.NewDecoder(resp.Body).Decode(dest) -} - -type HTTPError struct { - StatusCode int - Body string -} - -func (e *HTTPError) Error() string { - if e == nil { - return "" - } - if e.Body == "" { - return "http request failed" - } - return e.Body -} diff --git a/sdks/go/sdk/moss/internal/manage_api.go b/sdks/go/sdk/moss/internal/manage_api.go deleted file mode 100644 index daa31503..00000000 --- a/sdks/go/sdk/moss/internal/manage_api.go +++ /dev/null @@ -1,237 +0,0 @@ -package internal - -import "context" - -type ManageAPI struct { - httpClient *JSONHTTPClient -} - -func NewManageAPI(httpClient *JSONHTTPClient) *ManageAPI { - return &ManageAPI{httpClient: httpClient} -} - -type manageRequest struct { - Action string `json:"action"` - ProjectID string `json:"projectId"` - IndexName string `json:"indexName,omitempty"` - DocIDs []string `json:"docIds,omitempty"` -} - -type initUploadRequest struct { - Action string `json:"action"` - ProjectID string `json:"projectId"` - IndexName string `json:"indexName"` - ModelID string `json:"modelId"` - DocCount int `json:"docCount"` - Dimension int `json:"dimension"` -} - -type addDocsRequest struct { - Action string `json:"action"` - ProjectID string `json:"projectId"` - IndexName string `json:"indexName"` - Docs []DocumentInfoResponse `json:"docs"` - Options *addDocsOptions `json:"options,omitempty"` -} - -type addDocsOptions struct { - Upsert *bool `json:"upsert,omitempty"` -} - -type jobRequest struct { - Action string `json:"action"` - ProjectID string `json:"projectId"` - JobID string `json:"jobId"` -} - -type ModelRefResponse struct { - ID string `json:"id"` - Version *string `json:"version,omitempty"` -} - -type IndexInfoResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Version *string `json:"version,omitempty"` - Status string `json:"status"` - DocCount int `json:"docCount"` - CreatedAt *string `json:"createdAt,omitempty"` - UpdatedAt *string `json:"updatedAt,omitempty"` - Model ModelRefResponse `json:"model"` -} - -type DocumentInfoResponse struct { - ID string `json:"id"` - Text string `json:"text"` - Metadata map[string]string `json:"metadata,omitempty"` - Embedding []float32 `json:"embedding,omitempty"` -} - -type InitUploadResponse struct { - JobID string `json:"jobId"` - UploadURL string `json:"uploadUrl"` - ExpiresIn int `json:"expiresIn"` -} - -type MutationResponse struct { - JobID string `json:"jobId"` - Status string `json:"status"` -} - -type JobStatusResponse struct { - JobID string `json:"jobId"` - Status string `json:"status"` - Progress float64 `json:"progress"` - CurrentPhase *string `json:"currentPhase"` - Error *string `json:"error"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - CompletedAt *string `json:"completedAt"` -} - -func (a *ManageAPI) GetIndex(ctx context.Context, manageURL, projectID, projectKey, indexName string) (IndexInfoResponse, error) { - var response IndexInfoResponse - if err := a.do(ctx, manageURL, projectID, projectKey, manageRequest{ - Action: "getIndex", - ProjectID: projectID, - IndexName: indexName, - }, &response); err != nil { - return IndexInfoResponse{}, err - } - return response, nil -} - -func (a *ManageAPI) ListIndexes(ctx context.Context, manageURL, projectID, projectKey string) ([]IndexInfoResponse, error) { - var response []IndexInfoResponse - if err := a.do(ctx, manageURL, projectID, projectKey, manageRequest{ - Action: "listIndexes", - ProjectID: projectID, - }, &response); err != nil { - return nil, err - } - return response, nil -} - -func (a *ManageAPI) DeleteIndex(ctx context.Context, manageURL, projectID, projectKey, indexName string) (bool, error) { - var response bool - if err := a.do(ctx, manageURL, projectID, projectKey, manageRequest{ - Action: "deleteIndex", - ProjectID: projectID, - IndexName: indexName, - }, &response); err != nil { - return false, err - } - return response, nil -} - -func (a *ManageAPI) InitUpload( - ctx context.Context, - manageURL, projectID, projectKey, indexName, modelID string, - docCount, dimension int, -) (InitUploadResponse, error) { - var response InitUploadResponse - if err := a.do(ctx, manageURL, projectID, projectKey, initUploadRequest{ - Action: "initUpload", - ProjectID: projectID, - IndexName: indexName, - ModelID: modelID, - DocCount: docCount, - Dimension: dimension, - }, &response); err != nil { - return InitUploadResponse{}, err - } - return response, nil -} - -func (a *ManageAPI) StartBuild(ctx context.Context, manageURL, projectID, projectKey, jobID string) (MutationResponse, error) { - var response MutationResponse - if err := a.do(ctx, manageURL, projectID, projectKey, jobRequest{ - Action: "startBuild", - ProjectID: projectID, - JobID: jobID, - }, &response); err != nil { - return MutationResponse{}, err - } - return response, nil -} - -func (a *ManageAPI) AddDocs( - ctx context.Context, - manageURL, projectID, projectKey, indexName string, - docs []DocumentInfoResponse, - upsert *bool, -) (MutationResponse, error) { - request := addDocsRequest{ - Action: "addDocs", - ProjectID: projectID, - IndexName: indexName, - Docs: docs, - } - if upsert != nil { - request.Options = &addDocsOptions{Upsert: upsert} - } - - var response MutationResponse - if err := a.do(ctx, manageURL, projectID, projectKey, request, &response); err != nil { - return MutationResponse{}, err - } - return response, nil -} - -func (a *ManageAPI) DeleteDocs( - ctx context.Context, - manageURL, projectID, projectKey, indexName string, - docIDs []string, -) (MutationResponse, error) { - var response MutationResponse - if err := a.do(ctx, manageURL, projectID, projectKey, manageRequest{ - Action: "deleteDocs", - ProjectID: projectID, - IndexName: indexName, - DocIDs: docIDs, - }, &response); err != nil { - return MutationResponse{}, err - } - return response, nil -} - -func (a *ManageAPI) GetJobStatus(ctx context.Context, manageURL, projectID, projectKey, jobID string) (JobStatusResponse, error) { - var response JobStatusResponse - if err := a.do(ctx, manageURL, projectID, projectKey, jobRequest{ - Action: "getJobStatus", - ProjectID: projectID, - JobID: jobID, - }, &response); err != nil { - return JobStatusResponse{}, err - } - return response, nil -} - -func (a *ManageAPI) GetDocs( - ctx context.Context, - manageURL, projectID, projectKey, indexName string, - docIDs []string, -) ([]DocumentInfoResponse, error) { - request := manageRequest{ - Action: "getDocs", - ProjectID: projectID, - IndexName: indexName, - } - if len(docIDs) > 0 { - request.DocIDs = docIDs - } - - var response []DocumentInfoResponse - if err := a.do(ctx, manageURL, projectID, projectKey, request, &response); err != nil { - return nil, err - } - return response, nil -} - -func (a *ManageAPI) do(ctx context.Context, manageURL, projectID, projectKey string, payload any, dest any) error { - return a.httpClient.PostJSON(ctx, manageURL, map[string]string{ - "Content-Type": "application/json", - "X-Project-Key": projectKey, - "X-Service-Version": "v1", - }, payload, dest) -} diff --git a/sdks/go/sdk/moss/internal/query_api.go b/sdks/go/sdk/moss/internal/query_api.go deleted file mode 100644 index 1076a73b..00000000 --- a/sdks/go/sdk/moss/internal/query_api.go +++ /dev/null @@ -1,60 +0,0 @@ -package internal - -import "context" - -type QueryAPI struct { - httpClient *JSONHTTPClient -} - -func NewQueryAPI(httpClient *JSONHTTPClient) *QueryAPI { - return &QueryAPI{httpClient: httpClient} -} - -type queryRequest struct { - Query string `json:"query"` - IndexName string `json:"indexName"` - ProjectID string `json:"projectId"` - ProjectKey string `json:"projectKey"` - TopK int `json:"topK"` - QueryEmbedding []float32 `json:"queryEmbedding,omitempty"` -} - -type SearchResultResponse struct { - Docs []QueryResultDocumentInfoResponse `json:"docs"` - Query string `json:"query"` - IndexName *string `json:"indexName,omitempty"` - TimeTakenMs *int `json:"timeTakenMs,omitempty"` -} - -type QueryResultDocumentInfoResponse struct { - ID string `json:"id"` - Text string `json:"text"` - Metadata map[string]string `json:"metadata,omitempty"` - Score float64 `json:"score"` -} - -func (a *QueryAPI) Query( - ctx context.Context, - queryURL, projectID, projectKey, indexName, query string, - topK int, - queryEmbedding []float32, -) (SearchResultResponse, error) { - request := queryRequest{ - Query: query, - IndexName: indexName, - ProjectID: projectID, - ProjectKey: projectKey, - TopK: topK, - } - if len(queryEmbedding) > 0 { - request.QueryEmbedding = queryEmbedding - } - - var response SearchResultResponse - if err := a.httpClient.PostJSON(ctx, queryURL, map[string]string{ - "Content-Type": "application/json", - }, request, &response); err != nil { - return SearchResultResponse{}, err - } - return response, nil -} diff --git a/sdks/go/sdk/moss/local.go b/sdks/go/sdk/moss/local.go new file mode 100644 index 00000000..fcbd75a5 --- /dev/null +++ b/sdks/go/sdk/moss/local.go @@ -0,0 +1,90 @@ +package moss + +import ( + "context" + "strings" + + mosscore "github.com/usemoss/moss/sdks/go/bindings" +) + +// LoadIndex downloads an index into the local native runtime for fast querying. +func (c *Client) LoadIndex(ctx context.Context, indexName string, options *LoadIndexOptions) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + if err := c.validateManageRequest(indexName); err != nil { + return "", err + } + if options != nil && strings.TrimSpace(options.CachePath) != "" { + return "", ErrUnsupportedCachePath + } + + manager, err := c.ensureIndexManager() + if err != nil { + return "", err + } + + var bindingOptions *mosscore.LoadIndexOptions + if options != nil { + bindingOptions = &mosscore.LoadIndexOptions{ + AutoRefresh: options.AutoRefresh, + PollingIntervalInSeconds: options.PollingIntervalInSeconds, + } + } + + info, err := manager.LoadIndex(indexName, bindingOptions) + if err != nil { + return "", err + } + if info.Model.ID != string(ModelCustom) { + if err := manager.LoadQueryModel(indexName); err != nil { + return "", err + } + } + if info.Name != "" { + return info.Name, nil + } + return indexName, nil +} + +// UnloadIndex removes a previously loaded index from the local runtime. +func (c *Client) UnloadIndex(ctx context.Context, indexName string) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(indexName) == "" { + return ErrEmptyIndexName + } + + manager, err := c.ensureIndexManager() + if err != nil { + return err + } + return manager.UnloadIndex(indexName) +} + +// RefreshIndex refreshes a locally loaded index from the cloud when newer data is available. +func (c *Client) RefreshIndex(ctx context.Context, indexName string) (RefreshResult, error) { + if err := ctx.Err(); err != nil { + return RefreshResult{}, err + } + if strings.TrimSpace(indexName) == "" { + return RefreshResult{}, ErrEmptyIndexName + } + + manager, err := c.ensureIndexManager() + if err != nil { + return RefreshResult{}, err + } + + result, err := manager.RefreshIndex(indexName) + if err != nil { + return RefreshResult{}, err + } + return RefreshResult{ + IndexName: result.IndexName, + PreviousUpdatedAt: result.PreviousUpdatedAt, + NewUpdatedAt: result.NewUpdatedAt, + WasUpdated: result.WasUpdated, + }, nil +} diff --git a/sdks/go/sdk/moss/models.go b/sdks/go/sdk/moss/models.go index 520e5215..db9e05ee 100644 --- a/sdks/go/sdk/moss/models.go +++ b/sdks/go/sdk/moss/models.go @@ -84,7 +84,7 @@ type SearchResult struct { TimeTakenMs *int `json:"timeTakenMs,omitempty"` } -// QueryOptions customizes cloud query behavior. +// QueryOptions customizes local query behavior. type QueryOptions struct { Embedding []float32 `json:"embedding,omitempty"` TopK int `json:"topK,omitempty"` @@ -92,6 +92,13 @@ type QueryOptions struct { Filter map[string]any `json:"filter,omitempty"` } +// LoadIndexOptions configures local index loading behavior. +type LoadIndexOptions struct { + AutoRefresh bool `json:"autoRefresh,omitempty"` + PollingIntervalInSeconds uint64 `json:"pollingIntervalInSeconds,omitempty"` + CachePath string `json:"cachePath,omitempty"` +} + // GetDocumentsOptions optionally narrows document retrieval by ID. type GetDocumentsOptions struct { DocIDs []string `json:"docIds,omitempty"` @@ -135,3 +142,11 @@ type JobStatusResponse struct { UpdatedAt string `json:"updatedAt"` CompletedAt *string `json:"completedAt,omitempty"` } + +// RefreshResult describes the outcome of a local refresh operation. +type RefreshResult struct { + IndexName string `json:"indexName"` + PreviousUpdatedAt string `json:"previousUpdatedAt"` + NewUpdatedAt string `json:"newUpdatedAt"` + WasUpdated bool `json:"wasUpdated"` +} diff --git a/sdks/go/sdk/moss/mutation.go b/sdks/go/sdk/moss/mutation.go index 878fc014..632bc2bf 100644 --- a/sdks/go/sdk/moss/mutation.go +++ b/sdks/go/sdk/moss/mutation.go @@ -1,28 +1,25 @@ package moss import ( - "bytes" "context" - "encoding/binary" - "encoding/json" "fmt" - "io" - "math" - "net/http" "strings" "time" + + mosscore "github.com/usemoss/moss/sdks/go/bindings" ) const ( defaultPollInterval = 2 * time.Second defaultMutationTimeout = 30 * time.Minute maxConsecutivePollErrors = 3 - maxUploadRetries = 3 - baseUploadRetryDelay = 1 * time.Second ) -// CreateIndex initializes an upload, sends the bulk payload, starts the build, and polls until completion. +// CreateIndex creates a new index through the native bindings and polls until completion. func (c *Client) CreateIndex(ctx context.Context, indexName string, docs []DocumentInfo, options *CreateIndexOptions) (MutationResult, error) { + if err := ctx.Err(); err != nil { + return MutationResult{}, err + } if err := c.validateManageRequest(indexName); err != nil { return MutationResult{}, err } @@ -31,39 +28,33 @@ func (c *Client) CreateIndex(ctx context.Context, indexName string, docs []Docum } modelID := resolveModelID(docs, options) - dimension, err := resolveEmbeddingDimension(docs, modelID) - if err != nil { + if _, err := resolveEmbeddingDimension(docs, modelID); err != nil { return MutationResult{}, err } - initResponse, err := c.manageAPI.InitUpload(ctx, c.manageURL, c.projectID, c.projectKey, indexName, string(modelID), len(docs), dimension) - if err != nil { - return MutationResult{}, normalizeError(err) - } - - payload, err := serializeBulkPayload(docs, dimension) + manage, err := c.ensureManageClient() if err != nil { return MutationResult{}, err } - if err := c.uploadBulkPayload(ctx, initResponse.UploadURL, payload); err != nil { + response, err := manage.CreateIndex(indexName, toCoreDocumentInfos(docs), string(modelID)) + if err != nil { return MutationResult{}, err } - if _, err := c.manageAPI.StartBuild(ctx, c.manageURL, c.projectID, c.projectKey, initResponse.JobID); err != nil { - return MutationResult{}, normalizeError(err) - } - var onProgress func(JobProgress) if options != nil { onProgress = options.OnProgress } - return c.pollJobUntilComplete(ctx, initResponse.JobID, indexName, len(docs), onProgress) + return c.pollJobUntilComplete(ctx, response, onProgress) } // AddDocs appends or upserts documents and polls the async job until completion. func (c *Client) AddDocs(ctx context.Context, indexName string, docs []DocumentInfo, options *MutationOptions) (MutationResult, error) { + if err := ctx.Err(); err != nil { + return MutationResult{}, err + } if err := c.validateManageRequest(indexName); err != nil { return MutationResult{}, err } @@ -71,23 +62,31 @@ func (c *Client) AddDocs(ctx context.Context, indexName string, docs []DocumentI return MutationResult{}, ErrEmptyDocuments } - var upsert *bool + var bindingOptions *mosscore.MutationOptions var onProgress func(JobProgress) if options != nil { - upsert = options.Upsert + bindingOptions = &mosscore.MutationOptions{Upsert: options.Upsert} onProgress = options.OnProgress } - response, err := c.manageAPI.AddDocs(ctx, c.manageURL, c.projectID, c.projectKey, indexName, toDocumentInfoResponses(docs), upsert) + manage, err := c.ensureManageClient() if err != nil { - return MutationResult{}, normalizeError(err) + return MutationResult{}, err } - return c.pollJobUntilComplete(ctx, response.JobID, indexName, len(docs), onProgress) + response, err := manage.AddDocs(indexName, toCoreDocumentInfos(docs), bindingOptions) + if err != nil { + return MutationResult{}, err + } + + return c.pollJobUntilComplete(ctx, response, onProgress) } // DeleteDocs removes documents by ID and polls the async job until completion. func (c *Client) DeleteDocs(ctx context.Context, indexName string, docIDs []string, options *MutationOptions) (MutationResult, error) { + if err := ctx.Err(); err != nil { + return MutationResult{}, err + } if err := c.validateManageRequest(indexName); err != nil { return MutationResult{}, err } @@ -100,31 +99,41 @@ func (c *Client) DeleteDocs(ctx context.Context, indexName string, docIDs []stri onProgress = options.OnProgress } - response, err := c.manageAPI.DeleteDocs(ctx, c.manageURL, c.projectID, c.projectKey, indexName, docIDs) + manage, err := c.ensureManageClient() if err != nil { - return MutationResult{}, normalizeError(err) + return MutationResult{}, err + } + + response, err := manage.DeleteDocs(indexName, docIDs) + if err != nil { + return MutationResult{}, err } - return c.pollJobUntilComplete(ctx, response.JobID, indexName, len(docIDs), onProgress) + return c.pollJobUntilComplete(ctx, response, onProgress) } // GetJobStatus fetches the current status of an async mutation job. func (c *Client) GetJobStatus(ctx context.Context, jobID string) (JobStatusResponse, error) { - if err := validateCredentials(c.projectID, c.projectKey); err != nil { + if err := ctx.Err(); err != nil { return JobStatusResponse{}, err } - if strings.TrimSpace(c.manageURL) == "" { - return JobStatusResponse{}, ErrMissingManageURL + if err := validateCredentials(c.projectID, c.projectKey); err != nil { + return JobStatusResponse{}, err } if strings.TrimSpace(jobID) == "" { return JobStatusResponse{}, ErrEmptyJobID } - response, err := c.manageAPI.GetJobStatus(ctx, c.manageURL, c.projectID, c.projectKey, jobID) + manage, err := c.ensureManageClient() + if err != nil { + return JobStatusResponse{}, err + } + + response, err := manage.GetJobStatus(jobID) if err != nil { - return JobStatusResponse{}, normalizeError(err) + return JobStatusResponse{}, err } - return toJobStatusResponse(response), nil + return fromCoreJobStatusResponse(response), nil } func resolveModelID(docs []DocumentInfo, options *CreateIndexOptions) MossModel { @@ -171,102 +180,9 @@ func resolveEmbeddingDimension(docs []DocumentInfo, modelID MossModel) (int, err return dimension, nil } -func serializeBulkPayload(docs []DocumentInfo, dimension int) ([]byte, error) { - metadataDocs := make([]map[string]any, 0, len(docs)) - for _, doc := range docs { - item := map[string]any{ - "id": doc.ID, - "text": doc.Text, - } - if len(doc.Metadata) > 0 { - item["metadata"] = doc.Metadata - } - metadataDocs = append(metadataDocs, item) - } - - metadataBytes, err := json.Marshal(metadataDocs) - if err != nil { - return nil, err - } - - const headerSize = 20 - embeddingsSize := 0 - if dimension > 0 { - embeddingsSize = len(docs) * dimension * 4 - } - - buf := bytes.NewBuffer(make([]byte, 0, headerSize+len(metadataBytes)+embeddingsSize)) - buf.Write([]byte{'M', 'O', 'S', 'S'}) - for _, value := range []uint32{1, uint32(len(docs)), uint32(dimension), uint32(len(metadataBytes))} { - if err := binary.Write(buf, binary.LittleEndian, value); err != nil { - return nil, err - } - } - buf.Write(metadataBytes) - - for _, doc := range docs { - for _, value := range doc.Embedding { - if err := binary.Write(buf, binary.LittleEndian, value); err != nil { - return nil, err - } - } - } - - return buf.Bytes(), nil -} - -func (c *Client) uploadBulkPayload(ctx context.Context, uploadURL string, payload []byte) error { - var lastErr error - - for attempt := 0; attempt < maxUploadRetries; attempt++ { - req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, bytes.NewReader(payload)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/octet-stream") - - resp, err := c.httpClient.Do(req) - if err != nil { - lastErr = err - } else { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) - resp.Body.Close() - - if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { - return nil - } - - lastErr = &HTTPError{ - StatusCode: resp.StatusCode, - Body: strings.TrimSpace(string(body)), - } - - if resp.StatusCode < http.StatusInternalServerError { - return lastErr - } - } - - if attempt == maxUploadRetries-1 { - break - } - - delay := time.Duration(math.Pow(2, float64(attempt))) * baseUploadRetryDelay - timer := time.NewTimer(delay) - select { - case <-ctx.Done(): - timer.Stop() - return ctx.Err() - case <-timer.C: - } - } - - return lastErr -} - func (c *Client) pollJobUntilComplete( ctx context.Context, - jobID, indexName string, - docCount int, + result mosscore.MutationResult, onProgress func(JobProgress), ) (MutationResult, error) { timeoutCtx, cancel := context.WithTimeout(ctx, defaultMutationTimeout) @@ -276,9 +192,10 @@ func (c *Client) pollJobUntilComplete( defer ticker.Stop() consecutiveErrors := 0 + completed := fromCoreMutationResult(result) for { - status, err := c.GetJobStatus(timeoutCtx, jobID) + status, err := c.GetJobStatus(timeoutCtx, result.JobID) if err != nil { consecutiveErrors++ if consecutiveErrors >= maxConsecutivePollErrors { @@ -297,11 +214,7 @@ func (c *Client) pollJobUntilComplete( switch status.Status { case JobStatusCompleted: - return MutationResult{ - JobID: jobID, - IndexName: indexName, - DocCount: docCount, - }, nil + return completed, nil case JobStatusFailed: if status.Error != nil && *status.Error != "" { return MutationResult{}, fmt.Errorf("moss: job failed: %s", *status.Error) diff --git a/sdks/go/sdk/moss/mutation_test.go b/sdks/go/sdk/moss/mutation_test.go index 9aa06ef2..04b9d9b6 100644 --- a/sdks/go/sdk/moss/mutation_test.go +++ b/sdks/go/sdk/moss/mutation_test.go @@ -2,71 +2,52 @@ package moss import ( "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" "strings" - "sync/atomic" "testing" -) - -func TestCreateIndexRunsInitUploadUploadStartBuildAndPoll(t *testing.T) { - var initSeen, startSeen bool - var uploaded []byte - var pollCount atomic.Int32 - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - defer server.Close() - mux.HandleFunc("/manage", func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - var body map[string]any - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode request body: %v", err) - } + mosscore "github.com/usemoss/moss/sdks/go/bindings" +) - switch body["action"] { - case "initUpload": - initSeen = true - if body["modelId"] != "moss-minilm" { - t.Fatalf("unexpected modelId: %#v", body["modelId"]) +func TestCreateIndexUsesBindingsAndPollsJobStatus(t *testing.T) { + polls := 0 + client := newTestClient(&fakeManageRuntime{ + createIndexFn: func(name string, docs []mosscore.DocumentInfo, modelID string) (mosscore.MutationResult, error) { + if name != "support-docs" { + t.Fatalf("unexpected index name: %q", name) } - if body["dimension"] != float64(0) { - t.Fatalf("unexpected dimension: %#v", body["dimension"]) + if len(docs) != 2 { + t.Fatalf("unexpected doc count: %d", len(docs)) } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"jobId":"job-create","uploadUrl":"` + server.URL + `/upload","expiresIn":3600}`)) - case "startBuild": - startSeen = true - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"jobId":"job-create","status":"building"}`)) - case "getJobStatus": - w.Header().Set("Content-Type", "application/json") - if pollCount.Add(1) == 1 { - _, _ = w.Write([]byte(`{"jobId":"job-create","status":"building","progress":42,"currentPhase":"building_index","error":null,"createdAt":"2026-05-22T00:00:00Z","updatedAt":"2026-05-22T00:00:01Z","completedAt":null}`)) - return + if modelID != string(ModelMossMiniLM) { + t.Fatalf("unexpected model ID: %q", modelID) } - _, _ = w.Write([]byte(`{"jobId":"job-create","status":"completed","progress":100,"currentPhase":null,"error":null,"createdAt":"2026-05-22T00:00:00Z","updatedAt":"2026-05-22T00:00:02Z","completedAt":"2026-05-22T00:00:02Z"}`)) - default: - t.Fatalf("unexpected action: %#v", body["action"]) - } - }) - - mux.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - data, err := io.ReadAll(r.Body) - if err != nil { - t.Fatalf("read upload body: %v", err) - } - uploaded = data - w.WriteHeader(http.StatusOK) - }) + return mosscore.MutationResult{JobID: "job-create", IndexName: name, DocCount: len(docs)}, nil + }, + getJobStatusFn: func(jobID string) (mosscore.JobStatusResponse, error) { + polls++ + if polls == 1 { + phase := "building_index" + return mosscore.JobStatusResponse{ + JobID: jobID, + Status: string(JobStatusBuilding), + Progress: 42, + CurrentPhase: &phase, + CreatedAt: "2026-05-22T00:00:00Z", + UpdatedAt: "2026-05-22T00:00:01Z", + }, nil + } + return mosscore.JobStatusResponse{ + JobID: jobID, + Status: string(JobStatusCompleted), + Progress: 100, + CreatedAt: "2026-05-22T00:00:00Z", + UpdatedAt: "2026-05-22T00:00:02Z", + CompletedAt: ptr("2026-05-22T00:00:02Z"), + }, nil + }, + }, nil) - client := NewClient("project-id", "project-key", WithManageURL(server.URL+"/manage")) progresses := []JobProgress{} - result, err := client.CreateIndex(context.Background(), "support-docs", []DocumentInfo{ {ID: "doc-1", Text: "hello"}, {ID: "doc-2", Text: "world"}, @@ -78,16 +59,6 @@ func TestCreateIndexRunsInitUploadUploadStartBuildAndPoll(t *testing.T) { if err != nil { t.Fatalf("CreateIndex returned error: %v", err) } - - if !initSeen || !startSeen { - t.Fatalf("expected initUpload and startBuild to both run") - } - if len(uploaded) == 0 { - t.Fatal("expected upload payload to be sent") - } - if string(uploaded[:4]) != "MOSS" { - t.Fatalf("unexpected upload header: %q", string(uploaded[:4])) - } if result.JobID != "job-create" || result.IndexName != "support-docs" || result.DocCount != 2 { t.Fatalf("unexpected mutation result: %#v", result) } @@ -108,104 +79,98 @@ func TestCreateIndexRejectsMixedEmbeddings(t *testing.T) { } } -func TestAddDocsSendsJSONMutationAndPolls(t *testing.T) { - var gotBody map[string]any - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - defer server.Close() - - mux.HandleFunc("/manage", func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { - t.Fatalf("decode request body: %v", err) - } - - w.Header().Set("Content-Type", "application/json") - switch gotBody["action"] { - case "addDocs": - _, _ = w.Write([]byte(`{"jobId":"job-add","status":"building"}`)) - case "getJobStatus": - _, _ = w.Write([]byte(`{"jobId":"job-add","status":"completed","progress":100,"currentPhase":null,"error":null,"createdAt":"2026-05-22T00:00:00Z","updatedAt":"2026-05-22T00:00:01Z","completedAt":"2026-05-22T00:00:01Z"}`)) - default: - t.Fatalf("unexpected action: %#v", gotBody["action"]) - } - }) - +func TestAddDocsUsesBindingsAndConvertsOptions(t *testing.T) { upsert := true - client := NewClient("project-id", "project-key", WithManageURL(server.URL+"/manage")) + client := newTestClient(&fakeManageRuntime{ + addDocsFn: func(name string, docs []mosscore.DocumentInfo, options *mosscore.MutationOptions) (mosscore.MutationResult, error) { + if name != "support-docs" { + t.Fatalf("unexpected index name: %q", name) + } + if len(docs) != 1 || docs[0].ID != "doc-3" { + t.Fatalf("unexpected docs: %#v", docs) + } + if options == nil || options.Upsert == nil || !*options.Upsert { + t.Fatalf("expected upsert option to be forwarded, got %#v", options) + } + return mosscore.MutationResult{JobID: "job-add", IndexName: name, DocCount: len(docs)}, nil + }, + getJobStatusFn: func(jobID string) (mosscore.JobStatusResponse, error) { + return mosscore.JobStatusResponse{ + JobID: jobID, + Status: string(JobStatusCompleted), + Progress: 100, + CreatedAt: "2026-05-22T00:00:00Z", + UpdatedAt: "2026-05-22T00:00:01Z", + CompletedAt: ptr("2026-05-22T00:00:01Z"), + }, nil + }, + }, nil) + result, err := client.AddDocs(context.Background(), "support-docs", []DocumentInfo{ {ID: "doc-3", Text: "new"}, }, &MutationOptions{Upsert: &upsert}) if err != nil { t.Fatalf("AddDocs returned error: %v", err) } - if result.JobID != "job-add" || result.DocCount != 1 { t.Fatalf("unexpected add result: %#v", result) } - if gotBody["action"] != "getJobStatus" { - t.Fatalf("expected final request to be getJobStatus, got %#v", gotBody["action"]) - } } -func TestDeleteDocsSendsExpectedAction(t *testing.T) { - var firstAction string - var seenDelete bool - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - defer server.Close() - - mux.HandleFunc("/manage", func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - var body map[string]any - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode request body: %v", err) - } - action := body["action"].(string) - if firstAction == "" { - firstAction = action - } - if action == "deleteDocs" { - seenDelete = true - } - - w.Header().Set("Content-Type", "application/json") - if action == "deleteDocs" { - _, _ = w.Write([]byte(`{"jobId":"job-del","status":"building"}`)) - return - } - _, _ = w.Write([]byte(`{"jobId":"job-del","status":"completed","progress":100,"currentPhase":null,"error":null,"createdAt":"2026-05-22T00:00:00Z","updatedAt":"2026-05-22T00:00:01Z","completedAt":"2026-05-22T00:00:01Z"}`)) - }) +func TestDeleteDocsUsesBindings(t *testing.T) { + client := newTestClient(&fakeManageRuntime{ + deleteDocsFn: func(name string, docIDs []string) (mosscore.MutationResult, error) { + if name != "support-docs" { + t.Fatalf("unexpected index name: %q", name) + } + if len(docIDs) != 2 || docIDs[0] != "doc-1" || docIDs[1] != "doc-2" { + t.Fatalf("unexpected doc IDs: %#v", docIDs) + } + return mosscore.MutationResult{JobID: "job-del", IndexName: name, DocCount: len(docIDs)}, nil + }, + getJobStatusFn: func(jobID string) (mosscore.JobStatusResponse, error) { + return mosscore.JobStatusResponse{ + JobID: jobID, + Status: string(JobStatusCompleted), + Progress: 100, + CreatedAt: "2026-05-22T00:00:00Z", + UpdatedAt: "2026-05-22T00:00:01Z", + CompletedAt: ptr("2026-05-22T00:00:01Z"), + }, nil + }, + }, nil) - client := NewClient("project-id", "project-key", WithManageURL(server.URL+"/manage")) result, err := client.DeleteDocs(context.Background(), "support-docs", []string{"doc-1", "doc-2"}, nil) if err != nil { t.Fatalf("DeleteDocs returned error: %v", err) } - - if !seenDelete || firstAction != "deleteDocs" { - t.Fatalf("expected first action to be deleteDocs, got %q", firstAction) - } if result.DocCount != 2 { t.Fatalf("unexpected delete result: %#v", result) } } -func TestGetJobStatusDecodesResponse(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"jobId":"job-123","status":"building","progress":55,"currentPhase":"uploading","error":null,"createdAt":"2026-05-22T00:00:00Z","updatedAt":"2026-05-22T00:00:01Z","completedAt":null}`)) - })) - defer server.Close() +func TestGetJobStatusUsesBindingsRuntime(t *testing.T) { + client := newTestClient(&fakeManageRuntime{ + getJobStatusFn: func(jobID string) (mosscore.JobStatusResponse, error) { + if jobID != "job-123" { + t.Fatalf("unexpected job ID: %q", jobID) + } + phase := "uploading" + return mosscore.JobStatusResponse{ + JobID: jobID, + Status: string(JobStatusBuilding), + Progress: 55, + CurrentPhase: &phase, + CreatedAt: "2026-05-22T00:00:00Z", + UpdatedAt: "2026-05-22T00:00:01Z", + }, nil + }, + }, nil) - client := NewClient("project-id", "project-key", WithManageURL(server.URL)) status, err := client.GetJobStatus(context.Background(), "job-123") if err != nil { t.Fatalf("GetJobStatus returned error: %v", err) } - if status.JobID != "job-123" || status.Status != JobStatusBuilding || status.Progress != 55 { t.Fatalf("unexpected job status: %#v", status) } @@ -213,3 +178,7 @@ func TestGetJobStatusDecodesResponse(t *testing.T) { t.Fatalf("unexpected current phase: %#v", status.CurrentPhase) } } + +func ptr(value string) *string { + return &value +} diff --git a/sdks/go/sdk/moss/options.go b/sdks/go/sdk/moss/options.go index 495ced1a..9523b5e3 100644 --- a/sdks/go/sdk/moss/options.go +++ b/sdks/go/sdk/moss/options.go @@ -5,28 +5,24 @@ import "net/http" // Option customizes client construction. type Option func(*clientConfig) -// WithManageURL overrides the default manage endpoint. +// WithManageURL is retained for compatibility with earlier SDK scaffolding. +// The bindings-backed client currently ignores explicit endpoint overrides. func WithManageURL(url string) Option { return func(cfg *clientConfig) { cfg.manageURL = url - if cfg.queryURL == "" { - cfg.queryURL = defaultQueryURL(url) - } } } -// WithQueryURL overrides the default query endpoint. +// WithQueryURL is retained for compatibility with earlier SDK scaffolding. +// The bindings-backed client currently ignores explicit endpoint overrides. func WithQueryURL(url string) Option { return func(cfg *clientConfig) { cfg.queryURL = url } } -// WithHTTPClient injects a custom HTTP client. -func WithHTTPClient(httpClient *http.Client) Option { - return func(cfg *clientConfig) { - if httpClient != nil { - cfg.httpClient = httpClient - } - } +// WithHTTPClient is retained for compatibility with earlier SDK scaffolding. +// The bindings-backed client currently ignores custom HTTP transports. +func WithHTTPClient(_ *http.Client) Option { + return func(cfg *clientConfig) {} } diff --git a/sdks/go/sdk/moss/query.go b/sdks/go/sdk/moss/query.go index 05f9bcd4..8fb05512 100644 --- a/sdks/go/sdk/moss/query.go +++ b/sdks/go/sdk/moss/query.go @@ -1,31 +1,68 @@ package moss -import "context" +import ( + "context" + "encoding/json" +) const defaultTopK = 5 -// Query executes a cloud query against the configured index. +// Query executes a local query against a previously loaded index. func (c *Client) Query(ctx context.Context, indexName, query string, options *QueryOptions) (SearchResult, error) { + if err := ctx.Err(); err != nil { + return SearchResult{}, err + } if err := c.validateQueryRequest(indexName); err != nil { return SearchResult{}, err } - if options != nil && options.Filter != nil { - return SearchResult{}, ErrUnsupportedQueryFilter + + manager, err := c.ensureIndexManager() + if err != nil { + return SearchResult{}, err + } + if !manager.HasIndex(indexName) { + return SearchResult{}, ErrIndexNotLoaded } + return c.queryLocal(manager, indexName, query, options) +} +func (c *Client) queryLocal(manager indexRuntime, indexName, query string, options *QueryOptions) (SearchResult, error) { topK := defaultTopK - if options != nil && options.TopK > 0 { - topK = options.TopK + alpha := 0.8 + var embedding []float32 + var filterJSON *string + + if options != nil { + if options.TopK > 0 { + topK = options.TopK + } + if options.Alpha != nil { + alpha = *options.Alpha + } + if len(options.Embedding) > 0 { + embedding = options.Embedding + } + if options.Filter != nil { + bytes, err := json.Marshal(options.Filter) + if err != nil { + return SearchResult{}, err + } + value := string(bytes) + filterJSON = &value + } } - var embedding []float32 - if options != nil && len(options.Embedding) > 0 { - embedding = options.Embedding + if len(embedding) > 0 { + result, err := manager.Query(indexName, query, embedding, topK, float32(alpha), filterJSON) + if err != nil { + return SearchResult{}, err + } + return fromCoreSearchResult(result), nil } - response, err := c.queryAPI.Query(ctx, c.queryURL, c.projectID, c.projectKey, indexName, query, topK, embedding) + result, err := manager.QueryText(indexName, query, topK, float32(alpha), filterJSON) if err != nil { - return SearchResult{}, normalizeError(err) + return SearchResult{}, err } - return toSearchResult(response), nil + return fromCoreSearchResult(result), nil } diff --git a/sdks/go/sdk/moss/read.go b/sdks/go/sdk/moss/read.go index e4d668ff..440778ce 100644 --- a/sdks/go/sdk/moss/read.go +++ b/sdks/go/sdk/moss/read.go @@ -4,50 +4,71 @@ import "context" // GetIndex fetches metadata for a single index. func (c *Client) GetIndex(ctx context.Context, indexName string) (IndexInfo, error) { + if err := ctx.Err(); err != nil { + return IndexInfo{}, err + } if err := c.validateManageRequest(indexName); err != nil { return IndexInfo{}, err } - response, err := c.manageAPI.GetIndex(ctx, c.manageURL, c.projectID, c.projectKey, indexName) + manage, err := c.ensureManageClient() + if err != nil { + return IndexInfo{}, err + } + response, err := manage.GetIndex(indexName) if err != nil { - return IndexInfo{}, normalizeError(err) + return IndexInfo{}, err } - return toIndexInfo(response), nil + return fromCoreIndexInfo(response), nil } // ListIndexes returns all indexes for the configured project. func (c *Client) ListIndexes(ctx context.Context) ([]IndexInfo, error) { + if err := ctx.Err(); err != nil { + return nil, err + } if err := validateCredentials(c.projectID, c.projectKey); err != nil { return nil, err } - if c.manageURL == "" { - return nil, ErrMissingManageURL + manage, err := c.ensureManageClient() + if err != nil { + return nil, err } - response, err := c.manageAPI.ListIndexes(ctx, c.manageURL, c.projectID, c.projectKey) + response, err := manage.ListIndexes() if err != nil { - return nil, normalizeError(err) + return nil, err } out := make([]IndexInfo, 0, len(response)) for _, item := range response { - out = append(out, toIndexInfo(item)) + out = append(out, fromCoreIndexInfo(item)) } return out, nil } // DeleteIndex removes an index from the configured project. func (c *Client) DeleteIndex(ctx context.Context, indexName string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } if err := c.validateManageRequest(indexName); err != nil { return false, err } - ok, err := c.manageAPI.DeleteIndex(ctx, c.manageURL, c.projectID, c.projectKey, indexName) + manage, err := c.ensureManageClient() + if err != nil { + return false, err + } + ok, err := manage.DeleteIndex(indexName) if err != nil { - return false, normalizeError(err) + return false, err } return ok, nil } // GetDocs retrieves all documents for an index or a selected subset by ID. func (c *Client) GetDocs(ctx context.Context, indexName string, options *GetDocumentsOptions) ([]DocumentInfo, error) { + if err := ctx.Err(); err != nil { + return nil, err + } if err := c.validateManageRequest(indexName); err != nil { return nil, err } @@ -56,14 +77,18 @@ func (c *Client) GetDocs(ctx context.Context, indexName string, options *GetDocu docIDs = options.DocIDs } - response, err := c.manageAPI.GetDocs(ctx, c.manageURL, c.projectID, c.projectKey, indexName, docIDs) + manage, err := c.ensureManageClient() if err != nil { - return nil, normalizeError(err) + return nil, err + } + response, err := manage.GetDocs(indexName, docIDs) + if err != nil { + return nil, err } out := make([]DocumentInfo, 0, len(response)) for _, item := range response { - out = append(out, toDocumentInfo(item)) + out = append(out, fromCoreDocumentInfo(item)) } return out, nil } From 8d6955db210464187cb6120c88c889f988caf9f8 Mon Sep 17 00:00:00 2001 From: anirudh-makuluri Date: Sat, 23 May 2026 01:51:42 -0700 Subject: [PATCH 04/12] Preserve nil embeddings in Go bindings --- sdks/go/bindings/libmoss.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sdks/go/bindings/libmoss.go b/sdks/go/bindings/libmoss.go index 62e3dccd..29e86071 100644 --- a/sdks/go/bindings/libmoss.go +++ b/sdks/go/bindings/libmoss.go @@ -434,8 +434,9 @@ func convertDocuments(out *C.MossDocumentInfo, count C.uintptr_t) []DocumentInfo response := make([]DocumentInfo, 0, len(items)) for i := range items { item := items[i] - embedding := make([]float32, int(item.embedding_dim)) + var embedding []float32 if item.embedding != nil && item.embedding_dim > 0 { + embedding = make([]float32, int(item.embedding_dim)) values := unsafe.Slice(item.embedding, int(item.embedding_dim)) for j := range values { embedding[j] = float32(values[j]) From 4aacdeb6d1e4eca7ce0a69913bf5a86fb41f9cad Mon Sep 17 00:00:00 2001 From: anirudh-makuluri Date: Fri, 12 Jun 2026 01:45:49 -0700 Subject: [PATCH 05/12] resolved issues mentioned in comments --- sdks/go/bindings/errors.go | 1 + sdks/go/bindings/libmoss.go | 150 ++++++++++++++---- sdks/go/sdk/README.md | 3 +- .../go/sdk/examples/custom-embeddings/main.go | 2 +- sdks/go/sdk/moss/client.go | 45 +++++- sdks/go/sdk/moss/client_test.go | 111 ++++++++++++- sdks/go/sdk/moss/conversion.go | 8 +- sdks/go/sdk/moss/errors.go | 1 + sdks/go/sdk/moss/options.go | 21 ++- sdks/go/sdk/moss/query.go | 84 +++++++++- 10 files changed, 364 insertions(+), 62 deletions(-) diff --git a/sdks/go/bindings/errors.go b/sdks/go/bindings/errors.go index 8c0e91ac..16ca55d4 100644 --- a/sdks/go/bindings/errors.go +++ b/sdks/go/bindings/errors.go @@ -3,3 +3,4 @@ package mosscore import "errors" var ErrBindingsUnavailable = errors.New("mosscore: libmoss bindings are unavailable; build with -tags libmoss and configure the libmoss C SDK") +var ErrClientClosed = errors.New("mosscore: client is closed") diff --git a/sdks/go/bindings/libmoss.go b/sdks/go/bindings/libmoss.go index 29e86071..6e76ac83 100644 --- a/sdks/go/bindings/libmoss.go +++ b/sdks/go/bindings/libmoss.go @@ -19,6 +19,7 @@ import ( ) type ManageClient struct { + mu sync.Mutex ptr *C.MossClient } @@ -39,7 +40,12 @@ func NewManageClient(projectID, projectKey string) (*ManageClient, error) { } func (c *ManageClient) Close() error { - if c == nil || c.ptr == nil { + if c == nil { + return nil + } + c.mu.Lock() + defer c.mu.Unlock() + if c.ptr == nil { return nil } C.moss_client_free(c.ptr) @@ -64,8 +70,9 @@ func (c *ManageClient) CreateIndex(name string, docs []DocumentInfo, modelID str } var out *C.MossMutationResult - result := C.moss_client_create_index(c.ptr, cName, input.ptr(), C.uintptr_t(len(docs)), cModelID, &out) - if err := checkResult(result); err != nil { + if err := c.withClient(func(ptr *C.MossClient) C.MossResult { + return C.moss_client_create_index(ptr, cName, input.ptr(), C.uintptr_t(len(docs)), cModelID, &out) + }); err != nil { return MutationResult{}, err } defer C.moss_free_mutation_result(out) @@ -93,8 +100,9 @@ func (c *ManageClient) AddDocs(name string, docs []DocumentInfo, options *Mutati cOpts = &C.MossMutationOptions{upsert: C.bool(*options.Upsert)} } - result := C.moss_client_add_docs(c.ptr, cName, input.ptr(), C.uintptr_t(len(docs)), cOpts, &out) - if err := checkResult(result); err != nil { + if err := c.withClient(func(ptr *C.MossClient) C.MossResult { + return C.moss_client_add_docs(ptr, cName, input.ptr(), C.uintptr_t(len(docs)), cOpts, &out) + }); err != nil { return MutationResult{}, err } defer C.moss_free_mutation_result(out) @@ -114,8 +122,9 @@ func (c *ManageClient) DeleteDocs(name string, docIDs []string) (MutationResult, defer ids.free() var out *C.MossMutationResult - result := C.moss_client_delete_docs(c.ptr, cName, ids.ptr(), C.uintptr_t(len(docIDs)), &out) - if err := checkResult(result); err != nil { + if err := c.withClient(func(ptr *C.MossClient) C.MossResult { + return C.moss_client_delete_docs(ptr, cName, ids.ptr(), C.uintptr_t(len(docIDs)), &out) + }); err != nil { return MutationResult{}, err } defer C.moss_free_mutation_result(out) @@ -132,8 +141,9 @@ func (c *ManageClient) GetJobStatus(jobID string) (JobStatusResponse, error) { defer C.free(unsafe.Pointer(cJobID)) var out *C.MossJobStatusResponse - result := C.moss_client_get_job_status(c.ptr, cJobID, &out) - if err := checkResult(result); err != nil { + if err := c.withClient(func(ptr *C.MossClient) C.MossResult { + return C.moss_client_get_job_status(ptr, cJobID, &out) + }); err != nil { return JobStatusResponse{}, err } defer C.moss_free_job_status_response(out) @@ -155,8 +165,9 @@ func (c *ManageClient) GetIndex(name string) (IndexInfo, error) { defer C.free(unsafe.Pointer(cName)) var out *C.MossIndexInfo - result := C.moss_client_get_index(c.ptr, cName, &out) - if err := checkResult(result); err != nil { + if err := c.withClient(func(ptr *C.MossClient) C.MossResult { + return C.moss_client_get_index(ptr, cName, &out) + }); err != nil { return IndexInfo{}, err } defer C.moss_free_index_info(out) @@ -167,8 +178,9 @@ func (c *ManageClient) GetIndex(name string) (IndexInfo, error) { func (c *ManageClient) ListIndexes() ([]IndexInfo, error) { var out *C.MossIndexInfo var count C.uintptr_t - result := C.moss_client_list_indexes(c.ptr, &out, &count) - if err := checkResult(result); err != nil { + if err := c.withClient(func(ptr *C.MossClient) C.MossResult { + return C.moss_client_list_indexes(ptr, &out, &count) + }); err != nil { return nil, err } defer C.moss_free_index_info_list(out, count) @@ -186,8 +198,9 @@ func (c *ManageClient) DeleteIndex(name string) (bool, error) { defer C.free(unsafe.Pointer(cName)) var deleted C.bool - result := C.moss_client_delete_index(c.ptr, cName, &deleted) - if err := checkResult(result); err != nil { + if err := c.withClient(func(ptr *C.MossClient) C.MossResult { + return C.moss_client_delete_index(ptr, cName, &deleted) + }); err != nil { return false, err } return bool(deleted), nil @@ -202,8 +215,9 @@ func (c *ManageClient) GetDocs(name string, docIDs []string) ([]DocumentInfo, er var out *C.MossDocumentInfo var count C.uintptr_t - result := C.moss_client_get_docs(c.ptr, cName, ids.ptr(), C.uintptr_t(len(docIDs)), &out, &count) - if err := checkResult(result); err != nil { + if err := c.withClient(func(ptr *C.MossClient) C.MossResult { + return C.moss_client_get_docs(ptr, cName, ids.ptr(), C.uintptr_t(len(docIDs)), &out, &count) + }); err != nil { return nil, err } defer C.moss_free_documents(out, count) @@ -225,11 +239,17 @@ func NewIndexManager(projectID, projectKey string) (*IndexManager, error) { } func (m *IndexManager) Close() error { - if m == nil || m.ptr == nil { + if m == nil { + return nil + } + m.mu.Lock() + defer m.mu.Unlock() + if m.ptr == nil { return nil } C.moss_client_free(m.ptr) m.ptr = nil + m.loaded = map[string]struct{}{} return nil } @@ -246,15 +266,22 @@ func (m *IndexManager) LoadIndex(indexName string, options *LoadIndexOptions) (I } } - result := C.moss_client_load_index(m.ptr, cName, cOpts, &out) - if err := checkResult(result); err != nil { + if m == nil { + return IndexInfo{}, ErrClientClosed + } + m.mu.Lock() + defer m.mu.Unlock() + if m.ptr == nil { + return IndexInfo{}, ErrClientClosed + } + if err := withErrorThread(func() C.MossResult { + return C.moss_client_load_index(m.ptr, cName, cOpts, &out) + }); err != nil { return IndexInfo{}, err } defer C.moss_free_index_info(out) - m.mu.Lock() m.loaded[indexName] = struct{}{} - m.mu.Unlock() return convertIndexInfo(out), nil } @@ -262,13 +289,20 @@ func (m *IndexManager) UnloadIndex(indexName string) error { cName := C.CString(indexName) defer C.free(unsafe.Pointer(cName)) - result := C.moss_client_unload_index(m.ptr, cName) - if err := checkResult(result); err != nil { - return err + if m == nil { + return ErrClientClosed } m.mu.Lock() + defer m.mu.Unlock() + if m.ptr == nil { + return ErrClientClosed + } + if err := withErrorThread(func() C.MossResult { + return C.moss_client_unload_index(m.ptr, cName) + }); err != nil { + return err + } delete(m.loaded, indexName) - m.mu.Unlock() return nil } @@ -288,6 +322,16 @@ func (m *IndexManager) QueryText(indexName, query string, topK int, alpha float3 } func (m *IndexManager) LoadQueryModel(indexName string) error { + if m == nil { + return ErrClientClosed + } + m.mu.RLock() + defer m.mu.RUnlock() + if m.ptr == nil { + return ErrClientClosed + } + // libmoss loads bundled query models as part of moss_client_load_index. + // Keep this method for parity with SDKs that expose explicit model loading. return nil } @@ -296,8 +340,9 @@ func (m *IndexManager) RefreshIndex(indexName string) (RefreshResult, error) { defer C.free(unsafe.Pointer(cName)) var out *C.MossRefreshResult - result := C.moss_client_refresh_index(m.ptr, cName, &out) - if err := checkResult(result); err != nil { + if err := m.withClient(func(ptr *C.MossClient) C.MossResult { + return C.moss_client_refresh_index(ptr, cName, &out) + }); err != nil { return RefreshResult{}, err } defer C.moss_free_refresh_result(out) @@ -315,8 +360,9 @@ func (m *IndexManager) GetIndexInfo(indexName string) (IndexInfo, error) { defer C.free(unsafe.Pointer(cName)) var out *C.MossIndexInfo - result := C.moss_client_get_index(m.ptr, cName, &out) - if err := checkResult(result); err != nil { + if err := m.withClient(func(ptr *C.MossClient) C.MossResult { + return C.moss_client_get_index(ptr, cName, &out) + }); err != nil { return IndexInfo{}, err } defer C.moss_free_index_info(out) @@ -360,8 +406,9 @@ func (m *IndexManager) query(indexName, query string, queryEmbedding []float32, } var out *C.MossSearchResult - result := C.moss_client_query(m.ptr, cName, cQuery, opts, &out) - if err := checkResult(result); err != nil { + if err := m.withClient(func(ptr *C.MossClient) C.MossResult { + return C.moss_client_query(ptr, cName, cQuery, opts, &out) + }); err != nil { return SearchResult{}, err } defer C.moss_free_search_result(out) @@ -393,13 +440,48 @@ func newCClient(projectID, projectKey string) (*C.MossClient, error) { defer C.free(unsafe.Pointer(cProjectKey)) var out *C.MossClient - result := C.moss_client_new(cProjectID, cProjectKey, &out) - if err := checkResult(result); err != nil { + if err := withErrorThread(func() C.MossResult { + return C.moss_client_new(cProjectID, cProjectKey, &out) + }); err != nil { return nil, err } return out, nil } +func (c *ManageClient) withClient(call func(*C.MossClient) C.MossResult) error { + if c == nil { + return ErrClientClosed + } + c.mu.Lock() + defer c.mu.Unlock() + if c.ptr == nil { + return ErrClientClosed + } + return withErrorThread(func() C.MossResult { + return call(c.ptr) + }) +} + +func (m *IndexManager) withClient(call func(*C.MossClient) C.MossResult) error { + if m == nil { + return ErrClientClosed + } + m.mu.RLock() + defer m.mu.RUnlock() + if m.ptr == nil { + return ErrClientClosed + } + return withErrorThread(func() C.MossResult { + return call(m.ptr) + }) +} + +func withErrorThread(call func() C.MossResult) error { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + return checkResult(call()) +} + func checkResult(result C.MossResult) error { if result == C.OK { return nil diff --git a/sdks/go/sdk/README.md b/sdks/go/sdk/README.md index 7b3e7b20..bb89880b 100644 --- a/sdks/go/sdk/README.md +++ b/sdks/go/sdk/README.md @@ -13,6 +13,7 @@ The Go SDK now has two layers: - bindings-backed index creation and document mutation - bindings-backed index metadata and document reads - local index loading and query via native bindings +- cloud query fallback when an index is not loaded locally - optional caller-provided embeddings for custom indexes - env-gated live integration tests @@ -23,7 +24,7 @@ The Go SDK now has two layers: ## Installation -From this repository, use the module at: +From this repository, import the package at: ```go github.com/usemoss/moss/sdks/go/sdk/moss diff --git a/sdks/go/sdk/examples/custom-embeddings/main.go b/sdks/go/sdk/examples/custom-embeddings/main.go index e1c7aa50..70ed51e6 100644 --- a/sdks/go/sdk/examples/custom-embeddings/main.go +++ b/sdks/go/sdk/examples/custom-embeddings/main.go @@ -59,7 +59,7 @@ func main() { } for _, doc := range search.Docs { - fmt.Printf("%s %.3f\n", doc.ID, doc.Score) + fmt.Printf("%s %.3f %s\n", doc.ID, doc.Score, doc.Text) } if err := cleanup(ctx, client, indexName); err != nil { diff --git a/sdks/go/sdk/moss/client.go b/sdks/go/sdk/moss/client.go index 5182ba26..4b887256 100644 --- a/sdks/go/sdk/moss/client.go +++ b/sdks/go/sdk/moss/client.go @@ -1,15 +1,25 @@ package moss import ( + "net/http" + "os" "strings" "sync" + "time" mosscore "github.com/usemoss/moss/sdks/go/bindings" ) +const ( + DefaultManageURL = "https://service.usemoss.dev/v1/manage" + defaultTimeout = 60 * time.Second +) + type clientConfig struct { - manageURL string - queryURL string + manageURL string + queryURL string + querySet bool + httpClient *http.Client } type manageRuntime interface { @@ -42,6 +52,7 @@ type Client struct { projectKey string manageURL string queryURL string + httpClient *http.Client manageMu sync.Mutex manageClient manageRuntime indexMu sync.Mutex @@ -52,19 +63,27 @@ type Client struct { // NewClient constructs a new Moss client with optional overrides. func NewClient(projectID, projectKey string, opts ...Option) *Client { - cfg := clientConfig{} + cfg := clientConfig{ + manageURL: defaultManageURL(), + httpClient: &http.Client{Timeout: defaultTimeout}, + } + cfg.queryURL = defaultQueryURL(cfg.manageURL) for _, opt := range opts { if opt != nil { opt(&cfg) } } + if !cfg.querySet && strings.TrimSpace(cfg.queryURL) == "" { + cfg.queryURL = defaultQueryURL(cfg.manageURL) + } return &Client{ projectID: strings.TrimSpace(projectID), projectKey: strings.TrimSpace(projectKey), manageURL: strings.TrimSpace(cfg.manageURL), queryURL: strings.TrimSpace(cfg.queryURL), + httpClient: cfg.httpClient, manageFactory: func(projectID, projectKey string) (manageRuntime, error) { return mosscore.NewManageClient(projectID, projectKey) }, @@ -74,6 +93,23 @@ func NewClient(projectID, projectKey string, opts ...Option) *Client { } } +func defaultManageURL() string { + if value := strings.TrimSpace(os.Getenv("MOSS_CLOUD_API_MANAGE_URL")); value != "" { + return value + } + return DefaultManageURL +} + +func defaultQueryURL(manageURL string) string { + if value := strings.TrimSpace(os.Getenv("MOSS_CLOUD_QUERY_URL")); value != "" { + return value + } + if strings.TrimSpace(manageURL) == "" { + return "" + } + return strings.Replace(manageURL, "/v1/manage", "/query", 1) +} + func (c *Client) validateManageRequest(indexName string) error { if err := validateCredentials(c.projectID, c.projectKey); err != nil { return err @@ -88,6 +124,9 @@ func (c *Client) validateQueryRequest(indexName string) error { if err := validateCredentials(c.projectID, c.projectKey); err != nil { return err } + if strings.TrimSpace(c.queryURL) == "" { + return ErrMissingQueryURL + } if strings.TrimSpace(indexName) == "" { return ErrEmptyIndexName } diff --git a/sdks/go/sdk/moss/client_test.go b/sdks/go/sdk/moss/client_test.go index 865dc236..e4ecfdcb 100644 --- a/sdks/go/sdk/moss/client_test.go +++ b/sdks/go/sdk/moss/client_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "errors" + "net/http" + "net/http/httptest" "testing" mosscore "github.com/usemoss/moss/sdks/go/bindings" @@ -239,12 +241,91 @@ func TestGetDocsPassesDocIDsToBindings(t *testing.T) { } } -func TestQueryRequiresLoadedIndex(t *testing.T) { - client := newTestClient(nil, &fakeIndexRuntime{loaded: map[string]bool{}}) +func TestNewClientUsesDefaultQueryURL(t *testing.T) { + t.Setenv("MOSS_CLOUD_API_MANAGE_URL", "") + t.Setenv("MOSS_CLOUD_QUERY_URL", "") - _, err := client.Query(context.Background(), "support-docs", "refund policy", nil) - if !errors.Is(err, ErrIndexNotLoaded) { - t.Fatalf("expected ErrIndexNotLoaded, got %v", err) + client := NewClient("project-id", "project-key") + if client.queryURL != "https://service.usemoss.dev/query" { + t.Fatalf("unexpected query URL: %q", client.queryURL) + } +} + +func TestWithManageURLDerivesQueryURL(t *testing.T) { + client := NewClient("project-id", "project-key", WithManageURL("https://custom.example.com/v1/manage")) + + if client.queryURL != "https://custom.example.com/query" { + t.Fatalf("unexpected query URL: %q", client.queryURL) + } +} + +func TestQueryFallsBackToCloudWhenIndexIsNotLoaded(t *testing.T) { + var gotBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "docs":[{"id":"doc-1","text":"Refunds take 5-7 days","score":0.91,"metadata":{"topic":"refunds"}}], + "query":"refund policy", + "indexName":"support-docs", + "timeTakenMs":17 + }`)) + })) + defer server.Close() + + client := NewClient("project-id", "project-key", WithQueryURL(server.URL)) + client.indexMgr = &fakeIndexRuntime{loaded: map[string]bool{}} + + result, err := client.Query(context.Background(), "support-docs", "refund policy", &QueryOptions{ + TopK: 7, + Embedding: []float32{0.1, 0.2, 0.3}, + Filter: map[string]any{"field": "topic"}, + }) + if err != nil { + t.Fatalf("Query returned error: %v", err) + } + + if gotBody["projectId"] != "project-id" || gotBody["projectKey"] != "project-key" { + t.Fatalf("unexpected credentials payload: %#v", gotBody) + } + if gotBody["indexName"] != "support-docs" || gotBody["query"] != "refund policy" { + t.Fatalf("unexpected query payload: %#v", gotBody) + } + if gotBody["topK"] != float64(7) { + t.Fatalf("unexpected topK: %#v", gotBody["topK"]) + } + if _, ok := gotBody["queryEmbedding"]; !ok { + t.Fatalf("queryEmbedding missing from payload: %#v", gotBody) + } + if _, ok := gotBody["filter"]; ok { + t.Fatalf("filter should not be sent to cloud query: %#v", gotBody) + } + if len(result.Docs) != 1 || result.Docs[0].Score != 0.91 { + t.Fatalf("unexpected query result: %#v", result) + } + if result.TimeTakenMs == nil || *result.TimeTakenMs != 17 { + t.Fatalf("unexpected timeTakenMs: %#v", result.TimeTakenMs) + } +} + +func TestQueryFallsBackToCloudWhenBindingsAreUnavailable(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"docs":[],"query":"refund policy","indexName":"support-docs"}`)) + })) + defer server.Close() + + client := NewClient("project-id", "project-key", WithQueryURL(server.URL)) + + result, err := client.Query(context.Background(), "support-docs", "refund policy", nil) + if err != nil { + t.Fatalf("Query returned error: %v", err) + } + if result.Query != "refund policy" { + t.Fatalf("unexpected query result: %#v", result) } } @@ -303,6 +384,26 @@ func TestQueryUsesLocalBindingsAndSupportsFilters(t *testing.T) { } } +func TestQueryOmitsZeroTimeTaken(t *testing.T) { + client := newTestClient(nil, &fakeIndexRuntime{ + loaded: map[string]bool{"support-docs": true}, + queryTextFn: func(indexName, query string, topK int, alpha float32, filterJSON *string) (mosscore.SearchResult, error) { + return mosscore.SearchResult{ + Docs: []mosscore.QueryResultDocumentInfo{{ID: "doc-1", Text: "Refunds take 5-7 days", Score: 0.91}}, + Query: query, + }, nil + }, + }) + + result, err := client.Query(context.Background(), "support-docs", "refund policy", nil) + if err != nil { + t.Fatalf("Query returned error: %v", err) + } + if result.TimeTakenMs != nil { + t.Fatalf("expected nil TimeTakenMs, got %#v", result.TimeTakenMs) + } +} + func TestLoadIndexSkipsQueryModelForCustomEmbeddings(t *testing.T) { loadQueryModelCalled := false client := newTestClient(nil, &fakeIndexRuntime{ diff --git a/sdks/go/sdk/moss/conversion.go b/sdks/go/sdk/moss/conversion.go index 7946437d..fd811a62 100644 --- a/sdks/go/sdk/moss/conversion.go +++ b/sdks/go/sdk/moss/conversion.go @@ -63,12 +63,16 @@ func fromCoreSearchResult(value mosscore.SearchResult) SearchResult { }) } - timeTaken := value.TimeTakenMs + var timeTaken *int + if value.TimeTakenMs != 0 { + value := value.TimeTakenMs + timeTaken = &value + } return SearchResult{ Docs: docs, Query: value.Query, IndexName: value.IndexName, - TimeTakenMs: &timeTaken, + TimeTakenMs: timeTaken, } } diff --git a/sdks/go/sdk/moss/errors.go b/sdks/go/sdk/moss/errors.go index 1638300c..b096920e 100644 --- a/sdks/go/sdk/moss/errors.go +++ b/sdks/go/sdk/moss/errors.go @@ -8,6 +8,7 @@ import ( var ( ErrMissingProjectID = errors.New("moss: missing project ID") ErrMissingProjectKey = errors.New("moss: missing project key") + ErrMissingQueryURL = errors.New("moss: query URL is not configured") ErrEmptyIndexName = errors.New("moss: index name must not be empty") ErrEmptyJobID = errors.New("moss: job ID must not be empty") ErrEmptyDocuments = errors.New("moss: documents must not be empty") diff --git a/sdks/go/sdk/moss/options.go b/sdks/go/sdk/moss/options.go index 9523b5e3..8aa5e08f 100644 --- a/sdks/go/sdk/moss/options.go +++ b/sdks/go/sdk/moss/options.go @@ -5,24 +5,29 @@ import "net/http" // Option customizes client construction. type Option func(*clientConfig) -// WithManageURL is retained for compatibility with earlier SDK scaffolding. -// The bindings-backed client currently ignores explicit endpoint overrides. +// WithManageURL overrides the manage endpoint used to derive the default query endpoint. func WithManageURL(url string) Option { return func(cfg *clientConfig) { cfg.manageURL = url + if !cfg.querySet { + cfg.queryURL = defaultQueryURL(url) + } } } -// WithQueryURL is retained for compatibility with earlier SDK scaffolding. -// The bindings-backed client currently ignores explicit endpoint overrides. +// WithQueryURL overrides the cloud query endpoint used when an index is not loaded locally. func WithQueryURL(url string) Option { return func(cfg *clientConfig) { cfg.queryURL = url + cfg.querySet = true } } -// WithHTTPClient is retained for compatibility with earlier SDK scaffolding. -// The bindings-backed client currently ignores custom HTTP transports. -func WithHTTPClient(_ *http.Client) Option { - return func(cfg *clientConfig) {} +// WithHTTPClient injects a custom HTTP client for cloud query fallback. +func WithHTTPClient(httpClient *http.Client) Option { + return func(cfg *clientConfig) { + if httpClient != nil { + cfg.httpClient = httpClient + } + } } diff --git a/sdks/go/sdk/moss/query.go b/sdks/go/sdk/moss/query.go index 8fb05512..212ed75d 100644 --- a/sdks/go/sdk/moss/query.go +++ b/sdks/go/sdk/moss/query.go @@ -1,13 +1,17 @@ package moss import ( + "bytes" "context" "encoding/json" + "io" + "net/http" + "strings" ) const defaultTopK = 5 -// Query executes a local query against a previously loaded index. +// Query executes a local query when the index is loaded, otherwise falls back to cloud query. func (c *Client) Query(ctx context.Context, indexName, query string, options *QueryOptions) (SearchResult, error) { if err := ctx.Err(); err != nil { return SearchResult{}, err @@ -16,14 +20,10 @@ func (c *Client) Query(ctx context.Context, indexName, query string, options *Qu return SearchResult{}, err } - manager, err := c.ensureIndexManager() - if err != nil { - return SearchResult{}, err - } - if !manager.HasIndex(indexName) { - return SearchResult{}, ErrIndexNotLoaded + if manager, err := c.ensureIndexManager(); err == nil && manager.HasIndex(indexName) { + return c.queryLocal(manager, indexName, query, options) } - return c.queryLocal(manager, indexName, query, options) + return c.queryCloud(ctx, indexName, query, options) } func (c *Client) queryLocal(manager indexRuntime, indexName, query string, options *QueryOptions) (SearchResult, error) { @@ -66,3 +66,71 @@ func (c *Client) queryLocal(manager indexRuntime, indexName, query string, optio } return fromCoreSearchResult(result), nil } + +type cloudQueryRequest struct { + Query string `json:"query"` + IndexName string `json:"indexName"` + ProjectID string `json:"projectId"` + ProjectKey string `json:"projectKey"` + TopK int `json:"topK"` + QueryEmbedding []float32 `json:"queryEmbedding,omitempty"` +} + +func (c *Client) queryCloud(ctx context.Context, indexName, query string, options *QueryOptions) (SearchResult, error) { + topK := defaultTopK + var embedding []float32 + if options != nil { + if options.TopK > 0 { + topK = options.TopK + } + if len(options.Embedding) > 0 { + embedding = options.Embedding + } + } + + payload := cloudQueryRequest{ + Query: query, + IndexName: indexName, + ProjectID: c.projectID, + ProjectKey: c.projectKey, + TopK: topK, + } + if len(embedding) > 0 { + payload.QueryEmbedding = embedding + } + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(payload); err != nil { + return SearchResult{}, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.queryURL, &body) + if err != nil { + return SearchResult{}, err + } + req.Header.Set("Content-Type", "application/json") + + client := c.httpClient + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return SearchResult{}, err + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) + return SearchResult{}, &HTTPError{ + StatusCode: resp.StatusCode, + Body: strings.TrimSpace(string(body)), + } + } + + var result SearchResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return SearchResult{}, err + } + return result, nil +} From 0f4077ceb10b8d26603b16833108e45ac1247052 Mon Sep 17 00:00:00 2001 From: anirudh-makuluri Date: Fri, 12 Jun 2026 02:21:39 -0700 Subject: [PATCH 06/12] make refreshIndex use lock --- sdks/go/bindings/libmoss.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/sdks/go/bindings/libmoss.go b/sdks/go/bindings/libmoss.go index 6e76ac83..4adbf8ed 100644 --- a/sdks/go/bindings/libmoss.go +++ b/sdks/go/bindings/libmoss.go @@ -340,7 +340,7 @@ func (m *IndexManager) RefreshIndex(indexName string) (RefreshResult, error) { defer C.free(unsafe.Pointer(cName)) var out *C.MossRefreshResult - if err := m.withClient(func(ptr *C.MossClient) C.MossResult { + if err := m.withExclusiveClient(func(ptr *C.MossClient) C.MossResult { return C.moss_client_refresh_index(ptr, cName, &out) }); err != nil { return RefreshResult{}, err @@ -476,6 +476,20 @@ func (m *IndexManager) withClient(call func(*C.MossClient) C.MossResult) error { }) } +func (m *IndexManager) withExclusiveClient(call func(*C.MossClient) C.MossResult) error { + if m == nil { + return ErrClientClosed + } + m.mu.Lock() + defer m.mu.Unlock() + if m.ptr == nil { + return ErrClientClosed + } + return withErrorThread(func() C.MossResult { + return call(m.ptr) + }) +} + func withErrorThread(call func() C.MossResult) error { runtime.LockOSThread() defer runtime.UnlockOSThread() From 8731edf60a0c8fb69a4ca782ccb02fe30f4dfd87 Mon Sep 17 00:00:00 2001 From: anirudh-makuluri Date: Fri, 12 Jun 2026 13:15:10 -0700 Subject: [PATCH 07/12] resolved cloud query fallback issue --- sdks/go/sdk/README.md | 1 + sdks/go/sdk/moss/client_test.go | 37 +++++++++++++++++++++++++++++---- sdks/go/sdk/moss/errors.go | 1 + sdks/go/sdk/moss/query.go | 3 +++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/sdks/go/sdk/README.md b/sdks/go/sdk/README.md index bb89880b..6e442fe7 100644 --- a/sdks/go/sdk/README.md +++ b/sdks/go/sdk/README.md @@ -20,6 +20,7 @@ The Go SDK now has two layers: ## Current limitations - the SDK requires the `libmoss` C SDK and the `libmoss` build tag for real runtime operations +- cloud query fallback supports `TopK` and caller-provided embeddings; `Alpha` and `Filter` require a locally loaded index - `LoadIndexOptions.CachePath` is not exposed by the current `libmoss` C API yet ## Installation diff --git a/sdks/go/sdk/moss/client_test.go b/sdks/go/sdk/moss/client_test.go index e4ecfdcb..42c2d369 100644 --- a/sdks/go/sdk/moss/client_test.go +++ b/sdks/go/sdk/moss/client_test.go @@ -282,7 +282,6 @@ func TestQueryFallsBackToCloudWhenIndexIsNotLoaded(t *testing.T) { result, err := client.Query(context.Background(), "support-docs", "refund policy", &QueryOptions{ TopK: 7, Embedding: []float32{0.1, 0.2, 0.3}, - Filter: map[string]any{"field": "topic"}, }) if err != nil { t.Fatalf("Query returned error: %v", err) @@ -300,9 +299,6 @@ func TestQueryFallsBackToCloudWhenIndexIsNotLoaded(t *testing.T) { if _, ok := gotBody["queryEmbedding"]; !ok { t.Fatalf("queryEmbedding missing from payload: %#v", gotBody) } - if _, ok := gotBody["filter"]; ok { - t.Fatalf("filter should not be sent to cloud query: %#v", gotBody) - } if len(result.Docs) != 1 || result.Docs[0].Score != 0.91 { t.Fatalf("unexpected query result: %#v", result) } @@ -311,6 +307,39 @@ func TestQueryFallsBackToCloudWhenIndexIsNotLoaded(t *testing.T) { } } +func TestQueryCloudFallbackRejectsLocalOnlyOptions(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + client := NewClient("project-id", "project-key", WithQueryURL(server.URL)) + client.indexMgr = &fakeIndexRuntime{loaded: map[string]bool{}} + + alpha := 0.6 + for _, tc := range []struct { + name string + options *QueryOptions + }{ + {name: "alpha", options: &QueryOptions{Alpha: &alpha}}, + {name: "filter", options: &QueryOptions{Filter: map[string]any{"field": "topic"}}}, + {name: "alpha and filter", options: &QueryOptions{Alpha: &alpha, Filter: map[string]any{"field": "topic"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := client.Query(context.Background(), "support-docs", "refund policy", tc.options) + if !errors.Is(err, ErrCloudQueryOptions) { + t.Fatalf("expected ErrCloudQueryOptions, got %v", err) + } + }) + } + + if requests != 0 { + t.Fatalf("expected no cloud query requests, got %d", requests) + } +} + func TestQueryFallsBackToCloudWhenBindingsAreUnavailable(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/sdks/go/sdk/moss/errors.go b/sdks/go/sdk/moss/errors.go index b096920e..f9817e71 100644 --- a/sdks/go/sdk/moss/errors.go +++ b/sdks/go/sdk/moss/errors.go @@ -15,6 +15,7 @@ var ( ErrEmptyDocumentIDs = errors.New("moss: document IDs must not be empty") ErrIndexNotLoaded = errors.New("moss: index is not loaded locally; call LoadIndex first") ErrUnsupportedCachePath = errors.New("moss: LoadIndexOptions.CachePath is not supported by the current libmoss bindings") + ErrCloudQueryOptions = errors.New("moss: alpha and filter query options require a locally loaded index; call LoadIndex first") ) // HTTPError is retained for compatibility with earlier SDK scaffolding. diff --git a/sdks/go/sdk/moss/query.go b/sdks/go/sdk/moss/query.go index 212ed75d..8b574cc2 100644 --- a/sdks/go/sdk/moss/query.go +++ b/sdks/go/sdk/moss/query.go @@ -80,6 +80,9 @@ func (c *Client) queryCloud(ctx context.Context, indexName, query string, option topK := defaultTopK var embedding []float32 if options != nil { + if options.Alpha != nil || options.Filter != nil { + return SearchResult{}, ErrCloudQueryOptions + } if options.TopK > 0 { topK = options.TopK } From 53bbd8009c5de30244aff5f0e36dd9fbfd723378 Mon Sep 17 00:00:00 2001 From: anirudh-makuluri Date: Mon, 15 Jun 2026 01:59:30 -0700 Subject: [PATCH 08/12] restructure go sdk layout --- AGENTS.md | 2 ++ README.md | 1 + examples/go/README.md | 28 +++++++++++++++++++ .../examples => examples/go}/basic/main.go | 2 +- .../go}/custom-embeddings/main.go | 2 +- examples/go/go.mod | 11 ++++++++ sdks/go/README.md | 2 +- sdks/go/sdk/README.md | 11 ++++---- sdks/go/sdk/{moss => }/client.go | 0 sdks/go/sdk/{moss => }/client_test.go | 0 sdks/go/sdk/{moss => }/conversion.go | 0 sdks/go/sdk/{moss => }/errors.go | 0 sdks/go/sdk/{moss => }/integration_test.go | 0 sdks/go/sdk/{moss => }/local.go | 0 sdks/go/sdk/{moss => }/models.go | 0 sdks/go/sdk/{moss => }/mutation.go | 0 sdks/go/sdk/{moss => }/mutation_test.go | 0 sdks/go/sdk/{moss => }/options.go | 0 sdks/go/sdk/{moss => }/query.go | 0 sdks/go/sdk/{moss => }/read.go | 0 20 files changed, 51 insertions(+), 8 deletions(-) create mode 100644 examples/go/README.md rename {sdks/go/sdk/examples => examples/go}/basic/main.go (97%) rename {sdks/go/sdk/examples => examples/go}/custom-embeddings/main.go (97%) create mode 100644 examples/go/go.mod rename sdks/go/sdk/{moss => }/client.go (100%) rename sdks/go/sdk/{moss => }/client_test.go (100%) rename sdks/go/sdk/{moss => }/conversion.go (100%) rename sdks/go/sdk/{moss => }/errors.go (100%) rename sdks/go/sdk/{moss => }/integration_test.go (100%) rename sdks/go/sdk/{moss => }/local.go (100%) rename sdks/go/sdk/{moss => }/models.go (100%) rename sdks/go/sdk/{moss => }/mutation.go (100%) rename sdks/go/sdk/{moss => }/mutation_test.go (100%) rename sdks/go/sdk/{moss => }/options.go (100%) rename sdks/go/sdk/{moss => }/query.go (100%) rename sdks/go/sdk/{moss => }/read.go (100%) diff --git a/AGENTS.md b/AGENTS.md index e0be9b20..7a293445 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ examples/ javascript/ — Standalone TS usage examples javascript-web/ — Browser/Vite examples (no Node runtime) c/ — C binding examples + go/ — Standalone Go SDK usage examples bun/ — Bun runtime example python-classification/ — Text classification with Moss voice-agents/ - End-to-end voice agents (LiveKit-based) @@ -149,6 +150,7 @@ asks for an experimental landing spot. | `javascript/` | TypeScript (Node) | `comprehensive_sample.ts`, `cached_load_sample.ts`, `custom_authenticator_sample.ts` | | `javascript-web/` | TypeScript (browser/Vite) | `comprehensive_sample.ts`, `metadata_filtering_sample.ts` | | `c/` | C | `example_usage.c`, `metadata_filtering.c`, `session_usage.c` | +| `go/` | Go | `basic/main.go`, `custom-embeddings/main.go` | | `bun/` | Bun | Bun-native runtime example | | `python-classification/` | Python | `classify_sample.py` — zero-shot text classification via Moss | | `voice-agents/airline-pnr/` | Python (LiveKit) | Ambient retrieval: every user turn auto-queries a per-PNR Moss index before the LLM speaks | diff --git a/README.md b/README.md index 0f1cd7f7..809e4fae 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ examples/ │ └── custom_embedding_sample.ts ├── javascript-web/ # Browser / WASM SDK samples ├── c/ # C SDK samples (libmoss) +├── go/ # Go SDK samples ├── voice-agents/ # End-to-end voice agents (ambient + multi-agent) │ ├── airline-pnr/ # Ambient retrieval; per-PNR Moss indexes, swap mid-call │ └── mortgage-lending/ # Multi-agent flow with shared session state diff --git a/examples/go/README.md b/examples/go/README.md new file mode 100644 index 00000000..5d55059c --- /dev/null +++ b/examples/go/README.md @@ -0,0 +1,28 @@ +# Moss Go Examples + +Runnable examples for the Moss Go SDK. + +## Examples + +- [`basic/main.go`](./basic/main.go) creates an index, loads it, queries it, and deletes it. +- [`custom-embeddings/main.go`](./custom-embeddings/main.go) uses caller-provided vectors for documents and queries. + +## Run + +Set your Moss credentials: + +```bash +export MOSS_PROJECT_ID=... +export MOSS_PROJECT_KEY=... +``` + +Runtime operations require the `libmoss` C SDK and the `libmoss` build tag: + +```bash +export CGO_CFLAGS="-I/include" +export CGO_LDFLAGS="-L/lib" +export LD_LIBRARY_PATH="/lib" + +go run -tags libmoss ./basic +go run -tags libmoss ./custom-embeddings +``` diff --git a/sdks/go/sdk/examples/basic/main.go b/examples/go/basic/main.go similarity index 97% rename from sdks/go/sdk/examples/basic/main.go rename to examples/go/basic/main.go index 533c2b00..c98625b9 100644 --- a/sdks/go/sdk/examples/basic/main.go +++ b/examples/go/basic/main.go @@ -7,7 +7,7 @@ import ( "os" "time" - "github.com/usemoss/moss/sdks/go/sdk/moss" + "github.com/usemoss/moss/sdks/go/sdk" ) func main() { diff --git a/sdks/go/sdk/examples/custom-embeddings/main.go b/examples/go/custom-embeddings/main.go similarity index 97% rename from sdks/go/sdk/examples/custom-embeddings/main.go rename to examples/go/custom-embeddings/main.go index 70ed51e6..ccbe0f66 100644 --- a/sdks/go/sdk/examples/custom-embeddings/main.go +++ b/examples/go/custom-embeddings/main.go @@ -7,7 +7,7 @@ import ( "os" "time" - "github.com/usemoss/moss/sdks/go/sdk/moss" + "github.com/usemoss/moss/sdks/go/sdk" ) func main() { diff --git a/examples/go/go.mod b/examples/go/go.mod new file mode 100644 index 00000000..4bb0aec6 --- /dev/null +++ b/examples/go/go.mod @@ -0,0 +1,11 @@ +module github.com/usemoss/moss/examples/go + +go 1.22.2 + +require github.com/usemoss/moss/sdks/go/sdk v0.0.0 + +require github.com/usemoss/moss/sdks/go/bindings v0.0.0 // indirect + +replace github.com/usemoss/moss/sdks/go/sdk => ../../sdks/go/sdk + +replace github.com/usemoss/moss/sdks/go/bindings => ../../sdks/go/bindings diff --git a/sdks/go/README.md b/sdks/go/README.md index 5241313e..4fa9b51c 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -9,7 +9,7 @@ Current status: - bindings-backed manage operations for mutations and metadata reads - local `LoadIndex` / `UnloadIndex` / local `Query` via `libmoss` -- examples and unit tests +- examples under `examples/go/` and unit tests - env-gated integration test scaffold Important note: diff --git a/sdks/go/sdk/README.md b/sdks/go/sdk/README.md index 6e442fe7..cef8bbab 100644 --- a/sdks/go/sdk/README.md +++ b/sdks/go/sdk/README.md @@ -28,7 +28,7 @@ The Go SDK now has two layers: From this repository, import the package at: ```go -github.com/usemoss/moss/sdks/go/sdk/moss +github.com/usemoss/moss/sdks/go/sdk ``` Download the `libmoss` C SDK release and build with `-tags libmoss`. The @@ -45,7 +45,7 @@ import ( "fmt" "log" - "github.com/usemoss/moss/sdks/go/sdk/moss" + "github.com/usemoss/moss/sdks/go/sdk" ) func main() { @@ -136,16 +136,17 @@ batch. Runnable examples live here: -- [`examples/basic/main.go`](./examples/basic/main.go) -- [`examples/custom-embeddings/main.go`](./examples/custom-embeddings/main.go) +- [`../../../examples/go/basic/main.go`](../../../examples/go/basic/main.go) +- [`../../../examples/go/custom-embeddings/main.go`](../../../examples/go/custom-embeddings/main.go) Run them with native bindings enabled: ```bash +cd ../../../examples/go export CGO_CFLAGS="-I/include" export CGO_LDFLAGS="-L/lib" export LD_LIBRARY_PATH="/lib" -go run -tags libmoss ./examples/basic +go run -tags libmoss ./basic ``` ## Integration tests diff --git a/sdks/go/sdk/moss/client.go b/sdks/go/sdk/client.go similarity index 100% rename from sdks/go/sdk/moss/client.go rename to sdks/go/sdk/client.go diff --git a/sdks/go/sdk/moss/client_test.go b/sdks/go/sdk/client_test.go similarity index 100% rename from sdks/go/sdk/moss/client_test.go rename to sdks/go/sdk/client_test.go diff --git a/sdks/go/sdk/moss/conversion.go b/sdks/go/sdk/conversion.go similarity index 100% rename from sdks/go/sdk/moss/conversion.go rename to sdks/go/sdk/conversion.go diff --git a/sdks/go/sdk/moss/errors.go b/sdks/go/sdk/errors.go similarity index 100% rename from sdks/go/sdk/moss/errors.go rename to sdks/go/sdk/errors.go diff --git a/sdks/go/sdk/moss/integration_test.go b/sdks/go/sdk/integration_test.go similarity index 100% rename from sdks/go/sdk/moss/integration_test.go rename to sdks/go/sdk/integration_test.go diff --git a/sdks/go/sdk/moss/local.go b/sdks/go/sdk/local.go similarity index 100% rename from sdks/go/sdk/moss/local.go rename to sdks/go/sdk/local.go diff --git a/sdks/go/sdk/moss/models.go b/sdks/go/sdk/models.go similarity index 100% rename from sdks/go/sdk/moss/models.go rename to sdks/go/sdk/models.go diff --git a/sdks/go/sdk/moss/mutation.go b/sdks/go/sdk/mutation.go similarity index 100% rename from sdks/go/sdk/moss/mutation.go rename to sdks/go/sdk/mutation.go diff --git a/sdks/go/sdk/moss/mutation_test.go b/sdks/go/sdk/mutation_test.go similarity index 100% rename from sdks/go/sdk/moss/mutation_test.go rename to sdks/go/sdk/mutation_test.go diff --git a/sdks/go/sdk/moss/options.go b/sdks/go/sdk/options.go similarity index 100% rename from sdks/go/sdk/moss/options.go rename to sdks/go/sdk/options.go diff --git a/sdks/go/sdk/moss/query.go b/sdks/go/sdk/query.go similarity index 100% rename from sdks/go/sdk/moss/query.go rename to sdks/go/sdk/query.go diff --git a/sdks/go/sdk/moss/read.go b/sdks/go/sdk/read.go similarity index 100% rename from sdks/go/sdk/moss/read.go rename to sdks/go/sdk/read.go From 4640c8d7628332b3e60712ce38612f4d881a8e82 Mon Sep 17 00:00:00 2001 From: anirudh-makuluri Date: Mon, 15 Jun 2026 02:14:09 -0700 Subject: [PATCH 09/12] Handle Go query manager init errors --- sdks/go/sdk/client_test.go | 23 +++++++++++++++++++++++ sdks/go/sdk/query.go | 12 +++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/sdks/go/sdk/client_test.go b/sdks/go/sdk/client_test.go index 42c2d369..07be7d9a 100644 --- a/sdks/go/sdk/client_test.go +++ b/sdks/go/sdk/client_test.go @@ -358,6 +358,29 @@ func TestQueryFallsBackToCloudWhenBindingsAreUnavailable(t *testing.T) { } } +func TestQueryReturnsIndexManagerInitializationErrors(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + wantErr := errors.New("native runtime initialization failed") + client := NewClient("project-id", "project-key", WithQueryURL(server.URL)) + client.indexFactory = func(projectID, projectKey string) (indexRuntime, error) { + return nil, wantErr + } + + _, err := client.Query(context.Background(), "support-docs", "refund policy", nil) + if !errors.Is(err, wantErr) { + t.Fatalf("expected index manager error, got %v", err) + } + if requests != 0 { + t.Fatalf("expected no cloud query requests, got %d", requests) + } +} + func TestQueryUsesLocalBindingsAndSupportsFilters(t *testing.T) { client := newTestClient(nil, &fakeIndexRuntime{ loaded: map[string]bool{"support-docs": true}, diff --git a/sdks/go/sdk/query.go b/sdks/go/sdk/query.go index 8b574cc2..290dd267 100644 --- a/sdks/go/sdk/query.go +++ b/sdks/go/sdk/query.go @@ -4,9 +4,12 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "net/http" "strings" + + mosscore "github.com/usemoss/moss/sdks/go/bindings" ) const defaultTopK = 5 @@ -20,7 +23,14 @@ func (c *Client) Query(ctx context.Context, indexName, query string, options *Qu return SearchResult{}, err } - if manager, err := c.ensureIndexManager(); err == nil && manager.HasIndex(indexName) { + manager, err := c.ensureIndexManager() + if err != nil { + if errors.Is(err, mosscore.ErrBindingsUnavailable) { + return c.queryCloud(ctx, indexName, query, options) + } + return SearchResult{}, err + } + if manager.HasIndex(indexName) { return c.queryLocal(manager, indexName, query, options) } return c.queryCloud(ctx, indexName, query, options) From e76b6af99a3e5c6db4f6def5a42c64a91e5e1ff8 Mon Sep 17 00:00:00 2001 From: anirudh-makuluri Date: Mon, 15 Jun 2026 10:23:13 -0700 Subject: [PATCH 10/12] resolved comments --- sdks/go/sdk/client.go | 3 -- sdks/go/sdk/client_test.go | 66 ++++++++++++++++++++++++++++++++++++++ sdks/go/sdk/local.go | 4 +-- sdks/go/sdk/query.go | 8 +++-- 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/sdks/go/sdk/client.go b/sdks/go/sdk/client.go index 4b887256..7afa33bf 100644 --- a/sdks/go/sdk/client.go +++ b/sdks/go/sdk/client.go @@ -124,9 +124,6 @@ func (c *Client) validateQueryRequest(indexName string) error { if err := validateCredentials(c.projectID, c.projectKey); err != nil { return err } - if strings.TrimSpace(c.queryURL) == "" { - return ErrMissingQueryURL - } if strings.TrimSpace(indexName) == "" { return ErrEmptyIndexName } diff --git a/sdks/go/sdk/client_test.go b/sdks/go/sdk/client_test.go index 07be7d9a..a37b0df7 100644 --- a/sdks/go/sdk/client_test.go +++ b/sdks/go/sdk/client_test.go @@ -307,6 +307,43 @@ func TestQueryFallsBackToCloudWhenIndexIsNotLoaded(t *testing.T) { } } +func TestQueryUsesLocalBindingsWithEmptyQueryURL(t *testing.T) { + queryCalled := false + client := NewClient("project-id", "project-key", WithQueryURL("")) + client.indexMgr = &fakeIndexRuntime{ + loaded: map[string]bool{"support-docs": true}, + queryTextFn: func(indexName, query string, topK int, alpha float32, filterJSON *string) (mosscore.SearchResult, error) { + queryCalled = true + return mosscore.SearchResult{ + Docs: []mosscore.QueryResultDocumentInfo{{ID: "doc-1", Text: "Refunds take 5-7 days", Score: 0.91}}, + Query: query, + IndexName: &indexName, + }, nil + }, + } + + result, err := client.Query(context.Background(), "support-docs", "refund policy", nil) + if err != nil { + t.Fatalf("Query returned error: %v", err) + } + if !queryCalled { + t.Fatal("expected local query runtime to be used") + } + if len(result.Docs) != 1 || result.Docs[0].ID != "doc-1" { + t.Fatalf("unexpected query result: %#v", result) + } +} + +func TestQueryCloudFallbackRequiresQueryURL(t *testing.T) { + client := NewClient("project-id", "project-key", WithQueryURL("")) + client.indexMgr = &fakeIndexRuntime{loaded: map[string]bool{}} + + _, err := client.Query(context.Background(), "support-docs", "refund policy", nil) + if !errors.Is(err, ErrMissingQueryURL) { + t.Fatalf("expected ErrMissingQueryURL, got %v", err) + } +} + func TestQueryCloudFallbackRejectsLocalOnlyOptions(t *testing.T) { requests := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -492,6 +529,35 @@ func TestLoadIndexRejectsUnsupportedCachePath(t *testing.T) { } } +func TestRefreshIndexValidatesCredentialsBeforeInitializingRuntime(t *testing.T) { + for _, tc := range []struct { + name string + projectID string + projectKey string + wantErr error + }{ + {name: "missing project ID", projectID: "", projectKey: "project-key", wantErr: ErrMissingProjectID}, + {name: "missing project key", projectID: "project-id", projectKey: "", wantErr: ErrMissingProjectKey}, + } { + t.Run(tc.name, func(t *testing.T) { + client := NewClient(tc.projectID, tc.projectKey) + factoryCalled := false + client.indexFactory = func(projectID, projectKey string) (indexRuntime, error) { + factoryCalled = true + return &fakeIndexRuntime{}, nil + } + + _, err := client.RefreshIndex(context.Background(), "support-docs") + if !errors.Is(err, tc.wantErr) { + t.Fatalf("expected %v, got %v", tc.wantErr, err) + } + if factoryCalled { + t.Fatal("expected index runtime initialization to be skipped") + } + }) + } +} + func TestCloseReleasesInitializedBindings(t *testing.T) { manage := &fakeManageRuntime{} index := &fakeIndexRuntime{} diff --git a/sdks/go/sdk/local.go b/sdks/go/sdk/local.go index fcbd75a5..1ab1565c 100644 --- a/sdks/go/sdk/local.go +++ b/sdks/go/sdk/local.go @@ -68,8 +68,8 @@ func (c *Client) RefreshIndex(ctx context.Context, indexName string) (RefreshRes if err := ctx.Err(); err != nil { return RefreshResult{}, err } - if strings.TrimSpace(indexName) == "" { - return RefreshResult{}, ErrEmptyIndexName + if err := c.validateManageRequest(indexName); err != nil { + return RefreshResult{}, err } manager, err := c.ensureIndexManager() diff --git a/sdks/go/sdk/query.go b/sdks/go/sdk/query.go index 290dd267..ae416d17 100644 --- a/sdks/go/sdk/query.go +++ b/sdks/go/sdk/query.go @@ -53,11 +53,11 @@ func (c *Client) queryLocal(manager indexRuntime, indexName, query string, optio embedding = options.Embedding } if options.Filter != nil { - bytes, err := json.Marshal(options.Filter) + filterBytes, err := json.Marshal(options.Filter) if err != nil { return SearchResult{}, err } - value := string(bytes) + value := string(filterBytes) filterJSON = &value } } @@ -87,6 +87,10 @@ type cloudQueryRequest struct { } func (c *Client) queryCloud(ctx context.Context, indexName, query string, options *QueryOptions) (SearchResult, error) { + if strings.TrimSpace(c.queryURL) == "" { + return SearchResult{}, ErrMissingQueryURL + } + topK := defaultTopK var embedding []float32 if options != nil { From 103ecfeeeec2ecf5b3abc358c4e120378623c6f8 Mon Sep 17 00:00:00 2001 From: anirudh-makuluri Date: Mon, 15 Jun 2026 12:15:06 -0700 Subject: [PATCH 11/12] change defaultTopK to 10 --- sdks/go/sdk/query.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/go/sdk/query.go b/sdks/go/sdk/query.go index ae416d17..475b5760 100644 --- a/sdks/go/sdk/query.go +++ b/sdks/go/sdk/query.go @@ -12,7 +12,7 @@ import ( mosscore "github.com/usemoss/moss/sdks/go/bindings" ) -const defaultTopK = 5 +const defaultTopK = 10 // Query executes a local query when the index is loaded, otherwise falls back to cloud query. func (c *Client) Query(ctx context.Context, indexName, query string, options *QueryOptions) (SearchResult, error) { From e573f14db4b083c8efc5d4ce539144fb1d9911e4 Mon Sep 17 00:00:00 2001 From: anirudh-makuluri Date: Mon, 15 Jun 2026 12:16:14 -0700 Subject: [PATCH 12/12] change default top k --- sdks/go/sdk/query.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdks/go/sdk/query.go b/sdks/go/sdk/query.go index 475b5760..d43f8cc4 100644 --- a/sdks/go/sdk/query.go +++ b/sdks/go/sdk/query.go @@ -12,7 +12,7 @@ import ( mosscore "github.com/usemoss/moss/sdks/go/bindings" ) -const defaultTopK = 10 +const defaultTopK = 5 // Query executes a local query when the index is loaded, otherwise falls back to cloud query. func (c *Client) Query(ctx context.Context, indexName, query string, options *QueryOptions) (SearchResult, error) { @@ -91,7 +91,7 @@ func (c *Client) queryCloud(ctx context.Context, indexName, query string, option return SearchResult{}, ErrMissingQueryURL } - topK := defaultTopK + topK := 10 var embedding []float32 if options != nil { if options.Alpha != nil || options.Filter != nil {