From 2f6dd0467c5a7ed595097c22f8146aac6d32e2bd Mon Sep 17 00:00:00 2001 From: natirniger Date: Thu, 8 Oct 2026 16:09:43 -0700 Subject: [PATCH] Use the OpenAI Go client - Call the Responses API through openai-go - Keep examples in their own Go 1.25 module - Build and vet examples in CI Co-authored-by: Cursor --- .github/workflows/ci.yml | 4 + README.md | 17 +++ examples/go.mod | 20 +++ examples/go.sum | 16 +++ examples/reference_implementation/main.go | 164 +++++++--------------- 5 files changed, 107 insertions(+), 114 deletions(-) create mode 100644 examples/go.mod create mode 100644 examples/go.sum diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54a6781..e706745 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,3 +26,7 @@ jobs: - run: go vet ./... - if: matrix.go == 'stable' run: go test -race ./... + # The examples are a separate module: they use the OpenAI Go client, which needs Go 1.25+. + - if: matrix.go == 'stable' + working-directory: examples + run: go vet ./... && go build -o /tmp/sid-reference-implementation ./reference_implementation diff --git a/README.md b/README.md index 8998738..6ac0191 100644 --- a/README.md +++ b/README.md @@ -123,12 +123,29 @@ Each fork has an independently locked seen ledger. `ApplySnippet` and `UpdateSeen` remain separate operations, so callers should serialize that pair when multiple goroutines use the same fork as one agent. +## SID-2 reference implementation + +[`examples/reference_implementation`](examples/reference_implementation/main.go) +runs a complete SID-2 episode: tool schemas, the agent loop, search rendering, +reads, and the final report. Implement `vectorSearch` and `keywordSearch` +against your search backend, then run: + +```sh +cd examples +SID_API_KEY=your-api-key go run ./reference_implementation +``` + +The examples are a separate module because they call the Responses API with +the [OpenAI Go client](https://github.com/openai/openai-go), which requires +Go 1.25 or newer. The SDK itself has no dependency on it. + ## Development ```sh go test ./... go test -race ./... go vet ./... +(cd examples && go vet ./... && go build ./...) ``` ## License diff --git a/examples/go.mod b/examples/go.mod new file mode 100644 index 0000000..6f4edcb --- /dev/null +++ b/examples/go.mod @@ -0,0 +1,20 @@ +module github.com/sidhq/sid-go/examples + +go 1.25.0 + +require ( + github.com/openai/openai-go/v3 v3.74.0 + github.com/sidhq/sid-go v0.0.0 +) + +require ( + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/coder/websocket v1.8.15 // indirect + github.com/tidwall/gjson v1.19.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect +) + +// Build the examples against this checkout of the SDK. +replace github.com/sidhq/sid-go => ../ diff --git a/examples/go.sum b/examples/go.sum new file mode 100644 index 0000000..1a018d0 --- /dev/null +++ b/examples/go.sum @@ -0,0 +1,16 @@ +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/openai/openai-go/v3 v3.74.0 h1:MvAN/lrn8OUhA5KcFBerS86AzKpc+idrQzSvk8lDzTk= +github.com/openai/openai-go/v3 v3.74.0/go.mod h1:+dSPa+nbX+dNoXg1jecMnVpgRP+E/5IBA6Jiz9Pc8WM= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= diff --git a/examples/reference_implementation/main.go b/examples/reference_implementation/main.go index 319a06e..c3750a9 100644 --- a/examples/reference_implementation/main.go +++ b/examples/reference_implementation/main.go @@ -10,27 +10,29 @@ // // Run: // -// SID_API_KEY=your-api-key go run ./examples/reference_implementation +// cd examples && SID_API_KEY=your-api-key go run ./reference_implementation package main import ( - "bytes" "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" - "io" "log" "maps" - "net/http" "os" "slices" "strings" "sync" "unicode/utf8" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" + "github.com/openai/openai-go/v3/packages/param" + "github.com/openai/openai-go/v3/responses" + "github.com/openai/openai-go/v3/shared" sid "github.com/sidhq/sid-go" ) @@ -470,78 +472,6 @@ func report(cache *sid.DocumentCache, ids any) ([]string, error) { // unchanged on every turn; only input grows. // --------------------------------------------------------------------------- -// outputItem holds the fields the loop reads from one response output item. -// raw is echoed back verbatim so reasoning items keep their encrypted_content. -type outputItem struct { - raw json.RawMessage - Type string `json:"type"` - Name string `json:"name"` - Arguments string `json:"arguments"` - CallID string `json:"call_id"` - Content []struct { - Type string `json:"type"` - Text string `json:"text"` - } `json:"content"` -} - -type response struct { - Status string `json:"status"` - Output []json.RawMessage `json:"output"` - items []outputItem -} - -type client struct { - http *http.Client - url string - apiKey string - sessionID string // KV cache routing information: one session per episode - request object -} - -func (c *client) create(ctx context.Context, history []any) (*response, error) { - body := make(object, len(c.request)+1) - for key, value := range c.request { - body[key] = value - } - body["input"] = history - payload, err := json.Marshal(body) - if err != nil { - return nil, err - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url+"/responses", bytes.NewReader(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+c.apiKey) - req.Header.Set("x-session-affinity", c.sessionID) - httpResponse, err := c.http.Do(req) - if err != nil { - return nil, err - } - defer httpResponse.Body.Close() - data, err := io.ReadAll(httpResponse.Body) - if err != nil { - return nil, err - } - if httpResponse.StatusCode != http.StatusOK { - return nil, fmt.Errorf("responses API returned %s: %s", httpResponse.Status, data) - } - - var result response - if err := json.Unmarshal(data, &result); err != nil { - return nil, err - } - for _, raw := range result.Output { - item := outputItem{raw: raw} - if err := json.Unmarshal(raw, &item); err != nil { - return nil, err - } - result.items = append(result.items, item) - } - return &result, nil -} - type fetched struct { results []sid.Document err error @@ -553,44 +483,50 @@ func main() { if err != nil { log.Fatal(err) } + apiKey := os.Getenv("SID_API_KEY") + if apiKey == "" { + log.Fatal("SID_API_KEY is not set") + } + client := openai.NewClient(option.WithBaseURL(baseURL()), option.WithAPIKey(apiKey)) // One request shape, only update the history at each turn. - c := &client{ - http: http.DefaultClient, - url: baseURL(), - apiKey: os.Getenv("SID_API_KEY"), - sessionID: newSessionID(), - request: object{ - "model": model, - "max_output_tokens": 4096, - "store": false, - "include": []string{"reasoning.encrypted_content"}, - "instructions": instructions, - "tools": tools, - // Reasoning effort, tool_examples, and the target reward are managed server-side - "reasoning": object{"effort": reasoningEffort}, - "sid": object{"tool_examples": toolExamples, "reward": reward}, - }, - } - if c.apiKey == "" { - log.Fatal("SID_API_KEY is not set") + request := responses.ResponseNewParams{ + Model: model, + MaxOutputTokens: openai.Int(4096), + Store: openai.Bool(false), + Include: []responses.ResponseIncludable{responses.ResponseIncludableReasoningEncryptedContent}, + Instructions: openai.String(instructions), + // Reasoning effort, tool_examples, and the target reward are managed server-side + Reasoning: shared.ReasoningParam{Effort: reasoningEffort}, + } + options := []option.RequestOption{ + // The tools and the sid extension are plain JSON, set on the request body. + option.WithJSONSet("tools", tools), + option.WithJSONSet("sid", object{"tool_examples": toolExamples, "reward": reward}), + option.WithHeader("x-session-affinity", newSessionID()), // KV cache routing: one session per episode } // The whole conversation as Responses API input items, grown every turn. - history := []any{object{"role": "user", "content": question}} + history := responses.ResponseInputParam{ + responses.ResponseInputItemParamOfMessage(question, responses.EasyInputMessageRoleUser), + } + create := func() (*responses.Response, error) { + request.Input = responses.ResponseNewParamsInputUnion{OfInputItemList: history} + return client.Responses.New(ctx, request, options...) + } fmt.Printf("Q: %s\n", question) - resp, err := c.create(ctx, history) + resp, err := create() if err != nil { log.Fatal(err) } for turn := 1; turn <= maxTurns; turn++ { printModelOutput(turn, resp) - var calls []outputItem - for _, item := range resp.items { + var calls []responses.ResponseFunctionToolCall + for _, item := range resp.Output { if item.Type == "function_call" { - calls = append(calls, item) + calls = append(calls, item.AsFunctionCall()) } } arguments := make([]object, len(calls)) @@ -640,7 +576,7 @@ func main() { fmt.Printf("\n[max turns reached: %d: episode ends without a report]\n", maxTurns) return } - if resp.Status == "incomplete" { + if resp.Status == responses.ResponseStatusIncomplete { fmt.Println("\n[generation hit max_output_tokens; ending the episode]") return } @@ -648,11 +584,13 @@ func main() { // Non-terminal turn: append the model's output verbatim (the reasoning // items carry their encrypted_content), then feed back the tool outputs // (paired by call_id) plus the turn budget indicator as a user message. - for _, raw := range resp.Output { - history = append(history, raw) + for _, item := range resp.Output { + history = append(history, param.Override[responses.ResponseInputItemUnionParam](json.RawMessage(item.RawJSON()))) } for i, call := range calls { - history = append(history, object{"type": "function_call_output", "call_id": call.CallID, "output": observations[i]}) + output := responses.ResponseInputItemParamOfFunctionCallOutput(observations[i]) + output.OfFunctionCallOutput.CallID = openai.String(call.CallID) + history = append(history, output) } remaining := maxTurns - turn @@ -667,11 +605,11 @@ func main() { "Anything you have found but not reported is lost when this turn ends.", reportTool["name"])) } for _, message := range messages { - history = append(history, object{"role": "user", "content": message}) + history = append(history, responses.ResponseInputItemParamOfMessage(message, responses.EasyInputMessageRoleUser)) fmt.Printf("[user] %s\n", message) } - if resp, err = c.create(ctx, history); err != nil { + if resp, err = create(); err != nil { log.Fatal(err) } } @@ -718,7 +656,7 @@ func runTool( // tool's schema declares, and drop the ""/null "defaults" LLMs like to send. // Anything else the model got wrong comes back as a ToolError from the fetch // or the tool. -func toolArguments(call outputItem) object { +func toolArguments(call responses.ResponseFunctionToolCall) object { var arguments object properties := toolProperties(call.Name) if json.Unmarshal([]byte(call.Arguments), &arguments) != nil || properties == nil { @@ -755,20 +693,18 @@ func newSessionID() string { // printModelOutput prints the model's side of one turn: its thinking, any // prose, and its calls. -func printModelOutput(turn int, resp *response) { +func printModelOutput(turn int, resp *responses.Response) { fmt.Printf("\n--- turn %d (status=%s) %s\n", turn, resp.Status, strings.Repeat("-", 40)) - for _, item := range resp.items { + for _, item := range resp.Output { switch item.Type { case "reasoning": - for _, part := range item.Content { + for _, part := range item.AsReasoning().Content { fmt.Printf("[thinking] %s\n", part.Text) } case "message": - for _, part := range item.Content { - fmt.Printf("[assistant] %s\n", part.Text) - } + fmt.Printf("[assistant] %s\n", resp.OutputText()) case "function_call": - fmt.Printf(">>> %s(%s)\n", item.Name, item.Arguments) + fmt.Printf(">>> %s(%s)\n", item.Name, item.AsFunctionCall().Arguments) } } }