Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47671f3098 | |||
| 28195519d1 | |||
| 425f6ee5ee |
@@ -8,6 +8,7 @@ model/provider switching, and local tools.
|
||||
|
||||
- TUI-first chat experience with a light theme by default.
|
||||
- OpenAI-compatible `/v1/chat/completions` provider support.
|
||||
- OpenAI Responses API (`/v1/responses`) provider support.
|
||||
- Multiple providers from `~/.agentu/config.yaml`.
|
||||
- Session-only provider, model, and thinking-level switching.
|
||||
- Read-only project tools enabled by default.
|
||||
@@ -48,6 +49,7 @@ Use `--config <path>` to load another file.
|
||||
```yaml
|
||||
providers:
|
||||
loveuer:
|
||||
api_type: chat
|
||||
base_url: https://ai.loveuer.com
|
||||
api_key: ${AGENTU_API_KEY}
|
||||
models:
|
||||
@@ -64,6 +66,20 @@ Provider names are the keys under `providers`. At startup, agentu selects the
|
||||
first provider by name. Use `/model provider <name>` to switch providers for the
|
||||
current session.
|
||||
|
||||
`api_type` selects which API protocol the provider speaks:
|
||||
|
||||
```text
|
||||
chat OpenAI-compatible /v1/chat/completions (default)
|
||||
responses OpenAI Responses API /v1/responses
|
||||
```
|
||||
|
||||
The Responses API client converts the conversation history to Responses input
|
||||
items (`user`/`assistant` messages, `function_call`, and
|
||||
`function_call_output`), sends the system prompt as the top-level
|
||||
`instructions` field, streams `response.output_text.delta` events, and supports
|
||||
function calling through `response.output_item.added`,
|
||||
`response.function_call_arguments.delta`, and `response.output_item.done`.
|
||||
|
||||
`models` is required. `/model model <name>` is restricted to that list. Each
|
||||
entry is an object with:
|
||||
|
||||
@@ -83,7 +99,11 @@ none, middle, high, xhigh, max
|
||||
```
|
||||
|
||||
When configured or changed with `/model thinking ...`, the value is sent as a
|
||||
top-level OpenAI-compatible request field named `thinking`.
|
||||
top-level request field. The default field depends on `api_type`: `chat` uses
|
||||
`thinking`, while `responses` uses the official `reasoning` field, sent as
|
||||
`{"effort": ...}` with agentu's `middle` level mapped to the official `medium`
|
||||
value (`none`, `high`, `xhigh`, `max` pass through). Override with
|
||||
`thinking_param` if a provider expects a different field name.
|
||||
|
||||
## CLI Flags
|
||||
|
||||
@@ -147,7 +167,8 @@ Web tools are also available in read-only mode:
|
||||
|
||||
- `file_write` for creating or replacing whole files
|
||||
- `file_edit` for surgical line-range or pattern edits to existing files
|
||||
- `shell_run` for non-interactive shell commands
|
||||
- `shell_run` for non-interactive shell commands; output streams live into the
|
||||
TUI while the command runs instead of appearing only after completion
|
||||
|
||||
Use `--yolo` for prompts that ask agentu to run or inspect local commands, for
|
||||
example:
|
||||
@@ -174,5 +195,8 @@ Local config and build outputs are intentionally ignored by git.
|
||||
|
||||
- Provider/model/thinking changes are session-only; agentu does not write them
|
||||
back to `~/.agentu/config.yaml`.
|
||||
- `api_type` is per provider and cannot be changed at runtime; use
|
||||
`/model provider <name>` to switch to a provider configured with a different
|
||||
API type.
|
||||
- The config schema is intentionally strict during MVP development. Deprecated
|
||||
fields such as `provider` or `active_provider` are rejected.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
providers:
|
||||
loveuer:
|
||||
# API protocol: "chat" (OpenAI-compatible /v1/chat/completions, default)
|
||||
# or "responses" (OpenAI Responses API /v1/responses).
|
||||
api_type: chat
|
||||
base_url: https://ai.loveuer.com
|
||||
api_key: ${AGENTU_API_KEY}
|
||||
models:
|
||||
|
||||
+63
-19
@@ -15,15 +15,16 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMaxToolRounds = 50
|
||||
doomLoopThreshold = 3
|
||||
defaultMaxToolRounds = 50
|
||||
doomLoopThreshold = 3
|
||||
compactMinRecentMessages = 6
|
||||
compactRecentRetentionPercent = 20
|
||||
summaryMessageLimit = 8000
|
||||
contextSummaryPrefix = "[context summary]"
|
||||
toolLogArgumentLimit = 400
|
||||
toolLogOutputPreviewBytes = 4096
|
||||
toolLogOutputMarker = "[output]"
|
||||
summaryMessageLimit = 8000
|
||||
contextSummaryPrefix = "[context summary]"
|
||||
toolLogArgumentLimit = 400
|
||||
toolLogOutputPreviewBytes = 4096
|
||||
toolLogOutputMarker = "[output]"
|
||||
toolStreamLogLimit = tools.MaxToolOutputBytes
|
||||
|
||||
summarizerSystemPrompt = "You are a conversation summarizer. Summarize the following conversation history into a concise but detailed summary. Preserve:\n" +
|
||||
"- Key facts, decisions, and conclusions\n" +
|
||||
@@ -182,6 +183,7 @@ func (a *Agent) SetLastUsage(usage *llm.Usage) {
|
||||
copy := *usage
|
||||
a.lastUsage = ©
|
||||
}
|
||||
|
||||
// compactRetainedTokenBudget returns the number of estimated tokens to retain
|
||||
// based on compactRecentRetentionPercent, using integer ceiling division.
|
||||
func compactRetainedTokenBudget(totalTokens int) int {
|
||||
@@ -237,7 +239,6 @@ func compactRecentStart(messages []llm.Message, prefixEnd int) int {
|
||||
return compactTurnBoundary(messages, prefixEnd, start)
|
||||
}
|
||||
|
||||
|
||||
// Compact summarizes older conversation messages while preserving the system
|
||||
// prompt and recent messages intact.
|
||||
func (a *Agent) Compact(ctx context.Context) error {
|
||||
@@ -495,32 +496,75 @@ func (a *Agent) toolDefinitions() []llm.Tool {
|
||||
return a.toolRegistry.Definitions()
|
||||
}
|
||||
|
||||
func (a *Agent) executeTool(ctx context.Context, call llm.ToolCall) string {
|
||||
func (a *Agent) executeTool(ctx context.Context, call llm.ToolCall, logs io.Writer) (string, bool) {
|
||||
name := call.Function.Name
|
||||
|
||||
if a.toolRegistry == nil {
|
||||
return tools.FormatError(fmt.Errorf("tool registry is disabled"))
|
||||
return tools.FormatError(fmt.Errorf("tool registry is disabled")), false
|
||||
}
|
||||
tool, ok := a.toolRegistry.Get(name)
|
||||
if !ok {
|
||||
return tools.FormatError(fmt.Errorf("unknown tool: %s", name))
|
||||
return tools.FormatError(fmt.Errorf("unknown tool: %s", name)), false
|
||||
}
|
||||
|
||||
args := strings.TrimSpace(call.Function.Arguments)
|
||||
if args == "" {
|
||||
args = "{}"
|
||||
}
|
||||
raw := json.RawMessage(args)
|
||||
|
||||
toolCtx, cancel := context.WithTimeout(ctx, a.toolTimeout)
|
||||
defer cancel()
|
||||
output, err := tool.Execute(toolCtx, json.RawMessage(args))
|
||||
|
||||
if streaming, ok := tool.(tools.StreamingTool); ok {
|
||||
return a.executeStreamingTool(streaming, toolCtx, raw, name, args, logs)
|
||||
}
|
||||
|
||||
output, err := tool.Execute(toolCtx, raw)
|
||||
if err != nil {
|
||||
if output != "" {
|
||||
return output + "\n" + tools.FormatError(err)
|
||||
return output + "\n" + tools.FormatError(err), false
|
||||
}
|
||||
return tools.FormatError(err)
|
||||
return tools.FormatError(err), false
|
||||
}
|
||||
return output
|
||||
return output, false
|
||||
}
|
||||
|
||||
func (a *Agent) executeStreamingTool(streaming tools.StreamingTool, ctx context.Context, raw json.RawMessage, name, args string, logs io.Writer) (string, bool) {
|
||||
var streamed int
|
||||
emit := func(chunk string) error {
|
||||
if chunk == "" || logs == nil {
|
||||
return nil
|
||||
}
|
||||
if streamed >= toolStreamLogLimit {
|
||||
return nil
|
||||
}
|
||||
if len(chunk) > toolStreamLogLimit-streamed {
|
||||
chunk = chunk[:toolStreamLogLimit-streamed]
|
||||
streamed = toolStreamLogLimit
|
||||
_, err := fmt.Fprintf(logs, "\n[tool] %s %s\n%s\n%s\n[streaming output truncated after %d bytes]", name, compact(args, toolLogArgumentLimit), toolLogOutputMarker, chunk, toolStreamLogLimit)
|
||||
return err
|
||||
}
|
||||
streamed += len(chunk)
|
||||
_, err := fmt.Fprintf(logs, "\n[tool] %s %s\n%s\n%s", name, compact(args, toolLogArgumentLimit), toolLogOutputMarker, chunk)
|
||||
return err
|
||||
}
|
||||
|
||||
output, err := streaming.ExecuteStream(ctx, raw, emit)
|
||||
if logs != nil {
|
||||
if err != nil {
|
||||
_ = emit("\n" + tools.FormatError(err))
|
||||
} else if streamed == 0 {
|
||||
_ = emit("(no output)")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if output != "" {
|
||||
return output + "\n" + tools.FormatError(err), true
|
||||
}
|
||||
return tools.FormatError(err), true
|
||||
}
|
||||
return output, true
|
||||
}
|
||||
|
||||
func (a *Agent) hasMutatingCall(calls []llm.ToolCall) bool {
|
||||
@@ -550,9 +594,9 @@ func (a *Agent) executeTools(ctx context.Context, calls []llm.ToolCall, logs io.
|
||||
if a.hasMutatingCall(calls) {
|
||||
// Sequential: avoid racing mutating tools.
|
||||
for i, call := range calls {
|
||||
output := a.executeTool(ctx, call)
|
||||
output, streamed := a.executeTool(ctx, call, logs)
|
||||
results[i] = toolExecutionResult{call: call, output: output}
|
||||
if logs != nil {
|
||||
if logs != nil && !streamed {
|
||||
logToolOutput(logs, call, output)
|
||||
}
|
||||
}
|
||||
@@ -566,9 +610,9 @@ func (a *Agent) executeTools(ctx context.Context, calls []llm.ToolCall, logs io.
|
||||
wg.Add(1)
|
||||
go func(idx int, c llm.ToolCall) {
|
||||
defer wg.Done()
|
||||
output := a.executeTool(ctx, c)
|
||||
output, streamed := a.executeTool(ctx, c, logs)
|
||||
results[idx] = toolExecutionResult{call: c, output: output}
|
||||
if logs != nil {
|
||||
if logs != nil && !streamed {
|
||||
mu.Lock()
|
||||
logToolOutput(logs, c, output)
|
||||
mu.Unlock()
|
||||
|
||||
@@ -87,6 +87,86 @@ func TestAgentRunsToolLoop(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type streamToolProvider struct {
|
||||
calls int
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func (p *streamToolProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error {
|
||||
p.calls++
|
||||
switch p.calls {
|
||||
case 1:
|
||||
return emit(llm.StreamEvent{ToolCalls: []llm.ToolCallDelta{
|
||||
{Index: 0, ID: "call_1", Type: "function", Name: "test_stream", Arguments: `{}`},
|
||||
}})
|
||||
case 2:
|
||||
last := req.Messages[len(req.Messages)-1]
|
||||
if last.Role != llm.RoleTool || last.Content != "full result" {
|
||||
p.t.Fatalf("last message = %#v", last)
|
||||
}
|
||||
return emit(llm.StreamEvent{Content: "done"})
|
||||
default:
|
||||
p.t.Fatalf("unexpected call count %d", p.calls)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type streamEchoTool struct{}
|
||||
|
||||
func (streamEchoTool) Definition() llm.Tool {
|
||||
return llm.Tool{
|
||||
Type: "function",
|
||||
Function: llm.ToolFunction{
|
||||
Name: "test_stream",
|
||||
Description: "streaming test",
|
||||
Parameters: json.RawMessage(`{"type":"object"}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (streamEchoTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
return "full result", nil
|
||||
}
|
||||
|
||||
func (streamEchoTool) ExecuteStream(ctx context.Context, raw json.RawMessage, emit func(string) error) (string, error) {
|
||||
if err := emit("one\n"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := emit("two\n"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "full result", nil
|
||||
}
|
||||
|
||||
func TestAgentStreamsToolOutputToLogs(t *testing.T) {
|
||||
provider := &streamToolProvider{t: t}
|
||||
a := New(Options{
|
||||
Provider: provider,
|
||||
Model: "test",
|
||||
SystemPrompt: "system",
|
||||
ToolRegistry: tools.NewRegistry(streamEchoTool{}),
|
||||
})
|
||||
|
||||
var out strings.Builder
|
||||
var logs strings.Builder
|
||||
if err := a.RunTurn(context.Background(), "stream", &out, &logs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.String() != "done" {
|
||||
t.Fatalf("out = %q", out.String())
|
||||
}
|
||||
logText := logs.String()
|
||||
if strings.Count(logText, "[output]") != 2 {
|
||||
t.Fatalf("logs = %q", logText)
|
||||
}
|
||||
if !strings.Contains(logText, "one\n") || !strings.Contains(logText, "two\n") {
|
||||
t.Fatalf("logs missing streamed chunks: %q", logText)
|
||||
}
|
||||
if strings.Contains(logText, "[output]\nfull result") {
|
||||
t.Fatalf("logs contain duplicate full output: %q", logText)
|
||||
}
|
||||
}
|
||||
|
||||
type contentProvider struct {
|
||||
called bool
|
||||
text string
|
||||
|
||||
+8
-1
@@ -91,10 +91,17 @@ func loadConfig(configPath string) (*config.Config, error) {
|
||||
func defaultProvider(cfg *config.Config, httpClient *http.Client) (string, config.ProviderConfig, llm.Provider) {
|
||||
providerName := cfg.DefaultProviderName()
|
||||
providerConfig := cfg.Providers[providerName]
|
||||
provider := llm.NewOpenAICompatibleClient(providerConfig.BaseURL, providerConfig.APIKey, httpClient)
|
||||
provider := newProviderClient(providerConfig, httpClient)
|
||||
return providerName, providerConfig, provider
|
||||
}
|
||||
|
||||
func newProviderClient(providerConfig config.ProviderConfig, httpClient *http.Client) llm.Provider {
|
||||
if providerConfig.APIType == config.APITypeResponses {
|
||||
return llm.NewOpenAIResponsesClient(providerConfig.BaseURL, providerConfig.APIKey, httpClient)
|
||||
}
|
||||
return llm.NewOpenAICompatibleClient(providerConfig.BaseURL, providerConfig.APIKey, httpClient)
|
||||
}
|
||||
|
||||
func newAssistant(cfg *config.Config, providerName string, providerConfig config.ProviderConfig, provider llm.Provider, yolo bool, httpClient *http.Client) *agent.Agent {
|
||||
return agent.New(agent.Options{
|
||||
Provider: provider,
|
||||
|
||||
@@ -68,11 +68,29 @@ func (m *Manager) Resume(id string) error {
|
||||
return err
|
||||
}
|
||||
m.currentSession = sess
|
||||
m.restoreRuntime(sess)
|
||||
m.agent.SetMessages(sess.Messages)
|
||||
m.agent.SetLastUsage(sess.LastUsage)
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreRuntime switches the manager and agent back to the provider, model,
|
||||
// and thinking level recorded on a resumed session. Values that are no longer
|
||||
// valid for the current config are skipped and the defaults remain active.
|
||||
func (m *Manager) restoreRuntime(sess *Session) {
|
||||
if sess.Provider != "" && sess.Provider != m.providerName {
|
||||
if _, ok := m.cfg.Providers[sess.Provider]; ok {
|
||||
_, _ = m.SetProvider(sess.Provider)
|
||||
}
|
||||
}
|
||||
if sess.Model != "" && sess.Model != m.provider.Model {
|
||||
_, _ = m.SetModel(sess.Model)
|
||||
}
|
||||
if sess.Thinking != "" && sess.Thinking != m.provider.Thinking {
|
||||
_, _ = m.SetThinking(sess.Thinking)
|
||||
}
|
||||
}
|
||||
|
||||
// Save persists the current session to disk.
|
||||
func (m *Manager) Save() error {
|
||||
if m.store == nil || m.currentSession == nil {
|
||||
@@ -82,6 +100,8 @@ func (m *Manager) Save() error {
|
||||
m.currentSession.LastUsage = m.agent.LastUsage()
|
||||
m.currentSession.Provider = m.providerName
|
||||
m.currentSession.Model = m.provider.Model
|
||||
m.currentSession.APIType = m.provider.APIType
|
||||
m.currentSession.Thinking = m.provider.Thinking
|
||||
// Auto-name if empty
|
||||
if m.currentSession.Name == "" {
|
||||
m.currentSession.Name = DefaultName(m.currentSession.Messages)
|
||||
@@ -150,6 +170,8 @@ func (m *Manager) NewSession() (string, error) {
|
||||
m.currentSession.Messages = msgs
|
||||
m.currentSession.Provider = m.providerName
|
||||
m.currentSession.Model = m.provider.Model
|
||||
m.currentSession.APIType = m.provider.APIType
|
||||
m.currentSession.Thinking = m.provider.Thinking
|
||||
if m.currentSession.Name == "" {
|
||||
m.currentSession.Name = DefaultName(msgs)
|
||||
}
|
||||
@@ -204,10 +226,18 @@ func (m *Manager) CurrentThinking() string {
|
||||
return m.provider.Thinking
|
||||
}
|
||||
|
||||
func (m *Manager) CurrentAPIType() string {
|
||||
if m.provider.APIType == "" {
|
||||
return config.APITypeChat
|
||||
}
|
||||
return m.provider.APIType
|
||||
}
|
||||
|
||||
func (m *Manager) Info() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "provider: %s\n", m.CurrentProvider())
|
||||
fmt.Fprintf(&b, "model: %s\n", m.CurrentModel())
|
||||
fmt.Fprintf(&b, "api: %s\n", m.CurrentAPIType())
|
||||
fmt.Fprintf(&b, "thinking: %s\n", m.CurrentThinking())
|
||||
fmt.Fprintf(&b, "providers: %s\n", strings.Join(m.providerNames(), ", "))
|
||||
fmt.Fprintf(&b, "models: %s\n", strings.Join(m.provider.Models, ", "))
|
||||
@@ -223,7 +253,7 @@ func (m *Manager) SetProvider(name string) (string, error) {
|
||||
}
|
||||
m.providerName = name
|
||||
m.provider = provider
|
||||
m.syncAgent(llm.NewOpenAICompatibleClient(provider.BaseURL, provider.APIKey, m.httpClient))
|
||||
m.syncAgent(providerClient(provider, m.httpClient))
|
||||
return "Switched provider.\n" + m.summary(), nil
|
||||
}
|
||||
|
||||
@@ -249,7 +279,7 @@ func (m *Manager) SetThinking(level string) (string, error) {
|
||||
m.provider.Thinking = level
|
||||
m.provider.ThinkingConfigured = true
|
||||
if m.provider.ThinkingParam == "" {
|
||||
m.provider.ThinkingParam = "thinking"
|
||||
m.provider.ThinkingParam = config.DefaultThinkingParam(m.provider.APIType)
|
||||
}
|
||||
m.cfg.Providers[m.providerName] = m.provider
|
||||
m.syncAgent(nil)
|
||||
@@ -257,7 +287,14 @@ func (m *Manager) SetThinking(level string) (string, error) {
|
||||
}
|
||||
|
||||
func (m *Manager) summary() string {
|
||||
return fmt.Sprintf("provider: %s\nmodel: %s\nthinking: %s", m.CurrentProvider(), m.CurrentModel(), m.CurrentThinking())
|
||||
return fmt.Sprintf("provider: %s\nmodel: %s\napi: %s\nthinking: %s", m.CurrentProvider(), m.CurrentModel(), m.CurrentAPIType(), m.CurrentThinking())
|
||||
}
|
||||
|
||||
func providerClient(provider config.ProviderConfig, httpClient *http.Client) llm.Provider {
|
||||
if provider.APIType == config.APITypeResponses {
|
||||
return llm.NewOpenAIResponsesClient(provider.BaseURL, provider.APIKey, httpClient)
|
||||
}
|
||||
return llm.NewOpenAICompatibleClient(provider.BaseURL, provider.APIKey, httpClient)
|
||||
}
|
||||
|
||||
func (m *Manager) maxContextTokens() int {
|
||||
|
||||
@@ -2,6 +2,8 @@ package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -109,6 +111,64 @@ func TestManagerRejectsUnknownModelAndThinking(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerSwitchesToResponsesProvider(t *testing.T) {
|
||||
var hitPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hitPath = r.URL.Path
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(`data: {"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"ok"}` + "\n\n"))
|
||||
_, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_1","status":"completed"}}` + "\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
Providers: map[string]config.ProviderConfig{
|
||||
"chat": {
|
||||
Name: "chat",
|
||||
APIType: config.APITypeChat,
|
||||
BaseURL: "https://chat.example.com",
|
||||
APIKey: "sk-test",
|
||||
Model: "model-a",
|
||||
Models: []string{"model-a"},
|
||||
},
|
||||
"responses": {
|
||||
Name: "responses",
|
||||
APIType: config.APITypeResponses,
|
||||
BaseURL: server.URL,
|
||||
APIKey: "sk-test",
|
||||
Model: "model-b",
|
||||
Models: []string{"model-b"},
|
||||
},
|
||||
},
|
||||
}
|
||||
assistant := agent.New(agent.Options{Provider: &recordingProvider{}, Model: "model-a"})
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "sessions"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager, err := NewManager(cfg, assistant, server.Client(), store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := manager.SetProvider("responses"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if manager.CurrentAPIType() != config.APITypeResponses {
|
||||
t.Fatalf("api type = %q", manager.CurrentAPIType())
|
||||
}
|
||||
var out strings.Builder
|
||||
if err := assistant.RunTurn(context.Background(), "hi", &out, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hitPath != "/v1/responses" {
|
||||
t.Fatalf("path = %q, want /v1/responses", hitPath)
|
||||
}
|
||||
if out.String() != "ok" {
|
||||
t.Fatalf("output = %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerExposesModelCommandCandidates(t *testing.T) {
|
||||
manager, _, _ := testManager(t)
|
||||
models := manager.AvailableModels()
|
||||
@@ -343,3 +403,132 @@ func TestManagerUsesModelContextForAgentCompaction(t *testing.T) {
|
||||
t.Fatalf("answerCalls = %d, want 1", provider.answerCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerResumeRestoresRuntime(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(`data: {"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"ok"}` + "\n\n"))
|
||||
_, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_1","status":"completed"}}` + "\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
Providers: map[string]config.ProviderConfig{
|
||||
"chat": {
|
||||
Name: "chat",
|
||||
APIType: config.APITypeChat,
|
||||
BaseURL: "https://chat.example.com",
|
||||
APIKey: "sk-test",
|
||||
Model: "model-a",
|
||||
Models: []string{"model-a"},
|
||||
Thinking: "none",
|
||||
ThinkingParam: "thinking",
|
||||
ThinkingConfigured: true,
|
||||
},
|
||||
"resp": {
|
||||
Name: "resp",
|
||||
APIType: config.APITypeResponses,
|
||||
BaseURL: server.URL,
|
||||
APIKey: "sk-test",
|
||||
Model: "m1",
|
||||
Models: []string{"m1", "m2"},
|
||||
Thinking: "none",
|
||||
ThinkingParam: "reasoning",
|
||||
ThinkingConfigured: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
assistant := agent.New(agent.Options{
|
||||
Provider: &recordingProvider{},
|
||||
Model: "model-a",
|
||||
Thinking: "none",
|
||||
ThinkingParam: "thinking",
|
||||
ThinkingEnabled: true,
|
||||
})
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "sessions"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager, err := NewManager(cfg, assistant, server.Client(), store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Build a session on the responses provider with a custom model and thinking.
|
||||
manager.newSession()
|
||||
if _, err := manager.SetProvider("resp"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := manager.SetModel("m2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := manager.SetThinking("xhigh"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manager.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := manager.CurrentSessionID()
|
||||
|
||||
// Drift away from the saved runtime.
|
||||
if _, err := manager.SetProvider("chat"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := manager.SetModel("model-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := manager.SetThinking("none"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := manager.Resume(id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := manager.CurrentProvider(); got != "resp" {
|
||||
t.Fatalf("provider = %q, want resp", got)
|
||||
}
|
||||
if got := manager.CurrentModel(); got != "m2" {
|
||||
t.Fatalf("model = %q, want m2", got)
|
||||
}
|
||||
if got := manager.CurrentThinking(); got != "xhigh" {
|
||||
t.Fatalf("thinking = %q, want xhigh", got)
|
||||
}
|
||||
if got := manager.CurrentAPIType(); got != config.APITypeResponses {
|
||||
t.Fatalf("api type = %q, want responses", got)
|
||||
}
|
||||
runtime := assistant.RuntimeInfo()
|
||||
if runtime.ProviderName != "resp" || runtime.Model != "m2" || runtime.Thinking != "xhigh" {
|
||||
t.Fatalf("agent runtime = %#v", runtime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerResumeFallsBackWhenRuntimeMissing(t *testing.T) {
|
||||
manager, assistant, _ := testManager(t)
|
||||
|
||||
sess := &Session{
|
||||
ID: GenerateID(),
|
||||
Provider: "ghost",
|
||||
Model: "ghost-model",
|
||||
Thinking: "xhigh",
|
||||
Messages: []llm.Message{{Role: llm.RoleUser, Content: "hi"}},
|
||||
}
|
||||
if err := manager.store.Save(sess); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manager.Resume(sess.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := manager.CurrentProvider(); got != "one" {
|
||||
t.Fatalf("provider = %q, want default one", got)
|
||||
}
|
||||
if got := manager.CurrentModel(); got != "model-a" {
|
||||
t.Fatalf("model = %q, want default model-a", got)
|
||||
}
|
||||
if got := manager.CurrentThinking(); got != "xhigh" {
|
||||
t.Fatalf("thinking = %q, want restored xhigh", got)
|
||||
}
|
||||
runtime := assistant.RuntimeInfo()
|
||||
if runtime.ProviderName != "one" || runtime.Model != "model-a" {
|
||||
t.Fatalf("agent runtime = %#v", runtime)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ type Session struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
APIType string `json:"api_type,omitempty"`
|
||||
Thinking string `json:"thinking,omitempty"`
|
||||
Messages []llm.Message `json:"messages"`
|
||||
LastUsage *llm.Usage `json:"last_usage,omitempty"`
|
||||
}
|
||||
|
||||
+40
-5
@@ -1,6 +1,7 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"agentu/pkg/llm"
|
||||
)
|
||||
@@ -46,6 +48,10 @@ type shellRunArgs struct {
|
||||
}
|
||||
|
||||
func (t *ShellRunTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
return t.ExecuteStream(ctx, raw, nil)
|
||||
}
|
||||
|
||||
func (t *ShellRunTool) ExecuteStream(ctx context.Context, raw json.RawMessage, emit func(string) error) (string, error) {
|
||||
var args shellRunArgs
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return "", fmt.Errorf("parse arguments: %w", err)
|
||||
@@ -66,16 +72,45 @@ func (t *ShellRunTool) Execute(ctx context.Context, raw json.RawMessage) (string
|
||||
cmd := exec.CommandContext(ctx, shell, "-lc", args.Command)
|
||||
cmd.Dir = workdir
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
text := FormatResult(string(output))
|
||||
var mu sync.Mutex
|
||||
var combined bytes.Buffer
|
||||
writer := &shellStreamWriter{mu: &mu, buf: &combined, emit: emit}
|
||||
cmd.Stdout = writer
|
||||
cmd.Stderr = writer
|
||||
|
||||
runErr := cmd.Run()
|
||||
text := FormatResult(combined.String())
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return text, fmt.Errorf("command timed out: %w", ctx.Err())
|
||||
}
|
||||
if err != nil {
|
||||
if runErr != nil {
|
||||
if text != "" {
|
||||
return text, fmt.Errorf("command failed: %w", err)
|
||||
return text, fmt.Errorf("command failed: %w", runErr)
|
||||
}
|
||||
return "", fmt.Errorf("command failed: %w", err)
|
||||
return "", fmt.Errorf("command failed: %w", runErr)
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// shellStreamWriter captures command output for the final result while
|
||||
// forwarding every write to the streaming callback as it happens.
|
||||
type shellStreamWriter struct {
|
||||
mu *sync.Mutex
|
||||
buf *bytes.Buffer
|
||||
emit func(string) error
|
||||
}
|
||||
|
||||
func (w *shellStreamWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
n, writeErr := w.buf.Write(p)
|
||||
w.mu.Unlock()
|
||||
if writeErr != nil {
|
||||
return n, writeErr
|
||||
}
|
||||
if w.emit != nil {
|
||||
if err := w.emit(string(p)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestShellRunStreamsOutputWhileRunning(t *testing.T) {
|
||||
tool := NewShellRunTool(t.TempDir())
|
||||
var mu sync.Mutex
|
||||
var chunks []string
|
||||
output, err := tool.ExecuteStream(context.Background(), json.RawMessage(`{"command":"printf 'a\\n'; sleep 0.05; printf 'b\\n'"}`), func(chunk string) error {
|
||||
mu.Lock()
|
||||
chunks = append(chunks, chunk)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(output, "a") || !strings.Contains(output, "b") {
|
||||
t.Fatalf("output = %q", output)
|
||||
}
|
||||
mu.Lock()
|
||||
joined := strings.Join(chunks, "")
|
||||
mu.Unlock()
|
||||
if len(chunks) == 0 {
|
||||
t.Fatal("expected at least one streamed chunk")
|
||||
}
|
||||
if !strings.Contains(joined, "a") || !strings.Contains(joined, "b") {
|
||||
t.Fatalf("streamed chunks = %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellRunExecuteCollectsFullOutput(t *testing.T) {
|
||||
tool := NewShellRunTool(t.TempDir())
|
||||
output, err := tool.Execute(context.Background(), json.RawMessage(`{"command":"printf 'hello world'"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.TrimSpace(output) != "hello world" {
|
||||
t.Fatalf("output = %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellRunStreamsStderrAndStdout(t *testing.T) {
|
||||
tool := NewShellRunTool(t.TempDir())
|
||||
var mu sync.Mutex
|
||||
var chunks []string
|
||||
output, err := tool.ExecuteStream(context.Background(), json.RawMessage(`{"command":"echo out; echo err >&2"}`), func(chunk string) error {
|
||||
mu.Lock()
|
||||
chunks = append(chunks, chunk)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(output, "out") || !strings.Contains(output, "err") {
|
||||
t.Fatalf("output = %q", output)
|
||||
}
|
||||
mu.Lock()
|
||||
joined := strings.Join(chunks, "")
|
||||
mu.Unlock()
|
||||
if !strings.Contains(joined, "out") || !strings.Contains(joined, "err") {
|
||||
t.Fatalf("streamed chunks = %q", joined)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,15 @@ type Tool interface {
|
||||
Execute(ctx context.Context, args json.RawMessage) (string, error)
|
||||
}
|
||||
|
||||
// StreamingTool is optionally implemented by tools that can report partial
|
||||
// output while they execute. emit receives chunks of the tool's output as
|
||||
// they are produced; the returned string is still the complete result that
|
||||
// gets sent back to the model.
|
||||
type StreamingTool interface {
|
||||
Tool
|
||||
ExecuteStream(ctx context.Context, args json.RawMessage, emit func(string) error) (string, error)
|
||||
}
|
||||
|
||||
// MutatingTool is optionally implemented by tools that modify external state.
|
||||
// It is used by the agent to determine whether tool calls can run in parallel.
|
||||
type MutatingTool interface {
|
||||
|
||||
+15
-3
@@ -683,15 +683,27 @@ func (m *model) mergeToolLog(text string) bool {
|
||||
if incoming.output == "" {
|
||||
return false
|
||||
}
|
||||
incomingHeader, _, ok := strings.Cut(text, "\n[output]\n")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for i := len(m.messages) - 1; i >= 0; i-- {
|
||||
if m.messages[i].role != roleTool {
|
||||
continue
|
||||
}
|
||||
existing := parseToolLog(m.messages[i].content)
|
||||
if existing.name == incoming.name && existing.args == incoming.args && existing.output == "" {
|
||||
m.messages[i].content = text
|
||||
return true
|
||||
if existing.name != incoming.name || existing.args != incoming.args {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(m.messages[i].content, "\n[output]\n") {
|
||||
// Placeholder card from the tool-start log; first chunk fills it.
|
||||
m.messages[i].content = text
|
||||
} else {
|
||||
// Streaming tools send incremental chunks; append the raw delta
|
||||
// so line boundaries are preserved inside the same card.
|
||||
m.messages[i].content += strings.TrimPrefix(text, incomingHeader+"\n[output]\n")
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -395,6 +395,28 @@ func TestToolLogOutputUpdatesExistingToolCard(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolLogStreamingChunksAppendToSameCard(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
m.messages = append(m.messages, message{role: roleTool, content: `shell_run echo hi`})
|
||||
|
||||
if !m.mergeToolLog("shell_run echo hi\n[output]\none\n") {
|
||||
t.Fatal("expected first mergeToolLog to return true")
|
||||
}
|
||||
if !m.mergeToolLog("shell_run echo hi\n[output]\ntwo\n") {
|
||||
t.Fatal("expected second mergeToolLog to return true")
|
||||
}
|
||||
if len(m.messages) != 1 {
|
||||
t.Fatalf("messages count = %d, want 1", len(m.messages))
|
||||
}
|
||||
plain := stripANSI(m.renderMessages())
|
||||
if !strings.Contains(plain, "one") || !strings.Contains(plain, "two") {
|
||||
t.Fatalf("rendered output missing streamed lines:\n%s", plain)
|
||||
}
|
||||
if strings.Contains(plain, "onetwo") {
|
||||
t.Fatalf("streamed lines were concatenated:\n%s", plain)
|
||||
}
|
||||
}
|
||||
|
||||
type tuiCompactProvider struct{}
|
||||
|
||||
func (tuiCompactProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error {
|
||||
|
||||
+45
-1
@@ -14,6 +14,11 @@ import (
|
||||
|
||||
const DefaultPath = "~/.agentu/config.yaml"
|
||||
|
||||
const (
|
||||
APITypeChat = "chat"
|
||||
APITypeResponses = "responses"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Providers map[string]ProviderConfig `yaml:"providers"`
|
||||
Agent AgentConfig `yaml:"agent"`
|
||||
@@ -21,6 +26,7 @@ type Config struct {
|
||||
|
||||
type ProviderConfig struct {
|
||||
Name string
|
||||
APIType string
|
||||
BaseURL string
|
||||
Model string
|
||||
Models []string
|
||||
@@ -56,6 +62,7 @@ type rawConfig struct {
|
||||
}
|
||||
|
||||
type rawProviderConfig struct {
|
||||
APIType string `yaml:"api_type"`
|
||||
BaseURL string `yaml:"base_url"`
|
||||
Models []rawModelConfig `yaml:"models"`
|
||||
APIKey string `yaml:"api_key"`
|
||||
@@ -153,6 +160,9 @@ func (c *Config) Validate() error {
|
||||
for name, provider := range c.Providers {
|
||||
var missing []string
|
||||
prefix := "providers." + name
|
||||
if !IsAPIType(provider.APIType) {
|
||||
return fmt.Errorf("%s.api_type must be one of: %s", prefix, strings.Join(APITypes(), ", "))
|
||||
}
|
||||
if strings.TrimSpace(provider.BaseURL) == "" {
|
||||
missing = append(missing, prefix+".base_url")
|
||||
}
|
||||
@@ -220,6 +230,7 @@ func normalizeProvider(name string, raw rawProviderConfig) ProviderConfig {
|
||||
models, modelConfigs := normalizeModelConfigs(raw.Models)
|
||||
provider := ProviderConfig{
|
||||
Name: name,
|
||||
APIType: normalizeAPIType(raw.APIType),
|
||||
BaseURL: raw.BaseURL,
|
||||
Models: models,
|
||||
ModelConfigs: modelConfigs,
|
||||
@@ -230,11 +241,44 @@ func normalizeProvider(name string, raw rawProviderConfig) ProviderConfig {
|
||||
}
|
||||
applyModelDefaults(&provider)
|
||||
if provider.ThinkingConfigured && provider.ThinkingParam == "" {
|
||||
provider.ThinkingParam = "thinking"
|
||||
provider.ThinkingParam = DefaultThinkingParam(provider.APIType)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
func normalizeAPIType(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
return APITypeChat
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func APITypes() []string {
|
||||
return []string{APITypeChat, APITypeResponses}
|
||||
}
|
||||
|
||||
func IsAPIType(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
for _, apiType := range APITypes() {
|
||||
if value == apiType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DefaultThinkingParam returns the default request field used for the
|
||||
// thinking level. Chat-completions providers historically use a top-level
|
||||
// "thinking" field; the OpenAI Responses API uses "reasoning" with an
|
||||
// {"effort": ...} value.
|
||||
func DefaultThinkingParam(apiType string) string {
|
||||
if apiType == APITypeResponses {
|
||||
return "reasoning"
|
||||
}
|
||||
return "thinking"
|
||||
}
|
||||
|
||||
func normalizeModelConfigs(rawModels []rawModelConfig) ([]string, map[string]ModelConfig) {
|
||||
models := make([]string, 0, len(rawModels))
|
||||
configs := make(map[string]ModelConfig, len(rawModels))
|
||||
|
||||
@@ -370,3 +370,110 @@ providers:
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultsAPITypeToChat(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("AGENTU_TEST_KEY", "sk-test")
|
||||
path := filepath.Join(dir, "agentu.yaml")
|
||||
if err := os.WriteFile(path, []byte(`
|
||||
providers:
|
||||
test:
|
||||
base_url: https://example.com
|
||||
api_key: ${AGENTU_TEST_KEY}
|
||||
models:
|
||||
- id: test-model
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := cfg.Providers["test"].APIType; got != APITypeChat {
|
||||
t.Fatalf("api type = %q, want %q", got, APITypeChat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadParsesResponsesAPIType(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("AGENTU_TEST_KEY", "sk-test")
|
||||
path := filepath.Join(dir, "agentu.yaml")
|
||||
if err := os.WriteFile(path, []byte(`
|
||||
providers:
|
||||
openai:
|
||||
api_type: RESPONSES
|
||||
base_url: https://api.openai.com
|
||||
api_key: ${AGENTU_TEST_KEY}
|
||||
models:
|
||||
- id: gpt-test
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := cfg.Providers["openai"].APIType; got != APITypeResponses {
|
||||
t.Fatalf("api type = %q, want %q", got, APITypeResponses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidAPIType(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("AGENTU_TEST_KEY", "sk-test")
|
||||
path := filepath.Join(dir, "agentu.yaml")
|
||||
if err := os.WriteFile(path, []byte(`
|
||||
providers:
|
||||
test:
|
||||
api_type: completions
|
||||
base_url: https://example.com
|
||||
api_key: ${AGENTU_TEST_KEY}
|
||||
models:
|
||||
- id: test-model
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := Load(path)
|
||||
if err == nil || !strings.Contains(err.Error(), "api_type") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultsThinkingParamByAPIType(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("AGENTU_TEST_KEY", "sk-test")
|
||||
path := filepath.Join(dir, "agentu.yaml")
|
||||
if err := os.WriteFile(path, []byte(`
|
||||
providers:
|
||||
chat:
|
||||
api_type: chat
|
||||
base_url: https://chat.example.com
|
||||
api_key: ${AGENTU_TEST_KEY}
|
||||
models:
|
||||
- id: chat-model
|
||||
thinking: none
|
||||
responses:
|
||||
api_type: responses
|
||||
base_url: https://responses.example.com
|
||||
api_key: ${AGENTU_TEST_KEY}
|
||||
models:
|
||||
- id: responses-model
|
||||
thinking: high
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := cfg.Providers["chat"].ThinkingParam; got != "thinking" {
|
||||
t.Fatalf("chat thinking param = %q, want %q", got, "thinking")
|
||||
}
|
||||
if got := cfg.Providers["responses"].ThinkingParam; got != "reasoning" {
|
||||
t.Fatalf("responses thinking param = %q, want %q", got, "reasoning")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const responsesEndpoint = "/v1/responses"
|
||||
|
||||
// OpenAIResponsesClient implements llm.Provider against the OpenAI Responses
|
||||
// API (/v1/responses). It maps the agent's chat-style conversation history to
|
||||
// Responses input items (messages, function_call, function_call_output) and
|
||||
// converts Responses streaming events back into the shared StreamEvent shape.
|
||||
type OpenAIResponsesClient struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewOpenAIResponsesClient(baseURL, apiKey string, httpClient *http.Client) *OpenAIResponsesClient {
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
return &OpenAIResponsesClient{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
apiKey: apiKey,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *OpenAIResponsesClient) ChatStream(ctx context.Context, req ChatRequest, emit func(StreamEvent) error) error {
|
||||
body, err := json.Marshal(buildResponsesRequest(req))
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal responses request: %w", err)
|
||||
}
|
||||
state := &responsesStreamState{}
|
||||
return postResponsesSSE(ctx, c.baseURL, c.apiKey, c.httpClient, body, func(payload string) error {
|
||||
return state.handle(payload, emit)
|
||||
})
|
||||
}
|
||||
|
||||
func postResponsesSSE(ctx context.Context, baseURL, apiKey string, httpClient *http.Client, body []byte, handle func(payload string) error) error {
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= maxChatAttempts; attempt++ {
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+responsesEndpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create responses request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "text/event-stream")
|
||||
if apiKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("send responses request: %w", err)
|
||||
if !shouldRetryRequest(ctx, attempt, 0) {
|
||||
return lastErr
|
||||
}
|
||||
if waitErr := waitBeforeRetry(ctx); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
limit := io.LimitReader(resp.Body, 4096)
|
||||
data, _ := io.ReadAll(limit)
|
||||
_ = resp.Body.Close()
|
||||
lastErr = fmt.Errorf("responses request failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
if !shouldRetryRequest(ctx, attempt, resp.StatusCode) {
|
||||
return lastErr
|
||||
}
|
||||
if waitErr := waitBeforeRetry(ctx); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
return readResponsesSSE(resp.Body, handle)
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func readResponsesSSE(r io.Reader, handle func(payload string) error) error {
|
||||
reader := bufio.NewReader(r)
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return fmt.Errorf("read responses stream: %w", err)
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if payload == "[DONE]" {
|
||||
return nil
|
||||
}
|
||||
if handleErr := handle(payload); handleErr != nil {
|
||||
return handleErr
|
||||
}
|
||||
}
|
||||
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// buildResponsesRequest converts a ChatRequest into the /v1/responses body.
|
||||
func buildResponsesRequest(req ChatRequest) map[string]any {
|
||||
payload := map[string]any{
|
||||
"model": req.Model,
|
||||
"input": buildResponsesInput(req.Messages),
|
||||
"stream": true,
|
||||
}
|
||||
if instructions := responsesInstructions(req.Messages); instructions != "" {
|
||||
payload["instructions"] = instructions
|
||||
}
|
||||
if len(req.Tools) > 0 {
|
||||
payload["tools"] = buildResponsesTools(req.Tools)
|
||||
}
|
||||
if req.ToolChoice != "" {
|
||||
payload["tool_choice"] = req.ToolChoice
|
||||
}
|
||||
for key, value := range req.Extra {
|
||||
if _, exists := payload[key]; exists {
|
||||
continue
|
||||
}
|
||||
if key == "reasoning" {
|
||||
if level, ok := value.(string); ok {
|
||||
payload[key] = map[string]any{"effort": mapReasoningEffort(level)}
|
||||
continue
|
||||
}
|
||||
}
|
||||
payload[key] = value
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// buildResponsesInput maps chat-style messages to Responses input items.
|
||||
// Assistant tool calls become function_call items and tool results become
|
||||
// function_call_output items, preserving their position in the conversation.
|
||||
func buildResponsesInput(messages []Message) []any {
|
||||
items := make([]any, 0, len(messages)+4)
|
||||
for _, msg := range messages {
|
||||
switch msg.Role {
|
||||
case RoleSystem:
|
||||
// System instructions are sent as the top-level "instructions"
|
||||
// field and must not be duplicated inside the input array.
|
||||
continue
|
||||
case RoleUser:
|
||||
items = append(items, responsesMessageInput{Role: "user", Content: msg.Content})
|
||||
case RoleAssistant:
|
||||
if msg.Content != "" {
|
||||
items = append(items, responsesMessageInput{Role: "assistant", Content: msg.Content})
|
||||
}
|
||||
for _, call := range msg.ToolCalls {
|
||||
items = append(items, responsesFunctionCallInput{
|
||||
Type: "function_call",
|
||||
CallID: call.ID,
|
||||
Name: call.Function.Name,
|
||||
Arguments: call.Function.Arguments,
|
||||
})
|
||||
}
|
||||
case RoleTool:
|
||||
items = append(items, responsesFunctionCallOutput{
|
||||
Type: "function_call_output",
|
||||
CallID: msg.ToolCallID,
|
||||
Output: msg.Content,
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// responsesInstructions extracts system messages into a single instructions
|
||||
// string, which is the OpenAI Responses API way to pass developer/system
|
||||
// guidance (and the most prompt-cache friendly).
|
||||
func responsesInstructions(messages []Message) string {
|
||||
var instructions []string
|
||||
for _, msg := range messages {
|
||||
if msg.Role != RoleSystem {
|
||||
continue
|
||||
}
|
||||
if text := strings.TrimSpace(msg.Content); text != "" {
|
||||
instructions = append(instructions, text)
|
||||
}
|
||||
}
|
||||
return strings.Join(instructions, "\n\n")
|
||||
}
|
||||
|
||||
func buildResponsesTools(tools []Tool) []responsesTool {
|
||||
out := make([]responsesTool, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
out = append(out, responsesTool{
|
||||
Type: "function",
|
||||
Name: tool.Function.Name,
|
||||
Description: tool.Function.Description,
|
||||
Parameters: tool.Function.Parameters,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mapReasoningEffort(level string) string {
|
||||
level = strings.ToLower(strings.TrimSpace(level))
|
||||
if level == "middle" {
|
||||
return "medium"
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
type responsesMessageInput struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type responsesFunctionCallInput struct {
|
||||
Type string `json:"type"`
|
||||
CallID string `json:"call_id"`
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
|
||||
type responsesFunctionCallOutput struct {
|
||||
Type string `json:"type"`
|
||||
CallID string `json:"call_id"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
type responsesTool struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Parameters json.RawMessage `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
// responsesStreamState tracks in-flight output items while parsing SSE events.
|
||||
type responsesStreamState struct {
|
||||
calls map[int]*responsesCallBuilder
|
||||
done bool
|
||||
}
|
||||
|
||||
type responsesCallBuilder struct {
|
||||
index int
|
||||
id string
|
||||
callType string
|
||||
name string
|
||||
arguments strings.Builder
|
||||
finalized bool
|
||||
}
|
||||
|
||||
func (s *responsesStreamState) handle(payload string, emit func(StreamEvent) error) error {
|
||||
if s.done {
|
||||
return nil
|
||||
}
|
||||
var event responsesStreamEvent
|
||||
if err := json.Unmarshal([]byte(payload), &event); err != nil {
|
||||
return fmt.Errorf("parse responses stream payload: %w", err)
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "error":
|
||||
s.done = true
|
||||
return event.err()
|
||||
case "response.failed":
|
||||
s.done = true
|
||||
if event.Response != nil && event.Response.Error != nil {
|
||||
return fmt.Errorf("provider error: %s: %s", event.Response.Error.Code, event.Response.Error.Message)
|
||||
}
|
||||
return fmt.Errorf("provider error: response failed")
|
||||
case "response.incomplete":
|
||||
s.done = true
|
||||
return s.finish(emit, "length", nil)
|
||||
case "response.completed":
|
||||
s.done = true
|
||||
var usage *Usage
|
||||
if event.Response != nil && event.Response.Usage != nil {
|
||||
usage = &Usage{
|
||||
PromptTokens: event.Response.Usage.InputTokens,
|
||||
CompletionTokens: event.Response.Usage.OutputTokens,
|
||||
TotalTokens: event.Response.Usage.TotalTokens,
|
||||
}
|
||||
}
|
||||
return s.finish(emit, "stop", usage)
|
||||
case "response.output_text.delta":
|
||||
if event.Delta != "" {
|
||||
return emit(StreamEvent{Content: event.Delta})
|
||||
}
|
||||
case "response.output_item.added":
|
||||
if event.Item != nil && event.Item.Type == "function_call" {
|
||||
s.builder(event.OutputIndex).applyAdded(*event.Item)
|
||||
}
|
||||
case "response.function_call_arguments.delta":
|
||||
if event.Delta != "" {
|
||||
s.builder(event.OutputIndex).arguments.WriteString(event.Delta)
|
||||
}
|
||||
case "response.function_call_arguments.done":
|
||||
builder := s.builder(event.OutputIndex)
|
||||
if event.Name != "" {
|
||||
builder.name = event.Name
|
||||
}
|
||||
if event.Arguments != "" {
|
||||
builder.arguments.Reset()
|
||||
builder.arguments.WriteString(event.Arguments)
|
||||
}
|
||||
case "response.output_item.done":
|
||||
if event.Item != nil && event.Item.Type == "function_call" {
|
||||
builder := s.builder(event.OutputIndex)
|
||||
builder.applyDone(*event.Item)
|
||||
return s.emitCall(builder, emit)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// finish flushes any not-yet-finalized function calls and reports the
|
||||
// terminal finish reason.
|
||||
func (s *responsesStreamState) finish(emit func(StreamEvent) error, finishReason string, usage *Usage) error {
|
||||
if len(s.calls) == 0 {
|
||||
return emit(StreamEvent{FinishReason: finishReason, Usage: usage})
|
||||
}
|
||||
indexes := make([]int, 0, len(s.calls))
|
||||
for index := range s.calls {
|
||||
indexes = append(indexes, index)
|
||||
}
|
||||
sort.Ints(indexes)
|
||||
var deltas []ToolCallDelta
|
||||
for _, index := range indexes {
|
||||
builder := s.calls[index]
|
||||
if builder.finalized {
|
||||
continue
|
||||
}
|
||||
builder.finalized = true
|
||||
deltas = append(deltas, builder.delta())
|
||||
}
|
||||
if len(deltas) > 0 {
|
||||
if err := emit(StreamEvent{ToolCalls: deltas}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return emit(StreamEvent{FinishReason: finishReason, Usage: usage})
|
||||
}
|
||||
|
||||
func (s *responsesStreamState) emitCall(builder *responsesCallBuilder, emit func(StreamEvent) error) error {
|
||||
if builder.finalized {
|
||||
return nil
|
||||
}
|
||||
builder.finalized = true
|
||||
return emit(StreamEvent{ToolCalls: []ToolCallDelta{builder.delta()}})
|
||||
}
|
||||
|
||||
func (s *responsesStreamState) builder(index int) *responsesCallBuilder {
|
||||
if s.calls == nil {
|
||||
s.calls = make(map[int]*responsesCallBuilder)
|
||||
}
|
||||
builder := s.calls[index]
|
||||
if builder == nil {
|
||||
builder = &responsesCallBuilder{index: index}
|
||||
s.calls[index] = builder
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
func (b *responsesCallBuilder) applyAdded(item responsesStreamItem) {
|
||||
if b.id == "" {
|
||||
b.id = item.CallID
|
||||
if b.id == "" {
|
||||
b.id = item.ID
|
||||
}
|
||||
}
|
||||
if b.name == "" {
|
||||
b.name = item.Name
|
||||
}
|
||||
if item.Arguments != "" {
|
||||
b.arguments.Reset()
|
||||
b.arguments.WriteString(item.Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *responsesCallBuilder) applyDone(item responsesStreamItem) {
|
||||
if item.CallID != "" {
|
||||
b.id = item.CallID
|
||||
} else if item.ID != "" {
|
||||
b.id = item.ID
|
||||
}
|
||||
if item.Name != "" {
|
||||
b.name = item.Name
|
||||
}
|
||||
if item.Arguments != "" {
|
||||
b.arguments.Reset()
|
||||
b.arguments.WriteString(item.Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *responsesCallBuilder) delta() ToolCallDelta {
|
||||
id := b.id
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("call_%d", b.index)
|
||||
}
|
||||
callType := b.callType
|
||||
if callType == "" {
|
||||
callType = "function"
|
||||
}
|
||||
return ToolCallDelta{
|
||||
Index: b.index,
|
||||
ID: id,
|
||||
Type: callType,
|
||||
Name: b.name,
|
||||
Arguments: b.arguments.String(),
|
||||
}
|
||||
}
|
||||
|
||||
type responsesStreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
OutputIndex int `json:"output_index"`
|
||||
Delta string `json:"delta"`
|
||||
Arguments string `json:"arguments"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Param string `json:"param"`
|
||||
Item *responsesStreamItem `json:"item"`
|
||||
Response *responsesStreamResponse `json:"response"`
|
||||
Error *responsesStreamError `json:"error"`
|
||||
}
|
||||
|
||||
type responsesStreamItem struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
CallID string `json:"call_id"`
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
|
||||
type responsesStreamResponse struct {
|
||||
Status string `json:"status"`
|
||||
Error *responsesStreamError `json:"error"`
|
||||
Usage *responsesStreamUsage `json:"usage"`
|
||||
}
|
||||
|
||||
type responsesStreamUsage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
type responsesStreamError struct {
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Param string `json:"param"`
|
||||
}
|
||||
|
||||
func (e responsesStreamEvent) err() error {
|
||||
code, message, param := e.Code, e.Message, e.Param
|
||||
if code == "" && message == "" && e.Error != nil {
|
||||
code, message, param = e.Error.Code, e.Error.Message, e.Error.Param
|
||||
}
|
||||
if message == "" {
|
||||
if param != "" {
|
||||
return fmt.Errorf("provider error: %s", param)
|
||||
}
|
||||
return fmt.Errorf("provider error")
|
||||
}
|
||||
if code != "" {
|
||||
return fmt.Errorf("provider error: %s: %s", code, message)
|
||||
}
|
||||
return fmt.Errorf("provider error: %s", message)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildResponsesRequest(t *testing.T) {
|
||||
req := ChatRequest{
|
||||
Model: "gpt-test",
|
||||
Messages: []Message{
|
||||
{Role: RoleSystem, Content: "be concise"},
|
||||
{Role: RoleUser, Content: "hi"},
|
||||
{
|
||||
Role: RoleAssistant,
|
||||
Content: "let me check",
|
||||
ToolCalls: []ToolCall{{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: FunctionCall{
|
||||
Name: "file_read",
|
||||
Arguments: `{"path":"README.md"}`,
|
||||
},
|
||||
}},
|
||||
},
|
||||
{Role: RoleTool, ToolCallID: "call_1", Content: "contents"},
|
||||
{Role: RoleAssistant, Content: "done"},
|
||||
},
|
||||
Tools: []Tool{{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "file_read",
|
||||
Description: "Read a local file",
|
||||
Parameters: json.RawMessage(`{"type":"object","properties":{}}`),
|
||||
},
|
||||
}},
|
||||
ToolChoice: "auto",
|
||||
Extra: map[string]any{
|
||||
"reasoning": "middle",
|
||||
"thinking": "high",
|
||||
"temperature": 0.2,
|
||||
},
|
||||
}
|
||||
|
||||
payload := buildResponsesRequest(req)
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if raw["model"] != "gpt-test" || raw["stream"] != true {
|
||||
t.Fatalf("model/stream = %#v / %#v", raw["model"], raw["stream"])
|
||||
}
|
||||
if raw["instructions"] != "be concise" {
|
||||
t.Fatalf("instructions = %#v", raw["instructions"])
|
||||
}
|
||||
if raw["tool_choice"] != "auto" {
|
||||
t.Fatalf("tool_choice = %#v", raw["tool_choice"])
|
||||
}
|
||||
|
||||
input, ok := raw["input"].([]any)
|
||||
if !ok || len(input) != 5 {
|
||||
t.Fatalf("input = %#v", raw["input"])
|
||||
}
|
||||
checkInputItem := func(index int, wantType, wantKey, wantValue string) {
|
||||
t.Helper()
|
||||
item, ok := input[index].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("input[%d] = %#v", index, input[index])
|
||||
}
|
||||
if item[wantKey] != wantValue {
|
||||
t.Fatalf("input[%d].%s = %#v, want %q", index, wantKey, item[wantKey], wantValue)
|
||||
}
|
||||
if wantType != "" && item["type"] != wantType {
|
||||
t.Fatalf("input[%d].type = %#v, want %q", index, item["type"], wantType)
|
||||
}
|
||||
}
|
||||
checkInputItem(0, "", "role", "user")
|
||||
checkInputItem(1, "", "role", "assistant")
|
||||
checkInputItem(2, "function_call", "call_id", "call_1")
|
||||
checkInputItem(3, "function_call_output", "call_id", "call_1")
|
||||
checkInputItem(4, "", "role", "assistant")
|
||||
if item := input[2].(map[string]any); item["name"] != "file_read" || item["arguments"] != `{"path":"README.md"}` {
|
||||
t.Fatalf("function_call item = %#v", item)
|
||||
}
|
||||
if item := input[3].(map[string]any); item["output"] != "contents" {
|
||||
t.Fatalf("function_call_output item = %#v", item)
|
||||
}
|
||||
|
||||
tools, ok := raw["tools"].([]any)
|
||||
if !ok || len(tools) != 1 {
|
||||
t.Fatalf("tools = %#v", raw["tools"])
|
||||
}
|
||||
tool := tools[0].(map[string]any)
|
||||
if tool["type"] != "function" || tool["name"] != "file_read" || tool["description"] != "Read a local file" {
|
||||
t.Fatalf("tool = %#v", tool)
|
||||
}
|
||||
if _, ok := tool["parameters"].(map[string]any); !ok {
|
||||
t.Fatalf("tool parameters = %#v", tool["parameters"])
|
||||
}
|
||||
|
||||
reasoning, ok := raw["reasoning"].(map[string]any)
|
||||
if !ok || reasoning["effort"] != "medium" {
|
||||
t.Fatalf("reasoning = %#v", raw["reasoning"])
|
||||
}
|
||||
if raw["thinking"] != "high" {
|
||||
t.Fatalf("thinking = %#v", raw["thinking"])
|
||||
}
|
||||
if raw["temperature"] != 0.2 {
|
||||
t.Fatalf("temperature = %#v", raw["temperature"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesInstructionsCombinesSystemMessages(t *testing.T) {
|
||||
payload := buildResponsesRequest(ChatRequest{
|
||||
Model: "gpt-test",
|
||||
Messages: []Message{
|
||||
{Role: RoleSystem, Content: "first instruction"},
|
||||
{Role: RoleUser, Content: "hi"},
|
||||
{Role: RoleSystem, Content: "second instruction"},
|
||||
},
|
||||
})
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw["instructions"] != "first instruction\n\nsecond instruction" {
|
||||
t.Fatalf("instructions = %#v", raw["instructions"])
|
||||
}
|
||||
input, ok := raw["input"].([]any)
|
||||
if !ok || len(input) != 1 {
|
||||
t.Fatalf("input = %#v", raw["input"])
|
||||
}
|
||||
item, ok := input[0].(map[string]any)
|
||||
if !ok || item["role"] != "user" {
|
||||
t.Fatalf("input[0] = %#v", input[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesClientStreamsContentAndToolCalls(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/responses" {
|
||||
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)
|
||||
}
|
||||
if raw["stream"] != true {
|
||||
t.Fatal("request did not enable stream")
|
||||
}
|
||||
if raw["tool_choice"] != "auto" {
|
||||
t.Fatalf("tool_choice = %#v", raw["tool_choice"])
|
||||
}
|
||||
input, ok := raw["input"].([]any)
|
||||
if !ok || len(input) == 0 {
|
||||
t.Fatalf("input = %#v", raw["input"])
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
events := []string{
|
||||
`{"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_1","role":"assistant","content":[]}}`,
|
||||
`{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"Hel"}`,
|
||||
`{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"lo"}`,
|
||||
`{"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"file_read","arguments":""}}`,
|
||||
`{"type":"response.function_call_arguments.delta","item_id":"fc_1","output_index":1,"delta":"{\"path\""}`,
|
||||
`{"type":"response.function_call_arguments.delta","item_id":"fc_1","output_index":1,"delta":":\"README.md\"}"}`,
|
||||
`{"type":"response.function_call_arguments.done","item_id":"fc_1","output_index":1,"name":"file_read","arguments":"{\"path\":\"README.md\"}"}`,
|
||||
`{"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"file_read","arguments":"{\"path\":\"README.md\"}"}}`,
|
||||
`{"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_1","role":"assistant","content":[{"type":"output_text","text":"Hello"}]}}`,
|
||||
`{"type":"response.completed","response":{"id":"resp_1","status":"completed","usage":{"input_tokens":10,"output_tokens":20,"total_tokens":30}}}`,
|
||||
}
|
||||
for _, event := range events {
|
||||
_, _ = w.Write([]byte("data: " + event + "\n\n"))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewOpenAIResponsesClient(server.URL, "sk-test", server.Client())
|
||||
var content strings.Builder
|
||||
var calls []ToolCallDelta
|
||||
var finishes []string
|
||||
var usage *Usage
|
||||
err := client.ChatStream(context.Background(), ChatRequest{
|
||||
Model: "gpt-test",
|
||||
Messages: []Message{{Role: RoleUser, Content: "hi"}},
|
||||
ToolChoice: "auto",
|
||||
Tools: []Tool{{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "file_read",
|
||||
Description: "Read a local file",
|
||||
Parameters: json.RawMessage(`{"type":"object"}`),
|
||||
},
|
||||
}},
|
||||
}, func(event StreamEvent) error {
|
||||
content.WriteString(event.Content)
|
||||
calls = append(calls, event.ToolCalls...)
|
||||
if event.Usage != nil {
|
||||
usage = event.Usage
|
||||
}
|
||||
if event.FinishReason != "" {
|
||||
finishes = append(finishes, event.FinishReason)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content.String() != "Hello" {
|
||||
t.Fatalf("content = %q", content.String())
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls = %#v", calls)
|
||||
}
|
||||
call := calls[0]
|
||||
if call.Index != 1 || call.ID != "call_1" || call.Name != "file_read" {
|
||||
t.Fatalf("call = %#v", call)
|
||||
}
|
||||
if call.Arguments != `{"path":"README.md"}` {
|
||||
t.Fatalf("arguments = %q", call.Arguments)
|
||||
}
|
||||
if len(finishes) != 1 || finishes[0] != "stop" {
|
||||
t.Fatalf("finishes = %#v", finishes)
|
||||
}
|
||||
if usage == nil || usage.PromptTokens != 10 || usage.CompletionTokens != 20 || usage.TotalTokens != 30 {
|
||||
t.Fatalf("usage = %#v", usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesClientErrorEvent(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(`data: {"type":"error","code":"invalid_request_error","message":"bad request"}` + "\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewOpenAIResponsesClient(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(), "bad request") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesClientFailedEvent(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(`data: {"type":"response.failed","response":{"id":"resp_1","status":"failed","error":{"code":"server_error","message":"boom"}}}` + "\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewOpenAIResponsesClient(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(), "boom") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesClientIncomplete(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(`data: {"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"partial"}` + "\n\n"))
|
||||
_, _ = w.Write([]byte(`data: {"type":"response.incomplete","response":{"id":"resp_1","status":"incomplete","incomplete_details":{"reason":"max_tokens"}}}` + "\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewOpenAIResponsesClient(server.URL, "sk-test", server.Client())
|
||||
var content strings.Builder
|
||||
var finishes []string
|
||||
err := client.ChatStream(context.Background(), ChatRequest{Model: "test"}, func(event StreamEvent) error {
|
||||
content.WriteString(event.Content)
|
||||
if event.FinishReason != "" {
|
||||
finishes = append(finishes, event.FinishReason)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content.String() != "partial" {
|
||||
t.Fatalf("content = %q", content.String())
|
||||
}
|
||||
if len(finishes) != 1 || finishes[0] != "length" {
|
||||
t.Fatalf("finishes = %#v", finishes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesClientRetriesTransientHTTPError(t *testing.T) {
|
||||
attempts := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
http.Error(w, "temporary", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(`data: {"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"ok"}` + "\n\n"))
|
||||
_, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_1","status":"completed"}}` + "\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewOpenAIResponsesClient(server.URL, "sk-test", server.Client())
|
||||
var content strings.Builder
|
||||
err := client.ChatStream(context.Background(), ChatRequest{Model: "test"}, func(event StreamEvent) error {
|
||||
content.WriteString(event.Content)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts = %d, want 2", attempts)
|
||||
}
|
||||
if content.String() != "ok" {
|
||||
t.Fatalf("content = %q", content.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user