v0.0.1: agentu mvp
Add OpenAI-compatible providers, TUI, slash commands, and model switching. Add local file tools plus optional yolo shell execution. Document config, usage, and development workflow.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type OpenAICompatibleClient struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewOpenAICompatibleClient(baseURL, apiKey string, httpClient *http.Client) *OpenAICompatibleClient {
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
return &OpenAICompatibleClient{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
apiKey: apiKey,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *OpenAICompatibleClient) ChatStream(ctx context.Context, req ChatRequest, emit func(StreamEvent) error) error {
|
||||
req.Stream = true
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal chat request: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create chat request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "text/event-stream")
|
||||
if c.apiKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send chat request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
limit := io.LimitReader(resp.Body, 4096)
|
||||
data, _ := io.ReadAll(limit)
|
||||
return fmt.Errorf("chat request failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
}
|
||||
|
||||
return readSSE(resp.Body, emit)
|
||||
}
|
||||
|
||||
func readSSE(r io.Reader, emit func(StreamEvent) error) error {
|
||||
reader := bufio.NewReader(r)
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return fmt.Errorf("read stream: %w", err)
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if payload == "[DONE]" {
|
||||
return nil
|
||||
}
|
||||
event, parseErr := parseStreamPayload(payload)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
if event.Content != "" || len(event.ToolCalls) > 0 || event.FinishReason != "" {
|
||||
if emitErr := emit(event); emitErr != nil {
|
||||
return emitErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type streamPayload struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []toolCallDeltaJSON `json:"tool_calls"`
|
||||
} `json:"delta"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type toolCallDeltaJSON struct {
|
||||
Index int `json:"index"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
}
|
||||
|
||||
func parseStreamPayload(payload string) (StreamEvent, error) {
|
||||
var decoded streamPayload
|
||||
if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
|
||||
return StreamEvent{}, fmt.Errorf("parse stream payload: %w", err)
|
||||
}
|
||||
if decoded.Error != nil {
|
||||
if decoded.Error.Type != "" {
|
||||
return StreamEvent{}, fmt.Errorf("provider error: %s: %s", decoded.Error.Type, decoded.Error.Message)
|
||||
}
|
||||
return StreamEvent{}, fmt.Errorf("provider error: %s", decoded.Error.Message)
|
||||
}
|
||||
if len(decoded.Choices) == 0 {
|
||||
return StreamEvent{}, nil
|
||||
}
|
||||
|
||||
choice := decoded.Choices[0]
|
||||
event := StreamEvent{
|
||||
Content: choice.Delta.Content,
|
||||
FinishReason: choice.FinishReason,
|
||||
}
|
||||
for _, tc := range choice.Delta.ToolCalls {
|
||||
event.ToolCalls = append(event.ToolCalls, ToolCallDelta{
|
||||
Index: tc.Index,
|
||||
ID: tc.ID,
|
||||
Type: tc.Type,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
})
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenAICompatibleClientStreamsContentAndToolCalls(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
t.Fatalf("path = %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer sk-test" {
|
||||
t.Fatalf("authorization = %q", got)
|
||||
}
|
||||
body := mustReadBody(t, r)
|
||||
var raw map[string]any
|
||||
if err := json.NewDecoder(strings.NewReader(body)).Decode(&raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req ChatRequest
|
||||
if err := json.NewDecoder(strings.NewReader(body)).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw["thinking"] != "high" {
|
||||
t.Fatalf("thinking = %#v", raw["thinking"])
|
||||
}
|
||||
if !req.Stream {
|
||||
t.Fatal("request did not enable stream")
|
||||
}
|
||||
if req.ToolChoice != "auto" && len(req.Tools) > 0 {
|
||||
t.Fatalf("tool_choice = %q", req.ToolChoice)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"content":"hi "},"finish_reason":""}]}` + "\n\n"))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"file_read","arguments":"{\"path\""}}]},"finish_reason":""}]}` + "\n\n"))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":":\"README.md\"}"}}]},"finish_reason":"tool_calls"}]}` + "\n\n"))
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewOpenAICompatibleClient(server.URL, "sk-test", server.Client())
|
||||
var content strings.Builder
|
||||
var calls []ToolCallDelta
|
||||
err := client.ChatStream(context.Background(), ChatRequest{Model: "test", Extra: map[string]any{"thinking": "high"}}, func(event StreamEvent) error {
|
||||
content.WriteString(event.Content)
|
||||
calls = append(calls, event.ToolCalls...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content.String() != "hi " {
|
||||
t.Fatalf("content = %q", content.String())
|
||||
}
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls = %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "file_read" || calls[1].Arguments != ":\"README.md\"}" {
|
||||
t.Fatalf("unexpected calls: %#v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func mustReadBody(t *testing.T, r *http.Request) string {
|
||||
t.Helper()
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleClientHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad key", http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewOpenAICompatibleClient(server.URL, "sk-test", server.Client())
|
||||
err := client.ChatStream(context.Background(), ChatRequest{Model: "test"}, func(StreamEvent) error {
|
||||
return nil
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "status=401") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
const (
|
||||
RoleSystem = "system"
|
||||
RoleUser = "user"
|
||||
RoleAssistant = "assistant"
|
||||
RoleTool = "tool"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
ChatStream(ctx context.Context, req ChatRequest, emit func(StreamEvent) error) error
|
||||
}
|
||||
|
||||
type ChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
ToolChoice string `json:"tool_choice,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
Extra map[string]any `json:"-"`
|
||||
}
|
||||
|
||||
func (r ChatRequest) MarshalJSON() ([]byte, error) {
|
||||
payload := map[string]any{
|
||||
"model": r.Model,
|
||||
"messages": r.Messages,
|
||||
"stream": r.Stream,
|
||||
}
|
||||
if len(r.Tools) > 0 {
|
||||
payload["tools"] = r.Tools
|
||||
}
|
||||
if r.ToolChoice != "" {
|
||||
payload["tool_choice"] = r.ToolChoice
|
||||
}
|
||||
for key, value := range r.Extra {
|
||||
if _, exists := payload[key]; exists {
|
||||
continue
|
||||
}
|
||||
payload[key] = value
|
||||
}
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Function FunctionCall `json:"function,omitempty"`
|
||||
}
|
||||
|
||||
type FunctionCall struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
type Tool struct {
|
||||
Type string `json:"type"`
|
||||
Function ToolFunction `json:"function"`
|
||||
}
|
||||
|
||||
type ToolFunction struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters json.RawMessage `json:"parameters"`
|
||||
}
|
||||
|
||||
type StreamEvent struct {
|
||||
Content string
|
||||
ToolCalls []ToolCallDelta
|
||||
FinishReason string
|
||||
}
|
||||
|
||||
type ToolCallDelta struct {
|
||||
Index int
|
||||
ID string
|
||||
Type string
|
||||
Name string
|
||||
Arguments string
|
||||
}
|
||||
Reference in New Issue
Block a user