Skip to content

Commit f96eb6f

Browse files
authored
Merge pull request #26 from mutablelogic/v1
Added streaming chat, and tool calling for openai
2 parents 2ec7d13 + d26484c commit f96eb6f

15 files changed

+576
-135
lines changed

README.md

+95-28
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ This repository contains a generic HTTP client which can be adapted to provide:
77
* Ability to send files and data of type `multipart/form-data`
88
* Ability to send data of type `application/x-www-form-urlencoded`
99
* Debugging capabilities to see the request and response data
10-
* Streaming JSON responses
10+
* Streaming text events
1111

1212
API Documentation: https://pkg.go.dev/github.com/mutablelogic/go-client
1313

@@ -58,28 +58,31 @@ func main() {
5858
Various options can be passed to the client `New` method to control its behaviour:
5959

6060
* `OptEndpoint(value string)` sets the endpoint for all requests
61-
* `OptTimeout(value time.Duration)` sets the timeout on any request, which defaults to 30 seconds
62-
* `OptUserAgent(value string)` sets the user agent string on each API request
63-
* `OptTrace(w io.Writer, verbose bool)` allows you to debug the request and response data.
64-
When `verbose` is set to true, it also displays the payloads
65-
* `OptStrict()` turns on strict content type checking on anything returned from the API
66-
* `OptRateLimit(value float32)` sets the limit on number of requests per second and the API will sleep to regulate
67-
the rate limit when exceeded
68-
* `OptReqToken(value Token)` sets a request token for all client requests. This can be overridden by the client
69-
for individual requests using `OptToken`
70-
* `OptSkipVerify()` skips TLS certificate domain verification
71-
* `OptHeader(key, value string)` appends a custom header to each request
61+
* `OptTimeout(value time.Duration)` sets the timeout on any request, which defaults to 30 seconds.
62+
Timeouts can be ignored on a request-by-request basis using the `OptNoTimeout` option (see below).
63+
* `OptUserAgent(value string)` sets the user agent string on each API request.
64+
* `OptTrace(w io.Writer, verbose bool)` allows you to debug the request and response data.
65+
When `verbose` is set to true, it also displays the payloads.
66+
* `OptStrict()` turns on strict content type checking on anything returned from the API.
67+
* `OptRateLimit(value float32)` sets the limit on number of requests per second and the API
68+
will sleep to regulate the rate limit when exceeded.
69+
* `OptReqToken(value Token)` sets a request token for all client requests. This can be
70+
overridden by the client for individual requests using `OptToken` (see below).
71+
* `OptSkipVerify()` skips TLS certificate domain verification.
72+
* `OptHeader(key, value string)` appends a custom header to each request.
7273

7374
## Usage with a payload
7475

75-
The first argument to the `Do` method is the payload to send to the server, when set. You can create a payload
76-
using the following methods:
76+
The first argument to the `Do` method is the payload to send to the server, when set.
77+
You can create a payload using the following methods:
7778

7879
* `client.NewRequest()` returns a new empty payload which defaults to GET.
79-
* `client.NewJSONRequest(payload any, accept string)` returns a new request with a JSON payload which defaults to POST.
80-
* `client.NewMultipartRequest(payload any, accept string)` returns a new request with a Multipart Form data payload which
81-
defaults to POST.
82-
* `client.NewFormRequest(payload any, accept string)` returns a new request with a Form data payload which defaults to POST.
80+
* `client.NewJSONRequest(payload any, accept string)` returns a new request with
81+
a JSON payload which defaults to POST.
82+
* `client.NewMultipartRequest(payload any, accept string)` returns a new request with
83+
a Multipart Form data payload which defaults to POST.
84+
* `client.NewFormRequest(payload any, accept string)` returns a new request with a
85+
Form data payload which defaults to POST.
8386

8487
For example,
8588

@@ -131,7 +134,7 @@ type Payload interface {
131134

132135
## Request options
133136

134-
The signature of the `Do` method is:
137+
The signature of the `Do` method is as follows:
135138

136139
```go
137140
type Client interface {
@@ -143,16 +146,19 @@ type Client interface {
143146
}
144147
```
145148

146-
Various options can be passed to modify each individual request when using the `Do` method:
149+
If you pass a context to the `DoWithContext` method, then the request can be
150+
cancelled using the context in addition to the timeout. Various options can be passed to
151+
modify each individual request when using the `Do` method:
147152

148153
* `OptReqEndpoint(value string)` sets the endpoint for the request
149154
* `OptPath(value ...string)` appends path elements onto a request endpoint
150155
* `OptToken(value Token)` adds an authorization header (overrides the client OptReqToken option)
151156
* `OptQuery(value url.Values)` sets the query parameters to a request
152-
* `OptHeader(key, value string)` appends a custom header to the request
153-
* `OptResponse(func() error)` allows you to set a callback function to process a streaming response.
154-
See below for more details.
157+
* `OptHeader(key, value string)` sets a custom header to the request
155158
* `OptNoTimeout()` disables the timeout on the request, which is useful for long running requests
159+
* `OptTextStreamCallback(func(TextStreamCallback) error)` allows you to set a callback
160+
function to process a streaming text response of type `text/event-stream`. See below for
161+
more details.
156162

157163
## Authentication
158164

@@ -185,10 +191,45 @@ You can also set the token on a per-request basis using the `OptToken` option in
185191

186192
You can create a payload with form data:
187193

188-
* `client.NewFormRequest(payload any, accept string)` returns a new request with a Form data payload which defaults to POST.
189-
* `client.NewMultipartRequest(payload any, accept string)` returns a new request with a Multipart Form data payload which defaults to POST. This is useful for file uploads.
194+
* `client.NewFormRequest(payload any, accept string)` returns a new request with a Form
195+
data payload which defaults to POST.
196+
* `client.NewMultipartRequest(payload any, accept string)` returns a new request with
197+
a Multipart Form data payload which defaults to POST. This is useful for file uploads.
190198

191-
The payload should be a `struct` where the fields are converted to form tuples. File uploads require a field of type `multipart.File`.
199+
The payload should be a `struct` where the fields are converted to form tuples. File uploads require a field of type `multipart.File`. For example,
200+
201+
```go
202+
package main
203+
204+
import (
205+
client "github.com/mutablelogic/go-client"
206+
multipart "github.com/mutablelogic/go-client/pkg/multipart"
207+
)
208+
209+
type FileUpload struct {
210+
File multipart.File `json:"file"`
211+
}
212+
213+
func main() {
214+
// Create a new client
215+
c := client.New(client.OptEndpoint("https://api.example.com/api/v1"))
216+
217+
// Create a file upload request
218+
request := FileUpload{
219+
File: multipart.File{
220+
Path: "helloworld.txt",
221+
Body: strings.NewReader("Hello, world!"),
222+
}
223+
}
224+
225+
// Upload a file
226+
if payload, err := client.NewMultipartRequest(request, "*/*"); err != nil {
227+
// Handle error
228+
} else if err := c.Do(payload, &response, OptPath("upload")); err != nil {
229+
// Handle error
230+
}
231+
}
232+
```
192233

193234
## Unmarshalling responses
194235

@@ -202,6 +243,32 @@ type Unmarshaler interface {
202243

203244
## Streaming Responses
204245

205-
If the returned content is a stream of JSON responses, then you can use the `OptResponse(fn func() error)` option, which
206-
will be called by the `Do` method for each response. The function should return an error if the stream should be terminated.
246+
The client implements a streaming text event callback which can be used to process a stream of text events, as per the [Mozilla specification](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events).
247+
248+
In order to process streamed events, pass the `OptTextStreamCallback()` option to the request
249+
with a callback function, which should have the following signature:
250+
251+
```go
252+
func Callback(event client.TextStreamEvent) error {
253+
// Finish processing successfully
254+
if event.Event == "close" {
255+
return io.EOF
256+
}
257+
258+
// Decode the data into a JSON object
259+
var data map[string]any
260+
if err := event.Json(data); err != nil {
261+
return err
262+
}
263+
264+
// Return success - continue streaming
265+
return nil
266+
}
267+
```
268+
269+
The `TextStreamEvent` object has the following
270+
271+
If you return an error of type `io.EOF` from the callback, then the stream will be closed.
272+
Similarly, if you return any other error the stream will be closed and the error returned.
273+
207274
Usually, you would pair this option with `OptNoTimeout` to prevent the request from timing out.

client.go

+8-26
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"context"
55
"encoding/json"
66
"encoding/xml"
7-
"errors"
87
"fmt"
98
"io"
109
"mime"
@@ -57,7 +56,6 @@ const (
5756
PathSeparator = string(os.PathSeparator)
5857
ContentTypeAny = "*/*"
5958
ContentTypeJson = "application/json"
60-
ContentTypeJsonStream = "application/x-ndjson"
6159
ContentTypeTextXml = "text/xml"
6260
ContentTypeApplicationXml = "application/xml"
6361
ContentTypeTextPlain = "text/plain"
@@ -301,31 +299,20 @@ func do(client *http.Client, req *http.Request, accept string, strict bool, out
301299
return nil
302300
}
303301

304-
// Decode the body - and call any callback once the body has been decoded
302+
// Decode the body
305303
switch mimetype {
306-
case ContentTypeJson, ContentTypeJsonStream:
307-
dec := json.NewDecoder(response.Body)
308-
for {
309-
if err := dec.Decode(out); errors.Is(err, io.EOF) {
310-
break
311-
} else if err != nil {
312-
return err
313-
}
314-
if reqopts.callback != nil {
315-
if err := reqopts.callback(); err != nil {
316-
return err
317-
}
318-
}
304+
case ContentTypeJson:
305+
if err := json.NewDecoder(response.Body).Decode(out); err != nil {
306+
return err
307+
}
308+
case ContentTypeTextStream:
309+
if err := NewTextStream().Decode(response.Body, reqopts.textStreamCallback); err != nil {
310+
return err
319311
}
320312
case ContentTypeTextXml, ContentTypeApplicationXml:
321313
if err := xml.NewDecoder(response.Body).Decode(out); err != nil {
322314
return err
323315
}
324-
if reqopts.callback != nil {
325-
if err := reqopts.callback(); err != nil {
326-
return err
327-
}
328-
}
329316
default:
330317
if v, ok := out.(Unmarshaler); ok {
331318
return v.Unmarshal(mimetype, response.Body)
@@ -336,11 +323,6 @@ func do(client *http.Client, req *http.Request, accept string, strict bool, out
336323
} else {
337324
return ErrInternalAppError.Withf("do: response does not implement Unmarshaler for %q", mimetype)
338325
}
339-
if reqopts.callback != nil {
340-
if err := reqopts.callback(); err != nil {
341-
return err
342-
}
343-
}
344326
}
345327

346328
// Return success

clientopts.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ func OptUserAgent(value string) ClientOpt {
5656
// Setting verbose to true also displays the JSON response
5757
func OptTrace(w io.Writer, verbose bool) ClientOpt {
5858
return func(client *Client) error {
59-
client.Client.Transport = NewLogTransport(w, client.Client.Transport, verbose)
59+
client.Client.Transport = newLogTransport(w, client.Client.Transport, verbose)
6060
return nil
6161
}
6262
}

cmd/api/openai.go

+21-2
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,21 @@ func openaiChat(ctx context.Context, w *tablewriter.Writer, args []string) error
221221
opts = append(opts, openai.OptResponseFormat(openaiResponseFormat))
222222
}
223223
if openaiStream {
224-
opts = append(opts, openai.OptStream())
224+
opts = append(opts, openai.OptStream(func(choice schema.MessageChoice) {
225+
w := w.Output()
226+
if choice.Delta == nil {
227+
return
228+
}
229+
if choice.Delta.Role != "" {
230+
fmt.Fprintf(w, "\nrole: %q\n", choice.Delta.Role)
231+
}
232+
if choice.Delta.Content != "" {
233+
fmt.Fprintf(w, "%v", choice.Delta.Content)
234+
}
235+
if choice.FinishReason != "" {
236+
fmt.Printf("\nfinish_reason: %q\n", choice.FinishReason)
237+
}
238+
}))
225239
}
226240
if openaiUser != "" {
227241
opts = append(opts, openai.OptUser(openaiUser))
@@ -243,7 +257,12 @@ func openaiChat(ctx context.Context, w *tablewriter.Writer, args []string) error
243257
return err
244258
}
245259

246-
return w.Write(responses)
260+
// Write table (if not streaming)
261+
if !openaiStream {
262+
return w.Write(responses)
263+
} else {
264+
return nil
265+
}
247266
}
248267

249268
func openaiTranscribe(ctx context.Context, w *tablewriter.Writer, args []string) error {

pkg/ollama/chat.go

+1-6
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,7 @@ func (c *Client) ChatGenerate(ctx context.Context, model, prompt string, opts ..
6969
// Create a new request
7070
if req, err := client.NewJSONRequest(request); err != nil {
7171
return response.ChatStatus, err
72-
} else if err := c.DoWithContext(ctx, req, &response, client.OptPath("generate"), client.OptNoTimeout(), client.OptResponse(func() error {
73-
if request.callback != nil && response.Response != "" {
74-
request.callback(response.Response)
75-
}
76-
return nil
77-
})); err != nil {
72+
} else if err := c.DoWithContext(ctx, req, &response, client.OptPath("generate"), client.OptNoTimeout()); err != nil {
7873
return response.ChatStatus, err
7974
}
8075

pkg/ollama/model.go

+1-5
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package ollama
33
import (
44
"context"
55
"encoding/json"
6-
"fmt"
76
"net/http"
87
"time"
98

@@ -148,10 +147,7 @@ func (c *Client) PullModel(ctx context.Context, name string) error {
148147

149148
// Send the request
150149
var response respPullModel
151-
return c.DoWithContext(ctx, req, &response, client.OptPath("pull"), client.OptNoTimeout(), client.OptResponse(func() error {
152-
fmt.Println("TOOD:", response)
153-
return nil
154-
}))
150+
return c.DoWithContext(ctx, req, &response, client.OptPath("pull"), client.OptNoTimeout())
155151
}
156152

157153
// Create a new model with a name and contents of the Modelfile

0 commit comments

Comments
 (0)