iop/apps/node/internal/adapters/ollama/chat.go

256 lines
6.7 KiB
Go

package ollama
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"go.uber.org/zap"
"iop/apps/node/internal/runtime"
)
func (o *Ollama) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error {
chatReq, messages, err := o.buildChatRequest(spec)
if err != nil {
return err
}
if err := emitRuntimeEvent(ctx, sink, spec.RunID, runtime.EventTypeStart, ""); err != nil {
return err
}
body, err := json.Marshal(chatReq)
if err != nil {
return fmt.Errorf("ollama adapter: marshal request: %w", err)
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
joinURL(o.baseURL, "/api/chat"),
bytes.NewReader(body),
)
if err != nil {
return fmt.Errorf("ollama adapter: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
o.logger.Info("ollama adapter executing",
zap.String("run_id", spec.RunID),
zap.String("target", chatReq.Model),
zap.String("base_url", o.baseURL),
zap.Int("context_size", o.contextSize),
)
resp, err := o.client.Do(req)
if err != nil {
_ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("ollama request failed: %v", err))
return fmt.Errorf("ollama adapter: request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return emitHTTPStatusError(ctx, sink, spec.RunID, resp)
}
session := ollamaStreamSession{
runID: spec.RunID,
messages: messages,
sink: sink,
}
return session.consume(ctx, resp.Body)
}
func (o *Ollama) buildChatRequest(spec runtime.ExecutionSpec) (ollamaChatRequest, []ollamaMessage, error) {
model := strings.TrimSpace(spec.Target)
if model == "" {
model = stringInput(spec.Input, "model")
}
if model == "" {
return ollamaChatRequest{}, nil, fmt.Errorf("ollama adapter: target/model is required")
}
messages := ollamaMessagesFromInput(spec.Input)
if len(messages) == 0 {
return ollamaChatRequest{}, nil, fmt.Errorf("ollama adapter: prompt/messages are required")
}
req := ollamaChatRequest{
Model: model,
Messages: messages,
Stream: true,
}
if options := ollamaOptionsFromInput(spec.Input, o.contextSize); len(options) > 0 {
req.Options = options
}
req.Format = spec.Input["format"]
req.KeepAlive = spec.Input["keep_alive"]
req.Think = spec.Input["think"]
req.Tools = spec.Input["tools"]
return req, messages, nil
}
func emitHTTPStatusError(
ctx context.Context,
sink runtime.EventSink,
runID string,
resp *http.Response,
) error {
msg := readLimited(resp.Body, 4096)
if msg == "" {
msg = resp.Status
}
_ = emitError(ctx, sink, runID, fmt.Sprintf("ollama returned %s: %s", resp.Status, msg))
return fmt.Errorf("ollama adapter: non-2xx response: %s", resp.Status)
}
type ollamaStreamSession struct {
runID string
messages []ollamaMessage
sink runtime.EventSink
outputTokens int
toolCalls []any
}
func (s *ollamaStreamSession) consume(ctx context.Context, body io.Reader) error {
decoder := json.NewDecoder(body)
for {
var chunk ollamaChatChunk
if err := decoder.Decode(&chunk); err != nil {
if err == io.EOF {
break
}
_ = emitError(ctx, s.sink, s.runID, fmt.Sprintf("ollama stream decode failed: %v", err))
return fmt.Errorf("ollama adapter: decode stream: %w", err)
}
done, err := s.handleChunk(ctx, chunk)
if err != nil {
return err
}
if done {
return nil
}
}
_ = emitError(ctx, s.sink, s.runID, "ollama stream ended without done=true")
return fmt.Errorf("ollama adapter: stream ended without done=true")
}
func (s *ollamaStreamSession) handleChunk(ctx context.Context, chunk ollamaChatChunk) (bool, error) {
if chunk.Error != "" {
_ = emitError(ctx, s.sink, s.runID, chunk.Error)
return false, fmt.Errorf("ollama adapter: %s", chunk.Error)
}
if chunk.Message.Thinking != "" {
if err := emitRuntimeEvent(
ctx,
s.sink,
s.runID,
runtime.EventTypeReasoningDelta,
chunk.Message.Thinking,
); err != nil {
return false, err
}
}
if chunk.Message.Content != "" {
s.outputTokens += len(strings.Fields(chunk.Message.Content))
if err := emitRuntimeEvent(
ctx,
s.sink,
s.runID,
runtime.EventTypeDelta,
chunk.Message.Content,
); err != nil {
return false, err
}
}
if len(chunk.Message.ToolCalls) > 0 {
s.toolCalls = append(s.toolCalls, chunk.Message.ToolCalls...)
}
if !chunk.Done {
return false, nil
}
return true, s.emitComplete(ctx, chunk)
}
func (s *ollamaStreamSession) emitComplete(ctx context.Context, chunk ollamaChatChunk) error {
inputTokens := chunk.PromptEvalCount
if inputTokens == 0 {
inputTokens = countMessageWords(s.messages)
}
if chunk.EvalCount > 0 {
s.outputTokens = chunk.EvalCount
}
var metadata map[string]string
if len(s.toolCalls) > 0 {
if encoded, err := json.Marshal(s.toolCalls); err == nil {
metadata = map[string]string{
"finish_reason": "tool_calls",
runtimeMetadataOpenAIToolCalls: string(encoded),
}
}
}
return s.sink.Emit(ctx, runtime.RuntimeEvent{
RunID: s.runID,
Type: runtime.EventTypeComplete,
Message: "ollama chat complete",
Usage: &runtime.UsageStats{
InputTokens: inputTokens,
OutputTokens: s.outputTokens,
},
Metadata: metadata,
Timestamp: time.Now(),
})
}
func emitRuntimeEvent(
ctx context.Context,
sink runtime.EventSink,
runID string,
eventType runtime.EventType,
delta string,
) error {
return sink.Emit(ctx, runtime.RuntimeEvent{
RunID: runID,
Type: eventType,
Delta: delta,
Timestamp: time.Now(),
})
}
func emitError(ctx context.Context, sink runtime.EventSink, runID, msg string) error {
return sink.Emit(ctx, runtime.RuntimeEvent{
RunID: runID,
Type: runtime.EventTypeError,
Error: msg,
Timestamp: time.Now(),
})
}
type ollamaChatRequest struct {
Model string `json:"model"`
Messages []ollamaMessage `json:"messages"`
Stream bool `json:"stream"`
Options map[string]any `json:"options,omitempty"`
Format any `json:"format,omitempty"`
KeepAlive any `json:"keep_alive,omitempty"`
Think any `json:"think,omitempty"`
Tools any `json:"tools,omitempty"`
}
type ollamaMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Thinking string `json:"thinking,omitempty"`
Images []any `json:"images,omitempty"`
ToolCalls []any `json:"tool_calls,omitempty"`
ToolName string `json:"tool_name,omitempty"`
}
type ollamaChatChunk struct {
Message ollamaMessage `json:"message"`
Done bool `json:"done"`
PromptEvalCount int `json:"prompt_eval_count"`
EvalCount int `json:"eval_count"`
Error string `json:"error"`
}