256 lines
6.7 KiB
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"`
|
|
}
|