Refactol llm pkg

This commit is contained in:
dwrz
2026-10-30 22:36:15 +00:00
parent 652d41ca8f
commit ceaab03ea2
3 changed files with 239 additions and 79 deletions
+12 -4
View File
@@ -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,
+117 -75
View File
@@ -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"`
}
+110
View File
@@ -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)
}
})
}
}