From b77696f9cb10f695a0f755f493fde3f6b114e436 Mon Sep 17 00:00:00 2001 From: Matthew Foley Date: Sun, 1 Mar 2026 17:42:46 -0500 Subject: [PATCH 01/12] fix: adjust parsing (WIP) --- .gitignore | 2 ++ pkg/backfill/backfill.go | 3 +- pkg/llm/provider/ollama/ollama.go | 56 ++++++++++++++++++++++++++++--- pkg/llm/provider/ollama/types.go | 7 ++-- pkg/utils/string.go | 17 ++++++++++ 5 files changed, 77 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 34289b8..8c32afa 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ # HumanLayer .humanlayer/ + +logs/ \ No newline at end of file diff --git a/pkg/backfill/backfill.go b/pkg/backfill/backfill.go index a48eda3..dadf46b 100644 --- a/pkg/backfill/backfill.go +++ b/pkg/backfill/backfill.go @@ -10,6 +10,7 @@ import ( "github.com/papercomputeco/tapes/pkg/storage/ent" "github.com/papercomputeco/tapes/pkg/storage/ent/node" "github.com/papercomputeco/tapes/pkg/storage/sqlite" + "github.com/papercomputeco/tapes/pkg/utils" ) // Options configures backfill behavior. @@ -148,7 +149,7 @@ func (b *Backfiller) matchAndUpdate(ctx context.Context, entries []TranscriptEnt // Verify by content prefix if we have text content. if entryText != "" && len(ci.node.Content) > 0 { - nodeText := extractTextFromContent(ci.node.Content) + nodeText := utils.ExtractTextFromContent(ci.node.Content) if !contentPrefixMatch(entryText, nodeText, 200) { continue } diff --git a/pkg/llm/provider/ollama/ollama.go b/pkg/llm/provider/ollama/ollama.go index 14448d2..333dd41 100644 --- a/pkg/llm/provider/ollama/ollama.go +++ b/pkg/llm/provider/ollama/ollama.go @@ -2,14 +2,18 @@ package ollama import ( "encoding/json" - "github.com/papercomputeco/tapes/pkg/llm" + "github.com/papercomputeco/tapes/pkg/utils" + "os" ) // Provider implements the Provider interface for Ollama's API. -type Provider struct{} +type Provider struct { + reqCount int + respCount int +} -func New() *Provider { return &Provider{} } +func New() *Provider { return &Provider{reqCount: 0, respCount: 0} } func (o *Provider) Name() string { return "ollama" @@ -20,8 +24,28 @@ func (o *Provider) DefaultStreaming() bool { return true } +func (o *Provider) SavePayload(payloadType string, payload []byte) error { + var fileName string + if payloadType == "request" { + o.reqCount++ + fileName = "logs/" + o.Name() + "-" + payloadType + string(o.reqCount) + ".json" + } else { + o.respCount++ + fileName = "logs/" + o.Name() + "-" + payloadType + string(o.respCount) + ".json" + } + file, err := os.OpenFile(fileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer file.Close() // Ensure the file is closed after the function returns + + // Write the string to the file + _, writeErr := file.WriteString(string(payload)) + return writeErr +} func (o *Provider) ParseRequest(payload []byte) (*llm.ChatRequest, error) { var req ollamaRequest + defer o.SavePayload("request", payload) if err := json.Unmarshal(payload, &req); err != nil { return nil, err } @@ -33,6 +57,11 @@ func (o *Provider) ParseRequest(payload []byte) (*llm.ChatRequest, error) { Content: []llm.ContentBlock{}, } + if convertedContent := convertRawContent(msg.ContentRaw); convertedContent != "" { + // Set content string and clear out original + msg.Content = convertedContent + msg.ContentRaw = "" + } // Add text content if present if msg.Content != "" { converted.Content = append(converted.Content, llm.ContentBlock{Type: "text", Text: msg.Content}) @@ -107,13 +136,17 @@ func (o *Provider) ParseRequest(payload []byte) (*llm.ChatRequest, error) { func (o *Provider) ParseResponse(payload []byte) (*llm.ChatResponse, error) { var resp ollamaResponse + defer o.SavePayload("response", payload) if err := json.Unmarshal(payload, &resp); err != nil { return nil, err } // Convert message content var content []llm.ContentBlock - + if convertedContent := convertRawContent(resp.Message.ContentRaw); convertedContent != "" { + resp.Message.Content = convertedContent + resp.Message.ContentRaw = "" + } // Add text content if present if resp.Message.Content != "" { content = append(content, llm.ContentBlock{Type: "text", Text: resp.Message.Content}) @@ -183,3 +216,18 @@ func (o *Provider) ParseResponse(payload []byte) (*llm.ChatResponse, error) { func (o *Provider) ParseStreamChunk(_ []byte) (*llm.StreamChunk, error) { panic("Not yet implemented") } + + + +// convertRawContent converts the raw content from Ollama API messages to a string. +// The content can be either a string or an array of content blocks (maps with type/text fields). +func convertRawContent(contentRaw interface{}) string { + if s, ok := contentRaw.(string); ok { + return s + } + // Next check if we are looking at a slice of maps + if slice, ok := contentRaw.([]map[string]any); ok { + return utils.ExtractTextFromContent(slice) + } + return "" +} diff --git a/pkg/llm/provider/ollama/types.go b/pkg/llm/provider/ollama/types.go index 8112d26..2034573 100644 --- a/pkg/llm/provider/ollama/types.go +++ b/pkg/llm/provider/ollama/types.go @@ -15,7 +15,8 @@ type ollamaRequest struct { type ollamaMessage struct { Role string `json:"role"` - Content string `json:"content"` + Content string `` + ContentRaw interface{} `json:"content,omitempty"` // Base64-encoded images Images []string `json:"images,omitempty"` @@ -25,11 +26,11 @@ type ollamaMessage struct { } type ollamaToolCall struct { - ID string `json:"id"` + ID string `json:"id,omitempty"` Function struct { Index int `json:"index,omitempty"` Name string `json:"name"` - Arguments map[string]any `json:"arguments"` + Arguments map[string]any `json:"parameters"` } `json:"function"` } diff --git a/pkg/utils/string.go b/pkg/utils/string.go index e8efbdc..e44e639 100644 --- a/pkg/utils/string.go +++ b/pkg/utils/string.go @@ -1,5 +1,9 @@ package utils +import ( + "strings" +) + // Truncate is a simple string truncate func Truncate(s string, maxLen int) string { if len(s) <= maxLen { @@ -7,3 +11,16 @@ func Truncate(s string, maxLen int) string { } return s[:maxLen] + "..." } + +// extractTextFromContent concatenates text from content blocks. +func ExtractTextFromContent(content []map[string]any) string { + var sb strings.Builder + for _, block := range content { + if t, ok := block["type"].(string); ok && t == "text" { + if text, ok := block["text"].(string); ok { + sb.WriteString(text) + } + } + } + return sb.String() +} From 60e0d2302f9863179be832eae26df794809ef155 Mon Sep 17 00:00:00 2001 From: Matthew Foley Date: Wed, 4 Mar 2026 15:30:16 -0500 Subject: [PATCH 02/12] test: check ExtractTextFromContent --- pkg/utils/string_test.go | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/pkg/utils/string_test.go b/pkg/utils/string_test.go index 283f750..af6d29a 100644 --- a/pkg/utils/string_test.go +++ b/pkg/utils/string_test.go @@ -19,3 +19,44 @@ var _ = Describe("truncate", func() { Expect(result).To(Equal("this is a ...")) }) }) + +var _ = Describe("ExtractTextFromContent", func() { + It("returns empty with an empty slice", func() { + emptySlice := []map[string]any {} + result := ExtractTextFromContent(emptySlice) + Expect(result).To(Equal("")) + }) + + It("returns empty with an irrelevant slice", func() { + irrelevantSlice := []map[string]any{ + {"type": "image_url", "image_url": "data:image/png;ibVOR..."}, + {"type": "function", "function": {"name": "fetch", "arguments": "{\"url\": \"https://allrecipes.com/top-5-italian\"}"}}, + } + result := ExtractTextFromContent(irrelevantSlice) + Expect(result).To(Equal("")) + }) + + It("returns the expected content with matching content blocks", func() { + msg1 := "I need a recipe for chicken carbonara" + msg2 := ": User has an egg allergy, ensure recipes have documented substitutions." + contentBlocks := []map[string]any{ + {"type": "text", "text": msg1}, + {"type": "text", "text": msg2}, + } + result := ExtractTextFromContent(contentBlocks) + Expect(result).To(ContainSubstring(msg1)) + Expect(result).To(ContainSubstring(msg2)) + }) + + It("returns the expected content with mixed content blocks", func() { + imgContent := "data:image/png;ibVOR..." + textContent := "What's wrong with this picture" + mixedBlocks := []map[string]any{ + {"type": "text", "text": textContent}, + {"type": "image_url", "image_url": imgContent}, + } + result := ExtractTextFromContent(mixedBlocks) + Expect(result).To(ContainSubstring(textContent)) + Expect(result).ToNot(ContainSubstring(imgContent)) + }) +}) From d4b1b97e1148f2b24180b377555eab8c9e31b9d4 Mon Sep 17 00:00:00 2001 From: Matthew Foley Date: Wed, 4 Mar 2026 15:32:13 -0500 Subject: [PATCH 03/12] chore: pull out SavePayload function --- pkg/llm/provider/ollama/ollama.go | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/pkg/llm/provider/ollama/ollama.go b/pkg/llm/provider/ollama/ollama.go index 333dd41..41de6d4 100644 --- a/pkg/llm/provider/ollama/ollama.go +++ b/pkg/llm/provider/ollama/ollama.go @@ -4,7 +4,6 @@ import ( "encoding/json" "github.com/papercomputeco/tapes/pkg/llm" "github.com/papercomputeco/tapes/pkg/utils" - "os" ) // Provider implements the Provider interface for Ollama's API. @@ -24,28 +23,8 @@ func (o *Provider) DefaultStreaming() bool { return true } -func (o *Provider) SavePayload(payloadType string, payload []byte) error { - var fileName string - if payloadType == "request" { - o.reqCount++ - fileName = "logs/" + o.Name() + "-" + payloadType + string(o.reqCount) + ".json" - } else { - o.respCount++ - fileName = "logs/" + o.Name() + "-" + payloadType + string(o.respCount) + ".json" - } - file, err := os.OpenFile(fileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return err - } - defer file.Close() // Ensure the file is closed after the function returns - - // Write the string to the file - _, writeErr := file.WriteString(string(payload)) - return writeErr -} func (o *Provider) ParseRequest(payload []byte) (*llm.ChatRequest, error) { var req ollamaRequest - defer o.SavePayload("request", payload) if err := json.Unmarshal(payload, &req); err != nil { return nil, err } @@ -136,7 +115,6 @@ func (o *Provider) ParseRequest(payload []byte) (*llm.ChatRequest, error) { func (o *Provider) ParseResponse(payload []byte) (*llm.ChatResponse, error) { var resp ollamaResponse - defer o.SavePayload("response", payload) if err := json.Unmarshal(payload, &resp); err != nil { return nil, err } From 38b0711396c4ed1dbe2059f87a43fa71e09e7839 Mon Sep 17 00:00:00 2001 From: Matthew Foley Date: Wed, 4 Mar 2026 15:54:26 -0500 Subject: [PATCH 04/12] fix: finish removing payload saving things --- .gitignore | 2 -- pkg/llm/provider/ollama/ollama.go | 7 ++----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 8c32afa..34289b8 100644 --- a/.gitignore +++ b/.gitignore @@ -17,5 +17,3 @@ # HumanLayer .humanlayer/ - -logs/ \ No newline at end of file diff --git a/pkg/llm/provider/ollama/ollama.go b/pkg/llm/provider/ollama/ollama.go index 41de6d4..ef81d96 100644 --- a/pkg/llm/provider/ollama/ollama.go +++ b/pkg/llm/provider/ollama/ollama.go @@ -7,12 +7,9 @@ import ( ) // Provider implements the Provider interface for Ollama's API. -type Provider struct { - reqCount int - respCount int -} +type Provider struct {} -func New() *Provider { return &Provider{reqCount: 0, respCount: 0} } +func New() *Provider { return &Provider{} } func (o *Provider) Name() string { return "ollama" From 08fab0c6e6520b52ea6d656ae1f4f5d6c229839a Mon Sep 17 00:00:00 2001 From: Matthew Foley Date: Wed, 4 Mar 2026 18:08:01 -0500 Subject: [PATCH 05/12] fix: deal with lint errors --- pkg/llm/provider/ollama/ollama.go | 4 +--- pkg/llm/provider/ollama/types.go | 4 ++-- pkg/utils/string_test.go | 5 +++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/pkg/llm/provider/ollama/ollama.go b/pkg/llm/provider/ollama/ollama.go index ef81d96..cac6776 100644 --- a/pkg/llm/provider/ollama/ollama.go +++ b/pkg/llm/provider/ollama/ollama.go @@ -7,7 +7,7 @@ import ( ) // Provider implements the Provider interface for Ollama's API. -type Provider struct {} +type Provider struct{} func New() *Provider { return &Provider{} } @@ -192,8 +192,6 @@ func (o *Provider) ParseStreamChunk(_ []byte) (*llm.StreamChunk, error) { panic("Not yet implemented") } - - // convertRawContent converts the raw content from Ollama API messages to a string. // The content can be either a string or an array of content blocks (maps with type/text fields). func convertRawContent(contentRaw interface{}) string { diff --git a/pkg/llm/provider/ollama/types.go b/pkg/llm/provider/ollama/types.go index 2034573..a910180 100644 --- a/pkg/llm/provider/ollama/types.go +++ b/pkg/llm/provider/ollama/types.go @@ -14,8 +14,8 @@ type ollamaRequest struct { } type ollamaMessage struct { - Role string `json:"role"` - Content string `` + Role string `json:"role"` + Content string `` ContentRaw interface{} `json:"content,omitempty"` // Base64-encoded images diff --git a/pkg/utils/string_test.go b/pkg/utils/string_test.go index af6d29a..b729a7d 100644 --- a/pkg/utils/string_test.go +++ b/pkg/utils/string_test.go @@ -22,15 +22,16 @@ var _ = Describe("truncate", func() { var _ = Describe("ExtractTextFromContent", func() { It("returns empty with an empty slice", func() { - emptySlice := []map[string]any {} + emptySlice := []map[string]any{} result := ExtractTextFromContent(emptySlice) Expect(result).To(Equal("")) }) It("returns empty with an irrelevant slice", func() { + functionCall := map[string]string {"name": "fetch", "arguments": "{\"url\": \"https://allrecipes.com/top-5-italian\"}"} irrelevantSlice := []map[string]any{ {"type": "image_url", "image_url": "data:image/png;ibVOR..."}, - {"type": "function", "function": {"name": "fetch", "arguments": "{\"url\": \"https://allrecipes.com/top-5-italian\"}"}}, + {"type": "function", "function": functionCall}, } result := ExtractTextFromContent(irrelevantSlice) Expect(result).To(Equal("")) From b5a91e7450e70f0400b2a7f106373342031d0766 Mon Sep 17 00:00:00 2001 From: Matthew Foley Date: Wed, 4 Mar 2026 18:18:16 -0500 Subject: [PATCH 06/12] fix: reset to arguments --- pkg/llm/provider/ollama/types.go | 2 +- pkg/utils/string_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/llm/provider/ollama/types.go b/pkg/llm/provider/ollama/types.go index a910180..14dc05c 100644 --- a/pkg/llm/provider/ollama/types.go +++ b/pkg/llm/provider/ollama/types.go @@ -30,7 +30,7 @@ type ollamaToolCall struct { Function struct { Index int `json:"index,omitempty"` Name string `json:"name"` - Arguments map[string]any `json:"parameters"` + Arguments map[string]any `json:"arguments"` } `json:"function"` } diff --git a/pkg/utils/string_test.go b/pkg/utils/string_test.go index b729a7d..66c5542 100644 --- a/pkg/utils/string_test.go +++ b/pkg/utils/string_test.go @@ -28,7 +28,7 @@ var _ = Describe("ExtractTextFromContent", func() { }) It("returns empty with an irrelevant slice", func() { - functionCall := map[string]string {"name": "fetch", "arguments": "{\"url\": \"https://allrecipes.com/top-5-italian\"}"} + functionCall := map[string]string{"name": "fetch", "arguments": "{\"url\": \"https://allrecipes.com/top-5-italian\"}"} irrelevantSlice := []map[string]any{ {"type": "image_url", "image_url": "data:image/png;ibVOR..."}, {"type": "function", "function": functionCall}, From 1dcc4e258c79eb23ec50d5051fe39c2b54ba279e Mon Sep 17 00:00:00 2001 From: Matthew Foley Date: Wed, 4 Mar 2026 18:27:58 -0500 Subject: [PATCH 07/12] chore: make format --- pkg/backfill/backfill.go | 14 -------------- pkg/llm/provider/ollama/ollama.go | 3 ++- pkg/llm/provider/ollama/types.go | 6 +++--- 3 files changed, 5 insertions(+), 18 deletions(-) diff --git a/pkg/backfill/backfill.go b/pkg/backfill/backfill.go index dadf46b..0cdc271 100644 --- a/pkg/backfill/backfill.go +++ b/pkg/backfill/backfill.go @@ -3,7 +3,6 @@ package backfill import ( "context" "fmt" - "strings" "time" "github.com/papercomputeco/tapes/pkg/llm" @@ -207,19 +206,6 @@ func (b *Backfiller) matchAndUpdate(ctx context.Context, entries []TranscriptEnt return result, nil } -// extractTextFromContent concatenates text from content blocks. -func extractTextFromContent(content []map[string]any) string { - var sb strings.Builder - for _, block := range content { - if t, ok := block["type"].(string); ok && t == "text" { - if text, ok := block["text"].(string); ok { - sb.WriteString(text) - } - } - } - return sb.String() -} - // contentPrefixMatch checks if the first n characters of two strings match. func contentPrefixMatch(a, b string, n int) bool { if a == "" || b == "" { diff --git a/pkg/llm/provider/ollama/ollama.go b/pkg/llm/provider/ollama/ollama.go index cac6776..cd0c9ee 100644 --- a/pkg/llm/provider/ollama/ollama.go +++ b/pkg/llm/provider/ollama/ollama.go @@ -2,6 +2,7 @@ package ollama import ( "encoding/json" + "github.com/papercomputeco/tapes/pkg/llm" "github.com/papercomputeco/tapes/pkg/utils" ) @@ -194,7 +195,7 @@ func (o *Provider) ParseStreamChunk(_ []byte) (*llm.StreamChunk, error) { // convertRawContent converts the raw content from Ollama API messages to a string. // The content can be either a string or an array of content blocks (maps with type/text fields). -func convertRawContent(contentRaw interface{}) string { +func convertRawContent(contentRaw any) string { if s, ok := contentRaw.(string); ok { return s } diff --git a/pkg/llm/provider/ollama/types.go b/pkg/llm/provider/ollama/types.go index 14dc05c..1974aee 100644 --- a/pkg/llm/provider/ollama/types.go +++ b/pkg/llm/provider/ollama/types.go @@ -14,9 +14,9 @@ type ollamaRequest struct { } type ollamaMessage struct { - Role string `json:"role"` - Content string `` - ContentRaw interface{} `json:"content,omitempty"` + Role string `json:"role"` + Content string `json:"-"` + ContentRaw any `json:"content,omitempty"` // Base64-encoded images Images []string `json:"images,omitempty"` From 0294f4523df133298e890806458ae37bb204f96c Mon Sep 17 00:00:00 2001 From: Matthew Foley Date: Fri, 6 Mar 2026 22:24:12 -0500 Subject: [PATCH 08/12] test: check desired behavior --- pkg/llm/provider/ollama/ollama_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pkg/llm/provider/ollama/ollama_test.go b/pkg/llm/provider/ollama/ollama_test.go index 46b6f04..16eb4f3 100644 --- a/pkg/llm/provider/ollama/ollama_test.go +++ b/pkg/llm/provider/ollama/ollama_test.go @@ -501,4 +501,20 @@ var _ = Describe("Ollama Provider", func() { Expect(req.Messages[1].Content[0].ToolName).To(Equal("get_weather")) }) }) + Describe("ParseRequest with mixed content block types", func() { + It("parses requests with mixed data structures for content", func() { + payload := []byte(`{ + "model": "llama3", + "messages": [ + {"role": "user", "content": [ + {"type": "text", "text": "What changes would you suggest to boost test coverage?"}, + {"type": "text", "text": ": In PLAN mode, you must not modify any files."} + ]} + ] + }`) + req, err := p.ParseRequest(payload) + Expect(err).NotTo(HaveOccurred()) + Expect(req.Messages).To(HaveLen(1)) + }) + }) }) From 030e5eee66b824392449960d4fd1c3535bf22014 Mon Sep 17 00:00:00 2001 From: Matthew Foley Date: Sun, 8 Mar 2026 11:22:40 -0400 Subject: [PATCH 09/12] fix: parse tool_calls as string --- README.md | 2 +- pkg/llm/provider/ollama/ollama.go | 13 ++++++++++--- pkg/llm/provider/ollama/types.go | 11 +++++++---- proxy/proxy.go | 25 +++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 713311d..4fbd990 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ curl -fsSL https://download.tapes.dev/install | bash ``` Run Ollama and the `tapes` services. By default, `tapes` targets embeddings on Ollama -with the `embeddinggema:latest` model - pull this model with `ollama pull embeddinggema`: +with the `embeddinggemma:latest` model - pull this model with `ollama pull embeddinggemma`: ```bash ollama serve diff --git a/pkg/llm/provider/ollama/ollama.go b/pkg/llm/provider/ollama/ollama.go index cd0c9ee..54e3f16 100644 --- a/pkg/llm/provider/ollama/ollama.go +++ b/pkg/llm/provider/ollama/ollama.go @@ -2,7 +2,6 @@ package ollama import ( "encoding/json" - "github.com/papercomputeco/tapes/pkg/llm" "github.com/papercomputeco/tapes/pkg/utils" ) @@ -10,6 +9,14 @@ import ( // Provider implements the Provider interface for Ollama's API. type Provider struct{} +func getToolArgs(arguments []byte) map[string]any { + var toolArgs map[string]any + if toolParseErr := json.Unmarshal(arguments, &toolArgs); toolParseErr != nil { + toolArgs = make(map[string]any, 0) + } + return toolArgs +} + func New() *Provider { return &Provider{} } func (o *Provider) Name() string { @@ -58,7 +65,7 @@ func (o *Provider) ParseRequest(payload []byte) (*llm.ChatRequest, error) { Type: "tool_use", ToolUseID: tc.ID, ToolName: tc.Function.Name, - ToolInput: tc.Function.Arguments, + ToolInput: getToolArgs(tc.Function.Arguments), }) } @@ -142,7 +149,7 @@ func (o *Provider) ParseResponse(payload []byte) (*llm.ChatResponse, error) { Type: "tool_use", ToolUseID: tc.ID, ToolName: tc.Function.Name, - ToolInput: tc.Function.Arguments, + ToolInput: getToolArgs(tc.Function.Arguments), }) } diff --git a/pkg/llm/provider/ollama/types.go b/pkg/llm/provider/ollama/types.go index 1974aee..8b74a16 100644 --- a/pkg/llm/provider/ollama/types.go +++ b/pkg/llm/provider/ollama/types.go @@ -1,7 +1,10 @@ // Package ollama package ollama -import "time" +import ( + "encoding/json" + "time" +) // ollamaRequest represents Ollama's request format. type ollamaRequest struct { @@ -28,9 +31,9 @@ type ollamaMessage struct { type ollamaToolCall struct { ID string `json:"id,omitempty"` Function struct { - Index int `json:"index,omitempty"` - Name string `json:"name"` - Arguments map[string]any `json:"arguments"` + Index int `json:"index,omitempty"` + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` } `json:"function"` } diff --git a/proxy/proxy.go b/proxy/proxy.go index bc407cd..55d0eda 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -354,6 +354,12 @@ func (p *Proxy) handleHTTPRespToPipeWriter(httpResp *http.Response, pw *io.PipeW // and Anthropic), forwarding raw bytes verbatim to the pipe writer while // parsing events for telemetry accumulation. func (p *Proxy) handleSSEStream(httpResp *http.Response, pw *io.PipeWriter, parsedReq *llm.ChatRequest, prov provider.Provider, agentName string, startTime time.Time) { + p.logger.Debug("handling SSE Stream", + "model", parsedReq.Model, + "provider", prov.Name(), + "agent", agentName, + "duration", time.Since(startTime), + ) var allChunks [][]byte var fullContent strings.Builder var streamUsage llm.Usage @@ -394,6 +400,12 @@ func (p *Proxy) handleSSEStream(httpResp *http.Response, pw *io.PipeWriter, pars // Ollama), forwarding raw bytes to the pipe writer while accumulating chunks // for telemetry. func (p *Proxy) handleNDJSONStream(httpResp *http.Response, pw *io.PipeWriter, parsedReq *llm.ChatRequest, prov provider.Provider, agentName string, startTime time.Time) { + p.logger.Debug("handling NDJSON Stream", + "model", parsedReq.Model, + "provider", prov.Name(), + "agent", agentName, + "duration", time.Since(startTime), + ) var allChunks [][]byte var fullContent strings.Builder var streamUsage llm.Usage @@ -491,6 +503,7 @@ type streamMeta struct { func (p *Proxy) extractUsageFromSSE(data []byte, providerName string, usage *llm.Usage, meta *streamMeta) { var chunkData map[string]any if err := json.Unmarshal(data, &chunkData); err != nil { + p.logger.Error("error parsing usage chunk", "error", err) return } @@ -531,11 +544,20 @@ func (p *Proxy) extractUsageFromSSE(data []byte, providerName string, usage *llm usage.CompletionTokens = jsonInt(u, "completion_tokens") } case providerOllama: + p.logger.Debug("providerOllama extract usage", + "data", + string(data), + ) // Ollama includes usage in the final NDJSON line (done=true) if done, ok := chunkData["done"].(bool); ok && done { usage.PromptTokens = jsonInt(chunkData, "prompt_eval_count") usage.CompletionTokens = jsonInt(chunkData, "eval_count") } + // If Ollama is being consumed by opencode, chat completions read more like OpenAI + if u, ok := chunkData["usage"].(map[string]any); ok { + usage.PromptTokens = jsonInt(u, "prompt_tokens") + usage.CompletionTokens = jsonInt(u, "completion_tokens") + } } } @@ -579,6 +601,9 @@ func (p *Proxy) reconstructStreamedResponse(chunks [][]byte, fullContent string, if len(chunks) > 0 { lastChunk := chunks[len(chunks)-1] resp, err := prov.ParseResponse(lastChunk) + if err != nil { + p.logger.Error("response parse failed", "error", err) + } if err == nil && resp != nil { // If the last chunk has minimal content, supplement with accumulated content if resp.Message.GetText() == "" && fullContent != "" { From 6bc1f7853d9778863d6a99e8f408576e603734dd Mon Sep 17 00:00:00 2001 From: Matthew Foley Date: Sun, 8 Mar 2026 19:47:17 -0400 Subject: [PATCH 10/12] chore: revert to main --- README.md | 2 +- pkg/backfill/backfill.go | 17 +++++++++-- pkg/llm/provider/ollama/ollama.go | 37 +++-------------------- pkg/llm/provider/ollama/ollama_test.go | 16 ---------- pkg/llm/provider/ollama/types.go | 18 +++++------ pkg/utils/string.go | 17 ----------- pkg/utils/string_test.go | 42 -------------------------- proxy/proxy.go | 25 --------------- 8 files changed, 27 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index 4fbd990..713311d 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ curl -fsSL https://download.tapes.dev/install | bash ``` Run Ollama and the `tapes` services. By default, `tapes` targets embeddings on Ollama -with the `embeddinggemma:latest` model - pull this model with `ollama pull embeddinggemma`: +with the `embeddinggema:latest` model - pull this model with `ollama pull embeddinggema`: ```bash ollama serve diff --git a/pkg/backfill/backfill.go b/pkg/backfill/backfill.go index 0cdc271..a48eda3 100644 --- a/pkg/backfill/backfill.go +++ b/pkg/backfill/backfill.go @@ -3,13 +3,13 @@ package backfill import ( "context" "fmt" + "strings" "time" "github.com/papercomputeco/tapes/pkg/llm" "github.com/papercomputeco/tapes/pkg/storage/ent" "github.com/papercomputeco/tapes/pkg/storage/ent/node" "github.com/papercomputeco/tapes/pkg/storage/sqlite" - "github.com/papercomputeco/tapes/pkg/utils" ) // Options configures backfill behavior. @@ -148,7 +148,7 @@ func (b *Backfiller) matchAndUpdate(ctx context.Context, entries []TranscriptEnt // Verify by content prefix if we have text content. if entryText != "" && len(ci.node.Content) > 0 { - nodeText := utils.ExtractTextFromContent(ci.node.Content) + nodeText := extractTextFromContent(ci.node.Content) if !contentPrefixMatch(entryText, nodeText, 200) { continue } @@ -206,6 +206,19 @@ func (b *Backfiller) matchAndUpdate(ctx context.Context, entries []TranscriptEnt return result, nil } +// extractTextFromContent concatenates text from content blocks. +func extractTextFromContent(content []map[string]any) string { + var sb strings.Builder + for _, block := range content { + if t, ok := block["type"].(string); ok && t == "text" { + if text, ok := block["text"].(string); ok { + sb.WriteString(text) + } + } + } + return sb.String() +} + // contentPrefixMatch checks if the first n characters of two strings match. func contentPrefixMatch(a, b string, n int) bool { if a == "" || b == "" { diff --git a/pkg/llm/provider/ollama/ollama.go b/pkg/llm/provider/ollama/ollama.go index 54e3f16..14448d2 100644 --- a/pkg/llm/provider/ollama/ollama.go +++ b/pkg/llm/provider/ollama/ollama.go @@ -2,21 +2,13 @@ package ollama import ( "encoding/json" + "github.com/papercomputeco/tapes/pkg/llm" - "github.com/papercomputeco/tapes/pkg/utils" ) // Provider implements the Provider interface for Ollama's API. type Provider struct{} -func getToolArgs(arguments []byte) map[string]any { - var toolArgs map[string]any - if toolParseErr := json.Unmarshal(arguments, &toolArgs); toolParseErr != nil { - toolArgs = make(map[string]any, 0) - } - return toolArgs -} - func New() *Provider { return &Provider{} } func (o *Provider) Name() string { @@ -41,11 +33,6 @@ func (o *Provider) ParseRequest(payload []byte) (*llm.ChatRequest, error) { Content: []llm.ContentBlock{}, } - if convertedContent := convertRawContent(msg.ContentRaw); convertedContent != "" { - // Set content string and clear out original - msg.Content = convertedContent - msg.ContentRaw = "" - } // Add text content if present if msg.Content != "" { converted.Content = append(converted.Content, llm.ContentBlock{Type: "text", Text: msg.Content}) @@ -65,7 +52,7 @@ func (o *Provider) ParseRequest(payload []byte) (*llm.ChatRequest, error) { Type: "tool_use", ToolUseID: tc.ID, ToolName: tc.Function.Name, - ToolInput: getToolArgs(tc.Function.Arguments), + ToolInput: tc.Function.Arguments, }) } @@ -126,10 +113,7 @@ func (o *Provider) ParseResponse(payload []byte) (*llm.ChatResponse, error) { // Convert message content var content []llm.ContentBlock - if convertedContent := convertRawContent(resp.Message.ContentRaw); convertedContent != "" { - resp.Message.Content = convertedContent - resp.Message.ContentRaw = "" - } + // Add text content if present if resp.Message.Content != "" { content = append(content, llm.ContentBlock{Type: "text", Text: resp.Message.Content}) @@ -149,7 +133,7 @@ func (o *Provider) ParseResponse(payload []byte) (*llm.ChatResponse, error) { Type: "tool_use", ToolUseID: tc.ID, ToolName: tc.Function.Name, - ToolInput: getToolArgs(tc.Function.Arguments), + ToolInput: tc.Function.Arguments, }) } @@ -199,16 +183,3 @@ func (o *Provider) ParseResponse(payload []byte) (*llm.ChatResponse, error) { func (o *Provider) ParseStreamChunk(_ []byte) (*llm.StreamChunk, error) { panic("Not yet implemented") } - -// convertRawContent converts the raw content from Ollama API messages to a string. -// The content can be either a string or an array of content blocks (maps with type/text fields). -func convertRawContent(contentRaw any) string { - if s, ok := contentRaw.(string); ok { - return s - } - // Next check if we are looking at a slice of maps - if slice, ok := contentRaw.([]map[string]any); ok { - return utils.ExtractTextFromContent(slice) - } - return "" -} diff --git a/pkg/llm/provider/ollama/ollama_test.go b/pkg/llm/provider/ollama/ollama_test.go index 16eb4f3..46b6f04 100644 --- a/pkg/llm/provider/ollama/ollama_test.go +++ b/pkg/llm/provider/ollama/ollama_test.go @@ -501,20 +501,4 @@ var _ = Describe("Ollama Provider", func() { Expect(req.Messages[1].Content[0].ToolName).To(Equal("get_weather")) }) }) - Describe("ParseRequest with mixed content block types", func() { - It("parses requests with mixed data structures for content", func() { - payload := []byte(`{ - "model": "llama3", - "messages": [ - {"role": "user", "content": [ - {"type": "text", "text": "What changes would you suggest to boost test coverage?"}, - {"type": "text", "text": ": In PLAN mode, you must not modify any files."} - ]} - ] - }`) - req, err := p.ParseRequest(payload) - Expect(err).NotTo(HaveOccurred()) - Expect(req.Messages).To(HaveLen(1)) - }) - }) }) diff --git a/pkg/llm/provider/ollama/types.go b/pkg/llm/provider/ollama/types.go index 8b74a16..8112d26 100644 --- a/pkg/llm/provider/ollama/types.go +++ b/pkg/llm/provider/ollama/types.go @@ -1,10 +1,7 @@ // Package ollama package ollama -import ( - "encoding/json" - "time" -) +import "time" // ollamaRequest represents Ollama's request format. type ollamaRequest struct { @@ -17,9 +14,8 @@ type ollamaRequest struct { } type ollamaMessage struct { - Role string `json:"role"` - Content string `json:"-"` - ContentRaw any `json:"content,omitempty"` + Role string `json:"role"` + Content string `json:"content"` // Base64-encoded images Images []string `json:"images,omitempty"` @@ -29,11 +25,11 @@ type ollamaMessage struct { } type ollamaToolCall struct { - ID string `json:"id,omitempty"` + ID string `json:"id"` Function struct { - Index int `json:"index,omitempty"` - Name string `json:"name"` - Arguments json.RawMessage `json:"arguments"` + Index int `json:"index,omitempty"` + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` } `json:"function"` } diff --git a/pkg/utils/string.go b/pkg/utils/string.go index e44e639..e8efbdc 100644 --- a/pkg/utils/string.go +++ b/pkg/utils/string.go @@ -1,9 +1,5 @@ package utils -import ( - "strings" -) - // Truncate is a simple string truncate func Truncate(s string, maxLen int) string { if len(s) <= maxLen { @@ -11,16 +7,3 @@ func Truncate(s string, maxLen int) string { } return s[:maxLen] + "..." } - -// extractTextFromContent concatenates text from content blocks. -func ExtractTextFromContent(content []map[string]any) string { - var sb strings.Builder - for _, block := range content { - if t, ok := block["type"].(string); ok && t == "text" { - if text, ok := block["text"].(string); ok { - sb.WriteString(text) - } - } - } - return sb.String() -} diff --git a/pkg/utils/string_test.go b/pkg/utils/string_test.go index 66c5542..283f750 100644 --- a/pkg/utils/string_test.go +++ b/pkg/utils/string_test.go @@ -19,45 +19,3 @@ var _ = Describe("truncate", func() { Expect(result).To(Equal("this is a ...")) }) }) - -var _ = Describe("ExtractTextFromContent", func() { - It("returns empty with an empty slice", func() { - emptySlice := []map[string]any{} - result := ExtractTextFromContent(emptySlice) - Expect(result).To(Equal("")) - }) - - It("returns empty with an irrelevant slice", func() { - functionCall := map[string]string{"name": "fetch", "arguments": "{\"url\": \"https://allrecipes.com/top-5-italian\"}"} - irrelevantSlice := []map[string]any{ - {"type": "image_url", "image_url": "data:image/png;ibVOR..."}, - {"type": "function", "function": functionCall}, - } - result := ExtractTextFromContent(irrelevantSlice) - Expect(result).To(Equal("")) - }) - - It("returns the expected content with matching content blocks", func() { - msg1 := "I need a recipe for chicken carbonara" - msg2 := ": User has an egg allergy, ensure recipes have documented substitutions." - contentBlocks := []map[string]any{ - {"type": "text", "text": msg1}, - {"type": "text", "text": msg2}, - } - result := ExtractTextFromContent(contentBlocks) - Expect(result).To(ContainSubstring(msg1)) - Expect(result).To(ContainSubstring(msg2)) - }) - - It("returns the expected content with mixed content blocks", func() { - imgContent := "data:image/png;ibVOR..." - textContent := "What's wrong with this picture" - mixedBlocks := []map[string]any{ - {"type": "text", "text": textContent}, - {"type": "image_url", "image_url": imgContent}, - } - result := ExtractTextFromContent(mixedBlocks) - Expect(result).To(ContainSubstring(textContent)) - Expect(result).ToNot(ContainSubstring(imgContent)) - }) -}) diff --git a/proxy/proxy.go b/proxy/proxy.go index 55d0eda..bc407cd 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -354,12 +354,6 @@ func (p *Proxy) handleHTTPRespToPipeWriter(httpResp *http.Response, pw *io.PipeW // and Anthropic), forwarding raw bytes verbatim to the pipe writer while // parsing events for telemetry accumulation. func (p *Proxy) handleSSEStream(httpResp *http.Response, pw *io.PipeWriter, parsedReq *llm.ChatRequest, prov provider.Provider, agentName string, startTime time.Time) { - p.logger.Debug("handling SSE Stream", - "model", parsedReq.Model, - "provider", prov.Name(), - "agent", agentName, - "duration", time.Since(startTime), - ) var allChunks [][]byte var fullContent strings.Builder var streamUsage llm.Usage @@ -400,12 +394,6 @@ func (p *Proxy) handleSSEStream(httpResp *http.Response, pw *io.PipeWriter, pars // Ollama), forwarding raw bytes to the pipe writer while accumulating chunks // for telemetry. func (p *Proxy) handleNDJSONStream(httpResp *http.Response, pw *io.PipeWriter, parsedReq *llm.ChatRequest, prov provider.Provider, agentName string, startTime time.Time) { - p.logger.Debug("handling NDJSON Stream", - "model", parsedReq.Model, - "provider", prov.Name(), - "agent", agentName, - "duration", time.Since(startTime), - ) var allChunks [][]byte var fullContent strings.Builder var streamUsage llm.Usage @@ -503,7 +491,6 @@ type streamMeta struct { func (p *Proxy) extractUsageFromSSE(data []byte, providerName string, usage *llm.Usage, meta *streamMeta) { var chunkData map[string]any if err := json.Unmarshal(data, &chunkData); err != nil { - p.logger.Error("error parsing usage chunk", "error", err) return } @@ -544,20 +531,11 @@ func (p *Proxy) extractUsageFromSSE(data []byte, providerName string, usage *llm usage.CompletionTokens = jsonInt(u, "completion_tokens") } case providerOllama: - p.logger.Debug("providerOllama extract usage", - "data", - string(data), - ) // Ollama includes usage in the final NDJSON line (done=true) if done, ok := chunkData["done"].(bool); ok && done { usage.PromptTokens = jsonInt(chunkData, "prompt_eval_count") usage.CompletionTokens = jsonInt(chunkData, "eval_count") } - // If Ollama is being consumed by opencode, chat completions read more like OpenAI - if u, ok := chunkData["usage"].(map[string]any); ok { - usage.PromptTokens = jsonInt(u, "prompt_tokens") - usage.CompletionTokens = jsonInt(u, "completion_tokens") - } } } @@ -601,9 +579,6 @@ func (p *Proxy) reconstructStreamedResponse(chunks [][]byte, fullContent string, if len(chunks) > 0 { lastChunk := chunks[len(chunks)-1] resp, err := prov.ParseResponse(lastChunk) - if err != nil { - p.logger.Error("response parse failed", "error", err) - } if err == nil && resp != nil { // If the last chunk has minimal content, supplement with accumulated content if resp.Message.GetText() == "" && fullContent != "" { From 2a92eb441a47d37de6ac1f4ca54737c2a331d53e Mon Sep 17 00:00:00 2001 From: Matthew Foley Date: Sun, 8 Mar 2026 19:53:32 -0400 Subject: [PATCH 11/12] fix: fallback to openai format --- pkg/llm/provider/ollama/ollama.go | 7 +- pkg/llm/provider/openai/openai.go | 193 +--------------------------- pkg/llm/provider/openai/parser.go | 203 ++++++++++++++++++++++++++++++ proxy/proxy.go | 5 + 4 files changed, 215 insertions(+), 193 deletions(-) create mode 100644 pkg/llm/provider/openai/parser.go diff --git a/pkg/llm/provider/ollama/ollama.go b/pkg/llm/provider/ollama/ollama.go index 14448d2..4c5bce4 100644 --- a/pkg/llm/provider/ollama/ollama.go +++ b/pkg/llm/provider/ollama/ollama.go @@ -4,6 +4,7 @@ import ( "encoding/json" "github.com/papercomputeco/tapes/pkg/llm" + "github.com/papercomputeco/tapes/pkg/llm/provider/openai" ) // Provider implements the Provider interface for Ollama's API. @@ -22,8 +23,9 @@ func (o *Provider) DefaultStreaming() bool { func (o *Provider) ParseRequest(payload []byte) (*llm.ChatRequest, error) { var req ollamaRequest + // If we have trouble decoding the Parse Request initially, let's try falling back to OpenAI format if err := json.Unmarshal(payload, &req); err != nil { - return nil, err + return openai.ParseRequestPayload(payload) } messages := make([]llm.Message, 0, len(req.Messages)) @@ -107,8 +109,9 @@ func (o *Provider) ParseRequest(payload []byte) (*llm.ChatRequest, error) { func (o *Provider) ParseResponse(payload []byte) (*llm.ChatResponse, error) { var resp ollamaResponse + // If we have trouble decoding the Parse Request initially, let's try falling back to OpenAI format if err := json.Unmarshal(payload, &resp); err != nil { - return nil, err + return openai.ParseResponsePayload(payload) } // Convert message content diff --git a/pkg/llm/provider/openai/openai.go b/pkg/llm/provider/openai/openai.go index 6243247..4d476de 100644 --- a/pkg/llm/provider/openai/openai.go +++ b/pkg/llm/provider/openai/openai.go @@ -2,9 +2,6 @@ package openai import ( - "encoding/json" - "time" - "github.com/papercomputeco/tapes/pkg/llm" ) @@ -23,197 +20,11 @@ func (o *Provider) DefaultStreaming() bool { } func (o *Provider) ParseRequest(payload []byte) (*llm.ChatRequest, error) { - var req openaiRequest - if err := json.Unmarshal(payload, &req); err != nil { - return nil, err - } - - messages := make([]llm.Message, 0, len(req.Messages)) - for _, msg := range req.Messages { - converted := llm.Message{Role: msg.Role} - - switch content := msg.Content.(type) { - case string: - converted.Content = []llm.ContentBlock{{Type: "text", Text: content}} - case []any: - // Multimodal content (e.g., vision) - for _, item := range content { - if part, ok := item.(map[string]any); ok { - cb := llm.ContentBlock{} - if t, ok := part["type"].(string); ok { - cb.Type = t - } - if text, ok := part["text"].(string); ok { - cb.Text = text - } - if imageURL, ok := part["image_url"].(map[string]any); ok { - cb.Type = "image" - if url, ok := imageURL["url"].(string); ok { - cb.ImageURL = url - } - } - converted.Content = append(converted.Content, cb) - } - } - case nil: - // Empty content (can happen with tool calls) - converted.Content = []llm.ContentBlock{} - } - - // Handle tool calls in assistant messages - for _, tc := range msg.ToolCalls { - var input map[string]any - if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err == nil { - converted.Content = append(converted.Content, llm.ContentBlock{ - Type: "tool_use", - ToolUseID: tc.ID, - ToolName: tc.Function.Name, - ToolInput: input, - }) - } - } - - // Handle tool results - if msg.Role == "tool" && msg.ToolCallID != "" { - text := "" - if s, ok := msg.Content.(string); ok { - text = s - } - converted.Content = []llm.ContentBlock{{ - Type: "tool_result", - ToolResultID: msg.ToolCallID, - ToolOutput: text, - }} - } - - messages = append(messages, converted) - } - - // Parse stop sequences - var stop []string - switch s := req.Stop.(type) { - case string: - stop = []string{s} - case []any: - for _, item := range s { - if str, ok := item.(string); ok { - stop = append(stop, str) - } - } - } - - result := &llm.ChatRequest{ - Model: req.Model, - Messages: messages, - MaxTokens: req.MaxTokens, - Temperature: req.Temperature, - TopP: req.TopP, - Stop: stop, - Seed: req.Seed, - Stream: req.Stream, - RawRequest: payload, - } - - // Preserve OpenAI-specific fields - if req.FrequencyPenalty != nil || req.PresencePenalty != nil || req.ResponseFormat != nil { - result.Extra = make(map[string]any) - if req.FrequencyPenalty != nil { - result.Extra["frequency_penalty"] = *req.FrequencyPenalty - } - if req.PresencePenalty != nil { - result.Extra["presence_penalty"] = *req.PresencePenalty - } - if req.ResponseFormat != nil { - result.Extra["response_format"] = req.ResponseFormat - } - } - - return result, nil + return ParseRequestPayload(payload) } func (o *Provider) ParseResponse(payload []byte) (*llm.ChatResponse, error) { - var resp openaiResponse - if err := json.Unmarshal(payload, &resp); err != nil { - return nil, err - } - - if len(resp.Choices) == 0 { - // Return empty response if no choices - return &llm.ChatResponse{ - Model: resp.Model, - Done: true, - RawResponse: payload, - }, nil - } - - choice := resp.Choices[0] - msg := choice.Message - - // Convert message content - var content []llm.ContentBlock - switch c := msg.Content.(type) { - case string: - content = []llm.ContentBlock{{Type: "text", Text: c}} - case []any: - for _, item := range c { - if part, ok := item.(map[string]any); ok { - cb := llm.ContentBlock{} - if t, ok := part["type"].(string); ok { - cb.Type = t - } - if text, ok := part["text"].(string); ok { - cb.Text = text - } - content = append(content, cb) - } - } - case nil: - content = []llm.ContentBlock{} - } - - // Handle tool calls - for _, tc := range msg.ToolCalls { - var input map[string]any - if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err == nil { - content = append(content, llm.ContentBlock{ - Type: "tool_use", - ToolUseID: tc.ID, - ToolName: tc.Function.Name, - ToolInput: input, - }) - } - } - - var usage *llm.Usage - if resp.Usage != nil { - usage = &llm.Usage{ - PromptTokens: resp.Usage.PromptTokens, - CompletionTokens: resp.Usage.CompletionTokens, - TotalTokens: resp.Usage.TotalTokens, - } - if resp.Usage.PromptTokensDetails != nil { - usage.CacheReadInputTokens = resp.Usage.PromptTokensDetails.CachedTokens - } - } - - result := &llm.ChatResponse{ - Model: resp.Model, - Message: llm.Message{ - Role: msg.Role, - Content: content, - }, - Done: true, - StopReason: choice.FinishReason, - Usage: usage, - CreatedAt: time.Unix(resp.Created, 0), - RawResponse: payload, - Extra: map[string]any{ - "id": resp.ID, - "object": resp.Object, - }, - } - - return result, nil + return ParseResponsePayload(payload) } func (o *Provider) ParseStreamChunk(_ []byte) (*llm.StreamChunk, error) { diff --git a/pkg/llm/provider/openai/parser.go b/pkg/llm/provider/openai/parser.go new file mode 100644 index 0000000..f80d519 --- /dev/null +++ b/pkg/llm/provider/openai/parser.go @@ -0,0 +1,203 @@ +// Package openai +package openai + +import ( + "encoding/json" + "time" + + "github.com/papercomputeco/tapes/pkg/llm" +) + +func ParseRequestPayload(payload []byte) (*llm.ChatRequest, error) { + var req openaiRequest + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + + messages := make([]llm.Message, 0, len(req.Messages)) + for _, msg := range req.Messages { + converted := llm.Message{Role: msg.Role} + + switch content := msg.Content.(type) { + case string: + converted.Content = []llm.ContentBlock{{Type: "text", Text: content}} + case []any: + // Multimodal content (e.g., vision) + for _, item := range content { + if part, ok := item.(map[string]any); ok { + cb := llm.ContentBlock{} + if t, ok := part["type"].(string); ok { + cb.Type = t + } + if text, ok := part["text"].(string); ok { + cb.Text = text + } + if imageURL, ok := part["image_url"].(map[string]any); ok { + cb.Type = "image" + if url, ok := imageURL["url"].(string); ok { + cb.ImageURL = url + } + } + converted.Content = append(converted.Content, cb) + } + } + case nil: + // Empty content (can happen with tool calls) + converted.Content = []llm.ContentBlock{} + } + + // Handle tool calls in assistant messages + for _, tc := range msg.ToolCalls { + var input map[string]any + if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err == nil { + converted.Content = append(converted.Content, llm.ContentBlock{ + Type: "tool_use", + ToolUseID: tc.ID, + ToolName: tc.Function.Name, + ToolInput: input, + }) + } + } + + // Handle tool results + if msg.Role == "tool" && msg.ToolCallID != "" { + text := "" + if s, ok := msg.Content.(string); ok { + text = s + } + converted.Content = []llm.ContentBlock{{ + Type: "tool_result", + ToolResultID: msg.ToolCallID, + ToolOutput: text, + }} + } + + messages = append(messages, converted) + } + + // Parse stop sequences + var stop []string + switch s := req.Stop.(type) { + case string: + stop = []string{s} + case []any: + for _, item := range s { + if str, ok := item.(string); ok { + stop = append(stop, str) + } + } + } + + result := &llm.ChatRequest{ + Model: req.Model, + Messages: messages, + MaxTokens: req.MaxTokens, + Temperature: req.Temperature, + TopP: req.TopP, + Stop: stop, + Seed: req.Seed, + Stream: req.Stream, + RawRequest: payload, + } + + // Preserve OpenAI-specific fields + if req.FrequencyPenalty != nil || req.PresencePenalty != nil || req.ResponseFormat != nil { + result.Extra = make(map[string]any) + if req.FrequencyPenalty != nil { + result.Extra["frequency_penalty"] = *req.FrequencyPenalty + } + if req.PresencePenalty != nil { + result.Extra["presence_penalty"] = *req.PresencePenalty + } + if req.ResponseFormat != nil { + result.Extra["response_format"] = req.ResponseFormat + } + } + + return result, nil +} + +func ParseResponsePayload(payload []byte) (*llm.ChatResponse, error) { + var resp openaiResponse + if err := json.Unmarshal(payload, &resp); err != nil { + return nil, err + } + + if len(resp.Choices) == 0 { + // Return empty response if no choices + return &llm.ChatResponse{ + Model: resp.Model, + Done: true, + RawResponse: payload, + }, nil + } + + choice := resp.Choices[0] + msg := choice.Message + + // Convert message content + var content []llm.ContentBlock + switch c := msg.Content.(type) { + case string: + content = []llm.ContentBlock{{Type: "text", Text: c}} + case []any: + for _, item := range c { + if part, ok := item.(map[string]any); ok { + cb := llm.ContentBlock{} + if t, ok := part["type"].(string); ok { + cb.Type = t + } + if text, ok := part["text"].(string); ok { + cb.Text = text + } + content = append(content, cb) + } + } + case nil: + content = []llm.ContentBlock{} + } + + // Handle tool calls + for _, tc := range msg.ToolCalls { + var input map[string]any + if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err == nil { + content = append(content, llm.ContentBlock{ + Type: "tool_use", + ToolUseID: tc.ID, + ToolName: tc.Function.Name, + ToolInput: input, + }) + } + } + + var usage *llm.Usage + if resp.Usage != nil { + usage = &llm.Usage{ + PromptTokens: resp.Usage.PromptTokens, + CompletionTokens: resp.Usage.CompletionTokens, + TotalTokens: resp.Usage.TotalTokens, + } + if resp.Usage.PromptTokensDetails != nil { + usage.CacheReadInputTokens = resp.Usage.PromptTokensDetails.CachedTokens + } + } + + result := &llm.ChatResponse{ + Model: resp.Model, + Message: llm.Message{ + Role: msg.Role, + Content: content, + }, + Done: true, + StopReason: choice.FinishReason, + Usage: usage, + CreatedAt: time.Unix(resp.Created, 0), + RawResponse: payload, + Extra: map[string]any{ + "id": resp.ID, + "object": resp.Object, + }, + } + + return result, nil +} diff --git a/proxy/proxy.go b/proxy/proxy.go index bc407cd..161906d 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -536,6 +536,11 @@ func (p *Proxy) extractUsageFromSSE(data []byte, providerName string, usage *llm usage.PromptTokens = jsonInt(chunkData, "prompt_eval_count") usage.CompletionTokens = jsonInt(chunkData, "eval_count") } + // In some cases, Ollama matches openAI formats (e.g. with OpenCode) + if u, ok := chunkData["usage"].(map[string]any); ok { + usage.PromptTokens = jsonInt(u, "prompt_tokens") + usage.CompletionTokens = jsonInt(u, "completion_tokens") + } } } From f7923eb4fa5ed577f2e382f05bdd6c350b6d375c Mon Sep 17 00:00:00 2001 From: Brian Douglas Date: Wed, 25 Mar 2026 09:12:50 -0700 Subject: [PATCH 12/12] =?UTF-8?q?=F0=9F=90=9B=20fix:=20Detect=20OpenAI-for?= =?UTF-8?q?mat=20content=20loss=20in=20Ollama=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fallback triggered on json.Unmarshal errors, but Go's decoder silently zero-values type-mismatched fields (array→string) rather than erroring. Add content-loss detection after successful unmarshal: if a message has a role but lost its content, images, and tool calls, fall back to the OpenAI parser. Also adds tests for OpenCode-style requests with array content and OpenAI-format responses, and doc comments for exported parser functions. --- pkg/llm/provider/ollama/ollama.go | 30 ++++++++- pkg/llm/provider/ollama/ollama_test.go | 89 ++++++++++++++++++++++++++ pkg/llm/provider/openai/parser.go | 8 +++ 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/pkg/llm/provider/ollama/ollama.go b/pkg/llm/provider/ollama/ollama.go index 4c5bce4..b4b0df1 100644 --- a/pkg/llm/provider/ollama/ollama.go +++ b/pkg/llm/provider/ollama/ollama.go @@ -23,11 +23,18 @@ func (o *Provider) DefaultStreaming() bool { func (o *Provider) ParseRequest(payload []byte) (*llm.ChatRequest, error) { var req ollamaRequest - // If we have trouble decoding the Parse Request initially, let's try falling back to OpenAI format if err := json.Unmarshal(payload, &req); err != nil { return openai.ParseRequestPayload(payload) } + // Detect OpenAI-format payloads that unmarshal successfully but lose content. + // When content is a JSON array (e.g. OpenCode sending OpenAI-format requests + // to Ollama), Go's decoder silently zero-values the string field, producing + // messages with a role but no content, images, or tool calls. + if hasLostContent(req.Messages) { + return openai.ParseRequestPayload(payload) + } + messages := make([]llm.Message, 0, len(req.Messages)) for _, msg := range req.Messages { converted := llm.Message{ @@ -109,11 +116,17 @@ func (o *Provider) ParseRequest(payload []byte) (*llm.ChatRequest, error) { func (o *Provider) ParseResponse(payload []byte) (*llm.ChatResponse, error) { var resp ollamaResponse - // If we have trouble decoding the Parse Request initially, let's try falling back to OpenAI format if err := json.Unmarshal(payload, &resp); err != nil { return openai.ParseResponsePayload(payload) } + // Detect OpenAI-format responses: they use a "choices" array instead of a + // top-level "message" field, so resp.Message will be zero-valued while the + // model field is still populated from the JSON. + if resp.Model != "" && resp.Message.Role == "" && !resp.Done { + return openai.ParseResponsePayload(payload) + } + // Convert message content var content []llm.ContentBlock @@ -186,3 +199,16 @@ func (o *Provider) ParseResponse(payload []byte) (*llm.ChatResponse, error) { func (o *Provider) ParseStreamChunk(_ []byte) (*llm.StreamChunk, error) { panic("Not yet implemented") } + +// hasLostContent detects when an OpenAI-format payload was unmarshaled into +// Ollama types. Because ollamaMessage.Content is a string, array-valued content +// (e.g. [{type: "text", text: "..."}]) gets silently zero-valued by Go's JSON +// decoder, producing messages with a role but no content, images, or tool calls. +func hasLostContent(msgs []ollamaMessage) bool { + for _, m := range msgs { + if m.Role != "" && m.Content == "" && len(m.Images) == 0 && len(m.ToolCalls) == 0 { + return true + } + } + return false +} diff --git a/pkg/llm/provider/ollama/ollama_test.go b/pkg/llm/provider/ollama/ollama_test.go index 46b6f04..7eb744b 100644 --- a/pkg/llm/provider/ollama/ollama_test.go +++ b/pkg/llm/provider/ollama/ollama_test.go @@ -471,6 +471,95 @@ var _ = Describe("Ollama Provider", func() { }) }) + Describe("ParseRequest with OpenAI-format content (OpenCode compatibility)", func() { + It("parses array content from OpenCode/Ollama requests", func() { + // This is the exact format OpenCode sends when using Ollama, + // where content is an array of objects instead of a plain string. + // See: https://github.com/papercomputeco/tapes/issues/137 + payload := []byte(`{ + "model": "qwen3-coder:30b", + "max_tokens": 32000, + "top_p": 1, + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant." + }, + { + "role": "user", + "content": [ + {"type": "text", "text": "I want to plan a unit test"}, + {"type": "text", "text": "Additional context here"} + ] + } + ] + }`) + + req, err := p.ParseRequest(payload) + Expect(err).NotTo(HaveOccurred()) + Expect(req.Model).To(Equal("qwen3-coder:30b")) + Expect(req.Messages).To(HaveLen(2)) + Expect(req.Messages[0].Role).To(Equal("system")) + Expect(req.Messages[0].GetText()).To(Equal("You are a helpful assistant.")) + Expect(req.Messages[1].Role).To(Equal("user")) + Expect(req.Messages[1].Content).To(HaveLen(2)) + Expect(req.Messages[1].Content[0].Text).To(Equal("I want to plan a unit test")) + Expect(req.Messages[1].Content[1].Text).To(Equal("Additional context here")) + }) + + It("handles all-string OpenAI-format messages without false positive", func() { + // When all messages have string content, native Ollama parsing should + // be used (no fallback needed). + payload := []byte(`{ + "model": "llama2", + "messages": [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello!"} + ] + }`) + + req, err := p.ParseRequest(payload) + Expect(err).NotTo(HaveOccurred()) + Expect(req.Messages).To(HaveLen(2)) + Expect(req.Messages[1].GetText()).To(Equal("Hello!")) + }) + }) + + Describe("ParseResponse with OpenAI-format (OpenCode compatibility)", func() { + It("parses OpenAI-format response with choices array", func() { + payload := []byte(`{ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1677858242, + "model": "qwen3-coder:30b", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Here is the test plan." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150 + } + }`) + + resp, err := p.ParseResponse(payload) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.Model).To(Equal("qwen3-coder:30b")) + Expect(resp.Message.Role).To(Equal("assistant")) + Expect(resp.Message.GetText()).To(Equal("Here is the test plan.")) + Expect(resp.Usage).NotTo(BeNil()) + Expect(resp.Usage.PromptTokens).To(Equal(100)) + Expect(resp.Usage.CompletionTokens).To(Equal(50)) + }) + }) + Describe("ParseRequest with tool calls", func() { It("parses tool calls in assistant messages", func() { payload := []byte(`{ diff --git a/pkg/llm/provider/openai/parser.go b/pkg/llm/provider/openai/parser.go index f80d519..f91d2aa 100644 --- a/pkg/llm/provider/openai/parser.go +++ b/pkg/llm/provider/openai/parser.go @@ -8,6 +8,10 @@ import ( "github.com/papercomputeco/tapes/pkg/llm" ) +// ParseRequestPayload parses an OpenAI-format chat completion request payload +// into the common ChatRequest type. This is exported so other providers (e.g. +// Ollama) can fall back to OpenAI parsing when they receive OpenAI-compatible +// payloads. func ParseRequestPayload(payload []byte) (*llm.ChatRequest, error) { var req openaiRequest if err := json.Unmarshal(payload, &req); err != nil { @@ -117,6 +121,10 @@ func ParseRequestPayload(payload []byte) (*llm.ChatRequest, error) { return result, nil } +// ParseResponsePayload parses an OpenAI-format chat completion response payload +// into the common ChatResponse type. This is exported so other providers (e.g. +// Ollama) can fall back to OpenAI parsing when they receive OpenAI-compatible +// payloads. func ParseResponsePayload(payload []byte) (*llm.ChatResponse, error) { var resp openaiResponse if err := json.Unmarshal(payload, &resp); err != nil {