Files
kirogo/proxy/translator_test.go
edxeth 6151888df5 fix: stabilize thinking streams, multimodal parsing, and token accounting (#20)
* fix: stabilize multimodal image compatibility across OpenCode flows

Advertise vision-capable metadata in /v1/models and make model matching deterministic so OpenCode does not downgrade image support or route 4.6 models incorrectly. Expand request translation to accept OpenCode/OpenAI attachment shapes, sanitize [Image N] placeholders safely, keep image-only follow-up turns non-empty, and improve token accounting so base64 image bytes no longer inflate prompt token usage and trigger premature compaction.

* fix: deduplicate thinking streams and trim injected prompt noise

* fix: align /v1/messages thinking blocks and message_start usage

* fix: reduce repetitive thinking across tool turns

Select a single reasoning stream source, prevent chunk replay, and preserve structured tool-loop context so the model keeps continuity instead of re-planning each turn.

* fix: unify token counting on existing API endpoints

Compute usage deterministically on /v1/messages and /v1/chat/completions even when upstream omits tokenUsage.

- remove roo-only token path and keep behavior on existing endpoints
- add proxy/token_estimator.go with shared Claude/OpenAI estimators (input/system/messages/tools + output/thinking/tool calls)
- wire stream/non-stream handlers to use estimator-derived input/output usage
- update /v1/messages/count_tokens to reuse the same estimator
- keep robust upstream usage parsing/normalization in proxy/kiro.go while dropping parser-level estimate fallback

Why: direct upstream tests show metering/context events frequently arrive without tokenUsage in this environment; this made usage zero or inconsistent. Local deterministic accounting keeps reported usage stable and explicit.
2026-02-23 20:33:53 +08:00

199 lines
6.4 KiB
Go

package proxy
import (
"strings"
"testing"
)
func TestExtractOpenAIMessageTextStructured(t *testing.T) {
content := []interface{}{
map[string]interface{}{"type": "text", "text": "alpha"},
map[string]interface{}{"type": "input_text", "text": "beta"},
}
if got := extractOpenAIMessageText(content); got != "alphabeta" {
t.Fatalf("expected concatenated structured text, got %q", got)
}
nested := map[string]interface{}{
"content": []interface{}{map[string]interface{}{"type": "text", "text": "nested"}},
}
if got := extractOpenAIMessageText(nested); got != "nested" {
t.Fatalf("expected nested content extraction, got %q", got)
}
}
func TestOpenAIToKiroPreservesStructuredAssistantAndToolContent(t *testing.T) {
req := &OpenAIRequest{
Model: "claude-sonnet-4.5",
Messages: []OpenAIMessage{
{
Role: "system",
Content: []interface{}{
map[string]interface{}{"type": "text", "text": "system-a"},
map[string]interface{}{"type": "text", "text": "system-b"},
},
},
{Role: "user", Content: "first-question"},
{
Role: "assistant",
Content: []interface{}{
map[string]interface{}{"type": "text", "text": "assistant-structured"},
},
},
{
Role: "tool",
ToolCallID: "call_1",
Content: []interface{}{
map[string]interface{}{"type": "text", "text": "tool-result-structured"},
},
},
},
}
payload := OpenAIToKiro(req, false)
if len(payload.ConversationState.History) != 2 {
t.Fatalf("expected 2 history items, got %d", len(payload.ConversationState.History))
}
firstHistoryUser := payload.ConversationState.History[0].UserInputMessage
if firstHistoryUser == nil {
t.Fatalf("expected first history item to be user message")
}
if !strings.Contains(firstHistoryUser.Content, "system-a") ||
!strings.Contains(firstHistoryUser.Content, "system-b") ||
!strings.Contains(firstHistoryUser.Content, "first-question") {
t.Fatalf("expected merged system+user content, got %q", firstHistoryUser.Content)
}
historyAssistant := payload.ConversationState.History[1].AssistantResponseMessage
if historyAssistant == nil {
t.Fatalf("expected second history item to be assistant message")
}
if historyAssistant.Content != "assistant-structured" {
t.Fatalf("expected assistant structured content to be preserved, got %q", historyAssistant.Content)
}
cur := payload.ConversationState.CurrentMessage.UserInputMessage
if cur.Content != "tool-result-structured" {
t.Fatalf("expected tool-result continuation content, got %q", cur.Content)
}
if cur.UserInputMessageContext == nil || len(cur.UserInputMessageContext.ToolResults) != 1 {
t.Fatalf("expected one tool result in current context")
}
gotToolText := cur.UserInputMessageContext.ToolResults[0].Content[0].Text
if gotToolText != "tool-result-structured" {
t.Fatalf("expected structured tool result text, got %q", gotToolText)
}
}
func TestOpenAIToKiroAssistantMapContentInHistory(t *testing.T) {
req := &OpenAIRequest{
Model: "claude-sonnet-4.5",
Messages: []OpenAIMessage{
{Role: "user", Content: "u1"},
{Role: "assistant", Content: map[string]interface{}{"type": "text", "text": "assistant-map"}},
{Role: "user", Content: "u2"},
},
}
payload := OpenAIToKiro(req, false)
if len(payload.ConversationState.History) != 2 {
t.Fatalf("expected 2 history entries, got %d", len(payload.ConversationState.History))
}
assistant := payload.ConversationState.History[1].AssistantResponseMessage
if assistant == nil {
t.Fatalf("expected second history entry to be assistant")
}
if assistant.Content != "assistant-map" {
t.Fatalf("expected assistant map content preserved, got %q", assistant.Content)
}
}
func TestOpenAIToKiroAssistantToolCallsDoNotInjectPlaceholder(t *testing.T) {
req := &OpenAIRequest{
Model: "claude-sonnet-4.5",
Messages: []OpenAIMessage{
{Role: "user", Content: "find weather"},
{
Role: "assistant",
Content: nil,
ToolCalls: []ToolCall{{
ID: "call_1",
Type: "function",
Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{Name: "get_weather", Arguments: "{}"},
}},
},
{Role: "user", Content: "continue"},
},
}
payload := OpenAIToKiro(req, false)
if len(payload.ConversationState.History) < 2 {
t.Fatalf("expected history with assistant tool call")
}
assistant := payload.ConversationState.History[1].AssistantResponseMessage
if assistant == nil {
t.Fatalf("expected assistant history entry")
}
if assistant.Content != "" {
t.Fatalf("expected empty assistant content for tool-call-only turn, got %q", assistant.Content)
}
}
func TestOpenAIConversationIDStableFromAnchor(t *testing.T) {
baseMessages := []OpenAIMessage{
{Role: "system", Content: "You are helpful"},
{Role: "user", Content: "Build calculator"},
{Role: "assistant", Content: "Sure"},
{Role: "user", Content: "Continue"},
}
reqA := &OpenAIRequest{Model: "claude-sonnet-4.5", Messages: baseMessages}
reqB := &OpenAIRequest{Model: "claude-sonnet-4.5", Messages: append(baseMessages, OpenAIMessage{Role: "assistant", Content: "Next step"})}
payloadA := OpenAIToKiro(reqA, false)
payloadB := OpenAIToKiro(reqB, false)
if payloadA.ConversationState.ConversationID == "" || payloadB.ConversationState.ConversationID == "" {
t.Fatalf("expected non-empty conversation IDs")
}
if payloadA.ConversationState.ConversationID != payloadB.ConversationState.ConversationID {
t.Fatalf("expected stable conversation ID across turns, got %q vs %q", payloadA.ConversationState.ConversationID, payloadB.ConversationState.ConversationID)
}
}
func TestClaudeConversationIDStableFromAnchor(t *testing.T) {
reqA := &ClaudeRequest{
Model: "claude-sonnet-4.5",
System: "sys",
Messages: []ClaudeMessage{
{Role: "user", Content: "hello"},
},
}
reqB := &ClaudeRequest{
Model: "claude-sonnet-4.5",
System: "sys",
Messages: []ClaudeMessage{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "ok"},
{Role: "user", Content: "next"},
},
}
payloadA := ClaudeToKiro(reqA, false)
payloadB := ClaudeToKiro(reqB, false)
if payloadA.ConversationState.ConversationID == "" || payloadB.ConversationState.ConversationID == "" {
t.Fatalf("expected non-empty conversation IDs")
}
if payloadA.ConversationState.ConversationID != payloadB.ConversationState.ConversationID {
t.Fatalf("expected stable conversation ID across turns, got %q vs %q", payloadA.ConversationState.ConversationID, payloadB.ConversationState.ConversationID)
}
}