필드 테스트를 위해 edge OpenAI-compatible 경로와 node adapter 설정을 확장하고, Jenkins 바이너리 빌드 및 control-plane/web compose 배포 구성을 함께 정리한다.
289 lines
7.2 KiB
Go
289 lines
7.2 KiB
Go
// Package ollama provides an Adapter that calls a local Ollama server.
|
|
package ollama
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"iop/apps/node/internal/runtime"
|
|
"iop/packages/config"
|
|
)
|
|
|
|
const Name = "ollama"
|
|
|
|
type Ollama struct {
|
|
baseURL string
|
|
contextSize int
|
|
client *http.Client
|
|
logger *zap.Logger
|
|
}
|
|
|
|
func New(cfg config.OllamaConf, logger *zap.Logger) *Ollama {
|
|
baseURL := strings.TrimRight(cfg.BaseURL, "/")
|
|
if baseURL == "" {
|
|
baseURL = "http://localhost:11434"
|
|
}
|
|
return &Ollama{
|
|
baseURL: baseURL,
|
|
contextSize: cfg.ContextSize,
|
|
client: &http.Client{},
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
func (o *Ollama) Name() string { return Name }
|
|
|
|
func (o *Ollama) Capabilities(_ context.Context) (runtime.Capabilities, error) {
|
|
targets := o.fetchTargets()
|
|
return runtime.Capabilities{
|
|
AdapterName: Name,
|
|
Targets: targets,
|
|
MaxConcurrency: 4,
|
|
}, nil
|
|
}
|
|
|
|
func (o *Ollama) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error {
|
|
model := strings.TrimSpace(spec.Target)
|
|
if model == "" {
|
|
model = stringInput(spec.Input, "model")
|
|
}
|
|
if model == "" {
|
|
return fmt.Errorf("ollama adapter: target/model is required")
|
|
}
|
|
messages := ollamaMessagesFromInput(spec.Input)
|
|
if len(messages) == 0 {
|
|
return fmt.Errorf("ollama adapter: prompt/messages are required")
|
|
}
|
|
|
|
if err := sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeStart,
|
|
Timestamp: time.Now(),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
chatReq := ollamaChatRequest{
|
|
Model: model,
|
|
Messages: messages,
|
|
Stream: true,
|
|
}
|
|
if o.contextSize > 0 {
|
|
chatReq.Options = &ollamaOptions{NumCtx: o.contextSize}
|
|
}
|
|
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", 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 {
|
|
msg := readLimited(resp.Body, 4096)
|
|
if msg == "" {
|
|
msg = resp.Status
|
|
}
|
|
_ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("ollama returned %s: %s", resp.Status, msg))
|
|
return fmt.Errorf("ollama adapter: non-2xx response: %s", resp.Status)
|
|
}
|
|
|
|
decoder := json.NewDecoder(resp.Body)
|
|
outputTokens := 0
|
|
for {
|
|
var chunk ollamaChatChunk
|
|
if err := decoder.Decode(&chunk); err != nil {
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
_ = emitError(ctx, sink, spec.RunID, fmt.Sprintf("ollama stream decode failed: %v", err))
|
|
return fmt.Errorf("ollama adapter: decode stream: %w", err)
|
|
}
|
|
if chunk.Error != "" {
|
|
_ = emitError(ctx, sink, spec.RunID, chunk.Error)
|
|
return fmt.Errorf("ollama adapter: %s", chunk.Error)
|
|
}
|
|
if chunk.Message.Content != "" {
|
|
outputTokens += len(strings.Fields(chunk.Message.Content))
|
|
if err := sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeDelta,
|
|
Delta: chunk.Message.Content,
|
|
Timestamp: time.Now(),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if chunk.Done {
|
|
inputTokens := chunk.PromptEvalCount
|
|
if inputTokens == 0 {
|
|
inputTokens = countMessageWords(messages)
|
|
}
|
|
if chunk.EvalCount > 0 {
|
|
outputTokens = chunk.EvalCount
|
|
}
|
|
return sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeComplete,
|
|
Message: "ollama chat complete",
|
|
Usage: &runtime.UsageStats{
|
|
InputTokens: inputTokens,
|
|
OutputTokens: outputTokens,
|
|
},
|
|
Timestamp: time.Now(),
|
|
})
|
|
}
|
|
}
|
|
_ = emitError(ctx, sink, spec.RunID, "ollama stream ended without done=true")
|
|
return fmt.Errorf("ollama adapter: stream ended without done=true")
|
|
}
|
|
|
|
func (o *Ollama) fetchTargets() []string {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(o.baseURL, "/api/tags"), nil)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
resp, err := o.client.Do(req)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil
|
|
}
|
|
var tags ollamaTagsResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(tags.Models))
|
|
for _, model := range tags.Models {
|
|
if model.Name != "" {
|
|
out = append(out, model.Name)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func ollamaMessagesFromInput(input map[string]any) []ollamaMessage {
|
|
if raw, ok := input["messages"].([]any); ok {
|
|
out := make([]ollamaMessage, 0, len(raw))
|
|
for _, item := range raw {
|
|
m, ok := item.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
role, _ := m["role"].(string)
|
|
content, _ := m["content"].(string)
|
|
role = strings.TrimSpace(role)
|
|
content = strings.TrimSpace(content)
|
|
if role == "" || content == "" {
|
|
continue
|
|
}
|
|
out = append(out, ollamaMessage{Role: role, Content: content})
|
|
}
|
|
if len(out) > 0 {
|
|
return out
|
|
}
|
|
}
|
|
prompt := strings.TrimSpace(stringInput(input, "prompt"))
|
|
if prompt == "" {
|
|
return nil
|
|
}
|
|
return []ollamaMessage{{Role: "user", Content: prompt}}
|
|
}
|
|
|
|
func stringInput(input map[string]any, key string) string {
|
|
if input == nil {
|
|
return ""
|
|
}
|
|
if v, ok := input[key].(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func countMessageWords(messages []ollamaMessage) int {
|
|
n := 0
|
|
for _, msg := range messages {
|
|
n += len(strings.Fields(msg.Content))
|
|
}
|
|
return n
|
|
}
|
|
|
|
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(),
|
|
})
|
|
}
|
|
|
|
func joinURL(baseURL, path string) string {
|
|
u, err := url.Parse(baseURL)
|
|
if err != nil {
|
|
return strings.TrimRight(baseURL, "/") + path
|
|
}
|
|
u.Path = strings.TrimRight(u.Path, "/") + path
|
|
return u.String()
|
|
}
|
|
|
|
func readLimited(r io.Reader, limit int64) string {
|
|
b, _ := io.ReadAll(io.LimitReader(r, limit))
|
|
return strings.TrimSpace(string(b))
|
|
}
|
|
|
|
type ollamaChatRequest struct {
|
|
Model string `json:"model"`
|
|
Messages []ollamaMessage `json:"messages"`
|
|
Stream bool `json:"stream"`
|
|
Options *ollamaOptions `json:"options,omitempty"`
|
|
}
|
|
|
|
type ollamaOptions struct {
|
|
NumCtx int `json:"num_ctx,omitempty"`
|
|
}
|
|
|
|
type ollamaMessage struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
type ollamaTagsResponse struct {
|
|
Models []struct {
|
|
Name string `json:"name"`
|
|
} `json:"models"`
|
|
}
|