mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
022a6d4f54 |
No files matched your search
@@ -385,6 +385,14 @@ func sendBufferedEvent(events chan<- zeroruntime.StreamEvent, event zeroruntime.
|
||||
func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) chatCompletionRequest {
|
||||
messages := make([]chatMessage, 0, len(request.Messages))
|
||||
for _, message := range request.Messages {
|
||||
// Drop a degenerate assistant turn that carries neither text nor tool calls
|
||||
// (e.g. a sub-agent that failed with no output). The Anthropic/Gemini mappers
|
||||
// already skip empty turns; without this, the contentless message reaches
|
||||
// strict OpenAI-compatible servers and is rejected.
|
||||
if message.Role == zeroruntime.MessageRoleAssistant &&
|
||||
strings.TrimSpace(message.Content) == "" && len(message.ToolCalls) == 0 {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, mapMessage(message))
|
||||
}
|
||||
|
||||
@@ -445,11 +453,12 @@ func mapMessage(message zeroruntime.Message) chatMessage {
|
||||
// message that happens to carry Images keeps the plain string/nil content
|
||||
// path (its images are simply not serialized).
|
||||
if len(message.Images) == 0 || message.Role != zeroruntime.MessageRoleUser {
|
||||
// Preserve the legacy behavior: only non-empty text is serialized, so
|
||||
// empty content is omitted by the `omitempty` tag.
|
||||
if message.Content != "" {
|
||||
mapped.Content = message.Content
|
||||
}
|
||||
// Always set content (to "" when empty) so it serializes as `"content":""`
|
||||
// rather than being dropped. Strict OpenAI-compatible servers reject a
|
||||
// message with no content field; tool results and assistant-with-tool-calls
|
||||
// turns must still carry an (empty) content. Truly empty assistant turns are
|
||||
// dropped upstream in openAIRequest.
|
||||
mapped.Content = message.Content
|
||||
} else {
|
||||
parts := make([]contentPart, 0, len(message.Images)+1)
|
||||
if message.Content != "" {
|
||||
|
||||
@@ -1000,57 +1000,6 @@ func TestStreamCompletionEmitsDroppedOnNamelessToolCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRequestTextOnlyOmitsEmptyContent(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
got, err := json.Marshal(provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "hello"},
|
||||
{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
Content: "", // assistant turn that is *only* a tool call -> empty content must drop
|
||||
ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: "call_1",
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"README.md"}`,
|
||||
}},
|
||||
},
|
||||
{Role: zeroruntime.MessageRoleTool, Content: "contents", ToolCallID: "call_1"},
|
||||
},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
|
||||
// String content round-trips as a JSON string, and empty content is omitted.
|
||||
if !strings.Contains(string(got), `"content":"hello"`) {
|
||||
t.Fatalf("user content not serialized as string: %s", got)
|
||||
}
|
||||
if strings.Contains(string(got), `"content":""`) {
|
||||
t.Fatalf("empty assistant content must be omitted, got: %s", got)
|
||||
}
|
||||
|
||||
// Decode the assistant message and assert no content key at all.
|
||||
var decoded chatCompletionRequest
|
||||
var raw struct {
|
||||
Messages []map[string]any `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(got, &raw); err != nil {
|
||||
t.Fatalf("unmarshal request: %v", err)
|
||||
}
|
||||
_ = decoded
|
||||
if _, present := raw.Messages[2]["content"]; present {
|
||||
t.Fatalf("assistant (tool-only) message must omit content key, got: %#v", raw.Messages[2])
|
||||
}
|
||||
if raw.Messages[1]["content"] != "hello" {
|
||||
t.Fatalf("user content = %#v, want \"hello\"", raw.Messages[1]["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentPartImageURLMarshalsDataURI(t *testing.T) {
|
||||
parts := []contentPart{
|
||||
{Type: "text", Text: "look"},
|
||||
@@ -1076,38 +1025,6 @@ func TestContentPartImageURLMarshalsDataURI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRequestEmptyContentStaysOmittedAfterAnyRefactor(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
got, err := json.Marshal(provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
Content: "",
|
||||
ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: "call_1",
|
||||
Name: "read_file",
|
||||
Arguments: `{}`,
|
||||
}},
|
||||
},
|
||||
},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var raw struct {
|
||||
Messages []map[string]any `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(got, &raw); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if _, present := raw.Messages[0]["content"]; present {
|
||||
t.Fatalf("empty content boxed in any must stay omitted, got: %#v", raw.Messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapMessageBuildsImageURLContentParts(t *testing.T) {
|
||||
msg := mapMessage(zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
@@ -1165,8 +1082,8 @@ func TestMapMessageTextOnlyKeepsStringContent(t *testing.T) {
|
||||
t.Fatalf("Content = %#v, want string \"hi\"", msg.Content)
|
||||
}
|
||||
empty := mapMessage(zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: ""})
|
||||
if empty.Content != nil {
|
||||
t.Fatalf("empty text content = %#v, want nil so omitempty drops it", empty.Content)
|
||||
if got, ok := empty.Content.(string); !ok || got != "" {
|
||||
t.Fatalf("empty text content = %#v, want \"\" so it serializes as content:\"\" (strict servers reject a missing/null content)", empty.Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1246,3 +1163,66 @@ func TestStreamCompletionSerializesImageContentParts(t *testing.T) {
|
||||
t.Fatalf("image url = %#v, want data:image/png;base64,QUJD", imageURL["url"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIRequestEmptyContentHandling locks in the fix for strict OpenAI-
|
||||
// compatible servers (e.g. glm-* on Ollama-cloud) that reject a message whose
|
||||
// `content` is absent/null with "invalid message content type: <nil>": a
|
||||
// contentless assistant turn with no tool calls is dropped, and every other
|
||||
// message serializes an explicit `"content"` field (empty string when there is
|
||||
// no text) instead of omitting it.
|
||||
func TestOpenAIRequestEmptyContentHandling(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
req := provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "hi"},
|
||||
// Degenerate empty assistant turn (e.g. a sub-agent that failed with no
|
||||
// output): must be dropped, not sent.
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: " "},
|
||||
// Assistant with tool calls but no text: kept, content present as "".
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "", ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: "call_1", Name: "read_file", Arguments: "{}",
|
||||
}}},
|
||||
// Tool result with empty content: kept, content present as "".
|
||||
{Role: zeroruntime.MessageRoleTool, Content: "", ToolCallID: "call_1"},
|
||||
},
|
||||
})
|
||||
|
||||
if len(req.Messages) != 3 {
|
||||
t.Fatalf("empty assistant turn should be dropped; got %d messages: %#v", len(req.Messages), req.Messages)
|
||||
}
|
||||
|
||||
// No message may serialize with an absent/null content field.
|
||||
for i, message := range req.Messages {
|
||||
if message.Content == nil {
|
||||
t.Fatalf("message %d has nil content (would serialize as null/omitted): %#v", i, message)
|
||||
}
|
||||
data, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal message %d: %v", i, err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"content":`) {
|
||||
t.Fatalf("message %d omits the content field: %s", i, data)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Messages[0].Content != "hi" {
|
||||
t.Fatalf("user content = %#v, want \"hi\"", req.Messages[0].Content)
|
||||
}
|
||||
// The kept-but-textless messages must send content as an explicit empty string.
|
||||
for _, idx := range []int{1, 2} {
|
||||
data, err := json.Marshal(req.Messages[idx])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal message %d: %v", idx, err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"content":""`) {
|
||||
t.Fatalf("message %d should send content:\"\", got %s", idx, data)
|
||||
}
|
||||
}
|
||||
if len(req.Messages[1].ToolCalls) != 1 {
|
||||
t.Fatalf("assistant tool calls dropped: %#v", req.Messages[1])
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,13 @@ type streamOptions struct {
|
||||
}
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content,omitempty"`
|
||||
Role string `json:"role"`
|
||||
// Content has no omitempty: strict OpenAI-compatible servers (e.g. some
|
||||
// Ollama-cloud models like glm-*) reject a message whose `content` is absent
|
||||
// or null with "invalid message content type: <nil>". mapMessage always sets
|
||||
// this (to "" when there's no text), so a contentless message serializes as
|
||||
// `"content":""` rather than being dropped.
|
||||
Content any `json:"content"`
|
||||
ToolCalls []requestToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user