mirror of
https://github.com/hanzoai/visor.git
synced 2026-09-24 17:04:14 +00:00
This change moves the Server interface, DockerServer implementation, ResponseMetrics, APIResponse, and MakeAPIResponse functions from the individual LLM test packages (ollama, sglang, triton, vllm) into a new shared package llmutil. This reduces code duplication across the different LLM test clients. PiperOrigin-RevId: 907987791
817 lines
26 KiB
Go
817 lines
26 KiB
Go
// Copyright 2023 The gVisor Authors.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
// Package ollama provides an Ollama API client.
|
|
package ollama
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gvisor.dev/gvisor/pkg/test/dockerutil"
|
|
"gvisor.dev/gvisor/pkg/test/llmutil"
|
|
"gvisor.dev/gvisor/pkg/test/testutil"
|
|
)
|
|
|
|
const (
|
|
// Port is the port used by the ollama server.
|
|
Port = 11434
|
|
|
|
// curtQuery is a query that should result in a very curt response.
|
|
curtQuery = `Please reply with the single word: "Hello". Do not reply with any other word.`
|
|
)
|
|
|
|
// Ollama is an ollama client.
|
|
type Ollama struct {
|
|
// server is used to perform requests against the server.
|
|
server llmutil.Server
|
|
|
|
// logger is used to log.
|
|
logger testutil.Logger
|
|
|
|
// ModelNames is the list of available model names.
|
|
ModelNames []string
|
|
|
|
// cheapModels is a list of models that are known to be cheap.
|
|
// A caller may set this to make forcefully unloading a model quicker.
|
|
cheapModels []*Model
|
|
|
|
// HasGPU is set depending on whether the LLM has GPU access.
|
|
// ollama supports running both on CPU and GPU, and detects this
|
|
// by spawning nvidia-smi.
|
|
HasGPU bool
|
|
}
|
|
|
|
// New starts a new Ollama server in the given container,
|
|
// then waits for it to serve and returns the client.
|
|
func New(ctx context.Context, server llmutil.Server, logger testutil.Logger) (*Ollama, error) {
|
|
started := time.Now()
|
|
llm := &Ollama{
|
|
logger: logger,
|
|
server: server,
|
|
}
|
|
|
|
// Wait until serving.
|
|
if err := llm.WaitUntilServing(ctx); err != nil {
|
|
return nil, fmt.Errorf("ollama did not come up for serving: %w", err)
|
|
}
|
|
logger.Logf("Ollama serving API requests after %v", time.Since(started))
|
|
|
|
// Get list of model names.
|
|
modelNames, err := llm.listModelNames(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not list model names: %w", err)
|
|
}
|
|
if len(modelNames) == 0 {
|
|
return nil, errors.New("no models available")
|
|
}
|
|
llm.ModelNames = modelNames
|
|
logger.Logf("Available ollama model names: %v (loaded %v since container start)", modelNames, time.Since(started))
|
|
|
|
// Load the first model.
|
|
// This is necessary to force ollama to load a model, without which
|
|
// we cannot detect if it is using the GPU or not.
|
|
// This may fail during the process of loading the first model, so we keep
|
|
// iterating for a while.
|
|
_, err = llm.WarmModel(ctx, &Model{Name: llm.ModelNames[0]}, 1*time.Millisecond, false)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not load first model %q: %w", llm.ModelNames[0], err)
|
|
}
|
|
logger.Logf("Loaded first ollama model %q (%v since container start)", llm.ModelNames[0], time.Since(started))
|
|
|
|
// Now go over the logs and check if the GPU was used.
|
|
logs, err := llm.server.Logs(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not get logs: %w", err)
|
|
}
|
|
switch {
|
|
case strings.Contains(logs, "library=cpu"):
|
|
llm.HasGPU = false
|
|
case strings.Contains(logs, "library=cuda"):
|
|
llm.HasGPU = true
|
|
default:
|
|
return nil, fmt.Errorf("cannot determine whether ollama is using GPU from logs:\n%s", logs)
|
|
}
|
|
logger.Logf("Ollama successfully initialized in a total of %v", time.Since(started))
|
|
return llm, nil
|
|
}
|
|
|
|
// SetCheapModels can be used to inform this Ollama client as to the list of
|
|
// models it can use that are known to be cheap.
|
|
// This is useful when forcefully unloading models by swapping them with
|
|
// another one, to ensure that the one it is being swapped with is small.
|
|
// Therefore, there should be at least two models specified here.
|
|
func (llm *Ollama) SetCheapModels(cheapModels []*Model) {
|
|
llm.cheapModels = cheapModels
|
|
}
|
|
|
|
// NewDocker returns a new Ollama client talking to an Ollama server that runs
|
|
// in a local Docker container.
|
|
func NewDocker(ctx context.Context, cont *dockerutil.Container, logger testutil.Logger) (*Ollama, error) {
|
|
opts, err := dockerutil.GPURunOpts(dockerutil.SniffGPUOpts{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get GPU run options: %w", err)
|
|
}
|
|
opts.Image = "gpu/ollama"
|
|
started := time.Now()
|
|
if err := cont.Spawn(ctx, opts); err != nil {
|
|
return nil, fmt.Errorf("could not start ollama: %v", err)
|
|
}
|
|
logger.Logf("Ollama container started after %v", time.Since(started))
|
|
ds := &llmutil.DockerServer{
|
|
Container: cont,
|
|
Logger: logger,
|
|
Port: Port,
|
|
ClientImage: "gpu/ollama/client",
|
|
}
|
|
return New(ctx, ds, logger)
|
|
}
|
|
|
|
// instrumentedRequest makes an HTTP request to the ollama API.
|
|
// It returns the raw bytestream from the instrumented request logs.
|
|
func (llm *Ollama) instrumentedRequest(ctx context.Context, method, endpoint string, data []byte) ([]byte, error) {
|
|
if endpoint != "" && !strings.HasPrefix(endpoint, "/") {
|
|
return nil, fmt.Errorf("endpoint must be empty or start with '/', got %q", endpoint)
|
|
}
|
|
argvFn := func(hostPort string) []string {
|
|
argv := []string{
|
|
"httpclient",
|
|
fmt.Sprintf("--method=%s", method),
|
|
fmt.Sprintf("--url=%s%s", hostPort, endpoint),
|
|
}
|
|
if data != nil {
|
|
argv = append(argv, fmt.Sprintf("--post_base64=%s", base64.StdEncoding.EncodeToString(data)))
|
|
}
|
|
if ctxDeadline, hasDeadline := ctx.Deadline(); hasDeadline {
|
|
argv = append(argv, fmt.Sprintf("--timeout=%v", time.Until(ctxDeadline)))
|
|
}
|
|
return argv
|
|
}
|
|
rawResponse, err := llm.server.InstrumentedRequest(ctx, argvFn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", endpoint, err)
|
|
}
|
|
return rawResponse, nil
|
|
}
|
|
|
|
// jsonGet performs a JSON HTTP GET request.
|
|
func jsonGet[Out any](ctx context.Context, llm *Ollama, endpoint string) (*llmutil.APIResponse[Out], error) {
|
|
out, err := llm.instrumentedRequest(ctx, "GET", endpoint, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GET %q failed: %w", endpoint, err)
|
|
}
|
|
return llmutil.MakeAPIResponse[Out](out)
|
|
}
|
|
|
|
// jsonPost performs a JSON HTTP POST request.
|
|
func jsonPost[In, Out any](ctx context.Context, llm *Ollama, endpoint string, input In) (*llmutil.APIResponse[Out], error) {
|
|
query, err := json.Marshal(input)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not marshal input %v: %w", input, err)
|
|
}
|
|
out, err := llm.instrumentedRequest(ctx, "POST", endpoint, query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("POST %q %v failed: %w", endpoint, string(query), err)
|
|
}
|
|
return llmutil.MakeAPIResponse[Out](out)
|
|
}
|
|
|
|
// listModelNames lists the available model names.
|
|
func (llm *Ollama) listModelNames(ctx context.Context) ([]string, error) {
|
|
type model struct {
|
|
Name string `json:"name"`
|
|
ModifiedAt string `json:"modified_at"`
|
|
Size int `json:"size"`
|
|
}
|
|
type modelsList struct {
|
|
Models []model `json:"models"`
|
|
}
|
|
modelsResp, err := jsonGet[modelsList](ctx, llm, "/api/tags")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
models, err := modelsResp.Obj()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("malformed model tags response: %w", err)
|
|
}
|
|
modelNames := make([]string, len(models.Models))
|
|
for i, m := range models.Models {
|
|
modelNames[i] = m.Name
|
|
}
|
|
return modelNames, nil
|
|
}
|
|
|
|
// WaitUntilServing waits until ollama is serving, or the context expires.
|
|
func (llm *Ollama) WaitUntilServing(ctx context.Context) error {
|
|
for ctx.Err() == nil {
|
|
out, err := llm.instrumentedRequest(ctx, "GET", "/", nil)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if strings.Contains(string(out), "Ollama is running") {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("ollama did not respond: %w", ctx.Err())
|
|
}
|
|
|
|
// Model encodes a model and options for it.
|
|
type Model struct {
|
|
// Name is the name of the ollama model, e.g. "codellama:7b".
|
|
Name string
|
|
|
|
// Options maps parameter names to JSON-compatible values.
|
|
Options map[string]any
|
|
}
|
|
|
|
// String returns the model's name.
|
|
func (m *Model) String() string {
|
|
return m.Name
|
|
}
|
|
|
|
// modelTemperatureOption is the temperature option that most models have
|
|
// which controls how free they are from deviating from their most-likely
|
|
// token chain.
|
|
const modelTemperatureOption = "temperature"
|
|
|
|
// RaiseTemperature increases the "temperature" option of the model,
|
|
// if any.
|
|
func (m *Model) RaiseTemperature() {
|
|
temp, ok := m.Options[modelTemperatureOption]
|
|
if !ok {
|
|
temp = float64(0.0)
|
|
}
|
|
if m.Options == nil {
|
|
m.Options = map[string]any{}
|
|
}
|
|
m.Options[modelTemperatureOption] = min(1.0, temp.(float64)*2+.025)
|
|
}
|
|
|
|
// Copy returns a copy of the model.
|
|
func (m *Model) Copy() *Model {
|
|
modelCopy := *m
|
|
modelCopy.Options = make(map[string]any, len(m.Options))
|
|
for k, v := range m.Options {
|
|
modelCopy.Options[k] = v
|
|
}
|
|
return &modelCopy
|
|
}
|
|
|
|
// ZeroTemperatureModel returns a Model with the given name and an initial
|
|
// temperature setting of zero. This setting allows for consistent settings.
|
|
func ZeroTemperatureModel(name string) *Model {
|
|
return &Model{
|
|
Name: name,
|
|
Options: map[string]any{
|
|
modelTemperatureOption: 0.0,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Prompt is an ollama prompt.
|
|
type Prompt struct {
|
|
// Model is the model to query.
|
|
Model *Model
|
|
|
|
// If set, keep the model alive in memory for the given duration after this
|
|
// prompt is answered. A zero duration will use the ollama default (a few
|
|
// minutes). Note that model unloading is asynchronous, so the model will
|
|
// not be fully unloaded after only `KeepModelAlive` beyond prompt response.
|
|
KeepModelAlive time.Duration
|
|
|
|
// Query is the prompt string.
|
|
// Common leading whitespace will be removed.
|
|
Query string
|
|
|
|
// images is a set of attached images.
|
|
// Use AddImage to add an image.
|
|
images [][]byte
|
|
|
|
// Context is the conversational context to follow up on, if any.
|
|
// This is returned from `Response`.
|
|
Context ConversationContext
|
|
}
|
|
|
|
// AddImage attaches an image to the prompt.
|
|
// Returns itself for chainability.
|
|
func (p *Prompt) AddImage(data []byte) *Prompt {
|
|
p.images = append(p.images, data)
|
|
return p
|
|
}
|
|
|
|
// CleanQuery removes common whitespace from query lines, and all
|
|
// leading/ending whitespace-only lines.
|
|
// It is useful to be able to specify query string as indented strings
|
|
// without breaking visual continuity in Go code.
|
|
// For example (where dots are spaces):
|
|
//
|
|
// """\n
|
|
// ..The Quick Brown Fox\n
|
|
// ..Jumps Over\n
|
|
// ....The Lazy Dog\n
|
|
// ."""
|
|
//
|
|
// becomes:
|
|
//
|
|
// ""The Quick Brown Fox\n
|
|
// Jumps Over\n
|
|
// ..The Lazy Dog"""
|
|
func (p *Prompt) CleanQuery() string {
|
|
lines := strings.Split(p.Query, "\n")
|
|
|
|
// Trim lines at the beginning and end that are only whitespace.
|
|
trimmedLines := make([]string, 0, len(lines))
|
|
startedNonWhitespace := false
|
|
var block []string
|
|
for _, line := range lines {
|
|
trimmedLine := strings.TrimSpace(line)
|
|
if !startedNonWhitespace && trimmedLine != "" {
|
|
startedNonWhitespace = true
|
|
}
|
|
if startedNonWhitespace {
|
|
block = append(block, line)
|
|
}
|
|
if trimmedLine != "" {
|
|
trimmedLines = append(trimmedLines, block...)
|
|
block = block[:0]
|
|
}
|
|
}
|
|
|
|
// Find longest common whitespace prefix.
|
|
if len(trimmedLines) == 0 {
|
|
return ""
|
|
}
|
|
trimmedFirstLine := strings.TrimSpace(trimmedLines[0])
|
|
common := []rune(trimmedLines[0][:strings.Index(trimmedLines[0], trimmedFirstLine)])
|
|
for ; len(common) > 0; common = common[:len(common)-1] {
|
|
allMatch := true
|
|
for _, line := range trimmedLines[1:] {
|
|
if strings.TrimSpace(line) == "" {
|
|
continue // Ignore whitespace-only or empty lines.
|
|
}
|
|
if !strings.HasPrefix(line, string(common)) {
|
|
allMatch = false
|
|
break
|
|
}
|
|
}
|
|
if allMatch {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Remove it.
|
|
if len(common) > 0 {
|
|
for i, line := range trimmedLines {
|
|
trimmedLines[i] = strings.TrimPrefix(line, string(common))
|
|
}
|
|
}
|
|
|
|
return strings.Join(trimmedLines, "\n")
|
|
}
|
|
|
|
// String returns a human-friendly string representing this prompt.
|
|
func (p *Prompt) String() string {
|
|
return fmt.Sprintf("[%v] %s", p.Model, p.CleanQuery())
|
|
}
|
|
|
|
// WithHotterModel returns a copy of this prompt with the same model having
|
|
// a higher temperature.
|
|
func (p *Prompt) WithHotterModel() *Prompt {
|
|
promptCopy := *p
|
|
promptCopy.Model = p.Model.Copy()
|
|
promptCopy.Model.RaiseTemperature()
|
|
return &promptCopy
|
|
}
|
|
|
|
// promptJSON encodes the JSON data for a query.
|
|
type promptJSON struct {
|
|
Model string `json:"model"`
|
|
Prompt string `json:"prompt,omitempty"`
|
|
Images []string `json:"images"`
|
|
Stream bool `json:"stream"`
|
|
Context ConversationContext `json:"context"`
|
|
Options map[string]any `json:"options"`
|
|
KeepAlive string `json:"keep_alive,omitempty"`
|
|
}
|
|
|
|
// json encodes this prompt to the JSON format expected by Ollama.
|
|
func (p *Prompt) json() promptJSON {
|
|
keepAlive := ""
|
|
if p.KeepModelAlive != 0 {
|
|
keepAlive = p.KeepModelAlive.String()
|
|
}
|
|
images := make([]string, len(p.images))
|
|
for i, image := range p.images {
|
|
images[i] = base64.StdEncoding.EncodeToString(image)
|
|
}
|
|
return promptJSON{
|
|
Model: p.Model.Name,
|
|
Prompt: p.CleanQuery(),
|
|
Images: images,
|
|
Stream: true,
|
|
Context: p.Context,
|
|
Options: p.Model.Options,
|
|
KeepAlive: keepAlive,
|
|
}
|
|
}
|
|
|
|
// responseJSON is the JSON-format response from ollama about a prompt.
|
|
// Note that in `streamed` mode, the `Response` field contains a single token.
|
|
// To recover the whole response, all `Response` fields must be concatenated
|
|
// until the last `responseJSON`, identified as such by the `Done` field.
|
|
type responseJSON struct {
|
|
Model string `json:"model"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
Response string `json:"response"`
|
|
Done bool `json:"done"`
|
|
TotalNanos int `json:"total_duration"`
|
|
LoadNanos int `json:"load_duration"`
|
|
EvalCount int `json:"eval_count"`
|
|
EvalNanos int `json:"eval_duration"`
|
|
PromptEvalCount int `json:"prompt_eval_count"`
|
|
PromptEvalNanos int `json:"prompt_eval_duration"`
|
|
Context ConversationContext `json:"context"`
|
|
}
|
|
|
|
// Response represents a response to a query from Ollama.
|
|
type Response struct {
|
|
data []*responseJSON
|
|
metrics llmutil.ResponseMetrics
|
|
}
|
|
|
|
// Done returns whether the response was completely generated.
|
|
func (r *Response) Done() bool {
|
|
if len(r.data) == 0 {
|
|
return false
|
|
}
|
|
return r.data[len(r.data)-1].Done
|
|
}
|
|
|
|
// NumTokens returns the number of tokens in the response.
|
|
func (r *Response) NumTokens() int {
|
|
return len(r.data)
|
|
}
|
|
|
|
// String returns the response text, if it is done.
|
|
func (r *Response) String() string {
|
|
if len(r.data) == 0 {
|
|
return "<EMPTY>"
|
|
}
|
|
var fullResponse strings.Builder
|
|
gotDone := false
|
|
for i, token := range r.data {
|
|
fullResponse.WriteString(token.Response)
|
|
if token.Done {
|
|
if i != len(r.data)-1 {
|
|
fullResponse.WriteString("<CORRUPT>")
|
|
}
|
|
gotDone = true
|
|
break
|
|
}
|
|
}
|
|
if !gotDone {
|
|
return "<NOT DONE>"
|
|
}
|
|
return fullResponse.String()
|
|
}
|
|
|
|
// Text returns the body of the response, if it is done.
|
|
func (r *Response) Text() string {
|
|
if !r.Done() {
|
|
return ""
|
|
}
|
|
return r.String()
|
|
}
|
|
|
|
// TimeToFirstToken returns the time it took between the request starting
|
|
// and the first token being received by the client.
|
|
func (r *Response) TimeToFirstToken() time.Duration {
|
|
if !r.Done() {
|
|
return -1
|
|
}
|
|
return r.metrics.FirstByteRead.Sub(r.metrics.RequestSent)
|
|
}
|
|
|
|
// TimeToLastToken returns the time it took between the request starting
|
|
// and the last token being received by the client.
|
|
func (r *Response) TimeToLastToken() time.Duration {
|
|
if !r.Done() {
|
|
return -1
|
|
}
|
|
return r.metrics.LastByteRead.Sub(r.metrics.RequestSent)
|
|
}
|
|
|
|
// tokenIntervals returns the time between each token generation.
|
|
func (r *Response) tokenIntervals() []time.Duration {
|
|
if !r.Done() || len(r.data) < 2 {
|
|
return nil
|
|
}
|
|
intervals := make([]time.Duration, len(r.data)-1)
|
|
for i := 0; i < len(r.data)-1; i++ {
|
|
intervals[i] = r.data[i+1].CreatedAt.Sub(r.data[i].CreatedAt)
|
|
}
|
|
return intervals
|
|
}
|
|
|
|
// OutputTokensPerSecond computes the average number of output tokens
|
|
// generated per second.
|
|
func (r *Response) OutputTokensPerSecond() float64 {
|
|
if !r.Done() || r.EvalDuration() == 0 {
|
|
return -1
|
|
}
|
|
return float64(r.data[len(r.data)-1].EvalCount) / float64(r.EvalDuration().Seconds())
|
|
}
|
|
|
|
// TimePerOutputTokenAverage computes the average time to generate an output
|
|
// token.
|
|
func (r *Response) TimePerOutputTokenAverage() time.Duration {
|
|
if !r.Done() {
|
|
return -1
|
|
}
|
|
intervals := r.tokenIntervals()
|
|
var sum time.Duration
|
|
for _, interval := range intervals {
|
|
sum += interval
|
|
}
|
|
return sum / time.Duration(len(intervals))
|
|
}
|
|
|
|
// TimePerOutputTokenQuantile computes a quantile of the time it takes to
|
|
// generate an output token.
|
|
func (r *Response) TimePerOutputTokenQuantile(quantile float64) time.Duration {
|
|
if quantile < 0.0 || quantile > 1.0 {
|
|
panic("quantile must be between 0.0 and 1.0 inclusively")
|
|
}
|
|
if !r.Done() || r.EvalDuration() == 0 {
|
|
return -1
|
|
}
|
|
intervals := r.tokenIntervals()
|
|
sort.Slice(intervals, func(i, j int) bool { return intervals[i] < intervals[j] })
|
|
return intervals[int(quantile*float64(len(intervals)-1))]
|
|
}
|
|
|
|
// TokenGenerationStdDev returns the standard deviation of the time between
|
|
// token generations.
|
|
func (r *Response) TokenGenerationStdDev() time.Duration {
|
|
intervals := r.tokenIntervals()
|
|
if len(intervals) == 0 {
|
|
return -1
|
|
}
|
|
if len(intervals) == 1 {
|
|
return 0
|
|
}
|
|
|
|
var sum time.Duration
|
|
for _, interval := range intervals {
|
|
sum += interval
|
|
}
|
|
mean := sum / time.Duration(len(intervals))
|
|
variance := 0.0
|
|
for _, interval := range intervals {
|
|
intervalMinusMean := float64((interval - mean).Nanoseconds())
|
|
variance += intervalMinusMean * intervalMinusMean
|
|
}
|
|
variance = variance / float64(len(intervals)-1)
|
|
return time.Duration(math.Sqrt(variance)) * time.Nanosecond
|
|
}
|
|
|
|
// TotalDuration returns the total response generation time.
|
|
func (r *Response) TotalDuration() time.Duration {
|
|
if !r.Done() {
|
|
return time.Duration(0)
|
|
}
|
|
return time.Duration(r.data[len(r.data)-1].TotalNanos) * time.Nanosecond
|
|
}
|
|
|
|
// LoadDuration returns the load response generation time as reported
|
|
// by the ollama server.
|
|
func (r *Response) LoadDuration() time.Duration {
|
|
if !r.Done() {
|
|
return time.Duration(0)
|
|
}
|
|
return time.Duration(r.data[len(r.data)-1].LoadNanos) * time.Nanosecond
|
|
}
|
|
|
|
// EvalDuration returns the response evaluation time.
|
|
func (r *Response) EvalDuration() time.Duration {
|
|
if !r.Done() {
|
|
return time.Duration(0)
|
|
}
|
|
return time.Duration(r.data[len(r.data)-1].EvalNanos) * time.Nanosecond
|
|
}
|
|
|
|
// PromptEvalDuration returns the prompt evaluation time.
|
|
func (r *Response) PromptEvalDuration() time.Duration {
|
|
if !r.Done() {
|
|
return time.Duration(0)
|
|
}
|
|
return time.Duration(r.data[len(r.data)-1].PromptEvalNanos) * time.Nanosecond
|
|
}
|
|
|
|
// ConversationContext represents a conversational context.
|
|
// It is returned by a response and may be passed to a follow-up prompt.
|
|
type ConversationContext []int
|
|
|
|
// withServerLogsErr adds server logs to `err` if possible.
|
|
func (llm *Ollama) withServerLogsErr(ctx context.Context, err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if ctx.Err() != nil {
|
|
return fmt.Errorf("%w (+ context err: %v)", err, ctx.Err())
|
|
}
|
|
serverLogs, logsErr := llm.server.Logs(ctx)
|
|
if logsErr != nil {
|
|
return fmt.Errorf("%w (could not get server logs: %v)", err, logsErr)
|
|
}
|
|
if serverLogs != "" {
|
|
return fmt.Errorf("%w; ollama server logs:\n%v\n(end of ollama server logs)", err, serverLogs)
|
|
}
|
|
return fmt.Errorf("%w (server logs are empty)", err)
|
|
}
|
|
|
|
// getReplacementModel picks an available model other than `model`.
|
|
// It tries to find a one that is marked cheap if possible.
|
|
func (llm *Ollama) getReplacementModel(model *Model) (*Model, error) {
|
|
for _, cheapModel := range llm.cheapModels {
|
|
if cheapModel.Name != model.Name {
|
|
return cheapModel, nil
|
|
}
|
|
}
|
|
for _, otherModelName := range llm.ModelNames {
|
|
if otherModelName != model.Name {
|
|
return ZeroTemperatureModel(otherModelName), nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("cannot find a replacement model to load instead of %q (available: %v; cheap: %v)", model.Name, llm.ModelNames, llm.cheapModels)
|
|
}
|
|
|
|
// ModelLoadStats holds metrics about the model loading process.
|
|
type ModelLoadStats struct {
|
|
// ClientReportedDuration is the duration to load the model as perceived
|
|
// by the client, measured by HTTP client metrics.
|
|
ClientReportedDuration time.Duration
|
|
}
|
|
|
|
// WarmModel pre-warms a model in memory and keeps it warm for `keepWarmFor`.
|
|
// If `unloadFirst` is true, another model will be loaded before loading the
|
|
// requested model. This ensures that the model was loaded from a cold state.
|
|
func (llm *Ollama) WarmModel(ctx context.Context, model *Model, keepWarmFor time.Duration, unloadFirst bool) (*ModelLoadStats, error) {
|
|
if keepWarmFor <= 0 {
|
|
return nil, fmt.Errorf("keepWarmFor must be strictly positive, got %v", keepWarmFor)
|
|
}
|
|
if unloadFirst {
|
|
replacementModel, err := llm.getReplacementModel(model)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot find a replacement model to load instead of %q to forcefully unload it: %w", model.Name, err)
|
|
}
|
|
unloadCtx, unloadCancel := context.WithTimeout(ctx, 3*time.Minute)
|
|
_, err = llm.Prompt(unloadCtx, &Prompt{Model: replacementModel, KeepModelAlive: 1 * time.Millisecond})
|
|
unloadCancel()
|
|
if err != nil {
|
|
return nil, llm.withServerLogsErr(ctx, fmt.Errorf("unload prompt for replacement model %s failed: %w", replacementModel.Name, err))
|
|
}
|
|
select { // Wait for the model to get unloaded. Unfortunately there isn't a great way to do this but to sleep.
|
|
case <-time.After(20 * time.Second):
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
resp, err := llm.Prompt(ctx, &Prompt{Model: model, KeepModelAlive: keepWarmFor})
|
|
if err != nil {
|
|
return nil, llm.withServerLogsErr(ctx, fmt.Errorf("warmup prompt for model %s failed: %w", model.Name, err))
|
|
}
|
|
return &ModelLoadStats{
|
|
ClientReportedDuration: resp.metrics.TimeToFirstByte(),
|
|
}, nil
|
|
}
|
|
|
|
// Prompt returns the result of prompting the given `model` with `prompt`.
|
|
func (llm *Ollama) Prompt(ctx context.Context, prompt *Prompt) (*Response, error) {
|
|
resp, err := jsonPost[promptJSON, responseJSON](ctx, llm, "/api/generate", prompt.json())
|
|
if err != nil {
|
|
return nil, llm.withServerLogsErr(ctx, fmt.Errorf("prompt (%s %q) request failed: %w", prompt.Model.Name, prompt.CleanQuery(), err))
|
|
}
|
|
return &Response{data: resp.Objects, metrics: resp.Metrics}, nil
|
|
}
|
|
|
|
// PromptUntil repeatedly issues a prompt until `iterate` returns a nil error.
|
|
// `iterate` may optionally return an updated `Prompt` which will be used to
|
|
// follow up. This is useful to work around the flakiness of LLMs in tests.
|
|
func (llm *Ollama) PromptUntil(ctx context.Context, prompt *Prompt, iterate func(*Prompt, *Response) (*Prompt, error)) (*Response, error) {
|
|
var lastResponse *Response
|
|
var lastError error
|
|
attempts := 0
|
|
for ctx.Err() == nil {
|
|
response, err := llm.Prompt(ctx, prompt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("prompt request failed: %w", err)
|
|
}
|
|
attempts++
|
|
newPrompt, err := iterate(prompt, response)
|
|
if err == nil {
|
|
return response, nil
|
|
}
|
|
if newPrompt != nil {
|
|
prompt = newPrompt
|
|
}
|
|
lastResponse = response
|
|
lastError = err
|
|
}
|
|
return nil, fmt.Errorf("response %q (attempt #%d with prompt %v) did not match predicate: %v", lastResponse, attempts, prompt, lastError)
|
|
}
|
|
|
|
// Embedding holds the result of running an embedding model on a single input.
|
|
type Embedding struct {
|
|
Input string
|
|
Embedding []float64
|
|
}
|
|
|
|
// EmbeddingResponse represents the result of running an embedding model
|
|
// on a set of inputs.
|
|
type EmbeddingResponse struct {
|
|
// Model is the model used to generate the embeddings.
|
|
Model *Model
|
|
|
|
// Embeddings is the list of embeddings generated for the given inputs.
|
|
Embeddings []Embedding
|
|
|
|
// TotalDuration is the total duration of the embedding request as
|
|
// measured by the server, not the client.
|
|
TotalDuration time.Duration
|
|
|
|
// LoadDuration is the duration of the embedding model load time as measured
|
|
// by the server, not the client.
|
|
LoadDuration time.Duration
|
|
|
|
// PromptEvalCount is the number of prompt evaluations performed by the
|
|
// server.
|
|
PromptEvalCount int
|
|
|
|
// ResponseMetrics contains HTTP response metrics as perceived by the
|
|
// client.
|
|
ResponseMetrics llmutil.ResponseMetrics
|
|
}
|
|
|
|
// Embed generates embeddings for each of the given inputs.
|
|
func (llm *Ollama) Embed(ctx context.Context, model *Model, inputs []string) (*EmbeddingResponse, error) {
|
|
// embeddingRequestJSON is the JSON format of an embedding request.
|
|
type embeddingRequestJSON struct {
|
|
Model string `json:"model"`
|
|
Input []string `json:"input"`
|
|
}
|
|
|
|
// embeddingResponseJSON is the JSON format of an embedding response.
|
|
type embeddingResponseJSON struct {
|
|
Model string `json:"model"`
|
|
Embeddings [][]float64 `json:"embeddings"`
|
|
TotalDuration int64 `json:"total_duration"`
|
|
LoadDuration int64 `json:"load_duration"`
|
|
PromptEvalCount int `json:"prompt_eval_count"`
|
|
}
|
|
|
|
resp, err := jsonPost[embeddingRequestJSON, embeddingResponseJSON](ctx, llm, "/api/embed", embeddingRequestJSON{Model: model.Name, Input: inputs})
|
|
if err != nil {
|
|
return nil, llm.withServerLogsErr(ctx, fmt.Errorf("embedding request failed: %w", err))
|
|
}
|
|
obj, err := resp.Obj()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("malformed embedding response: %w", err)
|
|
}
|
|
if len(obj.Embeddings) != len(inputs) {
|
|
return nil, fmt.Errorf("embedding response has %d embeddings, but %d inputs were provided", len(obj.Embeddings), len(inputs))
|
|
}
|
|
embeddings := make([]Embedding, len(inputs))
|
|
for i, embedding := range obj.Embeddings {
|
|
embeddings[i] = Embedding{
|
|
Input: inputs[i],
|
|
Embedding: embedding,
|
|
}
|
|
}
|
|
return &EmbeddingResponse{
|
|
Model: model,
|
|
Embeddings: embeddings,
|
|
TotalDuration: time.Duration(obj.TotalDuration) * time.Nanosecond,
|
|
LoadDuration: time.Duration(obj.LoadDuration) * time.Nanosecond,
|
|
PromptEvalCount: obj.PromptEvalCount,
|
|
ResponseMetrics: resp.Metrics,
|
|
}, nil
|
|
}
|