From b77696f9cb10f695a0f755f493fde3f6b114e436 Mon Sep 17 00:00:00 2001 From: Matthew Foley Date: Sun, 1 Mar 2026 17:42:46 -0500 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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") + } } }