diff --git a/internal/llm/client_completions.go b/internal/llm/client_completions.go index c861a90..71527a4 100644 --- a/internal/llm/client_completions.go +++ b/internal/llm/client_completions.go @@ -72,9 +72,13 @@ func (c *Client) Completions( func (c *Client) completions( ctx context.Context, p CompletionsParams, ) (*ChatResponse, error) { - kwargs := map[string]any{} + // A nil map is omitted from the request; an empty map is + // sent as {}. Keep kwargs nil without a budget. + var kwargs map[string]any if p.ThinkingBudgetTokens != nil { - kwargs[chatTemplateEnableThinking] = *p.ThinkingBudgetTokens != 0 + kwargs = map[string]any{ + chatTemplateEnableThinking: *p.ThinkingBudgetTokens != 0, + } } body, err := json.Marshal(ChatRequest{ Model: p.Model, @@ -270,9 +274,13 @@ func (c *Client) CompletionsStream( ) (*CompletionStream, error) { ctx, cancel := context.WithTimeout(ctx, c.timeout) - kwargs := map[string]any{} + // A nil map is omitted from the request; an empty map is + // sent as {}. Keep kwargs nil without a budget. + var kwargs map[string]any if p.ThinkingBudgetTokens != nil { - kwargs[chatTemplateEnableThinking] = *p.ThinkingBudgetTokens != 0 + kwargs = map[string]any{ + chatTemplateEnableThinking: *p.ThinkingBudgetTokens != 0, + } } body, err := json.Marshal(ChatRequest{ ChatTemplateKWARGS: kwargs, diff --git a/internal/llm/llm.go b/internal/llm/llm.go index d29148f..590f9b0 100644 --- a/internal/llm/llm.go +++ b/internal/llm/llm.go @@ -39,7 +39,7 @@ func (e *APIError) Error() string { // Type is ToolTypeFunction. type APITool struct { // Function defines the callable function when Type is ToolTypeFunction. - Function *FunctionDef `json:"function,omitempty"` + Function *FunctionDef `json:"function,omitzero"` // Type selects the representation of the tool. Type ToolType `json:"type"` } @@ -53,65 +53,65 @@ type ChatRequest struct { // ChatTemplateKWARGS contains arguments made available to the // endpoint's chat template. // It is a llama.cpp extension to the OpenAI API. - ChatTemplateKWARGS map[string]any `json:"chat_template_kwargs,omitempty"` + ChatTemplateKWARGS map[string]any `json:"chat_template_kwargs,omitzero"` // FrequencyPenalty adjusts token likelihood according to frequency in // the generated text. Nil leaves the choice to the endpoint. - FrequencyPenalty *float32 `json:"frequency_penalty,omitempty"` + FrequencyPenalty *float32 `json:"frequency_penalty,omitzero"` // LogProbs requests log probabilities for generated tokens. - LogProbs bool `json:"logprobs,omitempty"` + LogProbs bool `json:"logprobs,omitzero"` // MaxCompletionTokens bounds generated tokens, including reasoning // tokens. - MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` + MaxCompletionTokens int `json:"max_completion_tokens,omitzero"` // MaxTokens is the deprecated predecessor of MaxCompletionTokens. - MaxTokens int `json:"max_tokens,omitempty"` + MaxTokens int `json:"max_tokens,omitzero"` // Messages is the conversation presented to the model, in order. Messages []Message `json:"messages"` // Model identifies the model to use according to the endpoint. Model string `json:"model"` // N is the number of alternatives to generate. - N int `json:"n,omitempty"` + N int `json:"n,omitzero"` // ParallelToolCalls controls whether the model may request multiple // function calls in one turn. Nil leaves the choice to the endpoint. - ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitzero"` // PresencePenalty adjusts token likelihood according to whether a // token has already appeared. Nil leaves the choice to the endpoint. - PresencePenalty *float32 `json:"presence_penalty,omitempty"` + PresencePenalty *float32 `json:"presence_penalty,omitzero"` // ResponseFormat constrains the representation of generated content. - ResponseFormat *ResponseFormat `json:"response_format,omitempty"` + ResponseFormat *ResponseFormat `json:"response_format,omitzero"` // Seed requests deterministic sampling on endpoints that support it. // Equal seeds and parameters do not guarantee equal results. - Seed *int `json:"seed,omitempty"` + Seed *int `json:"seed,omitzero"` // ServiceTier selects the endpoint's request-processing tier. - ServiceTier string `json:"service_tier,omitempty"` + ServiceTier string `json:"service_tier,omitzero"` // Stop contains sequences that end generation. // The returned text excludes the matched sequence. - Stop []string `json:"stop,omitempty"` + Stop []string `json:"stop,omitzero"` // Stream requests a sequence of StreamChunk values instead of // ChatResponse. - Stream bool `json:"stream,omitempty"` + Stream bool `json:"stream,omitzero"` // StreamOptions configures a streaming response. // It is meaningful only when Stream is true. - StreamOptions *StreamOptions `json:"stream_options,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitzero"` // Temperature controls sampling randomness. // Nil leaves the choice to the endpoint. - Temperature *float32 `json:"temperature,omitempty"` + Temperature *float32 `json:"temperature,omitzero"` // ThinkingBudgetTokens is a llama.cpp extension controlling the token // budget for reasoning. Nil omits it, a negative value requests an // unrestricted budget, zero requests an immediate end, and a positive // value sets the budget. - ThinkingBudgetTokens *int `json:"thinking_budget_tokens,omitempty"` + ThinkingBudgetTokens *int `json:"thinking_budget_tokens,omitzero"` // ToolChoice controls whether the model may or must call a tool. // It accepts a mode string or an endpoint-defined object selecting a // particular tool. - ToolChoice any `json:"tool_choice,omitempty"` + ToolChoice any `json:"tool_choice,omitzero"` // Tools describes the tools available to the model. - Tools []APITool `json:"tools,omitempty"` + Tools []APITool `json:"tools,omitzero"` // TopLogProbs is the number of most likely tokens reported at each // position when LogProbs is true. - TopLogProbs int `json:"top_logprobs,omitempty"` + TopLogProbs int `json:"top_logprobs,omitzero"` // TopP controls nucleus sampling. // Nil leaves the choice to the endpoint. - TopP *float32 `json:"top_p,omitempty"` + TopP *float32 `json:"top_p,omitzero"` } // Metadata describes one selected completion, separately from its message. @@ -327,20 +327,20 @@ type ChatResponse struct { // Choices contains the generated alternatives. Choices []Choice `json:"choices"` // Created is the endpoint's completion creation time in Unix seconds. - Created *int64 `json:"created,omitempty"` + Created *int64 `json:"created,omitzero"` // ID identifies the completion in the response body, not a request // header. - ID *string `json:"id,omitempty"` + ID *string `json:"id,omitzero"` // Model identifies the model that generated the response. - Model *string `json:"model,omitempty"` + Model *string `json:"model,omitzero"` // ServiceTier is the processing tier reported by the endpoint. - ServiceTier *string `json:"service_tier,omitempty"` + ServiceTier *string `json:"service_tier,omitzero"` // SystemFingerprint identifies the reported backend configuration. - SystemFingerprint *string `json:"system_fingerprint,omitempty"` + SystemFingerprint *string `json:"system_fingerprint,omitzero"` // Timings contains llama.cpp performance data, when reported. - Timings *Timings `json:"timings,omitempty"` + Timings *Timings `json:"timings,omitzero"` // Usage contains token counts, when reported. - Usage *Usage `json:"usage,omitempty"` + Usage *Usage `json:"usage,omitzero"` } // Choice is one generated alternative in a non-streaming response. Index is its @@ -348,12 +348,12 @@ type ChatResponse struct { // ended. Nil metadata fields mean the endpoint did not report those values. type Choice struct { // FinishReason is the endpoint's reason for ending generation. - FinishReason *string `json:"finish_reason,omitempty"` + FinishReason *string `json:"finish_reason,omitzero"` // Index is the zero-based position of this choice in the response. - Index *int `json:"index,omitempty"` + Index *int `json:"index,omitzero"` // Logprobs contains probabilities for this choice's content and // refusal. - Logprobs *Logprobs `json:"logprobs,omitempty"` + Logprobs *Logprobs `json:"logprobs,omitzero"` // Message is the generated message. Message Message `json:"message"` } @@ -379,48 +379,90 @@ const ( // Text. The package does not validate that correspondence. type ContentPart struct { // File contains the payload for ContentTypeFile. - File *FileInput `json:"file,omitempty"` + File *FileInput `json:"file,omitzero"` // ImageURL contains the payload for ContentTypeImageURL. - ImageURL *ImageURL `json:"image_url,omitempty"` + ImageURL *ImageURL `json:"image_url,omitzero"` // InputAudio contains the payload for ContentTypeInputAudio. - InputAudio *InputAudio `json:"input_audio,omitempty"` + InputAudio *InputAudio `json:"input_audio,omitzero"` // Refusal contains the payload for ContentTypeRefusal. - Refusal string `json:"refusal,omitempty"` + Refusal string `json:"refusal,omitzero"` // Text contains the payload for ContentTypeText. - Text string `json:"text,omitempty"` + Text string `json:"text,omitzero"` // Type identifies which payload field is meaningful. Type ContentType `json:"type"` } +// MarshalJSON writes the payload field that Type selects, then the type. +// The payload field is always written, even when it is empty. The OpenAI +// API requires the payload field in every content part, and a text part +// without a text key makes llama.cpp reject the request. Struct tags +// cannot express this: a field that is required for one part type must be +// omitted from the other part types. An unknown Type falls back to the +// struct tags. +func (p ContentPart) MarshalJSON() ([]byte, error) { + switch p.Type { + case ContentTypeText: + return json.Marshal(struct { + Text string `json:"text"` + Type ContentType `json:"type"` + }{p.Text, p.Type}) + case ContentTypeRefusal: + return json.Marshal(struct { + Refusal string `json:"refusal"` + Type ContentType `json:"type"` + }{p.Refusal, p.Type}) + case ContentTypeImageURL: + return json.Marshal(struct { + ImageURL *ImageURL `json:"image_url"` + Type ContentType `json:"type"` + }{p.ImageURL, p.Type}) + case ContentTypeInputAudio: + return json.Marshal(struct { + InputAudio *InputAudio `json:"input_audio"` + Type ContentType `json:"type"` + }{p.InputAudio, p.Type}) + case ContentTypeFile: + return json.Marshal(struct { + File *FileInput `json:"file"` + Type ContentType `json:"type"` + }{p.File, p.Type}) + } + // Unknown types keep the struct-tag encoding. The method must not + // reject them: the endpoint, not this client, defines which types + // are valid. + type alias ContentPart + return json.Marshal(alias(p)) +} + // Delta is an incremental message update in a StreamChunk. Reasoning and // ReasoningContent are alternative field names used by compatible endpoints; // StreamAccumulator preserves fragments from both. type Delta struct { // Content is the next fragment of message text. - Content string `json:"content,omitempty"` + Content string `json:"content,omitzero"` // Reasoning is the next reasoning fragment under the reasoning field // name. - Reasoning string `json:"reasoning,omitempty"` + Reasoning string `json:"reasoning,omitzero"` // ReasoningContent is the next reasoning fragment under the // reasoning_content field name. - ReasoningContent string `json:"reasoning_content,omitempty"` + ReasoningContent string `json:"reasoning_content,omitzero"` // Refusal is the next fragment of refusal text. - Refusal string `json:"refusal,omitempty"` + Refusal string `json:"refusal,omitzero"` // Role identifies the message role when supplied by the endpoint. - Role Role `json:"role,omitempty"` + Role Role `json:"role,omitzero"` // ToolCalls contains the next fragments of one or more tool calls. - ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitzero"` } // FileInput identifies file content either inline through FileData or by a // server-assigned FileID. type FileInput struct { // FileData is the base64-encoded contents of an inline file. - FileData string `json:"file_data,omitempty"` + FileData string `json:"file_data,omitzero"` // FileID references a file previously uploaded to the server. - FileID string `json:"file_id,omitempty"` + FileID string `json:"file_id,omitzero"` // Filename names an inline file supplied through FileData. - Filename string `json:"filename,omitempty"` + Filename string `json:"filename,omitzero"` } // Function identifies a called function and carries its arguments as a JSON @@ -428,15 +470,15 @@ type FileInput struct { // before invoking the function. type Function struct { // Arguments is the JSON-encoded argument object generated by the model. - Arguments string `json:"arguments,omitempty"` + Arguments string `json:"arguments,omitzero"` // Name identifies the function to call. - Name string `json:"name,omitempty"` + Name string `json:"name,omitzero"` } // FunctionDef declares a function the model may call. type FunctionDef struct { // Description states what the function does and when to call it. - Description string `json:"description,omitempty"` + Description string `json:"description,omitzero"` // Name identifies the function. Name string `json:"name"` // Parameters is a JSON Schema describing the arguments accepted by the @@ -444,7 +486,7 @@ type FunctionDef struct { Parameters any `json:"parameters"` // Strict requests exact conformance to Parameters, where the server // supports it. - Strict bool `json:"strict,omitempty"` + Strict bool `json:"strict,omitzero"` } // ImageDetail selects the fidelity used when processing an image. Auto lets the @@ -460,7 +502,7 @@ const ( // ImageURL carries an image content part. type ImageURL struct { // Detail is optional; when omitted the server chooses. - Detail ImageDetail `json:"detail,omitempty"` + Detail ImageDetail `json:"detail,omitzero"` // URL is a public image URL or a base64 data URI. URL string `json:"url"` } @@ -494,23 +536,23 @@ type InputAudio struct { type Message struct { // ContentParts holds the message content in parts; it is the // in-memory form of the JSON "content" field. - ContentParts []ContentPart `json:"content,omitempty"` + ContentParts []ContentPart `json:"content,omitzero"` // Name optionally identifies the sender. - Name string `json:"name,omitempty"` + Name string `json:"name,omitzero"` // Reasoning and ReasoningContent are alternative wire fields used by // compatible servers. After unmarshalling, ReasoningContent contains // either representation, with reasoning_content taking precedence when // both occur. - Reasoning string `json:"reasoning,omitempty"` - ReasoningContent string `json:"reasoning_content,omitempty"` + Reasoning string `json:"reasoning,omitzero"` + ReasoningContent string `json:"reasoning_content,omitzero"` // Refusal contains the endpoint's refusal text, when present. - Refusal string `json:"refusal,omitempty"` + Refusal string `json:"refusal,omitzero"` // Role identifies the author or purpose of the message. Role Role `json:"role"` // ToolCallID links a tool response to the call it answers. - ToolCallID string `json:"tool_call_id,omitempty"` + ToolCallID string `json:"tool_call_id,omitzero"` // ToolCalls contains calls requested by an assistant message. - ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitzero"` } // Text concatenates the Text payload of every text content part in order. It @@ -628,13 +670,13 @@ func (m *Message) UnmarshalJSON(data []byte) error { // [OpenAI Models API]: https://platform.openai.com/docs/api-reference/models/list type Model struct { // ContextLength is the model's context window in tokens, when reported. - ContextLength int `json:"context_length,omitempty"` + ContextLength int `json:"context_length,omitzero"` // Created is the model's creation time as Unix seconds. Created int64 `json:"created"` // ID identifies the model in inference requests. ID string `json:"id"` // Meta contains endpoint-specific model metadata, when reported. - Meta *ModelMeta `json:"meta,omitempty"` + Meta *ModelMeta `json:"meta,omitzero"` // Object is the endpoint's object type for the model entry. Object string `json:"object"` // OwnedBy identifies the organization that owns the model, when @@ -650,9 +692,9 @@ type Model struct { // [llama.cpp API documentation]: https://llama.app/docs/api type ModelMeta struct { // NCtx is the context size with which llama.cpp loaded the model. - NCtx int `json:"n_ctx,omitempty"` + NCtx int `json:"n_ctx,omitzero"` // NCtxTrain is the model's training context size. - NCtxTrain int `json:"n_ctx_train,omitempty"` + NCtxTrain int `json:"n_ctx_train,omitzero"` } // ModelsResponse is the response body of GET /models. @@ -678,7 +720,7 @@ const ( type ResponseFormat struct { // JSONSchema defines the required output schema when Type is // json_schema. - JSONSchema any `json:"json_schema,omitempty"` + JSONSchema any `json:"json_schema,omitzero"` // Type selects text, json_object, or json_schema output. Type string `json:"type"` } @@ -695,29 +737,29 @@ type StreamChunk struct { Delta Delta `json:"delta"` // FinishReason is the reported reason for ending this choice; // it does not establish receipt of DONE. - FinishReason *string `json:"finish_reason,omitempty"` + FinishReason *string `json:"finish_reason,omitzero"` // Index identifies the choice updated by this delta. Nil means // unreported and selects the first choice for accumulation. - Index *int `json:"index,omitempty"` + Index *int `json:"index,omitzero"` // Logprobs contains the next content and refusal token // probabilities. - Logprobs *Logprobs `json:"logprobs,omitempty"` + Logprobs *Logprobs `json:"logprobs,omitzero"` } `json:"choices"` // Created is the endpoint's completion creation time in Unix seconds. - Created *int64 `json:"created,omitempty"` + Created *int64 `json:"created,omitzero"` // ID identifies the completion in the response body, not a request // header. - ID *string `json:"id,omitempty"` + ID *string `json:"id,omitzero"` // Model identifies the model generating the response. - Model *string `json:"model,omitempty"` + Model *string `json:"model,omitzero"` // ServiceTier is the processing tier reported by the endpoint. - ServiceTier *string `json:"service_tier,omitempty"` + ServiceTier *string `json:"service_tier,omitzero"` // SystemFingerprint identifies the reported backend configuration. - SystemFingerprint *string `json:"system_fingerprint,omitempty"` + SystemFingerprint *string `json:"system_fingerprint,omitzero"` // Timings contains llama.cpp performance data, when reported. - Timings *Timings `json:"timings,omitempty"` + Timings *Timings `json:"timings,omitzero"` // Usage contains aggregate token counts, when reported. - Usage *Usage `json:"usage,omitempty"` + Usage *Usage `json:"usage,omitzero"` } // StreamOptions configures a streaming response. @@ -725,7 +767,7 @@ type StreamOptions struct { // IncludeUsage requests an additional chunk containing aggregate token // usage before the terminal data marker. If the stream is interrupted, // that chunk may not arrive. - IncludeUsage bool `json:"include_usage,omitempty"` + IncludeUsage bool `json:"include_usage,omitzero"` } // ToolCall is one tool call requested by the model. @@ -734,10 +776,10 @@ type ToolCall struct { Function Function `json:"function"` // ID identifies the call. The corresponding tool response carries the // same value in Message.ToolCallID. - ID string `json:"id,omitempty"` + ID string `json:"id,omitzero"` // Index identifies the call whose fragments a streaming delta // contains. It is omitted from non-streaming responses. - Index *int `json:"index,omitempty"` + Index *int `json:"index,omitzero"` // Type identifies the kind of tool being called. Type ToolType `json:"type"` } diff --git a/internal/llm/llm_test.go b/internal/llm/llm_test.go index 2aca7de..ce26614 100644 --- a/internal/llm/llm_test.go +++ b/internal/llm/llm_test.go @@ -34,6 +34,17 @@ func TestContentPartMarshalJSON(t *testing.T) { }, want: `{"refusal":"cannot comply","type":"refusal"}`, }, + { + // The payload field is required, also when it is empty. + name: "empty text", + part: ContentPart{Type: ContentTypeText}, + want: `{"text":"","type":"text"}`, + }, + { + name: "empty refusal", + part: ContentPart{Type: ContentTypeRefusal}, + want: `{"refusal":"","type":"refusal"}`, + }, { name: "image URL", part: ContentPart{ @@ -157,3 +168,102 @@ func TestMessageUnmarshalJSONReplacesArrayContent(t *testing.T) { t.Errorf("Message.Text() = %q, want %q", got, "new") } } + +// TestMessageEmptyTextRoundTrip is a regression test. ContentPart.Text used +// the omitzero option, so a text part with empty text marshaled as +// {"type":"text"}, without a text key. The message was stored in that form +// and sent to the model server on the next inference, and the server +// rejected the request: the OpenAI API requires the text field, and +// llama.cpp fails to parse the part. MarshalJSON now always writes the +// payload field. Rows stored in the old form are corrected when they are +// decoded and encoded again. +func TestMessageEmptyTextRoundTrip(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + // Model replies that contain only tool calls can send + // an empty content string. + name: "empty content string", + input: `{"role":"assistant","content":""}`, + want: `{"content":[{"text":"","type":"text"}],"role":"assistant"}`, + }, + { + // The UI sends this when the user edits a message's + // text to empty. + name: "empty text part", + input: `{"role":"user","content":[{"type":"text","text":""}]}`, + want: `{"content":[{"text":"","type":"text"}],"role":"user"}`, + }, + { + // Rows stored before the fix have this form. + name: "text part without text key", + input: `{"role":"user","content":[{"type":"text"}]}`, + want: `{"content":[{"text":"","type":"text"}],"role":"user"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var message Message + if err := json.Unmarshal([]byte(tt.input), &message); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + got, err := json.Marshal(message) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + if string(got) != tt.want { + t.Errorf("json.Marshal() = %s, want %s", got, tt.want) + } + }) + } +} + +// TestResponseFormatMarshalJSON records the schema-omission policy of +// ResponseFormat. The omitzero option drops only a nil schema. An empty +// JSON schema object is valid and permits any output, so it must be +// sent, not dropped. The omitempty option would drop it. +func TestResponseFormatMarshalJSON(t *testing.T) { + tests := []struct { + name string + format ResponseFormat + want string + }{ + { + name: "nil schema", + format: ResponseFormat{Type: "json_object"}, + want: `{"type":"json_object"}`, + }, + { + name: "schema object", + format: ResponseFormat{ + JSONSchema: map[string]any{"name": "calendar"}, + Type: "json_schema", + }, + want: `{"json_schema":{"name":"calendar"},"type":"json_schema"}`, + }, + { + name: "empty schema object", + format: ResponseFormat{ + JSONSchema: map[string]any{}, + Type: "json_schema", + }, + want: `{"json_schema":{},"type":"json_schema"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := json.Marshal(tt.format) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + if string(got) != tt.want { + t.Errorf("json.Marshal() = %s, want %s", got, tt.want) + } + }) + } +}