Skip to content

Commit 668341c

Browse files
authored
Merge pull request #37 from mutablelogic/v1
Adding JSON streaming
2 parents 3265d43 + 62b7dd1 commit 668341c

32 files changed

+1917
-69
lines changed

README.md

Lines changed: 18 additions & 5 deletions
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 text events
10+
* Streaming text and JSON events
1111

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

@@ -159,6 +159,9 @@ modify each individual request when using the `Do` method:
159159
* `OptTextStreamCallback(func(TextStreamCallback) error)` allows you to set a callback
160160
function to process a streaming text response of type `text/event-stream`. See below for
161161
more details.
162+
* `OptJsonStreamCallback(func(any) error)` allows you to set a callback for JSON streaming
163+
responses. The callback should have the signature `func(any) error`. See below for
164+
more details.
162165

163166
## Authentication
164167

@@ -191,9 +194,9 @@ You can also set the token on a per-request basis using the `OptToken` option in
191194

192195
You can create a payload with form data:
193196

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

199202
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,
@@ -241,9 +244,10 @@ type Unmarshaler interface {
241244
}
242245
```
243246

244-
## Streaming Responses
247+
## Text Streaming Responses
245248

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).
249+
The client implements a streaming text event callback which can be used to process a stream of text events,
250+
as per the [Mozilla specification](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events).
247251

248252
In order to process streamed events, pass the `OptTextStreamCallback()` option to the request
249253
with a callback function, which should have the following signature:
@@ -272,3 +276,12 @@ If you return an error of type `io.EOF` from the callback, then the stream will
272276
Similarly, if you return any other error the stream will be closed and the error returned.
273277

274278
Usually, you would pair this option with `OptNoTimeout` to prevent the request from timing out.
279+
280+
## JSON Streaming Responses
281+
282+
The client decodes JSON streaming responses by passing a callback function to the `OptJsonStreamCallback()` option.
283+
The callback with signature `func(any) error` is called for each JSON object in the stream, where the argument
284+
is the same type as the object in the request.
285+
286+
You can return an error from the callback to stop the stream and return the error, or return `io.EOF` to stop the stream
287+
immediately and return success.

client.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ const (
6161
PathSeparator = string(os.PathSeparator)
6262
ContentTypeAny = "*/*"
6363
ContentTypeJson = "application/json"
64+
ContentTypeJsonStream = "application/x-ndjson"
6465
ContentTypeTextXml = "text/xml"
6566
ContentTypeApplicationXml = "application/xml"
6667
ContentTypeTextPlain = "text/plain"
@@ -306,7 +307,7 @@ func do(client *http.Client, req *http.Request, accept string, strict bool, out
306307

307308
// Decode the body
308309
switch mimetype {
309-
case ContentTypeJson:
310+
case ContentTypeJson, ContentTypeJsonStream:
310311
// JSON decode is streamable
311312
dec := json.NewDecoder(response.Body)
312313
for {

cmd/agent/chat.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
// Packages
8+
markdown "github.com/MichaelMure/go-term-markdown"
9+
agent "github.com/mutablelogic/go-client/pkg/agent"
10+
)
11+
12+
/////////////////////////////////////////////////////////////////////
13+
// TYPES
14+
15+
type ChatCmd struct {
16+
Prompt string `arg:"" optional:"" help:"The prompt to generate a response for"`
17+
Agent string `flag:"agent" help:"The agent to use"`
18+
Model string `flag:"model" help:"The model to use"`
19+
Stream bool `flag:"stream" help:"Stream the response"`
20+
}
21+
22+
/////////////////////////////////////////////////////////////////////
23+
// PUBLIC METHODS
24+
25+
func (cmd *ChatCmd) Run(globals *Globals) error {
26+
// Get the agent and the model
27+
model_agent, model := globals.getModel(globals.ctx, cmd.Agent, cmd.Model)
28+
if model_agent == nil || model == nil {
29+
return fmt.Errorf("model %q not found, or not set on command line", globals.state.Model)
30+
}
31+
32+
// Generate the options
33+
opts := make([]agent.Opt, 0)
34+
if cmd.Stream {
35+
opts = append(opts, agent.OptStream(func(r agent.Response) {
36+
fmt.Println(r)
37+
}))
38+
}
39+
40+
// Add tools
41+
if tools := globals.getTools(); len(tools) > 0 {
42+
opts = append(opts, agent.OptTools(tools...))
43+
}
44+
45+
// If the prompt is empty, then we're in interative mode
46+
context := []agent.Context{}
47+
if cmd.Prompt == "" {
48+
if globals.term == nil {
49+
return fmt.Errorf("prompt is empty and not in interactive mode")
50+
}
51+
} else {
52+
context = append(context, model_agent.UserPrompt(cmd.Prompt))
53+
}
54+
55+
FOR_LOOP:
56+
for {
57+
// When there is no context, create some
58+
if len(context) == 0 {
59+
if prompt, err := globals.term.ReadLine(model.Name() + "> "); err != nil {
60+
return err
61+
} else if prompt == "" {
62+
break FOR_LOOP
63+
} else {
64+
context = append(context, model_agent.UserPrompt(prompt))
65+
}
66+
}
67+
68+
// Generate a chat completion
69+
response, err := model_agent.Generate(globals.ctx, model, context, opts...)
70+
if err != nil {
71+
return err
72+
}
73+
74+
// If the response is a tool call, then run the tool
75+
if response.ToolCall != nil {
76+
result, err := globals.runTool(globals.ctx, response.ToolCall)
77+
if err != nil {
78+
return err
79+
}
80+
response.Context = append(response.Context, result)
81+
} else {
82+
if globals.term != nil {
83+
w, _ := globals.term.Size()
84+
fmt.Println(string(markdown.Render(response.Text, w, 0)))
85+
} else {
86+
fmt.Println(response.Text)
87+
}
88+
89+
// Make empty context
90+
response.Context = []agent.Context{}
91+
}
92+
93+
// Context comes from the response
94+
context = response.Context
95+
}
96+
97+
// Return success
98+
return nil
99+
}
100+
101+
/////////////////////////////////////////////////////////////////////
102+
// PRIVATE METHODS
103+
104+
// Get the model, either from state or from the command-line flags.
105+
// If the model is not found, or there is another error, return nil
106+
func (globals *Globals) getModel(ctx context.Context, agent, model string) (agent.Agent, agent.Model) {
107+
state := globals.state
108+
if agent != "" {
109+
state.Agent = agent
110+
}
111+
if model != "" {
112+
state.Model = model
113+
}
114+
115+
// Cycle through the agents and models to find the one we want
116+
for _, agent := range globals.agents {
117+
// Filter by agent
118+
if state.Agent != "" && agent.Name() != state.Agent {
119+
continue
120+
}
121+
122+
// Retrieve the models for this agent
123+
models, err := agent.Models(ctx)
124+
if err != nil {
125+
continue
126+
}
127+
128+
// Filter by model
129+
for _, model := range models {
130+
if state.Model != "" && model.Name() != state.Model {
131+
continue
132+
}
133+
134+
// This is the model we're using....
135+
state.Agent = agent.Name()
136+
state.Model = model.Name()
137+
return agent, model
138+
}
139+
}
140+
141+
// No model found
142+
return nil, nil
143+
}
144+
145+
// Get the tools
146+
func (globals *Globals) getTools() []agent.Tool {
147+
return globals.tools
148+
}
149+
150+
// Return a tool by name. If the tool is not found, return nil
151+
func (globals *Globals) getTool(name string) agent.Tool {
152+
for _, tool := range globals.tools {
153+
if tool.Name() == name {
154+
return tool
155+
}
156+
}
157+
return nil
158+
}
159+
160+
// Run a tool from a tool call, and return the result
161+
func (globals *Globals) runTool(ctx context.Context, call *agent.ToolCall) (*agent.ToolResult, error) {
162+
tool := globals.getTool(call.Name)
163+
if tool == nil {
164+
return nil, fmt.Errorf("tool %q not found", call.Name)
165+
}
166+
167+
// Run the tool
168+
return tool.Run(ctx, call)
169+
}

cmd/agent/list_agents.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
)
7+
8+
/////////////////////////////////////////////////////////////////////
9+
// TYPES
10+
11+
type ListAgentsCmd struct {
12+
}
13+
14+
/////////////////////////////////////////////////////////////////////
15+
// METHODS
16+
17+
func (cmd *ListAgentsCmd) Run(ctx *Globals) error {
18+
result := make([]string, 0)
19+
for _, agent := range ctx.agents {
20+
result = append(result, agent.Name())
21+
}
22+
23+
data, err := json.MarshalIndent(result, "", " ")
24+
if err != nil {
25+
return err
26+
}
27+
28+
fmt.Println(string(data))
29+
30+
return nil
31+
}

cmd/agent/list_models.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
)
7+
8+
/////////////////////////////////////////////////////////////////////
9+
// TYPES
10+
11+
type ListModelsCmd struct {
12+
}
13+
14+
type modeljson struct {
15+
Agent string `json:"agent"`
16+
Model string `json:"model"`
17+
}
18+
19+
/////////////////////////////////////////////////////////////////////
20+
// METHODS
21+
22+
func (cmd *ListModelsCmd) Run(ctx *Globals) error {
23+
result := make([]modeljson, 0)
24+
for _, agent := range ctx.agents {
25+
models, err := agent.Models(ctx.ctx)
26+
if err != nil {
27+
return err
28+
}
29+
for _, model := range models {
30+
result = append(result, modeljson{Agent: agent.Name(), Model: model.Name()})
31+
}
32+
}
33+
34+
data, err := json.MarshalIndent(result, "", " ")
35+
if err != nil {
36+
return err
37+
}
38+
39+
fmt.Println(string(data))
40+
41+
return nil
42+
}

cmd/agent/list_tools.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
)
7+
8+
/////////////////////////////////////////////////////////////////////
9+
// TYPES
10+
11+
type ListToolsCmd struct {
12+
}
13+
14+
type tooljson struct {
15+
Provider string `json:"provider"`
16+
Name string `json:"name"`
17+
Description string `json:"description"`
18+
}
19+
20+
/////////////////////////////////////////////////////////////////////
21+
// METHODS
22+
23+
func (cmd *ListToolsCmd) Run(ctx *Globals) error {
24+
result := make([]tooljson, 0)
25+
for _, tool := range ctx.tools {
26+
result = append(result, tooljson{Provider: tool.Provider(), Name: tool.Name(), Description: tool.Description()})
27+
}
28+
29+
data, err := json.MarshalIndent(result, "", " ")
30+
if err != nil {
31+
return err
32+
}
33+
34+
fmt.Println(string(data))
35+
36+
return nil
37+
}

0 commit comments

Comments
 (0)