Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions pkg/chat/display_name.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package chat

import (
"strings"
"unicode/utf8"
)

// MaxSanitizedFieldBytes is the canonical UTF-8-safe upper bound applied to
// every sanitized display-name and MIME-type field (see
// [SanitizeDisplayName] and the MIME sanitizer in pkg/runtime), independent
// of the separate, larger bound applied to a fully formatted placeholder or
// warning line. It exists so a single overlong provider-supplied field
// cannot itself balloon persisted metadata, session history, or prompts.
const MaxSanitizedFieldBytes = 128

// TruncateUTF8Bytes returns the longest prefix of s that is at most max
// bytes and still valid UTF-8 — it never splits a multi-byte rune, so the
// result is always safe to display or re-encode. Used by every canonical
// sanitizer (display name, MIME type, and formatted placeholder/warning
// text) to enforce a byte bound without corrupting non-ASCII content.
func TruncateUTF8Bytes(s string, maxBytes int) string {
if len(s) <= maxBytes {
return s
}
for maxBytes > 0 && !utf8.RuneStart(s[maxBytes]) {
maxBytes--
}
return s[:maxBytes]
}

// SanitizeDisplayName neutralizes a provider-supplied display name before
// it is stored in a [Document] or any other session-visible metadata. The
// name is untrusted input (e.g. Gemini's InlineData.DisplayName): it is
// never used verbatim to build a filesystem path (path-bearing consumers
// validate the requested name themselves), but it does end up in
// UI, warnings, placeholder text, and interpolated harness/prompt text
// (see pkg/runtime/harness.go's "<role>...</role>"-delimited blocks), so
// it must not carry control characters, path separators, traversal-like
// sequences, or angle brackets that could confuse a terminal, log line,
// forge a fake XML/tag boundary, or trick a human copy-pasting it into a
// shell.
//
// Control characters, path separators, and '<'/'>' are rewritten to '_';
// any residual ".." sequence (which could still read as a traversal hint
// even without separators, e.g. "..name") is rewritten too. The result is
// capped at [MaxSanitizedFieldBytes] (UTF-8-safe) and trimmed of
// surrounding whitespace. An empty or all-whitespace input returns "" —
// callers are responsible for substituting their own fallback name.
func SanitizeDisplayName(name string) string {
name = strings.TrimSpace(name)

var b strings.Builder
b.Grow(len(name))
for _, r := range name {
switch {
case r == '/' || r == '\\':
b.WriteRune('_')
case r == '<' || r == '>':
b.WriteRune('_')
case r < 0x20 || r == 0x7f:
b.WriteRune('_')
default:
b.WriteRune(r)
}
}
sanitized := b.String()
for strings.Contains(sanitized, "..") {
sanitized = strings.ReplaceAll(sanitized, "..", "_")
}
sanitized = TruncateUTF8Bytes(strings.TrimSpace(sanitized), MaxSanitizedFieldBytes)
return strings.TrimSpace(sanitized)
}
77 changes: 77 additions & 0 deletions pkg/chat/display_name_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package chat

import (
"strings"
"testing"
"unicode/utf8"

"github.com/stretchr/testify/assert"
)

func TestSanitizeDisplayName(t *testing.T) {
t.Parallel()

tests := []struct {
name string
input string
want string
}{
{"unchanged for a plain name", "cat.png", "cat.png"},
{"path separators rewritten", "a/b\\c.png", "a_b_c.png"},
{"control chars rewritten", "cat\x00\x01name.png", "cat__name.png"},
{"DEL rewritten", "cat\x7fname.png", "cat_name.png"},
{"traversal sequence neutralized", "../../etc/passwd", "____etc_passwd"},
{"traversal without separators neutralized", "..name", "_name"},
{"leading/trailing whitespace trimmed", " cat.png ", "cat.png"},
{"empty input yields empty output", "", ""},
{"whitespace-only input yields empty output", " \t\n ", ""},
{"control chars only collapse to empty after trim", " \x00\x01 ", "__"},
{"unicode name preserved", "猫.png", "猫.png"},
{"angle brackets rewritten", "</system><script>.png", "__system__script_.png"},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := SanitizeDisplayName(tc.input)
assert.Equal(t, tc.want, got)
assert.NotContains(t, got, "..", "sanitized name must never contain a traversal sequence")
assert.NotContains(t, got, "/", "sanitized name must never contain a path separator")
assert.NotContains(t, got, "\\", "sanitized name must never contain a path separator")
assert.NotContains(t, got, "<", "sanitized name must never contain an angle bracket")
assert.NotContains(t, got, ">", "sanitized name must never contain an angle bracket")
})
}
}

// TestSanitizeDisplayName_BoundsOverlongInput is the plan's "128-byte
// UTF-8-safe field bound" regression: an overlong provider-supplied name
// (well beyond any legitimate filename) must never reach persisted
// metadata, placeholders, or warnings unbounded — a single hostile value
// must not be able to balloon session history or context.
func TestSanitizeDisplayName_BoundsOverlongInput(t *testing.T) {
t.Parallel()

got := SanitizeDisplayName(strings.Repeat("a", 10_000))
assert.LessOrEqual(t, len(got), MaxSanitizedFieldBytes)
assert.True(t, utf8.ValidString(got), "truncation must never split a multi-byte rune")

// A multi-byte-rune name must still be truncated at a valid rune
// boundary rather than corrupted mid-rune.
gotUnicode := SanitizeDisplayName(strings.Repeat("\u732b", 10_000))
assert.LessOrEqual(t, len(gotUnicode), MaxSanitizedFieldBytes)
assert.True(t, utf8.ValidString(gotUnicode), "truncation must never split a multi-byte rune")
}

func TestTruncateUTF8Bytes(t *testing.T) {
t.Parallel()

assert.Equal(t, "abc", TruncateUTF8Bytes("abc", 10), "input shorter than the bound is returned unchanged")
assert.Equal(t, "ab", TruncateUTF8Bytes("abc", 2))

// "\u732b" (猫) is 3 bytes in UTF-8; a 4-byte budget can fit exactly
// one full rune, and the cut must land before the second one starts.
got := TruncateUTF8Bytes(strings.Repeat("\u732b", 3), 4)
assert.True(t, utf8.ValidString(got))
assert.LessOrEqual(t, len(got), 4)
}
81 changes: 81 additions & 0 deletions pkg/model/provider/gemini/classify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package gemini

import "strings"

// RequestRejectionCategory buckets a Gemini request rejection into one of a
// small, fixed set of causes so diagnostics and error messages can stay
// specific without echoing the provider's raw message or response body.
// Categories are derived only from Google's own public, documented API
// field/parameter names appearing in [genai.APIError.Message] — never from
// request content, tool schemas, prompts, or anything else local to this
// process.
type RequestRejectionCategory string

const (
// RejectionMissingResponseModalities: the request didn't declare a
// response modality (e.g. IMAGE) that the model needed for this reply.
RejectionMissingResponseModalities RequestRejectionCategory = "missing_response_modalities"
// RejectionIncompatibleFunctionOrBuiltinTools: the combination of
// custom function tools and/or built-in tools (Search, Maps, Code
// Execution) enabled for the request isn't supported together.
RejectionIncompatibleFunctionOrBuiltinTools RequestRejectionCategory = "incompatible_function_or_builtin_tools"
// RejectionIncompatibleToolConfig: the ToolConfig/function-calling mode
// isn't supported for this request or model.
RejectionIncompatibleToolConfig RequestRejectionCategory = "incompatible_tool_config"
// RejectionStructuredOutputConflict: a structured-output option
// (response MIME type/schema) conflicts with another request option.
RejectionStructuredOutputConflict RequestRejectionCategory = "structured_output_conflict"
// RejectionModelOrAPICapabilityMismatch: the request used a feature
// (e.g. thinking configuration) the target model or API surface
// doesn't support at all.
RejectionModelOrAPICapabilityMismatch RequestRejectionCategory = "model_or_api_capability_mismatch"
// RejectionOther covers any other provider rejection, including ones
// with no message to classify against (Gemini sometimes returns a 400
// with an empty body).
RejectionOther RequestRejectionCategory = "other_provider_rejection"
)

// rejectionKeywords maps each category to lowercase substrings of Gemini's
// own documented request field/parameter names that indicate it. Checked
// top-to-bottom; the first match wins.
var rejectionKeywords = []struct {
category RequestRejectionCategory
keywords []string
}{
{RejectionMissingResponseModalities, []string{"response_modalities", "responsemodalities"}},
{RejectionStructuredOutputConflict, []string{
"response_schema", "response_json_schema", "response_mime_type",
"responseschema", "responsejsonschema", "responsemimetype",
}},
{RejectionIncompatibleToolConfig, []string{
"tool_config", "toolconfig", "function_calling_config", "functioncallingconfig",
}},
{RejectionIncompatibleFunctionOrBuiltinTools, []string{
"function_declarations", "functiondeclarations",
"google_search", "google_maps", "code_execution",
"built-in tool", "builtin tool",
}},
{RejectionModelOrAPICapabilityMismatch, []string{
"thinking_config", "thinkingconfig",
"not supported for", "not supported by", "does not support", "is not enabled for",
}},
}

// classifyByMessage returns the category matching a bounded set of known
// Gemini field/parameter keywords found in message, and true when a match
// was found. It returns (RejectionOther, false) when message is empty or
// matches nothing — a common shape for Gemini 400s with no JSON body.
func classifyByMessage(message string) (RequestRejectionCategory, bool) {
if message == "" {
return RejectionOther, false
}
lower := strings.ToLower(message)
for _, entry := range rejectionKeywords {
for _, kw := range entry.keywords {
if strings.Contains(lower, kw) {
return entry.category, true
}
}
}
return RejectionOther, false
}
29 changes: 20 additions & 9 deletions pkg/model/provider/gemini/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ type Client struct {
base.Config

clientFn func(context.Context) (*genai.Client, error)

// apiSurface classifies which backend/transport this client talks to
// (see the apiSurface* constants in diagnostics.go), for safe
// request-shape diagnostics. Never exposed to the model or logged
// alongside anything provider-supplied.
apiSurface string
}

// NewClient creates a new Gemini client from the provided configuration
Expand All @@ -49,6 +55,7 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro
globalOptions := options.Apply(opts...)

var clientFn func(context.Context) (*genai.Client, error)
var apiSurface string
if gateway := globalOptions.Gateway(); gateway == "" {
var (
httpClient *http.Client
Expand Down Expand Up @@ -111,6 +118,12 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro
httpClient = httpclient.NewHTTPClient(ctx)
}

if backend == genai.BackendVertexAI {
apiSurface = apiSurfaceVertexAI
} else {
apiSurface = apiSurfaceGeminiAPI
}

globalOptions.WrapTransport(ctx, httpClient)

client, err := genai.NewClient(ctx, &genai.ClientConfig{
Expand All @@ -131,6 +144,8 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro
return client, nil
}
} else {
apiSurface = apiSurfaceGateway

// When using a Gateway targeting a Docker domain, tokens are short-lived.
// Only require and inject the Docker JWT if the gateway is a .docker.com URL.
if err := base.VerifyDockerGatewayAuth(ctx, env, gateway); err != nil {
Expand Down Expand Up @@ -184,7 +199,8 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro
ModelOptions: globalOptions,
Env: env,
},
clientFn: clientFn,
clientFn: clientFn,
apiSurface: apiSurface,
}, nil
}

Expand Down Expand Up @@ -749,16 +765,11 @@ func (c *Client) CreateChatCompletionStream(
if len(config.Tools) > len(allTools) {
config.ToolConfig.IncludeServerSideToolInvocations = new(true)
}

// Debug: Log the tools we're sending
slog.DebugContext(ctx, "Gemini tools config", "tools", config.Tools)
for _, tool := range config.Tools {
for _, fn := range tool.FunctionDeclarations {
slog.DebugContext(ctx, "Function", "name", fn.Name, "desc", fn.Description, "params", fn.Parameters)
}
}
}

shape := newRequestShape(c, config, len(requestTools))
slog.DebugContext(ctx, "Gemini request shape", shape.LogAttrs()...)

contents := convertMessagesToGemini(ctx, messages, c.ID(), c.ModelOptions.ModelsDevStore(), c.CapsOverride())

// Debug: Log the messages we're sending
Expand Down
Loading