Files
LocalAI/core/schema/ollama.go
LocalAI [bot] 661a0c3b9d fix(ollama): accept float-encoded integer options (fixes #9837) (#9849)
fix(ollama): accept float-encoded integer options (num_ctx, top_k, ...)

Home Assistant's Ollama integration encodes integer options as JSON
floats (e.g. `"num_ctx": 8192.0`). Stdlib `json.Unmarshal` refuses to
decode a number with fractional notation into an `int` field, so the
entire request was rejected with HTTP 400 before reaching the backend:

  Unmarshal type error: expected=int, got=number 8192.0,
  field=options.num_ctx

Add a custom `UnmarshalJSON` on `OllamaOptions` that routes the int
fields (`top_k`, `num_predict`, `seed`, `repeat_last_n`, `num_ctx`)
through `*json.Number`, then converts via `Int64()` with a `Float64()`
fallback. Public field types are unchanged, so endpoint code is
untouched. Float fields and `stop` continue to parse via the default
path.

Fixes #9837

Assisted-by: Claude Code:claude-opus-4-7

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-05-16 18:38:19 +02:00

352 lines
12 KiB
Go

package schema
import (
"context"
"encoding/json"
"fmt"
"time"
)
// OllamaOptions represents runtime parameters for Ollama generation
type OllamaOptions struct {
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
TopK *int `json:"top_k,omitempty"`
NumPredict *int `json:"num_predict,omitempty"`
RepeatPenalty float64 `json:"repeat_penalty,omitempty"`
RepeatLastN int `json:"repeat_last_n,omitempty"`
Seed *int `json:"seed,omitempty"`
Stop []string `json:"stop,omitempty"`
NumCtx int `json:"num_ctx,omitempty"`
}
// UnmarshalJSON accepts integer parameters encoded as either JSON ints
// (`8192`) or JSON floats (`8192.0`). Some clients - notably Home Assistant's
// Ollama integration - serialize ints as floats, which stdlib json refuses
// to decode into int fields. See https://github.com/mudler/LocalAI/issues/9837.
func (o *OllamaOptions) UnmarshalJSON(data []byte) error {
type aux struct {
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
TopK *json.Number `json:"top_k,omitempty"`
NumPredict *json.Number `json:"num_predict,omitempty"`
RepeatPenalty float64 `json:"repeat_penalty,omitempty"`
RepeatLastN *json.Number `json:"repeat_last_n,omitempty"`
Seed *json.Number `json:"seed,omitempty"`
Stop []string `json:"stop,omitempty"`
NumCtx *json.Number `json:"num_ctx,omitempty"`
}
var a aux
if err := json.Unmarshal(data, &a); err != nil {
return err
}
o.Temperature = a.Temperature
o.TopP = a.TopP
o.RepeatPenalty = a.RepeatPenalty
o.Stop = a.Stop
var err error
if o.TopK, err = jsonNumberToIntPtr(a.TopK); err != nil {
return fmt.Errorf("options.top_k: %w", err)
}
if o.NumPredict, err = jsonNumberToIntPtr(a.NumPredict); err != nil {
return fmt.Errorf("options.num_predict: %w", err)
}
if o.Seed, err = jsonNumberToIntPtr(a.Seed); err != nil {
return fmt.Errorf("options.seed: %w", err)
}
if o.RepeatLastN, err = jsonNumberToInt(a.RepeatLastN); err != nil {
return fmt.Errorf("options.repeat_last_n: %w", err)
}
if o.NumCtx, err = jsonNumberToInt(a.NumCtx); err != nil {
return fmt.Errorf("options.num_ctx: %w", err)
}
return nil
}
// jsonNumberToInt parses a json.Number literal as an int, tolerating both
// integer (`8192`) and float (`8192.0`) encodings. A nil pointer or empty
// string yields 0, matching the zero-value semantics of the int fields.
func jsonNumberToInt(n *json.Number) (int, error) {
if n == nil || *n == "" {
return 0, nil
}
if i, err := n.Int64(); err == nil {
return int(i), nil
}
f, err := n.Float64()
if err != nil {
return 0, err
}
return int(f), nil
}
func jsonNumberToIntPtr(n *json.Number) (*int, error) {
if n == nil {
return nil, nil
}
i, err := jsonNumberToInt(n)
if err != nil {
return nil, err
}
return &i, nil
}
// OllamaMessage represents a message in Ollama chat format
type OllamaMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Images []string `json:"images,omitempty"`
ToolCalls []any `json:"tool_calls,omitempty"`
}
// OllamaChatRequest represents a request to the Ollama Chat API
type OllamaChatRequest struct {
Model string `json:"model"`
Messages []OllamaMessage `json:"messages"`
Stream *bool `json:"stream,omitempty"`
Format any `json:"format,omitempty"`
Options *OllamaOptions `json:"options,omitempty"`
Tools []any `json:"tools,omitempty"`
// Internal fields
Context context.Context `json:"-"`
Cancel context.CancelFunc `json:"-"`
}
// ModelName implements the LocalAIRequest interface
func (r *OllamaChatRequest) ModelName(s *string) string {
if s != nil {
r.Model = *s
}
return r.Model
}
// IsStream returns whether streaming is enabled (defaults to true for Ollama)
func (r *OllamaChatRequest) IsStream() bool {
if r.Stream == nil {
return true
}
return *r.Stream
}
// OllamaChatResponse represents a response from the Ollama Chat API
type OllamaChatResponse struct {
Model string `json:"model"`
CreatedAt time.Time `json:"created_at"`
Message OllamaMessage `json:"message"`
Done bool `json:"done"`
DoneReason string `json:"done_reason,omitempty"`
TotalDuration int64 `json:"total_duration,omitempty"`
LoadDuration int64 `json:"load_duration,omitempty"`
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
PromptEvalDuration int64 `json:"prompt_eval_duration,omitempty"`
EvalCount int `json:"eval_count,omitempty"`
EvalDuration int64 `json:"eval_duration,omitempty"`
}
// OllamaGenerateRequest represents a request to the Ollama Generate API
type OllamaGenerateRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
System string `json:"system,omitempty"`
Stream *bool `json:"stream,omitempty"`
Raw bool `json:"raw,omitempty"`
Format any `json:"format,omitempty"`
Options *OllamaOptions `json:"options,omitempty"`
// Context from a previous generate call for continuation
Context []int `json:"context,omitempty"`
// Internal fields
Ctx context.Context `json:"-"`
Cancel context.CancelFunc `json:"-"`
}
// ModelName implements the LocalAIRequest interface
func (r *OllamaGenerateRequest) ModelName(s *string) string {
if s != nil {
r.Model = *s
}
return r.Model
}
// IsStream returns whether streaming is enabled (defaults to true for Ollama)
func (r *OllamaGenerateRequest) IsStream() bool {
if r.Stream == nil {
return true
}
return *r.Stream
}
// OllamaGenerateResponse represents a response from the Ollama Generate API
type OllamaGenerateResponse struct {
Model string `json:"model"`
CreatedAt time.Time `json:"created_at"`
Response string `json:"response"`
Done bool `json:"done"`
DoneReason string `json:"done_reason,omitempty"`
Context []int `json:"context,omitempty"`
TotalDuration int64 `json:"total_duration,omitempty"`
LoadDuration int64 `json:"load_duration,omitempty"`
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
PromptEvalDuration int64 `json:"prompt_eval_duration,omitempty"`
EvalCount int `json:"eval_count,omitempty"`
EvalDuration int64 `json:"eval_duration,omitempty"`
}
// OllamaEmbedRequest represents a request to the Ollama Embed API.
// Ollama's /api/embed endpoint accepts both `input` and `prompt` as the
// input string value (see https://github.com/ollama/ollama/blob/main/docs/api.md#generate-embeddings),
// so both keys are deserialized here for client compatibility.
type OllamaEmbedRequest struct {
Model string `json:"model"`
Input any `json:"input,omitempty"` // string or []string
Prompt any `json:"prompt,omitempty"` // string or []string (Ollama alias for Input)
Options *OllamaOptions `json:"options,omitempty"`
}
// ModelName implements the LocalAIRequest interface
func (r *OllamaEmbedRequest) ModelName(s *string) string {
if s != nil {
r.Model = *s
}
return r.Model
}
// GetInputStrings normalizes the Input/Prompt field to a string slice.
// Input takes precedence over Prompt when both are provided.
func (r *OllamaEmbedRequest) GetInputStrings() []string {
if v := normalizeOllamaEmbedInput(r.Input); v != nil {
return v
}
return normalizeOllamaEmbedInput(r.Prompt)
}
func normalizeOllamaEmbedInput(v any) []string {
switch v := v.(type) {
case string:
if v == "" {
return nil
}
return []string{v}
case []any:
var result []string
for _, item := range v {
if s, ok := item.(string); ok {
result = append(result, s)
}
}
return result
case []string:
return v
}
return nil
}
// OllamaEmbedResponse represents a response from the Ollama Embed API
type OllamaEmbedResponse struct {
Model string `json:"model"`
Embeddings [][]float32 `json:"embeddings"`
TotalDuration int64 `json:"total_duration,omitempty"`
LoadDuration int64 `json:"load_duration,omitempty"`
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
}
// OllamaShowRequest represents a request to the Ollama Show API
type OllamaShowRequest struct {
Name string `json:"name"`
Model string `json:"model"`
Verbose bool `json:"verbose,omitempty"`
}
// ModelName implements the LocalAIRequest interface
func (r *OllamaShowRequest) ModelName(s *string) string {
name := r.Name
if name == "" {
name = r.Model
}
if s != nil {
r.Name = *s
}
return name
}
// OllamaShowResponse represents a response from the Ollama Show API
type OllamaShowResponse struct {
Modelfile string `json:"modelfile"`
Parameters string `json:"parameters"`
Template string `json:"template"`
License string `json:"license,omitempty"`
Details OllamaModelDetails `json:"details"`
ModelInfo map[string]any `json:"model_info,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
}
// OllamaModelDetails contains model metadata
type OllamaModelDetails struct {
ParentModel string `json:"parent_model,omitempty"`
Format string `json:"format,omitempty"`
Family string `json:"family,omitempty"`
Families []string `json:"families,omitempty"`
ParameterSize string `json:"parameter_size,omitempty"`
QuantizationLevel string `json:"quantization_level,omitempty"`
}
// OllamaModelEntry represents a model in the list response
type OllamaModelEntry struct {
Name string `json:"name"`
Model string `json:"model"`
ModifiedAt time.Time `json:"modified_at"`
Size int64 `json:"size"`
Digest string `json:"digest"`
Details OllamaModelDetails `json:"details"`
Capabilities []string `json:"capabilities,omitempty"`
}
// OllamaListResponse represents a response from the Ollama Tags API
type OllamaListResponse struct {
Models []OllamaModelEntry `json:"models"`
}
// OllamaPsEntry represents a running model in the ps response
type OllamaPsEntry struct {
Name string `json:"name"`
Model string `json:"model"`
Size int64 `json:"size"`
Digest string `json:"digest"`
Details OllamaModelDetails `json:"details"`
ExpiresAt time.Time `json:"expires_at"`
SizeVRAM int64 `json:"size_vram"`
Capabilities []string `json:"capabilities,omitempty"`
}
// OllamaPsResponse represents a response from the Ollama Ps API
type OllamaPsResponse struct {
Models []OllamaPsEntry `json:"models"`
}
// OllamaVersionResponse represents a response from the Ollama Version API
type OllamaVersionResponse struct {
Version string `json:"version"`
}
// OllamaPullRequest represents a request to pull a model
type OllamaPullRequest struct {
Name string `json:"name"`
Insecure bool `json:"insecure,omitempty"`
Stream *bool `json:"stream,omitempty"`
}
// OllamaDeleteRequest represents a request to delete a model
type OllamaDeleteRequest struct {
Name string `json:"name"`
Model string `json:"model"`
}
// OllamaCopyRequest represents a request to copy a model
type OllamaCopyRequest struct {
Source string `json:"source"`
Destination string `json:"destination"`
}