iop/apps/node/internal/adapters/ollama/ollama.go
toki a52ecb8efe 기능: OpenAI 서빙과 필드 배포 구성을 추가한다
필드 테스트를 위해 edge OpenAI-compatible 경로와 node adapter 설정을 확장하고, Jenkins 바이너리 빌드 및 control-plane/web compose 배포 구성을 함께 정리한다.
2026-05-19 16:04:23 +09:00

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"`
}