Files
Justin Truong d13cc0804a fix(providers): carry tool name on tool results for Gemini
Gemini drops tool_call_id and pairs functionCall/functionResponse by
function name, so its OpenAI-compat shim requires a non-empty
FunctionResponse.name. The name was only recoverable through a reverse
id->name lookup over assistant tool_calls still present in the request
window, which fails after pruning, truncation or tool_call collapse and
produces HTTP 400 "Name cannot be empty" on any follow-up iteration.

Add Message.ToolName, set it at every tool-result creation site and
preserve it through context pruning, then prefer it when serializing.
Fall back to the existing index for history persisted before the field,
so old sessions keep working without a reset.

When neither source resolves a name, drop the unlabelled tool result
instead of emitting an empty one: an empty name is a guaranteed 400 and
a synthetic name would match no prior functionCall. Gated behind the
existing Gemini detection so other OpenAI-compat hosts, which pair by
tool_call_id, are unaffected.

Field is JSON-optional, so session history needs no migration and older
binaries ignore it on rollback.
2026-08-03 22:50:06 +07:00

224 lines
10 KiB
Go

package providers
import (
"context"
"encoding/json"
"time"
)
// Options keys used in ChatRequest.Options across providers.
const (
OptMaxTokens = "max_tokens"
OptTemperature = "temperature"
OptToolChoice = "tool_choice"
OptThinkingLevel = "thinking_level"
OptReasoningEffort = "reasoning_effort"
OptEnableThinking = "enable_thinking"
OptThinkingBudget = "thinking_budget"
// OptStripThinking (bool) tells stream handlers to drop reasoning tokens
// from ChatResponse.Thinking and onChunk callbacks. Usage.ThinkingTokens
// and RawAssistantContent are preserved (billing + tool passback safety).
OptStripThinking = "strip_thinking"
// Middleware-related options (Phase 2 will use these)
OptServiceTier = "service_tier"
OptFastMode = "fast_mode"
OptPromptCacheKey = "prompt_cache_key"
OptPromptCacheRetention = "prompt_cache_retention"
)
// TokenSource provides an OAuth access token (with auto-refresh).
type TokenSource interface {
Token() (string, error)
}
type RouteEligibilityClass string
const (
RouteEligibilityHealthy RouteEligibilityClass = "healthy"
RouteEligibilityUnknown RouteEligibilityClass = "unknown"
RouteEligibilityBlocked RouteEligibilityClass = "blocked"
)
type RouteEligibility struct {
Class RouteEligibilityClass
Reason string
}
type RouteEligibilityAware interface {
RouteEligibility(ctx context.Context) RouteEligibility
}
// Provider is the interface all LLM providers must implement.
type Provider interface {
// Chat sends messages to the LLM and returns a response.
// tools defines available tool schemas; model overrides the default.
Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error)
// ChatStream sends messages and streams response chunks via callback.
// Returns the final complete response after streaming ends.
ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error)
// DefaultModel returns the provider's default model name.
DefaultModel() string
// Name returns the provider identifier (e.g. "anthropic", "openai").
Name() string
}
// ThinkingCapable is optionally implemented by providers that support extended thinking.
// Used to gate thinking_level injection so it's not sent to providers that ignore it.
type ThinkingCapable interface {
SupportsThinking() bool
}
// ChatRequest contains the input for a Chat/ChatStream call.
type ChatRequest struct {
Messages []Message `json:"messages"`
Tools []ToolDefinition `json:"tools,omitempty"`
Model string `json:"model,omitempty"`
Options map[string]any `json:"options,omitempty"`
}
// ChatResponse is the result from an LLM call.
type ChatResponse struct {
Content string `json:"content"`
Thinking string `json:"thinking,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
FinishReason string `json:"finish_reason"` // "stop", "tool_calls", "length"
Usage *Usage `json:"usage,omitempty"`
// Phase is Codex-specific (gpt-5.3-codex): "commentary" or "final_answer".
// Agent loop must persist this on assistant messages for Codex performance.
Phase string `json:"phase,omitempty"`
// RawAssistantContent preserves the raw content blocks array from the provider response.
// Used by Anthropic to pass thinking blocks back in tool use loops (required by API).
RawAssistantContent json.RawMessage `json:"-"`
// ThinkingSignature is the accumulated signature from streaming thinking blocks.
// Required by Anthropic API for tool use passback when thinking is enabled.
ThinkingSignature string `json:"-"`
// Images holds generated images returned by image_generation_call tools (Codex).
// Not persisted to DB; populated at runtime from provider response.
Images []ImageContent `json:"-"`
}
// StreamChunk is a piece of a streaming response.
type StreamChunk struct {
Content string `json:"content,omitempty"`
Thinking string `json:"thinking,omitempty"`
Done bool `json:"done,omitempty"`
Images []ImageContent `json:"images,omitempty"` // image generation frames (Codex)
}
// ImageContent represents an image (either base64-encoded or a direct URL) for vision-capable models.
type ImageContent struct {
MimeType string `json:"mime_type"` // e.g. "image/jpeg"
Data string `json:"data"` // base64-encoded image bytes
URL string `json:"url,omitempty"` // URL of the image
Partial bool `json:"partial,omitempty"` // true for intermediate frames (Codex image_generation_call)
}
// VideoContent represents a video (either base64-encoded or a direct URL) for video-capable models.
type VideoContent struct {
MimeType string `json:"mime_type"` // e.g. "video/mp4"
Data string `json:"data"` // base64-encoded video bytes
URL string `json:"url,omitempty"` // URL of the video
Partial bool `json:"partial,omitempty"` // true for intermediate frames
}
// MediaRef is a lightweight reference to a persistently stored media file.
// Stored in session JSONB (~60 bytes each) instead of megabytes for base64.
// On reload, MediaRefs are resolved to file paths and loaded into Images (for images).
type MediaRef struct {
ID string `json:"id"` // unique media ID (uuid)
MimeType string `json:"mime_type"` // e.g. "image/jpeg", "application/pdf"
Kind string `json:"kind"` // "image", "video", "audio", "document"
Path string `json:"path,omitempty"` // absolute workspace path (persisted for /v1/files/ serving)
Prompt string `json:"prompt,omitempty"` // prompt that generated this asset, if known
}
// Message represents a conversation message.
type Message struct {
Role string `json:"role"` // "system", "user", "assistant", "tool"
Content string `json:"content"`
Thinking string `json:"thinking,omitempty"` // reasoning_content for thinking models (Kimi, DeepSeek, etc.)
Images []ImageContent `json:"-"` // vision: base64 images (runtime only, never persisted to DB)
Videos []VideoContent `json:"-"` // vision: base64 videos (runtime only, never persisted to DB)
MediaRefs []MediaRef `json:"media_refs,omitempty"` // persistent media file references
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"` // for role="tool" responses
IsError bool `json:"is_error,omitempty"` // for role="tool" responses
// ToolName is the originating tool's name for role="tool" messages.
// Google Gemini drops tool_call_id entirely and pairs functionCall↔functionResponse
// by function name, so its OpenAI-compat shim requires a non-empty
// FunctionResponse.name. Carrying the name on the message itself keeps it
// recoverable after pruning, truncation or tool_call collapse — all of which
// can remove the assistant tool_call that a reverse id→name lookup depends on.
// Must match the assistant ToolCall.Name that was sent on the wire (raw name,
// not the resolved registry name). Empty on history persisted before this field.
ToolName string `json:"tool_name,omitempty"`
// Phase is a Codex-specific field (gpt-5.3-codex) indicating message purpose.
// Values: "commentary" (intermediate), "final_answer" (closeout), or "" (unset).
// Must be persisted and passed back in subsequent requests for Codex performance.
// Other providers ignore this field.
Phase string `json:"phase,omitempty"`
// RawAssistantContent carries raw provider content blocks through tool loop iterations.
// Anthropic requires thinking blocks to be passed back exactly as received.
RawAssistantContent json.RawMessage `json:"-"`
// CreatedAt records when this message was added to the session.
// Pointer type so that older messages (stored before this field existed) deserialize as nil,
// allowing the frontend to fall back to synthetic timestamps.
CreatedAt *time.Time `json:"created_at,omitempty"`
// Transient messages are runtime-only context for the next provider call.
// They must not be persisted to session history or serialized to providers.
Transient bool `json:"-"`
}
// ToolCall represents a tool invocation requested by the LLM.
type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
Metadata map[string]string `json:"metadata,omitempty"` // provider-specific (e.g. Gemini thought_signature)
ParseError string `json:"parse_error,omitempty"` // set when arguments JSON was malformed/truncated
}
// ToolDefinition describes a tool available to the LLM.
// Type is "function" for standard function tools, or a native provider tool type
// (e.g. "image_generation") for first-class provider-native tools.
// Function is nil when Type is not "function".
type ToolDefinition struct {
Type string `json:"type"` // "function" | "image_generation" | ...
Function *ToolFunctionSchema `json:"function,omitempty"` // nil when Type != "function"
}
// ToolFunctionSchema is the schema for a function tool.
type ToolFunctionSchema struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]any `json:"parameters"`
Strict *bool `json:"strict,omitempty"` // OpenAI strict mode — constrained decoding
}
// Usage tracks token consumption.
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
CacheCreationTokens int `json:"cache_creation_input_tokens,omitempty"`
CacheReadTokens int `json:"cache_read_input_tokens,omitempty"`
PromptTokensIncludeCachedSegments bool `json:"prompt_tokens_include_cached_segments,omitempty"`
ThinkingTokens int `json:"thinking_tokens,omitempty"`
RequestCount int `json:"request_count,omitempty"`
ImageCount int `json:"image_count,omitempty"`
WebSearchCount int `json:"web_search_count,omitempty"`
}