From 97f2429de70d5bacba3eb3ea779e0a7491789967 Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Tue, 27 Oct 2026 11:31:38 -0400 Subject: [PATCH] HOUSE-79 part 13: upstream v8.0.44 changes for review --- internal/util/gemini_schema.go | 988 ++++++++++++++++++---- internal/util/github.go | 27 +- internal/util/header_helpers.go | 29 +- internal/util/provider.go | 4 +- internal/util/responses_tools.go | 264 +++++- internal/util/translator.go | 1319 +++++++++++++++++++++++++++++- sdk/access/errors.go | 24 + sdk/auth/antigravity.go | 12 + sdk/auth/codex.go | 2 +- sdk/auth/filestore.go | 78 +- sdk/httptransport/decorator.go | 75 ++ 11 files changed, 2576 insertions(+), 246 deletions(-) create mode 100644 sdk/httptransport/decorator.go diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index c21fb596a..8bf0dea6d 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -5,6 +5,7 @@ import ( "bytes" "encoding/json" "fmt" + "reflect" "sort" "strconv" "strings" @@ -27,12 +28,16 @@ const placeholderReasonDescription = "Brief explanation of why you are calling t // once already; scope every call site to the schema itself. type jsonSchemaCleanOptions struct { - addPlaceholder bool - addMissingArrayItems bool - antigravitySemantics bool - removeToolTitle bool - removeGeminiMetadata bool - flattenUnions bool + addPlaceholder bool + addMissingArrayItems bool + antigravitySemantics bool + removeToolTitle bool + removeGeminiMetadata bool + flattenUnions bool + // flattenMultiBranchUnions forces multi-branch anyOf/oneOf unions down to a single + // strongest branch instead of preserving them. It is used on routes whose upstream + // rejects nested multi-branch unions even though they are valid JSON Schema. + flattenMultiBranchUnions bool forceEnumStringType bool dropAllEnums bool dropBooleanEnums bool @@ -53,13 +58,28 @@ func CleanJSONSchemaForAntigravity(jsonStr string) string { // type of generated function arguments, so numeric and boolean types must not be rewritten. // requirePlaceholder is used only for Claude VALIDATED mode. func CleanJSONSchemaForAntigravityTool(jsonStr string, requirePlaceholder bool) string { + return cleanJSONSchemaForAntigravityTool(jsonStr, requirePlaceholder, false) +} + +// CleanJSONSchemaForAntigravityClaudeTool is the Antigravity tool cleaner for Claude +// models. It applies the same Antigravity schema cleanup, but flattens multi-branch +// anyOf/oneOf unions to a single strongest branch: the Antigravity Claude backend +// converts Gemini function declarations into Claude custom.input_schema and rejects +// nested multi-branch unions with "JSON schema is invalid", while the Gemini +// backends accept preserved unions (see Issue 3662 and its regression follow-up). +func CleanJSONSchemaForAntigravityClaudeTool(jsonStr string, requirePlaceholder bool) string { + return cleanJSONSchemaForAntigravityTool(jsonStr, requirePlaceholder, true) +} + +func cleanJSONSchemaForAntigravityTool(jsonStr string, requirePlaceholder, flattenMultiBranchUnions bool) string { return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{ - addPlaceholder: requirePlaceholder, - addMissingArrayItems: true, - antigravitySemantics: true, - removeToolTitle: !requirePlaceholder, - flattenUnions: true, - dropAllEnums: true, + addPlaceholder: requirePlaceholder, + addMissingArrayItems: true, + antigravitySemantics: true, + removeToolTitle: !requirePlaceholder, + flattenUnions: true, + flattenMultiBranchUnions: flattenMultiBranchUnions, + dropAllEnums: true, }) } @@ -71,7 +91,7 @@ func CleanJSONSchemaForAntigravityTool(jsonStr string, requirePlaceholder bool) // additionalProperties: false (which Antigravity natively enforces for response schemas). // - Description hints + deletion: unsupported or accepted-but-ignored constraints. // - Flattened: allOf merged into properties/required. -// - Projected: anyOf/oneOf select the strongest branch; null branches become nullable:true. +// - Unions: multi-branch anyOf/oneOf unions are preserved; single-type nullable unions ([T, null]) project to T with nullable:true. // - Resolved: local $ref targets are inlined before $defs/definitions are removed. // - Dropped: unresolved $ref (after a hint), metadata, unsupported object-key constraints, // conditional keywords (after non-conflicting properties are retained), and x-* extensions. @@ -112,13 +132,15 @@ func CleanJSONSchemaForGeminiJSONSchema(jsonStr string) string { // cleanJSONSchema performs the core cleaning operations on the JSON schema. func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { - // Phase 0: Normalize malformed schemas (e.g. bare property maps and boolean required from MCP tools) - jsonStr = normalizeMalformedSchemaObjects(jsonStr, options.addMissingArrayItems) - - // Phase 1: Convert and add hints - if options.antigravitySemantics { - jsonStr = inlineLocalRefs(jsonStr) - } + // Phase 0: Normalize malformed schemas (e.g. bare property maps and boolean required from MCP tools). + // Array placeholders and array type inference are deferred to Phase 3 (sanitizeArrayItems) after + // local references are inlined and allOf/union structures are resolved, so synthetic placeholders + // do not clobber items supplied by $ref targets or allOf branches. + jsonStr = normalizeMalformedSchemaObjects(jsonStr) + + // Phase 1: Convert and add hints. Every cleaner removes $defs/definitions later, so local + // references must be expanded here for every route; otherwise nested fields are lost. + jsonStr = inlineLocalRefsForClean(jsonStr) jsonStr = convertRefsToHints(jsonStr, options.antigravitySemantics) jsonStr = convertConstToEnum(jsonStr) jsonStr = convertEnumValuesToStrings(jsonStr, options.forceEnumStringType) @@ -132,13 +154,20 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { jsonStr = moveNotToDescription(jsonStr) } - // Phase 2: Flatten complex structures + // Phase 2: Flatten complex structures. Once a JSON Schema contains a conjunction, + // local shape assumptions are unsafe in ancestors, siblings, and descendants alike. + preserveConjunctions := options.preserveStandardConstraints && hasSchemaConjunction(jsonStr) jsonStr = mergeConditionals(jsonStr) - jsonStr = mergeAllOf(jsonStr) - if options.flattenUnions { - jsonStr = flattenAnyOfOneOf(jsonStr) + if !options.preserveStandardConstraints { + // The JSON Schema carrier must keep conjunctions, including ref sibling conflicts. + jsonStr = mergeAllOf(jsonStr) + } + if !preserveConjunctions { + if options.flattenUnions { + jsonStr = flattenAnyOfOneOf(jsonStr, options.preserveStandardConstraints, options.flattenMultiBranchUnions) + } + jsonStr = flattenTypeArrays(jsonStr, options.antigravitySemantics) } - jsonStr = flattenTypeArrays(jsonStr, options.antigravitySemantics) // Phase 3: Cleanup jsonStr = removeUnsupportedKeywords(jsonStr, options) @@ -151,8 +180,11 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { // Keep that harmless metadata policy without losing Antigravity's native nullable support. jsonStr = removeKeywords(jsonStr, []string{"title"}) } - jsonStr = cleanupRequiredFields(jsonStr) - jsonStr = sanitizeArrayItems(jsonStr) + if !preserveConjunctions { + jsonStr = cleanupRequiredFields(jsonStr) + jsonStr = sanitizeObjectProperties(jsonStr) + jsonStr = sanitizeArrayItems(jsonStr, options.addMissingArrayItems) + } // Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement) if options.addPlaceholder { jsonStr = addEmptySchemaPlaceholder(jsonStr) @@ -161,10 +193,34 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { return jsonStr } -// sanitizeArrayItems ensures that any schema node declaring "items" has "type": "array". -// Gemini's protobuf validator enforces a strict field predicate on items ($type == Type.ARRAY); -// if type is missing, it is inferred as array; if type is explicitly a non-array, items is removed. -func sanitizeArrayItems(jsonStr string) string { +// sanitizeObjectProperties normalizes native Schema object declarations. The caller skips this +// pass for JSON Schema conjunctions, where local properties do not imply an object type. +func sanitizeObjectProperties(jsonStr string) string { + paths := findPaths(jsonStr, "properties") + sortByDepth(paths) + for _, p := range paths { + parentPath := trimSuffix(p, ".properties") + if isPropertyDefinition(parentPath) { + continue + } + props := gjson.Get(jsonStr, p) + if !props.IsObject() { + continue + } + typePath := joinPath(parentPath, "type") + t := gjson.Get(jsonStr, typePath).String() + if !strings.EqualFold(t, "object") { + updated, _ := sjson.SetBytes([]byte(jsonStr), typePath, "object") + jsonStr = string(updated) + } + } + return jsonStr +} + +// sanitizeArrayItems normalizes native Schema arrays and supplies missing items placeholders. +// The caller skips this pass for JSON Schema conjunctions, where element and instance types +// may be constrained elsewhere and synthesizing local fields can make the intersection impossible. +func sanitizeArrayItems(jsonStr string, addMissing bool) string { paths := findPaths(jsonStr, "items") sortByDepth(paths) for _, p := range paths { @@ -181,6 +237,23 @@ func sanitizeArrayItems(jsonStr string) string { jsonStr, _ = sjson.Delete(jsonStr, p) } } + + if !addMissing { + return jsonStr + } + typePaths := findPaths(jsonStr, "type") + sortByDepth(typePaths) + for _, p := range typePaths { + parentPath := trimSuffix(p, ".type") + if isPropertyDefinition(parentPath) || !isArrayDeclaredType(gjson.Get(jsonStr, p).Value()) { + continue + } + if gjson.Get(jsonStr, joinPath(parentPath, "items")).Exists() || gjson.Get(jsonStr, joinPath(parentPath, "$ref")).Exists() { + continue + } + updated, _ := sjson.SetRawBytes([]byte(jsonStr), joinPath(parentPath, "items"), []byte(`{"type":"string"}`)) + jsonStr = string(updated) + } return jsonStr } @@ -274,8 +347,8 @@ func removePlaceholderFields(jsonStr string) string { // certain MCP tool definitions (e.g. Asana MCP server): // 1. Bare property maps missing the "type": "object" and "properties": {...} wrappers are wrapped. // 2. Boolean "required": true on property definitions are stripped and promoted to the parent's "required" array. -// 3. Tool array schemas missing "items" receive a string item schema required by Gemini and Antigravity. -func normalizeMalformedSchemaObjects(jsonStr string, addMissingArrayItems bool) string { +// 3. Boolean true subschemas are normalized to empty schema objects. +func normalizeMalformedSchemaObjects(jsonStr string) string { if jsonStr == "" { return jsonStr } @@ -299,7 +372,7 @@ func normalizeMalformedSchemaObjects(jsonStr string, addMissingArrayItems bool) // If wrapped in single-key {"schema": ...} by cleanNestedSchema, unwrap, repair, and re-wrap. if len(rootMap) == 1 { if innerSchema, ok := rootMap["schema"].(map[string]any); ok { - repairedInner, modified := repairSchemaNode(innerSchema, addMissingArrayItems) + repairedInner, modified := repairSchemaNode(innerSchema) if !modified { return jsonStr } @@ -317,7 +390,7 @@ func normalizeMalformedSchemaObjects(jsonStr string, addMissingArrayItems bool) } } - repaired, modified := repairSchemaNode(rootMap, addMissingArrayItems) + repaired, modified := repairSchemaNode(rootMap) if !modified { return jsonStr } @@ -412,7 +485,7 @@ func isAPIRequestDocument(m map[string]any) bool { return false } -func repairSchemaNode(node map[string]any, addMissingArrayItems bool) (map[string]any, bool) { +func repairSchemaNode(node map[string]any) (map[string]any, bool) { if node == nil { return nil, false } @@ -438,7 +511,7 @@ func repairSchemaNode(node map[string]any, addMissingArrayItems bool) (map[strin } if len(bareProps) > 0 { - repairedProps, promotedReqs, _ := repairPropertyMap(bareProps, addMissingArrayItems) + repairedProps, promotedReqs, _ := repairPropertyMap(bareProps) for k := range bareProps { delete(clone, k) } @@ -468,9 +541,10 @@ func repairSchemaNode(node map[string]any, addMissingArrayItems bool) (map[strin } } - // 2. If node has a "properties" map, recursively repair all properties inside it + // 2. Repair declared properties without inferring a type before refs are expanded. + // Final sanitization handles native object and array schemas once refs and conjunctions are resolved. if propsVal, ok := clone["properties"].(map[string]any); ok { - repairedProps, promotedReqs, propsMod := repairPropertyMap(propsVal, addMissingArrayItems) + repairedProps, promotedReqs, propsMod := repairPropertyMap(propsVal) if propsMod { clone["properties"] = repairedProps modified = true @@ -483,31 +557,15 @@ func repairSchemaNode(node map[string]any, addMissingArrayItems bool) (map[strin } } - // Gemini and Antigravity reject tool array schemas without an items definition, - // and reject tool schemas with items whose type is not ARRAY. - if addMissingArrayItems { - if isArrayDeclaredType(clone["type"]) { - if _, hasItems := clone["items"]; !hasItems { - clone["items"] = map[string]any{"type": "string"} - modified = true - } - } else if _, hasItems := clone["items"]; hasItems { - if clone["type"] == nil || clone["type"] == "" { - clone["type"] = "array" - modified = true - } - } - } - // 3. Recurse into all other standard schema containers if itemsVal, ok := clone["items"].(map[string]any); ok { - repairedItems, itemsMod := repairSchemaNode(itemsVal, addMissingArrayItems) + repairedItems, itemsMod := repairSchemaNode(itemsVal) if itemsMod { clone["items"] = repairedItems modified = true } } else if itemsList, ok := clone["items"].([]any); ok { - repairedList, listMod := repairSchemaList(itemsList, addMissingArrayItems) + repairedList, listMod := repairSchemaList(itemsList) if listMod { clone["items"] = repairedList modified = true @@ -518,7 +576,7 @@ func repairSchemaNode(node map[string]any, addMissingArrayItems bool) (map[strin } if addProps, ok := clone["additionalProperties"].(map[string]any); ok { - repairedAddProps, addPropsMod := repairSchemaNode(addProps, addMissingArrayItems) + repairedAddProps, addPropsMod := repairSchemaNode(addProps) if addPropsMod { clone["additionalProperties"] = repairedAddProps modified = true @@ -526,7 +584,7 @@ func repairSchemaNode(node map[string]any, addMissingArrayItems bool) (map[strin } if patProps, ok := clone["patternProperties"].(map[string]any); ok { - repairedPatProps, _, patMod := repairPropertyMap(patProps, addMissingArrayItems) + repairedPatProps, _, patMod := repairPropertyMap(patProps) if patMod { clone["patternProperties"] = repairedPatProps modified = true @@ -535,7 +593,7 @@ func repairSchemaNode(node map[string]any, addMissingArrayItems bool) (map[strin for _, key := range []string{"if", "then", "else", "not", "contains", "propertyNames", "unevaluatedProperties", "unevaluatedItems", "contentSchema", "additionalItems"} { if subVal, ok := clone[key].(map[string]any); ok { - repairedSub, subMod := repairSchemaNode(subVal, addMissingArrayItems) + repairedSub, subMod := repairSchemaNode(subVal) if subMod { clone[key] = repairedSub modified = true @@ -548,7 +606,7 @@ func repairSchemaNode(node map[string]any, addMissingArrayItems bool) (map[strin for _, key := range []string{"anyOf", "oneOf", "allOf", "prefixItems"} { if listVal, ok := clone[key].([]any); ok { - repairedList, listMod := repairSchemaList(listVal, addMissingArrayItems) + repairedList, listMod := repairSchemaList(listVal) if listMod { clone[key] = repairedList modified = true @@ -562,7 +620,7 @@ func repairSchemaNode(node map[string]any, addMissingArrayItems bool) (map[strin defsModified := false for dk, dv := range defsVal { if defMap, ok := dv.(map[string]any); ok { - repairedDef, defMod := repairSchemaNode(defMap, addMissingArrayItems) + repairedDef, defMod := repairSchemaNode(defMap) repairedDefs[dk] = repairedDef if defMod { defsModified = true @@ -585,12 +643,12 @@ func repairSchemaNode(node map[string]any, addMissingArrayItems bool) (map[strin return clone, modified } -func repairSchemaList(list []any, addMissingArrayItems bool) ([]any, bool) { +func repairSchemaList(list []any) ([]any, bool) { var repairedList []any listModified := false for _, item := range list { if itemMap, ok := item.(map[string]any); ok { - repairedItem, itemMod := repairSchemaNode(itemMap, addMissingArrayItems) + repairedItem, itemMod := repairSchemaNode(itemMap) repairedList = append(repairedList, repairedItem) if itemMod { listModified = true @@ -605,7 +663,7 @@ func repairSchemaList(list []any, addMissingArrayItems bool) ([]any, bool) { return repairedList, listModified } -func repairPropertyMap(props map[string]any, addMissingArrayItems bool) (map[string]any, []string, bool) { +func repairPropertyMap(props map[string]any) (map[string]any, []string, bool) { out := make(map[string]any, len(props)) var promotedReqs []string modified := false @@ -636,7 +694,7 @@ func repairPropertyMap(props map[string]any, addMissingArrayItems bool) (map[str } } - repairedChild, childMod := repairSchemaNode(childClone, addMissingArrayItems) + repairedChild, childMod := repairSchemaNode(childClone) if childMod { modified = true } @@ -685,17 +743,34 @@ func mergeStringSlices(existing, promoted []string) []string { return res } -// InlineLocalRefs resolves JSON Pointer references against the original schema before definition -// containers are stripped. Each expansion receives its own copy, sibling keywords override the -// referenced definition, and cycles terminate as a typed hint instead of recursing forever. +// InlineLocalRefs resolves JSON Pointer references against the original schema. Every reference +// receives its own copy as long as the expansion budget lasts, conflicting sibling assertions +// remain conjunctive, and cycles terminate as a typed hint instead of recursing forever. func InlineLocalRefs(jsonStr string) string { return inlineLocalRefs(jsonStr) } -// inlineLocalRefs resolves JSON Pointer references against the original schema before definition -// containers are stripped. Each expansion receives its own copy, sibling keywords override the -// referenced definition, and cycles terminate as a typed hint instead of recursing forever. +// maxInlinedSchemaBytes bounds the total size of schema material copied into the output by local +// reference expansion. Shared definitions fan out once per reference, so a small input can expand +// exponentially; expansions past the budget degrade to a typed hint like cycles do. +const maxInlinedSchemaBytes = 256 * 1024 + +// maxFallbackDescriptionBytes bounds the target description copied into a degraded reference, so a +// large shared description cannot be repeated per reference and defeat the expansion budget. +const maxFallbackDescriptionBytes = 512 + +// inlineLocalRefs resolves JSON Pointer references against the original schema. Every reference +// receives its own copy as long as the expansion budget lasts, conflicting sibling assertions +// remain conjunctive, and cycles terminate as a typed hint instead of recursing forever. func inlineLocalRefs(jsonStr string) string { + return inlineLocalRefsRooted(jsonStr, nil, false) +} + +// inlineLocalRefsForClean inlines local references for schema cleaning. Cleaning sometimes +// receives the single-key {"schema": ...} carrier used to nest a schema one level down (see Phase +// 0); references inside it must then resolve against the inner schema, not the carrier, or every +// lookup misses. The carrier shape and root keys are otherwise preserved byte-for-byte. +func inlineLocalRefsForClean(jsonStr string) string { if !strings.Contains(jsonStr, `"$ref"`) { return jsonStr } @@ -707,7 +782,43 @@ func inlineLocalRefs(jsonStr string) string { return jsonStr } - resolved := resolveLocalRefs(root, root, make(map[string]bool)) + resolveRoot := root + wrapped := false + if rootMap, ok := root.(map[string]any); ok && len(rootMap) == 1 { + if inner, okInner := rootMap["schema"].(map[string]any); okInner { + resolveRoot = inner + wrapped = true + } + } + if !wrapped { + return inlineLocalRefsRooted(jsonStr, root, true) + } + + state := newRefResolveState() + resolved := resolveSchemaRoot(resolveRoot, state, true) + out, err := json.Marshal(map[string]any{"schema": resolved}) + if err != nil { + return jsonStr + } + return string(out) +} + +func inlineLocalRefsRooted(jsonStr string, parsedRoot any, forClean bool) string { + if !strings.Contains(jsonStr, `"$ref"`) { + return jsonStr + } + + root := parsedRoot + if root == nil { + decoder := json.NewDecoder(strings.NewReader(jsonStr)) + decoder.UseNumber() + if err := decoder.Decode(&root); err != nil { + return jsonStr + } + } + + state := newRefResolveState() + resolved := resolveSchemaRoot(root, state, forClean) out, err := json.Marshal(resolved) if err != nil { return jsonStr @@ -715,43 +826,414 @@ func inlineLocalRefs(jsonStr string) string { return string(out) } -func resolveLocalRefs(root, value any, active map[string]bool) any { +// refResolveState carries the remaining expansion budget of one inlining pass. Every reference +// charges the size of the target it copies (nested references charge their own targets), so +// shared-definition fan-out stops once the budget is spent while ordinary reuse still expands. +type refResolveState struct { + budget int +} + +func newRefResolveState() *refResolveState { + return &refResolveState{budget: maxInlinedSchemaBytes} +} + +// sortedKeys returns map keys in a deterministic order, so budget allocation never depends on +// Go map iteration order. +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +// resolveSchemaRoot resolves references in the schema body before resolving definition +// containers. Definitions only matter through references, so walking them first would spend the +// expansion budget on copies the cleaners delete next (and whose map order is arbitrary), while +// the fields that are actually kept degraded to hints. For the public InlineLocalRefs the +// containers are kept, so they are still resolved afterwards with whatever budget remains; when +// cleaning they are dropped here altogether (references still resolve against the original +// root), so their bytes never reach the intermediate JSON at all. +func resolveSchemaRoot(root any, state *refResolveState, forClean bool) any { + rootMap, ok := root.(map[string]any) + if !ok { + return resolveLocalRefs(root, root, make(map[string]bool), state, false, forClean) + } + + out := make(map[string]any, len(rootMap)) + for _, key := range sortedKeys(rootMap) { + item := rootMap[key] + switch key { + case "$defs", "definitions": + if !forClean { + out[key] = nil // placeholder, resolved after the schema body below + } + case "$ref": + out[key] = item + default: + out[key] = resolveSchemaEntry(rootMap, key, item, make(map[string]bool), state, forClean) + } + } + + // A root-level reference and its already resolved siblings use the same conjunction rules + // as nested references. Keep document declarations outside any generated allOf branches. + if ref, hasRef := rootMap["$ref"].(string); hasRef && strings.HasPrefix(ref, "#/") { + if target, okTarget := resolveJSONPointer(rootMap, ref); okTarget { + if size := targetExpansionSize(target, forClean); size <= state.budget { + state.budget -= size + active := map[string]bool{ref: true} + if targetMap, okMap := resolveLocalRefs(rootMap, target, active, state, false, forClean).(map[string]any); okMap { + delete(out, "$ref") + delete(out, "$defs") + delete(out, "definitions") + delete(out, "$schema") + out = mergeInlinedRefSchemas(targetMap, out) + if dialect, exists := rootMap["$schema"]; exists { + out["$schema"] = dialect + } + } else { + out = resolveRefFallback(rootMap, out, target, ref, make(map[string]bool), state, forClean) + } + } else { + out = resolveRefFallback(rootMap, out, target, ref, make(map[string]bool), state, forClean) + } + delete(out, "$ref") + } + } + + if !forClean { + for _, key := range []string{"$defs", "definitions"} { + if item, exists := rootMap[key]; exists { + out[key] = resolveSchemaEntry(rootMap, key, item, make(map[string]bool), state, false) + } + } + } + return out +} + +// targetExpansionSize estimates what copying a reference target into the output costs. When +// cleaning, definitions nested inside the target are deleted later and never copied, so they are +// excluded; otherwise a small target carrying a large unused definitions block would be degraded +// purely because of material that is about to be thrown away. Name-map and literal context is +// threaded so properties named like a container keyword, and data inside literal values, still +// count in full. +func targetExpansionSize(value any, forClean bool) int { + if !forClean { + return jsonValueSize(value) + } + return cleanExpansionSize(value, false) +} + +func cleanExpansionSize(value any, inNameMap bool) int { + switch node := value.(type) { + case nil: + return 4 + case bool: + return 5 + case json.Number: + return len(node.String()) + case string: + return jsonStringEncodedSize(node) + case []any: + size := 2 + for _, item := range node { + size += cleanExpansionSize(item, false) + 1 + if size > maxInlinedSchemaBytes+1 { + return maxInlinedSchemaBytes + 1 + } + } + return size + case map[string]any: + size := 2 + for key, item := range node { + if !inNameMap && (key == "$defs" || key == "definitions") { + continue + } + child := item + childInNameMap := false + if !inNameMap { + if isLiteralValueKeyword(key) { + size += jsonStringEncodedSize(key) + 1 + jsonValueSize(child) + 1 + if size > maxInlinedSchemaBytes+1 { + return maxInlinedSchemaBytes + 1 + } + continue + } + if _, isNameMap := schemaNameMapKeywords[key]; isNameMap { + childInNameMap = true + } + } + size += jsonStringEncodedSize(key) + 1 + cleanExpansionSize(child, childInNameMap) + 1 + if size > maxInlinedSchemaBytes+1 { + return maxInlinedSchemaBytes + 1 + } + } + return size + default: + return 16 + } +} + +// jsonValueSize estimates the encoded size of a decoded JSON value as encoding/json will emit +// it, including string escaping (notably the six-byte \u00XX forms for <, >, & and control +// characters). It is used to bound ref expansion, so it must not under-count what serialization +// will actually produce. +func jsonValueSize(value any) int { + switch node := value.(type) { + case nil: + return 4 + case bool: + return 5 + case json.Number: + return len(node.String()) + case string: + return jsonStringEncodedSize(node) + case []any: + size := 2 + for _, item := range node { + size += jsonValueSize(item) + 1 + } + return size + case map[string]any: + size := 2 + for key, item := range node { + size += jsonStringEncodedSize(key) + 1 + jsonValueSize(item) + 1 + } + return size + default: + return 16 + } +} + +// jsonStringEncodedSize returns the encoded length of one JSON string including quotes, +// mirroring encoding/json's default HTML escaping. +func jsonStringEncodedSize(s string) int { + size := 2 + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c < 0x20: + if c == '\n' || c == '\r' || c == '\t' { + size += 2 + } else { + size += 6 + } + case c == '"' || c == '\\': + size += 2 + case c == '<' || c == '>' || c == '&': + size += 6 + case c < 0x80: + size++ + case c == 0xe2 && i+2 < len(s) && s[i+1] == 0x80 && (s[i+2] == 0xa8 || s[i+2] == 0xa9): + size += 6 + i += 2 + default: + size++ + } + } + return size +} + +// literalValueKeywords carry client data rather than subschemas. Cleaning and ref inlining must +// copy their values verbatim: keys inside them that happen to look like schema keywords are data. +var literalValueKeywords = map[string]struct{}{ + "default": {}, + "example": {}, + "examples": {}, + "const": {}, + "enum": {}, +} + +func isLiteralValueKeyword(key string) bool { + _, ok := literalValueKeywords[key] + return ok +} + +func resolveLocalRefs(root, value any, active map[string]bool, state *refResolveState, inNameMap, forClean bool) any { switch node := value.(type) { case []any: out := make([]any, len(node)) for i, item := range node { - out[i] = resolveLocalRefs(root, item, active) + out[i] = resolveLocalRefs(root, item, active, state, false, forClean) } return out case map[string]any: - ref, hasRef := node["$ref"].(string) - if hasRef && strings.HasPrefix(ref, "#/") { - if target, ok := resolveJSONPointer(root, ref); ok { - if active[ref] { - return cyclicRefFallback(node, target, ref) - } - active[ref] = true - resolvedTarget := resolveLocalRefs(root, target, active) - delete(active, ref) - if targetMap, okTarget := resolvedTarget.(map[string]any); okTarget { - out := make(map[string]any, len(targetMap)+len(node)) - for key, item := range targetMap { - out[key] = item + if !inNameMap { + ref, hasRef := node["$ref"].(string) + if hasRef && strings.HasPrefix(ref, "#/") { + if target, ok := resolveJSONPointer(root, ref); ok { + if active[ref] { + return resolveRefFallback(root, node, target, ref, active, state, forClean) } - for key, item := range node { - if key == "$ref" { - continue + // Charge the stored target size and let nested references charge their own + // costs during recursion. The total copied material stays bounded, while an + // over-budget chain still expands its affordable outer levels and only the + // deepest references degrade to hints. + if size := targetExpansionSize(target, forClean); size > state.budget { + return resolveRefFallback(root, node, target, ref, active, state, forClean) + } else { + state.budget -= size + } + active[ref] = true + resolvedTarget := resolveLocalRefs(root, target, active, state, false, forClean) + delete(active, ref) + if targetMap, okTarget := resolvedTarget.(map[string]any); okTarget { + siblings := make(map[string]any, len(node)) + for _, key := range sortedKeys(node) { + if key == "$ref" { + continue + } + if forClean && (key == "$defs" || key == "definitions") { + continue + } + siblings[key] = resolveSchemaEntry(root, key, node[key], active, state, forClean) } - out[key] = resolveLocalRefs(root, item, active) + return mergeInlinedRefSchemas(targetMap, siblings) } - return out } } } + out := make(map[string]any, len(node)) + for _, key := range sortedKeys(node) { + if forClean && !inNameMap && (key == "$defs" || key == "definitions") { + continue // dropped with every other definitions container when cleaning + } + out[key] = resolveSchemaEntry(root, key, node[key], active, state, forClean) + } + return out + default: + return value + } +} + +// mergeInlinedRefSchemas keeps both complete schemas when assertions collide. Merging only +// the conflicting keyword would change the scope of properties/additionalProperties and other +// interacting keywords. Annotation siblings may still override the target's annotations. +func mergeInlinedRefSchemas(target, siblings map[string]any) map[string]any { + if len(siblings) > 0 && (target["unevaluatedProperties"] != nil || target["unevaluatedItems"] != nil) { + return conjoinInlinedRefSchemas(target, siblings) + } + // These assertions depend on adjacent keywords in their own schema. Even equal values + // must not inherit property declarations, tuple prefixes, or match counts across a ref. + for _, pair := range [][2]string{ + {"additionalProperties", "properties"}, + {"additionalProperties", "patternProperties"}, + {"additionalProperties", "additionalProperties"}, + {"items", "prefixItems"}, + {"contains", "contains"}, + {"contains", "minContains"}, + {"contains", "maxContains"}, + } { + if (target[pair[0]] != nil && siblings[pair[1]] != nil) || (siblings[pair[0]] != nil && target[pair[1]] != nil) { + return conjoinInlinedRefSchemas(target, siblings) + } + } + out := make(map[string]any, len(target)+len(siblings)) + for key, value := range target { + out[key] = value + } + for key, value := range siblings { + switch key { + case "title", "description", "default", "example", "examples", "$comment", "deprecated", "readOnly", "writeOnly": + // Annotations do not relax validation constraints. + default: + if previous, exists := target[key]; exists && !reflect.DeepEqual(previous, value) { + return conjoinInlinedRefSchemas(target, siblings) + } + } + out[key] = value + } + return out +} + +// conjoinInlinedRefSchemas replaces $ref with an allOf applicator without moving its siblings. +// In particular, sibling unevaluatedProperties/unevaluatedItems must still see annotations +// produced by the reference target, while the target's own evaluation scope stays isolated. +func conjoinInlinedRefSchemas(target, siblings map[string]any) map[string]any { + out := make(map[string]any, len(siblings)+1) + for key, value := range siblings { + out[key] = value + } + branches := []any{target} + if existing, ok := siblings["allOf"].([]any); ok { + branches = append(branches, existing...) + } + out["allOf"] = branches + return out +} + +// resolveRefFallback degrades a reference that cannot be expanded (cycle or exhausted budget) to +// a typed hint, but still resolves the sibling subschemas on the same node so their own references +// are not left dangling behind the degraded one. Literal values stay verbatim through +// resolveSchemaEntry. The description assembled by cyclicRefFallback is kept afterwards so +// resolved siblings cannot clobber the hint. +func resolveRefFallback(root any, node map[string]any, target any, ref string, active map[string]bool, state *refResolveState, forClean bool) map[string]any { + out := cyclicRefFallback(node, target, ref) + // The target description copied into the fallback is expansion output like any other; charge + // it to the budget. Once the budget is spent, fallbacks carry only the short hint, so a large + // number of degraded references cannot multiply copied descriptions without bound. Cyclic + // fallbacks omit over-long descriptions entirely, and omitted content is not charged. + if targetMap, okMap := target.(map[string]any); okMap { + if description, okDescription := targetMap["description"].(string); okDescription && description != "" && len(description) <= maxFallbackDescriptionBytes { + if _, hasOwn := node["description"]; !hasOwn { + cost := jsonStringEncodedSize(description) + if cost > state.budget { + out["description"] = "See: " + refName(ref) + } else { + state.budget -= cost + } + } + } + } + for _, key := range sortedKeys(node) { + if key == "$ref" || key == "description" { + continue + } + if forClean && (key == "$defs" || key == "definitions") { + delete(out, key) + continue + } + out[key] = resolveSchemaEntry(root, key, node[key], active, state, forClean) + } + return out +} + +// resolveSchemaEntry resolves one map entry during ref inlining. A literal-value keyword's value +// is client data and is deep-copied without resolving refs inside it. Under a name map +// ("properties" and friends), however, a key spelled like a literal keyword is an author-chosen +// property name whose value is a subschema; name-map entries are therefore resolved directly +// here so such property names keep their schema semantics. Nested definition containers outside +// the root still resolve through this path; when cleaning, callers skip them entirely instead. +func resolveSchemaEntry(root any, key string, item any, active map[string]bool, state *refResolveState, forClean bool) any { + if isLiteralValueKeyword(key) { + return deepCopyJSONValue(item) + } + if _, isNameMap := schemaNameMapKeywords[key]; isNameMap { + if nameMap, ok := item.(map[string]any); ok { + out := make(map[string]any, len(nameMap)) + for _, name := range sortedKeys(nameMap) { + out[name] = resolveLocalRefs(root, nameMap[name], active, state, false, forClean) + } + return out + } + } + return resolveLocalRefs(root, item, active, state, false, forClean) +} + +func deepCopyJSONValue(value any) any { + switch node := value.(type) { + case []any: + out := make([]any, len(node)) + for i, item := range node { + out[i] = deepCopyJSONValue(item) + } + return out + case map[string]any: out := make(map[string]any, len(node)) for key, item := range node { - out[key] = resolveLocalRefs(root, item, active) + out[key] = deepCopyJSONValue(item) } return out default: @@ -788,6 +1270,11 @@ func cyclicRefFallback(node map[string]any, target any, ref string) map[string]a if targetMap, ok := target.(map[string]any); ok { for _, key := range []string{"type", "nullable", "description"} { if value, exists := targetMap[key]; exists { + if key == "description" { + if description, okDescription := value.(string); okDescription && len(description) > maxFallbackDescriptionBytes { + continue + } + } out[key] = value } } @@ -821,10 +1308,12 @@ func convertRefsToHints(jsonStr string, preserveSiblings bool) string { sortByDepth(paths) for _, p := range paths { + parentPath := trimSuffix(p, ".$ref") + if isPropertyDefinition(parentPath) { + continue // a property literally named "$ref", not a reference keyword + } refVal := gjson.Get(jsonStr, p).String() defName := refName(refVal) - - parentPath := trimSuffix(p, ".$ref") hint := fmt.Sprintf("See: %s", defName) if !preserveSiblings { if existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != "" { @@ -1091,76 +1580,248 @@ func mergeMissingSchemaAtPath(jsonStr, destination string, incoming gjson.Result return jsonStr } -func flattenAnyOfOneOf(jsonStr string) string { - for _, key := range []string{"anyOf", "oneOf"} { - paths := findPaths(jsonStr, key) - sortByDepth(paths) +func isRequiredOnlyUnionBranch(item gjson.Result) bool { + if !item.IsObject() || !item.Get("required").Exists() { + return false + } + for _, key := range []string{"type", "properties", "items", "enum", "anyOf", "oneOf", "allOf"} { + if item.Get(key).Exists() { + return false + } + } + return true +} - for _, p := range paths { - arr := gjson.Get(jsonStr, p) - if !arr.IsArray() || len(arr.Array()) == 0 { - continue +func materializeRequiredOnlyBranch(item, parentProps gjson.Result) (gjson.Result, bool) { + if !parentProps.IsObject() { + return gjson.Result{}, false + } + reqs := getStrings(item.Raw, "required") + if len(reqs) == 0 { + return gjson.Result{}, false + } + raw := item.Raw + for _, reqName := range reqs { + escaped := escapeGJSONPathKey(reqName) + propVal := parentProps.Get(escaped) + if !propVal.Exists() { + return gjson.Result{}, false + } + updated, errSet := sjson.SetRawBytes([]byte(raw), "properties."+escaped, []byte(propVal.Raw)) + if errSet == nil { + raw = string(updated) + } + } + if updated, errSet := sjson.SetBytes([]byte(raw), "type", "object"); errSet == nil { + raw = string(updated) + } + return gjson.Parse(raw), true +} + +func isNullOnlyBranch(item gjson.Result) bool { + if item.Get("properties").Exists() || item.Get("items").Exists() { + return false + } + t := item.Get("type") + if t.Type == gjson.String { + return t.String() == "null" + } + if t.IsArray() && len(t.Array()) > 0 { + for _, elem := range t.Array() { + if elem.String() != "null" { + return false } + } + return true + } + return false +} - parentPath := trimSuffix(p, "."+key) - parent := gjson.Get(jsonStr, parentPath) - if parentPath == "" { - parent = gjson.Parse(jsonStr) +func partitionUnionBranches(items []gjson.Result, parentProps gjson.Result) (nonNullItems []gjson.Result, hasNull, hasFilteredBranch bool) { + for _, item := range items { + if item.Type == gjson.True { + nonNullItems = append(nonNullItems, gjson.Parse("{}")) + hasFilteredBranch = true + continue + } + if !item.IsObject() { + hasFilteredBranch = true + continue + } + if isNullOnlyBranch(item) { + hasNull = true + continue + } + if isRequiredOnlyUnionBranch(item) { + if materialized, ok := materializeRequiredOnlyBranch(item, parentProps); ok { + nonNullItems = append(nonNullItems, materialized) + hasFilteredBranch = true + continue } + hasFilteredBranch = true + continue + } + nonNullItems = append(nonNullItems, item) + } + return +} - items := arr.Array() +func flattenAnyOfOneOf(jsonStr string, preserveOneOf, flattenMultiBranch bool) string { + pathsByField := findPathsByFields(jsonStr, []string{"oneOf", "anyOf"}) + var paths []string + for _, key := range []string{"oneOf", "anyOf"} { + paths = append(paths, pathsByField[key]...) + } + sortByDepth(paths) - // If the parent already defines properties (e.g. an object schema with anyOf/oneOf constraints), - // do not replace the parent with a single branch. Instead, merge any branch properties - // into the parent and delete the union keyword. - if parentProps := parent.Get("properties"); parentProps.IsObject() { - hasNull := false - for _, item := range items { - if item.Get("type").String() == "null" { - hasNull = true - } - if branchProps := item.Get("properties"); branchProps.IsObject() { - branchProps.ForEach(func(propKey, propVal gjson.Result) bool { - destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(propKey.String())) - jsonStr = mergeMissingSchemaAtPath(jsonStr, destPath, propVal) - return true - }) - } + for _, p := range paths { + key := "anyOf" + if p == "oneOf" || strings.HasSuffix(p, ".oneOf") { + key = "oneOf" + } + + arr := gjson.Get(jsonStr, p) + if !arr.IsArray() || len(arr.Array()) == 0 { + continue + } + + parentPath := trimSuffix(p, "."+key) + if isPropertyDefinition(parentPath) { + continue + } + parent := gjson.Get(jsonStr, parentPath) + if parentPath == "" { + parent = gjson.Parse(jsonStr) + } + + // When a node declares both anyOf and oneOf on a native Schema carrier (!preserveOneOf), + // protobuf Schema has only a single anyOf field and cannot represent a second union + // conjunction structurally. Preserve anyOf as the primary union and move the sibling + // oneOf constraint to a description hint instead of concatenating it into anyOf. + // Routes that flatten multi-branch unions skip this shortcut: the primary union is + // about to be flattened away too, and hinting the raw oneOf JSON into the + // description would only add a giant, misleading hint. + if key == "oneOf" && !flattenMultiBranch && !preserveOneOf && parent.Get("anyOf").IsArray() { + jsonStr = appendHint(jsonStr, parentPath, "oneOf: "+arr.Raw) + jsonStr, _ = sjson.Delete(jsonStr, p) + continue + } + + items := arr.Array() + parentProps := parent.Get("properties") + nonNullItems, hasNull, hasFilteredBranch := partitionUnionBranches(items, parentProps) + // An earlier union-preserving pass may already have stripped the null branch and + // recorded it as nullable:true on this node (translator -> executor chain). That + // marker is part of the schema contract, so the flattening below must inherit it. + nullable := hasNull || parent.Get("nullable").Bool() + + // Preserve multi-branch unions (2+ standalone non-null branches) so non-first branches are not lost. + // On native Gemini/Antigravity Schema carriers (!preserveOneOf), normalize oneOf to anyOf because + // the protobuf Schema message supports anyOf but rejects oneOf. + // Routes that reject multi-branch unions upstream (flattenMultiBranch) skip this + // preservation and fall through to the single-strongest-branch flattening below. + if len(nonNullItems) >= 2 && !flattenMultiBranch { + targetKey := key + if key == "oneOf" && !preserveOneOf { + targetKey = "anyOf" + } + targetPath := joinPath(parentPath, targetKey) + + if targetKey != key || hasNull || hasFilteredBranch { + rawBranches := make([]json.RawMessage, 0, len(nonNullItems)) + for _, item := range nonNullItems { + rawBranches = append(rawBranches, json.RawMessage(item.Raw)) } - if hasNull { - updated, _ := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true) + updated, errSet := sjson.SetBytes([]byte(jsonStr), targetPath, rawBranches) + if errSet == nil { jsonStr = string(updated) } - jsonStr, _ = sjson.Delete(jsonStr, p) - continue + if targetKey != key { + jsonStr, _ = sjson.Delete(jsonStr, p) + } + } + if hasNull { + updated, errSet := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true) + if errSet == nil { + jsonStr = string(updated) + } + jsonStr = appendHint(jsonStr, parentPath, "(nullable)") } + continue + } - parentDesc := gjson.Get(jsonStr, descriptionPath(parentPath)).String() - bestIdx, allTypes := selectBest(items) - selected := items[bestIdx].Raw - hasNull := false - for _, item := range items { - if item.Get("type").String() == "null" { - hasNull = true - break + // If the parent already defines properties (e.g. an object schema with unmaterializable + // union constraints or a single-branch union), do not replace the parent with a single branch. + // Instead, merge any branch properties into the parent and delete the union keyword. + if parentProps.IsObject() { + for _, item := range nonNullItems { + if branchProps := item.Get("properties"); branchProps.IsObject() { + branchProps.ForEach(func(propKey, propVal gjson.Result) bool { + destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(propKey.String())) + jsonStr = mergeMissingSchemaAtPath(jsonStr, destPath, propVal) + return true + }) } } - if hasNull && items[bestIdx].Get("type").String() != "null" { - updated, _ := sjson.SetBytes([]byte(selected), "nullable", true) - selected = string(updated) + if nullable { + updated, _ := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true) + jsonStr = string(updated) + } + if parent.Get("type").String() == "" { + updated, _ := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "type"), "object") + jsonStr = string(updated) } + jsonStr, _ = sjson.Delete(jsonStr, p) + continue + } - if parentDesc != "" { + if len(nonNullItems) == 0 { + if !hasNull { + jsonStr, _ = sjson.Delete(jsonStr, p) + continue + } + selected := `{"type":"null"}` + if parentDesc := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); parentDesc != "" { selected = mergeDescriptionRaw(selected, parentDesc) } + jsonStr = setRawAt(jsonStr, parentPath, selected) + continue + } - if len(allTypes) > 1 { - hint := "Accepts: " + strings.Join(allTypes, " | ") - selected = appendHintRaw(selected, hint) + candidateItems := items + if hasFilteredBranch || len(nonNullItems) >= 2 { + candidateItems = nonNullItems + if hasNull { + candidateItems = append(append([]gjson.Result(nil), nonNullItems...), gjson.Parse(`{"type":"null"}`)) } + } - jsonStr = setRawAt(jsonStr, parentPath, selected) + parentDesc := gjson.Get(jsonStr, descriptionPath(parentPath)).String() + _, allTypes := selectBest(candidateItems) + // Select the strongest non-null branch (ties keep the first one), matching the + // pre-preservation flattening behavior. selectBest already treats union-bearing + // branches as score-0, so a nested union never wins over a null-free scalar here. + selected := nonNullItems[0].Raw + if bestIdx, _ := selectBest(nonNullItems); bestIdx >= 0 && bestIdx < len(nonNullItems) { + selected = nonNullItems[bestIdx].Raw } + if nullable { + updated, _ := sjson.SetBytes([]byte(selected), "nullable", true) + selected = string(updated) + } + + if parentDesc != "" { + selected = mergeDescriptionRaw(selected, parentDesc) + } + + if len(allTypes) > 1 { + hint := "Accepts: " + strings.Join(allTypes, " | ") + selected = appendHintRaw(selected, hint) + } else if nullable { + selected = appendHintRaw(selected, "(nullable)") + } + + jsonStr = setRawAt(jsonStr, parentPath, selected) } return jsonStr } @@ -1223,6 +1884,8 @@ func flattenTypeArrays(jsonStr string, preserveNativeNullable bool) string { if len(nonNullTypes) > 0 { if gjson.Get(jsonStr, joinPath(parentPath, "items")).Exists() && contains(nonNullTypes, "array") { firstType = "array" + } else if gjson.Get(jsonStr, joinPath(parentPath, "properties")).Exists() && contains(nonNullTypes, "object") { + firstType = "object" } else { firstType = nonNullTypes[0] } @@ -1286,9 +1949,10 @@ func removeUnsupportedKeywords(jsonStr string, options jsonSchemaCleanOptions) s "$schema", "$defs", "definitions", "const", "$ref", "$id", "id", "additionalProperties", "$anchor", "$vocabulary", "$dynamicRef", "$dynamicAnchor", "propertyNames", "patternProperties", // Gemini doesn't support these schema keywords + "dependentRequired", "dependentSchemas", "dependencies", "if", "then", "else", - "$comment", "enumDescriptions", "enumTitles", "prefill", "deprecated", "encrypted", // Schema metadata fields unsupported by Gemini - "additionalItems", "unevaluatedProperties", "unevaluatedItems", "contentSchema", + "$comment", "enumDescriptions", "enumTitles", "prefill", "deprecated", "encrypted", "discriminator", "readOnly", "writeOnly", // Schema metadata fields unsupported by Gemini + "additionalItems", "unevaluatedProperties", "unevaluatedItems", "contentSchema", "contentMediaType", "contentEncoding", ) if options.antigravitySemantics { keywords = append(keywords, "not") @@ -1358,12 +2022,26 @@ func walkForExtensions(value gjson.Result, path string, paths *[]string) { return true } + // Literal values are client data; x-* keys inside them are not schema extensions. + if isLiteralValueKeyword(keyStr) && !isPropertyDefinition(path) { + return true + } + walkForExtensions(val, childPath, paths) return true }) } } +func hasSchemaConjunction(jsonStr string) bool { + for _, path := range findPaths(jsonStr, "allOf") { + if gjson.Get(jsonStr, path).IsArray() { + return true + } + } + return false +} + func cleanupRequiredFields(jsonStr string) string { for _, p := range findPaths(jsonStr, "required") { parentPath := trimSuffix(p, ".required") @@ -1423,6 +2101,9 @@ func addEmptySchemaPlaceholder(jsonStr string) string { reqPath := joinPath(parentPath, "required") reqVal := gjson.Get(jsonStr, reqPath) hasRequiredProperties := reqVal.IsArray() && len(reqVal.Array()) > 0 + if gjson.Get(jsonStr, joinPath(parentPath, "anyOf")).IsArray() || gjson.Get(jsonStr, joinPath(parentPath, "oneOf")).IsArray() { + continue + } needsPlaceholder := false if !propsVal.Exists() { @@ -1471,7 +2152,7 @@ func addEmptySchemaPlaceholder(jsonStr string) string { func findPaths(jsonStr, field string) []string { var paths []string - Walk(gjson.Parse(jsonStr), "", field, &paths) + WalkSchema(gjson.Parse(jsonStr), "", field, &paths) return paths } @@ -1503,6 +2184,12 @@ func walkForFields(value gjson.Result, path string, fields map[string]struct{}, paths[keyStr] = append(paths[keyStr], childPath) } + // Literal values are client data; schema-looking keys inside them are not keywords. + // Under a name map the key is a property name instead, so keep descending there. + if isLiteralValueKeyword(keyStr) && !isPropertyDefinition(path) { + return true + } + walkForFields(val, childPath, fields, paths) return true }) @@ -1540,11 +2227,14 @@ func setRawAt(jsonStr, path, value string) string { } // schemaNameMapKeywords are the schema keywords whose value maps author-chosen names to -// subschemas. A key directly under one of them is a name, never a schema keyword. +// subschemas. A key directly under one of them is a name, never a schema keyword. The Draft-07 +// "dependencies" container qualifies for object (schema) values; array values are property-name +// lists and are never walked as maps by the checks that use this set. var schemaNameMapKeywords = map[string]struct{}{ "properties": {}, "patternProperties": {}, "dependentSchemas": {}, + "dependencies": {}, "$defs": {}, "definitions": {}, } diff --git a/internal/util/github.go b/internal/util/github.go index 17f57cefe..0f05146d8 100644 --- a/internal/util/github.go +++ b/internal/util/github.go @@ -1,25 +1,12 @@ package util -import ( - "os" - "strings" -) +import "github.com/router-for-me/CLIProxyAPI/v8/internal/githubauth" -// ResolveGitHubToken returns the configured GitHub API token in priority order: -// 1. GITHUB_TOKEN -// 2. github_token -// 3. GITSTORE_GIT_TOKEN (only if GITSTORE_GIT_URL points to github.com) +// ResolveGitHubToken returns the GitHub API token in priority order: +// 1. server.github-token +// 2. GITHUB_TOKEN +// 3. github_token +// 4. GITSTORE_GIT_TOKEN (only if GITSTORE_GIT_URL points to github.com) func ResolveGitHubToken() string { - for _, name := range []string{"GITHUB_TOKEN", "github_token"} { - if token := strings.TrimSpace(os.Getenv(name)); token != "" { - return token - } - } - - gitURL := strings.ToLower(strings.TrimSpace(os.Getenv("GITSTORE_GIT_URL"))) - if !strings.Contains(gitURL, "github.com") { - return "" - } - - return strings.TrimSpace(os.Getenv("GITSTORE_GIT_TOKEN")) + return githubauth.ResolveToken() } diff --git a/internal/util/header_helpers.go b/internal/util/header_helpers.go index a16978360..7457e8209 100644 --- a/internal/util/header_helpers.go +++ b/internal/util/header_helpers.go @@ -43,6 +43,24 @@ func HasExplicitSessionID(ctx context.Context) bool { // SessionIDResolver is a pluggable resolver for extracting the internal session ID. var SessionIDResolver func(ctx context.Context, clientHeaders http.Header) string +// ResolveClientHeaders returns the client request headers from the provided slice +// or falls back to the Gin request headers in r.Context() when available. +func ResolveClientHeaders(r *http.Request, clientHeaders ...http.Header) http.Header { + if len(clientHeaders) > 0 && clientHeaders[0] != nil { + return clientHeaders[0] + } + if r != nil && r.Context() != nil { + ctx := r.Context() + if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { + return ginCtx.Request.Header + } + if ginCtx, ok := ctx.(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { + return ginCtx.Request.Header + } + } + return nil +} + // ApplyCustomHeadersFromAttrs applies user-defined headers stored in the provided attributes map. // Custom headers override built-in defaults when conflicts occur. // If clientHeaders is provided (or if the request context carries a Gin context), any custom header @@ -55,20 +73,11 @@ func ApplyCustomHeadersFromAttrs(r *http.Request, attrs map[string]string, clien if r == nil { return } - var ch http.Header var ctx context.Context if r.Context() != nil { ctx = r.Context() } - if len(clientHeaders) > 0 && clientHeaders[0] != nil { - ch = clientHeaders[0] - } else if ctx != nil { - if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { - ch = ginCtx.Request.Header - } else if ginCtx, ok := ctx.(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { - ch = ginCtx.Request.Header - } - } + ch := ResolveClientHeaders(r, clientHeaders...) applyCustomHeaders(r, extractCustomHeaders(attrs, ch, ctx)) } diff --git a/internal/util/provider.go b/internal/util/provider.go index e49992971..810a9cb2d 100644 --- a/internal/util/provider.go +++ b/internal/util/provider.go @@ -283,7 +283,9 @@ func shouldMaskQueryParam(key string) bool { return false } key = strings.TrimSuffix(key, "[]") - if key == "key" || strings.Contains(key, "api-key") || strings.Contains(key, "apikey") || strings.Contains(key, "api_key") { + // The management API deletes a client API key by ?value=, so a plain + // "value" parameter can carry a full secret into the request log. + if key == "key" || key == "value" || strings.Contains(key, "api-key") || strings.Contains(key, "apikey") || strings.Contains(key, "api_key") { return true } if strings.Contains(key, "token") || strings.Contains(key, "secret") { diff --git a/internal/util/responses_tools.go b/internal/util/responses_tools.go index a8c95e88c..b2c7dafca 100644 --- a/internal/util/responses_tools.go +++ b/internal/util/responses_tools.go @@ -18,8 +18,31 @@ type ResponsesToolIdentity struct { Namespace string Custom bool ApplyPatch bool // Resolved from the winning original declaration, never from the upstream name. + ToolSearch bool // Marks the function shim standing in for a client-executed tool_search declaration. } +const ( + // ResponsesToolSearchType is the Responses tool type for deferred tool discovery. + ResponsesToolSearchType = "tool_search" + // ResponsesToolSearchExecutionClient marks a tool_search that the client executes itself. + ResponsesToolSearchExecutionClient = "client" + // ResponsesToolSearchFunctionName is the function name exposed to providers + // that have no native tool_search support. + ResponsesToolSearchFunctionName = "tool_search" + // ResponsesClientToolSearchKey is the internal forwardMap key for the + // client-executed tool_search shim. The leading NUL byte cannot appear in a + // JSON identifier, preventing collisions with user-declared tool names. + ResponsesClientToolSearchKey = "\x00client_tool_search" + // ResponsesToolSearchCallType is the Responses item type for a tool_search invocation. + ResponsesToolSearchCallType = "tool_search_call" + // ResponsesToolSearchOutputType is the Responses input item carrying discovered tools. + ResponsesToolSearchOutputType = "tool_search_output" + // ResponsesDiscoveredToolPriority ranks tools loaded by tool_search_output after declared tools. + ResponsesDiscoveredToolPriority = 2 + + responsesToolSearchDefaultParameters = `{"type":"object","properties":{"query":{"type":"string","description":"Search query for deferred tools."},"limit":{"type":"number","description":"Maximum number of tools to return."}},"required":["query"],"additionalProperties":false}` +) + // ResponsesToolDescriptor is an internal representation of a tool declaration in a Responses request. type ResponsesToolDescriptor struct { Name string // Qualified name (e.g. "functions__exec" or "exec") @@ -27,9 +50,10 @@ type ResponsesToolDescriptor struct { Namespace string // Namespace if any (e.g. "functions") ToolType string // "function", "custom", etc. Tool gjson.Result - SourcePriority int // 0 for top-level tools, 1 for additional_tools + SourcePriority int // 0 for top-level tools, 1 for additional_tools, 2 for tool_search_output Direct bool // true if declared directly, false if declared as namespace child Order int // original discovery order + ClientSearch bool // true for a client-executed tool_search declaration } // QualifyResponsesNamespaceToolName qualifies a child tool name with its namespace. @@ -48,7 +72,7 @@ func QualifyResponsesNamespaceToolName(namespaceName, childName string) string { return namespaceName + "__" + childName } -func responsesToolSources(root gjson.Result) []struct { +func responsesToolSources(root gjson.Result, includeClientToolSearch bool) []struct { tools gjson.Result priority int } { @@ -72,10 +96,33 @@ func responsesToolSources(root gjson.Result) []struct { } return true }) + if includeClientToolSearch { + input.ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == ResponsesToolSearchOutputType && IsResponsesClientToolSearchItem(item) { + appendSource(item.Get("tools"), ResponsesDiscoveredToolPriority) + } + return true + }) + } } return sources } +// IsResponsesClientToolSearchDeclaration reports whether a tool declaration is +// a client-executed Responses tool_search. +func IsResponsesClientToolSearchDeclaration(tool gjson.Result) bool { + return strings.TrimSpace(tool.Get("type").String()) == ResponsesToolSearchType && + strings.TrimSpace(tool.Get("execution").String()) == ResponsesToolSearchExecutionClient +} + +// IsResponsesClientToolSearchItem reports whether a tool_search_call or +// tool_search_output input item belongs to client-executed tool search. +// Hosted server search explicitly sets execution to "server". +func IsResponsesClientToolSearchItem(item gjson.Result) bool { + execution := strings.TrimSpace(item.Get("execution").String()) + return execution == "" || execution == ResponsesToolSearchExecutionClient +} + func responsesToolName(tool gjson.Result) string { if name := strings.TrimSpace(tool.Get("name").String()); name != "" { return name @@ -117,8 +164,12 @@ func responsesToolParameters(tool gjson.Result) gjson.Result { // CollectResponsesToolDescriptors extracts all tool descriptors from a Responses request root. func CollectResponsesToolDescriptors(root gjson.Result) []ResponsesToolDescriptor { + return collectResponsesToolDescriptors(root, false) +} + +func collectResponsesToolDescriptors(root gjson.Result, includeClientToolSearch bool) []ResponsesToolDescriptor { var descriptors []ResponsesToolDescriptor - appendDescriptor := func(tool gjson.Result, name, localName, namespace string, toolType string, sourcePriority int, direct bool) { + appendDescriptor := func(tool gjson.Result, name, localName, namespace string, toolType string, sourcePriority int, direct bool, clientSearch bool) { if name == "" { return } @@ -131,6 +182,7 @@ func CollectResponsesToolDescriptors(root gjson.Result) []ResponsesToolDescripto SourcePriority: sourcePriority, Direct: direct, Order: len(descriptors), + ClientSearch: clientSearch, }) } appendNamespaceChildren := func(namespaceTool gjson.Result, sourcePriority int) { @@ -150,30 +202,55 @@ func CollectResponsesToolDescriptors(root gjson.Result) []ResponsesToolDescripto qualifiedName := QualifyResponsesNamespaceToolName(namespaceName, childName) switch strings.TrimSpace(child.Get("type").String()) { case "", "function": - appendDescriptor(child, qualifiedName, childName, namespaceName, "function", sourcePriority, false) + appendDescriptor(child, qualifiedName, childName, namespaceName, "function", sourcePriority, false, false) case "custom": - appendDescriptor(child, qualifiedName, childName, namespaceName, "custom", sourcePriority, false) + appendDescriptor(child, qualifiedName, childName, namespaceName, "custom", sourcePriority, false, false) } return true }) } - for _, source := range responsesToolSources(root) { + for _, source := range responsesToolSources(root, includeClientToolSearch) { source.tools.ForEach(func(_, tool gjson.Result) bool { toolType := strings.TrimSpace(tool.Get("type").String()) switch toolType { case "", "function": name := responsesToolName(tool) - appendDescriptor(tool, name, name, "", "function", source.priority, true) + appendDescriptor(tool, name, name, "", "function", source.priority, true, false) case "custom": name := responsesToolName(tool) - appendDescriptor(tool, name, name, "", "custom", source.priority, true) + appendDescriptor(tool, name, name, "", "custom", source.priority, true, false) case "namespace": appendNamespaceChildren(tool, source.priority) + case ResponsesToolSearchType: + if includeClientToolSearch && source.priority < ResponsesDiscoveredToolPriority && IsResponsesClientToolSearchDeclaration(tool) { + appendDescriptor(tool, ResponsesToolSearchFunctionName, ResponsesToolSearchFunctionName, "", ResponsesToolSearchType, source.priority, true, true) + } } return true }) } - return descriptors + + if !includeClientToolSearch || !responsesHasClientToolSearch(descriptors) { + return descriptors + } + kept := make([]ResponsesToolDescriptor, 0, len(descriptors)) + for _, descriptor := range descriptors { + if !descriptor.ClientSearch && descriptor.SourcePriority != ResponsesDiscoveredToolPriority && descriptor.Tool.Get("defer_loading").Bool() { + continue + } + descriptor.Order = len(kept) + kept = append(kept, descriptor) + } + return kept +} + +func responsesHasClientToolSearch(descriptors []ResponsesToolDescriptor) bool { + for i := range descriptors { + if descriptors[i].ClientSearch { + return true + } + } + return false } func responsesToolDescriptorPrecedes(left, right ResponsesToolDescriptor) bool { @@ -188,8 +265,12 @@ func responsesToolDescriptorPrecedes(left, right ResponsesToolDescriptor) bool { // CollectResponsesToolWinners collects deduplicated winning descriptors for each qualified tool name. func CollectResponsesToolWinners(root gjson.Result) map[string]ResponsesToolDescriptor { + return collectResponsesToolWinnersFromDescriptors(CollectResponsesToolDescriptors(root)) +} + +func collectResponsesToolWinnersFromDescriptors(descriptors []ResponsesToolDescriptor) map[string]ResponsesToolDescriptor { winners := map[string]ResponsesToolDescriptor{} - for _, descriptor := range CollectResponsesToolDescriptors(root) { + for _, descriptor := range descriptors { current, exists := winners[descriptor.Name] if !exists || responsesToolDescriptorPrecedes(descriptor, current) { winners[descriptor.Name] = descriptor @@ -251,64 +332,159 @@ func disambiguateResponsesSanitizedName(base, original string, used map[string]s } } +type responsesToolWinnerKey struct { + name string + clientSearch bool +} + // BuildGeminiFunctionDeclarations builds Gemini function declarations, forward name mapping, and reverse identity mapping. func BuildGeminiFunctionDeclarations(root gjson.Result) ([][]byte, map[string]string, map[string]ResponsesToolIdentity) { - descriptors := CollectResponsesToolDescriptors(root) - winners := CollectResponsesToolWinners(root) + return buildGeminiFunctionDeclarations(root, true) +} - seenNames := make(map[string]struct{}) +// buildGeminiFunctionDeclarations skips per-tool declaration construction and schema +// cleanup when the caller only needs the name and identity maps. +func buildGeminiFunctionDeclarations(root gjson.Result, withDeclarations bool) ([][]byte, map[string]string, map[string]ResponsesToolIdentity) { + descriptors := collectResponsesToolDescriptors(root, true) + winners := make(map[responsesToolWinnerKey]ResponsesToolDescriptor, len(descriptors)) + for _, descriptor := range descriptors { + key := responsesToolWinnerKey{name: descriptor.Name, clientSearch: descriptor.ClientSearch} + current, exists := winners[key] + if !exists || responsesToolDescriptorPrecedes(descriptor, current) { + winners[key] = descriptor + } + } + + seenKeys := make(map[responsesToolWinnerKey]struct{}, len(winners)) var winningList []ResponsesToolDescriptor for _, descriptor := range descriptors { - winner, ok := winners[descriptor.Name] + key := responsesToolWinnerKey{name: descriptor.Name, clientSearch: descriptor.ClientSearch} + winner, ok := winners[key] if !ok || winner.Order != descriptor.Order { continue } - if _, seen := seenNames[descriptor.Name]; seen { + if _, seen := seenKeys[key]; seen { continue } - seenNames[descriptor.Name] = struct{}{} + seenKeys[key] = struct{}{} winningList = append(winningList, descriptor) } + hasClientSearchHistory := false + if include := root.Get("input"); include.Exists() && include.IsArray() { + include.ForEach(func(_, item gjson.Result) bool { + typ := item.Get("type").String() + if (typ == ResponsesToolSearchCallType || typ == ResponsesToolSearchOutputType) && IsResponsesClientToolSearchItem(item) { + hasClientSearchHistory = true + return false + } + return true + }) + } + if len(winningList) == 0 { - return nil, nil, nil + if !hasClientSearchHistory { + return nil, nil, nil + } + forwardMap := map[string]string{ + ResponsesClientToolSearchKey: ResponsesToolSearchFunctionName, + ResponsesToolSearchFunctionName: ResponsesToolSearchFunctionName, + } + reverseMap := map[string]ResponsesToolIdentity{ + ResponsesToolSearchFunctionName: { + Name: ResponsesToolSearchFunctionName, + ToolSearch: true, + }, + } + return nil, forwardMap, reverseMap } + hasClientSearch := false + userOwnsExactToolSearch := false qualifiedNames := make([]string, 0, len(winningList)) for _, desc := range winningList { + if desc.ClientSearch { + hasClientSearch = true + continue + } + if desc.Name == ResponsesToolSearchFunctionName { + userOwnsExactToolSearch = true + } qualifiedNames = append(qualifiedNames, desc.Name) } sanitizedMap := sanitizeResponsesToolNames(qualifiedNames) + if sanitizedMap == nil { + sanitizedMap = make(map[string]string, len(winningList)) + } + + clientSearchGeminiName := "" + if hasClientSearch || hasClientSearchHistory { + used := make(map[string]string, len(sanitizedMap)+1) + for origName, mappedName := range sanitizedMap { + used[mappedName] = origName + } + if !userOwnsExactToolSearch { + // Reserve "tool_search" for the client tool_search shim and + // disambiguate any non-exact tool (e.g. "tool.search") that sanitized to it. + if collidingOrig, taken := used[ResponsesToolSearchFunctionName]; taken && collidingOrig != ResponsesToolSearchFunctionName { + used[ResponsesToolSearchFunctionName] = ResponsesClientToolSearchKey + newMapped := disambiguateResponsesSanitizedName(ResponsesToolSearchFunctionName, collidingOrig, used) + sanitizedMap[collidingOrig] = newMapped + used[newMapped] = collidingOrig + } else { + used[ResponsesToolSearchFunctionName] = ResponsesClientToolSearchKey + } + clientSearchGeminiName = ResponsesToolSearchFunctionName + } else { + clientSearchGeminiName = disambiguateResponsesSanitizedName(ResponsesToolSearchFunctionName, ResponsesClientToolSearchKey, used) + used[clientSearchGeminiName] = ResponsesClientToolSearchKey + } + } - forwardMap := make(map[string]string, len(winningList)*2) - reverseMap := make(map[string]ResponsesToolIdentity, len(winningList)*2) + forwardMap := make(map[string]string, len(winningList)*2+1) + reverseMap := make(map[string]ResponsesToolIdentity, len(winningList)*2+1) + if clientSearchGeminiName != "" { + forwardMap[ResponsesClientToolSearchKey] = clientSearchGeminiName + forwardMap[clientSearchGeminiName] = clientSearchGeminiName + if !userOwnsExactToolSearch { + forwardMap[ResponsesToolSearchFunctionName] = clientSearchGeminiName + } + reverseMap[clientSearchGeminiName] = ResponsesToolIdentity{ + Name: ResponsesToolSearchFunctionName, + ToolSearch: true, + } + } var declarations [][]byte for _, desc := range winningList { geminiName := desc.Name - if mapped, ok := sanitizedMap[desc.Name]; ok && mapped != "" { + if desc.ClientSearch { + geminiName = clientSearchGeminiName + } else if mapped, ok := sanitizedMap[desc.Name]; ok && mapped != "" { geminiName = mapped } else { geminiName = SanitizeFunctionName(desc.Name) } - forwardMap[desc.Name] = geminiName - if desc.LocalName != "" && desc.LocalName != desc.Name { - if _, exists := forwardMap[desc.LocalName]; !exists { - forwardMap[desc.LocalName] = geminiName - } + if !desc.ClientSearch { + forwardMap[desc.Name] = geminiName } + forwardMap[geminiName] = geminiName identity := ResponsesToolIdentity{ Name: desc.LocalName, Namespace: desc.Namespace, Custom: desc.ToolType == "custom", ApplyPatch: applypatch.IsCustomTool(desc.Tool), + ToolSearch: desc.ClientSearch, } reverseMap[geminiName] = identity - if desc.Name != geminiName { + if !desc.ClientSearch && desc.Name != geminiName { reverseMap[desc.Name] = identity } + if !withDeclarations { + continue + } funcDecl := []byte(`{"name":"","description":"","parametersJsonSchema":{}}`) funcDecl, _ = sjson.SetBytes(funcDecl, "name", geminiName) @@ -321,6 +497,8 @@ func BuildGeminiFunctionDeclarations(root gjson.Result) ([][]byte, map[string]st funcDecl, _ = sjson.SetRawBytes(funcDecl, "parametersJsonSchema", applypatch.Parameters()) } else if desc.ToolType == "custom" { funcDecl, _ = sjson.SetRawBytes(funcDecl, "parametersJsonSchema", []byte(`{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}`)) + } else if desc.ClientSearch { + funcDecl, _ = sjson.SetRawBytes(funcDecl, "parametersJsonSchema", buildGeminiToolSearchParametersSchema(desc.Tool)) } else { params := responsesToolParameters(desc.Tool) if params.Exists() { @@ -330,9 +508,29 @@ func BuildGeminiFunctionDeclarations(root gjson.Result) ([][]byte, map[string]st declarations = append(declarations, funcDecl) } + // Populate local-name aliases after all direct/qualified names are registered + // so a namespace child's local name never shadows a direct tool or the search shim. + for _, desc := range winningList { + if !desc.ClientSearch && desc.LocalName != "" && desc.LocalName != desc.Name { + if _, exists := forwardMap[desc.LocalName]; !exists { + forwardMap[desc.LocalName] = forwardMap[desc.Name] + } + } + } + return declarations, forwardMap, reverseMap } +func buildGeminiToolSearchParametersSchema(tool gjson.Result) []byte { + if params := responsesToolParameters(tool); params.Exists() && params.IsObject() { + cleaned := CleanJSONSchemaForGeminiJSONSchema(params.Raw) + if gjson.Valid(cleaned) && gjson.Parse(cleaned).IsObject() { + return []byte(cleaned) + } + } + return []byte(responsesToolSearchDefaultParameters) +} + // ResponsesToolReverseIdentityMap builds a Gemini function name -> ResponsesToolIdentity map from a Responses request raw JSON. func ResponsesToolReverseIdentityMap(rawJSON []byte) map[string]ResponsesToolIdentity { if len(rawJSON) == 0 || !gjson.ValidBytes(rawJSON) { @@ -342,7 +540,7 @@ func ResponsesToolReverseIdentityMap(rawJSON []byte) map[string]ResponsesToolIde if req := root.Get("request"); req.Exists() && (req.Get("model").Exists() || req.Get("input").Exists() || req.Get("tools").Exists()) { root = req } - _, _, reverseMap := BuildGeminiFunctionDeclarations(root) + _, _, reverseMap := buildGeminiFunctionDeclarations(root, false) return reverseMap } @@ -354,6 +552,15 @@ func MapResponsesToolName(forwardMap map[string]string, name string) string { return SanitizeFunctionName(name) } +// MapResponsesClientToolSearchName returns the mapped Gemini function name for +// the client-executed tool_search shim. +func MapResponsesClientToolSearchName(forwardMap map[string]string) string { + if mapped, ok := forwardMap[ResponsesClientToolSearchKey]; ok && mapped != "" { + return mapped + } + return ResponsesToolSearchFunctionName +} + // ConvertResponsesToolChoiceToGemini translates Responses tool_choice into Gemini functionCallingConfig JSON. func ConvertResponsesToolChoiceToGemini(toolChoice gjson.Result, forwardMap map[string]string) ([]byte, bool) { if !toolChoice.Exists() { @@ -379,6 +586,9 @@ func ConvertResponsesToolChoiceToGemini(toolChoice gjson.Result, forwardMap map[ mode = "AUTO" case "required", "any": mode = "ANY" + case ResponsesToolSearchType: + mode = "ANY" + allowedNames = append(allowedNames, MapResponsesClientToolSearchName(forwardMap)) case "function", "custom", "tool", "": mode = "ANY" name := strings.TrimSpace(toolChoice.Get("name").String()) diff --git a/internal/util/translator.go b/internal/util/translator.go index 42596c1c2..d60591823 100644 --- a/internal/util/translator.go +++ b/internal/util/translator.go @@ -9,6 +9,7 @@ import ( "encoding/hex" "fmt" "sort" + "strconv" "strings" log "github.com/sirupsen/logrus" @@ -28,6 +29,19 @@ import ( // The function works recursively, building dot-notation paths to each occurrence // of the specified field throughout the JSON structure. func Walk(value gjson.Result, path, field string, paths *[]string) { + walk(value, path, field, paths, false) +} + +// WalkSchema is Walk for JSON Schema documents: it records the field wherever a schema keyword +// may appear, but does not descend into literal values (default, example, examples, const, enum), +// whose contents are client data rather than subschemas. The literal keyword itself is still +// recorded when it is the searched field, and under a property-name map a key spelled like one is +// a property name whose subschema is still walked. +func WalkSchema(value gjson.Result, path, field string, paths *[]string) { + walk(value, path, field, paths, true) +} + +func walk(value gjson.Result, path, field string, paths *[]string, schemaAware bool) { switch value.Type { case gjson.JSON: // For JSON objects and arrays, iterate through each child @@ -48,7 +62,10 @@ func Walk(value gjson.Result, path, field string, paths *[]string) { if keyStr == field { *paths = append(*paths, childPath) } - Walk(val, childPath, field, paths) + if schemaAware && isLiteralValueKeyword(keyStr) && !isPropertyDefinition(path) { + return true + } + walk(val, childPath, field, paths, schemaAware) return true }) case gjson.String, gjson.Number, gjson.True, gjson.False, gjson.Null: @@ -234,6 +251,19 @@ func CanonicalToolName(name string) string { // ToolNameMapFromClaudeRequest returns a canonical-name -> original-name map extracted from a Claude request. // It is used to restore exact tool name casing for clients that require strict tool name matching (e.g. Claude Code). func ToolNameMapFromClaudeRequest(rawJSON []byte) map[string]string { + return toolNameMapFromClaudeRequest(rawJSON, false) +} + +// UnambiguousToolNameMapFromClaudeRequest precomputes the legacy Claude mapper's +// collision policy so response chunks only need MapToolName, not the request body. +// Ambiguous canonical keys are stored with an empty value, so the returned map is +// only meaningful with MapToolName (which keeps the upstream name for empty +// values); do not index it directly. +func UnambiguousToolNameMapFromClaudeRequest(rawJSON []byte) map[string]string { + return toolNameMapFromClaudeRequest(rawJSON, true) +} + +func toolNameMapFromClaudeRequest(rawJSON []byte, rejectAmbiguous bool) map[string]string { if len(rawJSON) == 0 || !gjson.ValidBytes(rawJSON) { return nil } @@ -257,8 +287,12 @@ func ToolNameMapFromClaudeRequest(rawJSON []byte) map[string]string { if key == "" { return true } - if _, exists := out[key]; !exists { + if previous, exists := out[key]; !exists { out[key] = name + } else if rejectAmbiguous && previous != name { + // An empty value preserves the upstream name for every spelling of + // an ambiguous canonical key, including exact declared names. + out[key] = "" } return true }) @@ -279,6 +313,41 @@ func MapToolName(toolNameMap map[string]string, name string) string { return name } +// MapClaudeToolName restores the client-facing tool name while preferring an exact +// declared name over canonical (case/leading-underscore-insensitive) restoration. +// It re-scans the request body on every call; hot paths should precompute with +// UnambiguousToolNameMapFromClaudeRequest and use MapToolName instead. +// This prevents distinct tools such as "Read" and "read" from being collapsed to +// the first declaration when the upstream already returned an exact name. +func MapClaudeToolName(toolNameMap map[string]string, originalRequestRawJSON []byte, name string) string { + if name == "" { + return name + } + if len(originalRequestRawJSON) > 0 && gjson.ValidBytes(originalRequestRawJSON) { + tools := gjson.GetBytes(originalRequestRawJSON, "tools") + if tools.IsArray() { + canonical := CanonicalToolName(name) + canonicalNames := map[string]bool{} + for _, tool := range tools.Array() { + declared := strings.TrimSpace(tool.Get("name").String()) + if declared == "" { + declared = strings.TrimSpace(tool.Get("function.name").String()) + } + if declared == name { + return name + } + if declared != "" && CanonicalToolName(declared) == canonical { + canonicalNames[declared] = true + } + } + if len(canonicalNames) > 1 { + return name + } + } + } + return MapToolName(toolNameMap, name) +} + // SanitizedFunctionNameMap builds an original-name → sanitized-name map from request tools. // Exact duplicate names share a mapping. Distinct names that sanitize to the same value receive // deterministic hash suffixes so every declaration remains addressable within the 64-byte limit. @@ -494,3 +563,1249 @@ func RestoreSanitizedToolName(toolNameMap map[string]string, sanitizedName strin } return sanitizedName } + +type claudeObjectSchemaInfo struct { + properties map[string][]gjson.Result + required map[string]bool + hasProperties bool + allowsDynamic bool + forbidsDynamic bool + hasPatternProperties bool + complex bool + unsafeComplex bool + declaresObject bool + branchInfos []claudeObjectSchemaInfo +} + +func claudeSchemaAllowsDynamic(schema gjson.Result) bool { + // patternProperties permit matching keys even when additionalProperties is false + // (the latter only restricts keys matching no pattern), so pattern permission is + // never cancelled by an additionalProperties: false sibling. + if schema.Get("patternProperties").IsObject() && len(schema.Get("patternProperties").Map()) > 0 { + return true + } + additionalProperties := schema.Get("additionalProperties") + if additionalProperties.Exists() && additionalProperties.Type != gjson.False { + return true + } + return false +} + +func claudeSchemaHasPatternProperties(schema gjson.Result) bool { + return schema.Get("patternProperties").IsObject() && len(schema.Get("patternProperties").Map()) > 0 +} + +func claudeSchemaForbidsDynamic(schema gjson.Result) bool { + additionalProperties := schema.Get("additionalProperties") + return additionalProperties.Exists() && additionalProperties.Type == gjson.False +} + +func claudeResolveLocalRef(root gjson.Result, ref string) (gjson.Result, bool) { + if !strings.HasPrefix(ref, "#/") { + return gjson.Result{}, false + } + current := root + for _, segment := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") { + segment = strings.ReplaceAll(strings.ReplaceAll(segment, "~1", "/"), "~0", "~") + next, ok := current.Map()[segment] + if !ok { + if current.IsArray() { + index, errAtoi := strconv.Atoi(segment) + if errAtoi != nil || index < 0 || index >= len(current.Array()) { + return gjson.Result{}, false + } + next = current.Array()[index] + } else { + return gjson.Result{}, false + } + } + current = next + } + return current, true +} + +func analyzeClaudeObjectSchema(schema gjson.Result, root gjson.Result) claudeObjectSchemaInfo { + return analyzeClaudeObjectSchemaDepth(schema, root, 0, map[string]bool{}) +} + +func claudeSchemaDeclaresObject(schema gjson.Result) bool { + typeValue := schema.Get("type") + if typeValue.String() == "object" { + return true + } + if typeValue.IsArray() { + for _, item := range typeValue.Array() { + if item.String() == "object" { + return true + } + } + } + return false +} + +func claudeUnionBranches(schema gjson.Result) ([]gjson.Result, bool) { + var branches []gjson.Result + seen := map[string]bool{} + for _, keyword := range []string{"anyOf", "oneOf"} { + union := schema.Get(keyword) + if !union.Exists() { + continue + } + if !union.IsArray() { + return nil, false + } + for _, branch := range union.Array() { + branchType := branch.Get("type") + if branchType.String() == "null" { + continue + } + if branchType.IsArray() && len(branchType.Array()) == 1 && branchType.Array()[0].String() == "null" { + continue + } + if seen[branch.Raw] { + continue + } + seen[branch.Raw] = true + branches = append(branches, branch) + } + } + return branches, true +} + +var claudeAnalyzedSchemaKeywords = map[string]bool{ + "type": true, "description": true, "title": true, "properties": true, + "required": true, "items": true, "additionalProperties": true, + "patternProperties": true, "anyOf": true, "oneOf": true, "allOf": true, + "$ref": true, "$defs": true, "definitions": true, "nullable": true, + "minLength": true, "maxLength": true, "minimum": true, "maximum": true, + "exclusiveMinimum": true, "exclusiveMaximum": true, "multipleOf": true, + "pattern": true, "format": true, "default": true, "examples": true, + "minItems": true, "maxItems": true, "maxProperties": true, + "$schema": true, "$id": true, "id": true, + "$comment": true, "deprecated": true, "readOnly": true, "writeOnly": true, + "example": true, "contentEncoding": true, "contentMediaType": true, +} + +func claudeSchemaHasUnanalyzedKeyword(schema gjson.Result) bool { + for key := range schema.Map() { + if strings.HasPrefix(key, "x-") { + continue + } + if !claudeAnalyzedSchemaKeywords[key] { + return true + } + } + return false +} + +func analyzeClaudeObjectSchemaDepth(schema gjson.Result, root gjson.Result, depth int, visitedRefs map[string]bool) claudeObjectSchemaInfo { + info := claudeObjectSchemaInfo{properties: map[string][]gjson.Result{}, required: map[string]bool{}} + if depth > 16 || !schema.Exists() || !schema.IsObject() { + info.complex = true + return info + } + if claudeSchemaDeclaresObject(schema) { + info.declaresObject = true + } + if claudeSchemaHasUnanalyzedKeyword(schema) { + // Any keyword outside the explicitly analyzed set (enum, const, contains, + // conditionals, unevaluated*, $dynamicRef, vendor applicators, ...) may + // constrain legal values in ways this analyzer does not model. Such schemas + // are unsafe-complex (unlike multi-branch unions, whose branches are + // collected): no destructive cleaning may run on them at all, even when a + // union keyword is also present. + info.complex = true + info.unsafeComplex = true + return info + } + if schema.Get("anyOf").Exists() || schema.Get("oneOf").Exists() { + branches, okBranches := claudeUnionBranches(schema) + if !okBranches || len(branches) == 0 { + info.complex = true + return info + } + if len(branches) > 1 { + // Multi-branch unions stay complex so this level never normalizes arguments, + // but collect every branch's declared properties so child recursion and + // placeholder protection still see the full original declaration. The + // per-branch infos are kept separately because union branches are + // alternatives: their dynamic-property policies must not be combined as + // simultaneous constraints. + for _, branch := range branches { + branchInfo := analyzeClaudeObjectSchemaDepth(branch, root, depth+1, visitedRefs) + info.branchInfos = append(info.branchInfos, branchInfo) + mergeClaudeObjectSchemaInfo(&info, branchInfo) + } + if claudeSchemaAllowsDynamic(schema) { + info.allowsDynamic = true + } + if claudeSchemaForbidsDynamic(schema) { + info.forbidsDynamic = true + } + if claudeSchemaHasPatternProperties(schema) { + info.hasPatternProperties = true + } + for _, requiredName := range schema.Get("required").Array() { + info.required[requiredName.String()] = true + } + props := schema.Get("properties") + if props.IsObject() { + info.hasProperties = true + info.declaresObject = true + for key, value := range props.Map() { + info.properties[key] = appendUniqueSchema(info.properties[key], value) + } + } + // Sibling $ref/allOf applicators still constrain this node alongside the + // union; collect them too or their declared child properties would be + // invisible to protection and child recursion. + if ref := schema.Get("$ref"); ref.Exists() { + refValue := ref.String() + if !visitedRefs[refValue] { + if target, ok := claudeResolveLocalRef(root, refValue); ok { + visitedRefs[refValue] = true + resolved := analyzeClaudeObjectSchemaDepth(target, root, depth+1, visitedRefs) + delete(visitedRefs, refValue) + mergeClaudeObjectSchemaInfo(&info, resolved) + } + } + } + if allOf := schema.Get("allOf"); allOf.IsArray() { + for _, branch := range allOf.Array() { + branchInfo := analyzeClaudeObjectSchemaDepth(branch, root, depth+1, visitedRefs) + mergeClaudeObjectSchemaInfo(&info, branchInfo) + } + } + info.complex = true + return info + } + branchInfo := analyzeClaudeObjectSchemaDepth(branches[0], root, depth+1, visitedRefs) + mergeClaudeObjectSchemaInfo(&info, branchInfo) + } + if ref := schema.Get("$ref"); ref.Exists() { + refValue := ref.String() + if visitedRefs[refValue] { + info.complex = true + return info + } + target, ok := claudeResolveLocalRef(root, refValue) + if !ok { + info.complex = true + return info + } + visitedRefs[refValue] = true + resolved := analyzeClaudeObjectSchemaDepth(target, root, depth+1, visitedRefs) + delete(visitedRefs, refValue) + mergeClaudeObjectSchemaInfo(&info, resolved) + } + if allOf := schema.Get("allOf"); allOf.IsArray() { + for _, branch := range allOf.Array() { + branchInfo := analyzeClaudeObjectSchemaDepth(branch, root, depth+1, visitedRefs) + mergeClaudeObjectSchemaInfo(&info, branchInfo) + } + } else if allOf.Exists() { + info.complex = true + } + if claudeSchemaAllowsDynamic(schema) { + info.allowsDynamic = true + } + if claudeSchemaForbidsDynamic(schema) { + info.forbidsDynamic = true + } + if claudeSchemaHasPatternProperties(schema) { + info.hasPatternProperties = true + } + props := schema.Get("properties") + if props.Exists() { + if !props.IsObject() { + info.complex = true + } else { + info.hasProperties = true + info.declaresObject = true + for key, value := range props.Map() { + info.properties[key] = appendUniqueSchema(info.properties[key], value) + } + } + } + for _, requiredName := range schema.Get("required").Array() { + info.required[requiredName.String()] = true + } + return info +} + +func appendUniqueSchema(schemas []gjson.Result, value gjson.Result) []gjson.Result { + for _, existing := range schemas { + if existing.Raw == value.Raw { + return schemas + } + } + return append(schemas, value) +} + +func mergeClaudeObjectSchemaInfo(dst *claudeObjectSchemaInfo, src claudeObjectSchemaInfo) { + if dst.properties == nil { + dst.properties = map[string][]gjson.Result{} + } + if dst.required == nil { + dst.required = map[string]bool{} + } + if src.hasProperties { + dst.hasProperties = true + for key, values := range src.properties { + for _, value := range values { + dst.properties[key] = appendUniqueSchema(dst.properties[key], value) + } + } + } + for name := range src.required { + dst.required[name] = true + } + dst.branchInfos = append(dst.branchInfos, src.branchInfos...) + dst.allowsDynamic = dst.allowsDynamic || src.allowsDynamic + dst.forbidsDynamic = dst.forbidsDynamic || src.forbidsDynamic + dst.hasPatternProperties = dst.hasPatternProperties || src.hasPatternProperties + dst.complex = dst.complex || src.complex + dst.unsafeComplex = dst.unsafeComplex || src.unsafeComplex + dst.declaresObject = dst.declaresObject || src.declaresObject +} + +func findClaudeToolSchema(originalRequestRawJSON []byte, toolName string) (gjson.Result, gjson.Result, bool) { + if toolName == "" || len(originalRequestRawJSON) == 0 || !gjson.ValidBytes(originalRequestRawJSON) { + return gjson.Result{}, gjson.Result{}, false + } + root := gjson.ParseBytes(originalRequestRawJSON) + tools := root.Get("tools") + if !tools.IsArray() { + return gjson.Result{}, root, false + } + type candidate struct { + name string + schema gjson.Result + found bool + } + var exact []candidate + canonicalToolName := CanonicalToolName(toolName) + canonicalMatches := map[string][]candidate{} + for _, tool := range tools.Array() { + name := strings.TrimSpace(tool.Get("name").String()) + if name == "" { + name = strings.TrimSpace(tool.Get("function.name").String()) + } + if name == "" { + continue + } + schema := tool.Get("input_schema") + if !schema.Exists() { + schema = tool.Get("parameters") + } + if !schema.Exists() { + schema = tool.Get("function.parameters") + } + c := candidate{name: name, schema: schema, found: true} + if name == toolName { + exact = append(exact, c) + } + if CanonicalToolName(name) == canonicalToolName { + canonicalMatches[name] = append(canonicalMatches[name], c) + } + } + if len(exact) == 1 { + return exact[0].schema, root, true + } + if len(exact) > 1 { + firstRaw := exact[0].schema.Raw + identical := true + for _, other := range exact[1:] { + if other.schema.Raw != firstRaw { + identical = false + break + } + } + if identical { + return exact[0].schema, root, true + } + return gjson.Result{}, root, false + } + if len(canonicalMatches) == 1 { + for _, matches := range canonicalMatches { + if len(matches) == 1 { + return matches[0].schema, root, true + } + firstRaw := matches[0].schema.Raw + identical := true + for _, other := range matches[1:] { + if other.schema.Raw != firstRaw { + identical = false + break + } + } + if identical { + return matches[0].schema, root, true + } + } + } + return gjson.Result{}, root, false +} + +// ClaudeToolIsParameterless reports whether the tool was declared in the original +// Claude request with a missing schema or an explicitly closed empty object schema +// (additionalProperties: false). Schemas that allow dynamic additional or pattern +// properties, including objects that omit additionalProperties, are not parameterless. +func ClaudeToolIsParameterless(toolName string, originalRequestRawJSON []byte) bool { + schema, root, found := findClaudeToolSchema(originalRequestRawJSON, toolName) + if !found { + return false + } + if !schema.Exists() { + return true + } + _ = root + info := analyzeClaudeObjectSchema(schema, schema) + if info.complex || claudeInfoAnyBranchAllowsDynamic(info) || !info.declaresObject || !info.forbidsDynamic { + return false + } + return len(info.required) == 0 && (!info.hasProperties || len(info.properties) == 0) +} + +func resolveClaudeArrayItemsSchema(schema gjson.Result, root gjson.Result, depth int, visitedRefs map[string]bool) ([]gjson.Result, bool) { + if depth > 16 || !schema.Exists() || !schema.IsObject() { + return nil, false + } + var items []gjson.Result + deterministic := true + collect := func(candidate gjson.Result) { + items = appendUniqueSchema(items, candidate) + } + if ref := schema.Get("$ref"); ref.Exists() { + refValue := ref.String() + if visitedRefs[refValue] { + return nil, false + } + target, ok := claudeResolveLocalRef(root, refValue) + if !ok { + return nil, false + } + visitedRefs[refValue] = true + targetItems, targetOK := resolveClaudeArrayItemsSchema(target, root, depth+1, visitedRefs) + delete(visitedRefs, refValue) + if !targetOK { + deterministic = false + } else { + for _, item := range targetItems { + collect(item) + } + } + } + if schema.Get("anyOf").Exists() || schema.Get("oneOf").Exists() { + branches, okBranches := claudeUnionBranches(schema) + if !okBranches || len(branches) != 1 { + deterministic = false + } else { + branchItems, branchOK := resolveClaudeArrayItemsSchema(branches[0], root, depth+1, visitedRefs) + if !branchOK { + deterministic = false + } else { + for _, item := range branchItems { + collect(item) + } + } + } + } + if allOf := schema.Get("allOf"); allOf.IsArray() { + for _, branch := range allOf.Array() { + branchItems, branchOK := resolveClaudeArrayItemsSchema(branch, root, depth+1, visitedRefs) + if !branchOK { + deterministic = false + continue + } + for _, item := range branchItems { + collect(item) + } + } + } else if allOf.Exists() { + deterministic = false + } + if directItems := schema.Get("items"); directItems.Exists() { + collect(directItems) + } + return items, deterministic +} + +func claudeDeclaredPropertyPlaceholders(info claudeObjectSchemaInfo) (bool, bool) { + _, reasonDeclared := info.properties["reason"] + _, underscoreDeclared := info.properties["_"] + return reasonDeclared, underscoreDeclared +} + +func claudeArraySchemaUnsafe(schema gjson.Result, root gjson.Result) bool { + return analyzeClaudeObjectSchema(schema, root).unsafeComplex +} + +func cleanClaudeToolArray(argsJSON string, schemas []gjson.Result, root gjson.Result, path string) string { + for _, schema := range schemas { + if claudeArraySchemaUnsafe(schema, root) { + // Value-constraining keywords on the array itself (e.g. enum of whole + // arrays) are not analyzed; keep the array untouched. + return argsJSON + } + } + var itemSchemas []gjson.Result + deterministic := true + for _, schema := range schemas { + resolvedItems, okItems := resolveClaudeArrayItemsSchema(schema, root, 0, map[string]bool{}) + if !okItems { + deterministic = false + continue + } + if len(resolvedItems) == 0 { + // A candidate without an items schema (e.g. free-form "{}") accepts + // arbitrary elements; it must stay in the candidate set so element + // cleaning sees the free alternative instead of only strict siblings. + itemSchemas = appendUniqueSchema(itemSchemas, schema) + continue + } + for _, item := range resolvedItems { + itemSchemas = appendUniqueSchema(itemSchemas, item) + } + } + if !deterministic || len(itemSchemas) == 0 { + return argsJSON + } + cleaned := argsJSON + elementCount := len(gjson.Get(cleaned, path).Array()) + for index := 0; index < elementCount; index++ { + elementPath := joinPath(path, strconv.Itoa(index)) + element := gjson.Get(cleaned, elementPath) + if element.IsObject() { + cleaned = cleanClaudeToolObject(cleaned, itemSchemas, root, elementPath, false) + } else if element.IsArray() { + cleaned = cleanClaudeToolArray(cleaned, itemSchemas, root, elementPath) + } + } + return cleaned +} + +func cleanClaudeToolObject(argsJSON string, schemas []gjson.Result, root gjson.Result, path string, topLevel bool) string { + argsResult := gjson.Get(argsJSON, path) + if path == "" { + argsResult = gjson.Parse(argsJSON) + } + if !argsResult.IsObject() || len(schemas) == 0 { + return argsJSON + } + merged := claudeObjectSchemaInfo{properties: map[string][]gjson.Result{}} + for _, schema := range schemas { + mergeClaudeObjectSchemaInfo(&merged, analyzeClaudeObjectSchema(schema, root)) + } + if merged.complex || !merged.declaresObject { + return argsJSON + } + isEmptySchema := len(merged.required) == 0 && (!merged.hasProperties || len(merged.properties) == 0) + allSchemasDeclareObject := true + for _, schema := range schemas { + if !analyzeClaudeObjectSchema(schema, root).declaresObject { + allSchemasDeclareObject = false + break + } + } + if isEmptySchema && allSchemasDeclareObject && merged.forbidsDynamic && !claudeInfoAnyBranchAllowsDynamic(merged) && !claudeOriginalsAnyBranchAllowsDynamic(schemas, root) && !claudeOriginalsAnyCandidateAcceptsImplicitly(schemas, root, argsResult) { + if len(argsResult.Map()) == 0 { + return argsJSON + } + if topLevel { + return "{}" + } + updated, _ := sjson.SetRaw(argsJSON, path, "{}") + return updated + } + keys := make([]string, 0, len(merged.properties)) + for key := range merged.properties { + keys = append(keys, key) + } + sort.Strings(keys) + cleaned := argsJSON + for _, key := range keys { + propertySchemas := merged.properties[key] + childPath := joinPath(path, escapeGJSONPathKey(key)) + child := gjson.Get(cleaned, childPath) + if !child.Exists() { + continue + } + currentForBranch := gjson.Parse(cleaned) + if path != "" { + currentForBranch = gjson.Get(cleaned, path) + } + if claudeParentBranchAcceptsChild(merged, key, currentForBranch) || claudeOriginalsMayPermitChild(schemas, root, key, currentForBranch) { + continue + } + if child.IsObject() { + cleaned = cleanClaudeToolObject(cleaned, propertySchemas, root, childPath, false) + } else if child.IsArray() { + cleaned = cleanClaudeToolArray(cleaned, propertySchemas, root, childPath) + } + } + if claudeInfoAnyBranchAllowsDynamic(merged) { + return cleaned + } + current := gjson.Parse(cleaned) + if path != "" { + current = gjson.Get(cleaned, path) + } + if !current.IsObject() { + return cleaned + } + reasonDeclared, underscoreDeclared := claudeDeclaredPropertyPlaceholders(merged) + for _, placeholder := range []string{"reason", "_"} { + if placeholder == "reason" && reasonDeclared { + continue + } + if placeholder == "_" && underscoreDeclared { + continue + } + if merged.required[placeholder] { + continue + } + if claudeInfoAnyBranchAllowsDynamic(merged) || claudeOriginalsAnyBranchAllowsDynamic(schemas, root) { + continue + } + if claudeParentBranchAcceptsChild(merged, placeholder, current) { + continue + } + if claudeOriginalsAnyCandidateAcceptsImplicitly(schemas, root, current) { + continue + } + if _, ok := current.Map()[placeholder]; ok { + placeholderPath := joinPath(path, placeholder) + cleaned, _ = sjson.Delete(cleaned, placeholderPath) + } + } + parsedCleaned := gjson.Parse(cleaned) + if path != "" { + parsedCleaned = gjson.Get(cleaned, path) + } + if parsedCleaned.IsObject() && len(parsedCleaned.Map()) == 0 && path == "" { + return "{}" + } + return cleaned +} + +func findTranslatedClaudeToolSchemas(translatedRequestRawJSON []byte, toolName string, originalRequestRawJSON []byte) []gjson.Result { + if toolName == "" || len(translatedRequestRawJSON) == 0 || !gjson.ValidBytes(translatedRequestRawJSON) { + return nil + } + root := gjson.ParseBytes(translatedRequestRawJSON) + type namedSchema struct { + name string + schema gjson.Result + } + var named []namedSchema + collectNamed := func(tools gjson.Result) { + if !tools.IsArray() { + return + } + for _, toolGroup := range tools.Array() { + decls := toolGroup.Get("functionDeclarations") + if !decls.IsArray() { + continue + } + for _, decl := range decls.Array() { + name := strings.TrimSpace(decl.Get("name").String()) + if name == "" { + continue + } + schema := decl.Get("parametersJsonSchema") + if !schema.Exists() { + schema = decl.Get("parameters") + } + if schema.Exists() && schema.IsObject() { + named = append(named, namedSchema{name: name, schema: schema}) + } + } + } + } + collectNamed(root.Get("tools")) + collectNamed(root.Get("request.tools")) + if len(named) == 0 { + return nil + } + canonicalToolName := CanonicalToolName(toolName) + sanitizedToolName := SanitizeFunctionName(toolName) + // Request translation renames colliding tool names via the request-wide + // disambiguated map (hash suffixes); the response name is the restored + // client name, so match the actually-sent name when it can be derived. + sentName := "" + if forward := SanitizedFunctionNameMap(originalRequestRawJSON); forward != nil { + sentName = forward[toolName] + } + var exact []gjson.Result + canonicalByName := map[string][]gjson.Result{} + for _, entry := range named { + if entry.name == toolName || (sentName != "" && entry.name == sentName) { + exact = append(exact, entry.schema) + } + if CanonicalToolName(entry.name) == canonicalToolName || entry.name == sanitizedToolName || SanitizeFunctionName(entry.name) == sanitizedToolName { + canonicalByName[entry.name] = append(canonicalByName[entry.name], entry.schema) + } + } + if len(exact) > 0 { + return exact + } + if len(canonicalByName) == 1 { + for _, schemas := range canonicalByName { + return schemas + } + } + return nil +} + +func claudeOriginalDeclaresPropertyDeep(schema gjson.Result, root gjson.Result, property string, depth int, visitedRefs map[string]bool) bool { + if depth > 16 || !schema.Exists() || !schema.IsObject() { + return false + } + if ref := schema.Get("$ref"); ref.Exists() { + refValue := ref.String() + if !visitedRefs[refValue] { + if target, ok := claudeResolveLocalRef(root, refValue); ok { + visitedRefs[refValue] = true + declared := claudeOriginalDeclaresPropertyDeep(target, root, property, depth+1, visitedRefs) + delete(visitedRefs, refValue) + if declared { + return true + } + } + } + } + if _, ok := schema.Get("properties").Map()[property]; ok { + return true + } + for _, requiredName := range schema.Get("required").Array() { + if requiredName.String() == property { + return true + } + } + for _, keyword := range []string{"allOf", "anyOf", "oneOf"} { + for _, branch := range schema.Get(keyword).Array() { + if claudeOriginalDeclaresPropertyDeep(branch, root, property, depth+1, visitedRefs) { + return true + } + } + } + return false +} + +func claudeInfoAnyBranchAllowsDynamic(info claudeObjectSchemaInfo) bool { + if info.hasPatternProperties { + return true + } + if len(info.branchInfos) == 0 { + return info.allowsDynamic && !info.forbidsDynamic + } + for _, branch := range info.branchInfos { + if claudeInfoAnyBranchAllowsDynamic(branch) { + return true + } + } + return info.allowsDynamic && !info.forbidsDynamic +} + +func claudeBranchMayAccept(info claudeObjectSchemaInfo, key string, current gjson.Result) bool { + // patternProperties permit matching keys regardless of additionalProperties. + if info.hasPatternProperties { + return true + } + // A branch that does not explicitly forbid additional properties accepts the + // key by JSON Schema default. To avoid treating every unconstrained sibling + // branch as permission (which would mask genuine placeholder cleaning), this + // implicit permission only counts when the current object matches the branch + // by carrying every property the branch requires. + if !info.forbidsDynamic { + matches := true + for requiredName := range info.required { + if _, ok := current.Map()[requiredName]; !ok { + matches = false + break + } + } + if matches { + return true + } + } + if info.allowsDynamic && !info.forbidsDynamic { + return true + } + for _, branch := range info.branchInfos { + _, branchDeclaresKey := branch.properties[key] + if branchDeclaresKey { + // This branch constrains the key with its own explicit schema, + // which is already part of the collected candidates. + continue + } + if claudeBranchMayAccept(branch, key, current) { + return true + } + } + return false +} + +func claudeParentBranchAcceptsChild(originalInfo claudeObjectSchemaInfo, key string, current gjson.Result) bool { + if len(originalInfo.branchInfos) == 0 { + return false + } + for _, branch := range originalInfo.branchInfos { + _, branchDeclaresKey := branch.properties[key] + if branchDeclaresKey { + continue + } + if claudeBranchMayAccept(branch, key, current) { + return true + } + } + return false +} + +func claudeCandidateMayPermitChild(info claudeObjectSchemaInfo, key string, current gjson.Result) bool { + if _, declaresKey := info.properties[key]; declaresKey { + return false + } + if info.hasPatternProperties { + return true + } + if info.forbidsDynamic { + return false + } + for requiredName := range info.required { + if _, ok := current.Map()[requiredName]; !ok { + return false + } + } + return true +} + +func claudeOriginalsMayPermitChild(originals []gjson.Result, root gjson.Result, key string, current gjson.Result) bool { + if len(originals) <= 1 { + return false + } + for _, original := range originals { + info := analyzeClaudeObjectSchema(original, root) + if info.complex { + continue + } + if claudeCandidateMayPermitChild(info, key, current) { + return true + } + } + return false +} + +func claudeOriginalsAnyCandidateAcceptsImplicitly(originals []gjson.Result, root gjson.Result, current gjson.Result) bool { + if len(originals) <= 1 { + return false + } + for _, original := range originals { + info := analyzeClaudeObjectSchema(original, root) + if info.complex || info.forbidsDynamic { + continue + } + matches := true + for requiredName := range info.required { + if _, ok := current.Map()[requiredName]; !ok { + matches = false + break + } + } + if matches { + return true + } + } + return false +} + +func claudeOriginalsAnyBranchAllowsDynamic(originals []gjson.Result, root gjson.Result) bool { + for _, original := range originals { + if info := analyzeClaudeObjectSchema(original, root); claudeInfoAnyBranchAllowsDynamic(info) { + return true + } + } + return false +} + +func mergedClaudeOriginalInfo(originals []gjson.Result, root gjson.Result) claudeObjectSchemaInfo { + merged := claudeObjectSchemaInfo{properties: map[string][]gjson.Result{}} + for _, original := range originals { + mergeClaudeObjectSchemaInfo(&merged, analyzeClaudeObjectSchema(original, root)) + } + return merged +} + +func cleanClaudeToolArgumentsByTranslatedSchema(argsJSON string, translatedSchemas []gjson.Result, originals []gjson.Result, root gjson.Result, path string, topLevel bool) string { + current := gjson.Parse(argsJSON) + if path != "" { + current = gjson.Get(argsJSON, path) + } + if !current.IsObject() || len(translatedSchemas) == 0 || len(originals) == 0 { + return argsJSON + } + for _, original := range originals { + if !original.IsObject() { + return argsJSON + } + } + originalInfo := mergedClaudeOriginalInfo(originals, root) + if originalInfo.unsafeComplex { + return argsJSON + } + if originalInfo.complex { + // Multi-branch unions in the original cannot be analyzed declaratively; fall through + // and rely on the translated schema, which records the actually-sent branches. + hasUnion := false + for _, original := range originals { + if original.Get("anyOf").IsArray() || original.Get("oneOf").IsArray() { + hasUnion = true + } + } + if !hasUnion { + return argsJSON + } + } + cleaned := argsJSON + // Recurse into declared children using matching translated child schemas. + if originalInfo.hasProperties { + keys := make([]string, 0, len(originalInfo.properties)) + for key := range originalInfo.properties { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + childPath := joinPath(path, escapeGJSONPathKey(key)) + child := gjson.Get(cleaned, childPath) + if !child.Exists() { + continue + } + var translatedChildren []gjson.Result + for _, translated := range translatedSchemas { + translatedInfo := analyzeClaudeObjectSchema(translated, translated) + for _, childSchema := range translatedInfo.properties[key] { + translatedChildren = appendUniqueSchema(translatedChildren, childSchema) + } + } + if len(translatedChildren) == 0 { + continue + } + originalChildren := originalInfo.properties[key] + currentForBranch := gjson.Parse(cleaned) + if path != "" { + currentForBranch = gjson.Get(cleaned, path) + } + if claudeParentBranchAcceptsChild(originalInfo, key, currentForBranch) || claudeOriginalsMayPermitChild(originals, root, key, currentForBranch) { + // Another alternative branch/candidate may accept this key without an + // explicit property schema (dynamic, pattern, or implicit-open matched + // permission); the collected explicit candidates do not constrain it + // then, so skip destructive recursion into this subtree. + continue + } + if child.IsObject() && len(originalChildren) > 0 { + cleaned = cleanClaudeToolArgumentsByTranslatedSchema(cleaned, translatedChildren, originalChildren, root, childPath, false) + } else if child.IsArray() && len(originalChildren) > 0 { + cleaned = cleanClaudeTranslatedArray(cleaned, translatedChildren, originalChildren, root, childPath) + } + } + } + currentAfterChildren := gjson.Parse(cleaned) + if path != "" { + currentAfterChildren = gjson.Get(cleaned, path) + } + if !currentAfterChildren.IsObject() { + return cleaned + } + for _, placeholder := range []string{"reason", "_"} { + if _, declared := originalInfo.properties[placeholder]; declared { + continue + } + if originalInfo.required[placeholder] { + continue + } + if claudeInfoAnyBranchAllowsDynamic(originalInfo) || claudeOriginalsAnyBranchAllowsDynamic(originals, root) { + // Some alternative branch (or candidate schema) accepts dynamic keys, so + // this value may be legitimate user data under that branch even though + // another branch received an injected placeholder. Never delete it. + continue + } + if claudeOriginalsAnyCandidateAcceptsImplicitly(originals, root, currentAfterChildren) { + // Alternative candidate child schemas extracted from different union + // branches: one candidate that the current object matches accepts the + // key implicitly, so the value may be legitimate under that candidate. + continue + } + if claudeParentBranchAcceptsChild(originalInfo, placeholder, currentAfterChildren) { + // An alternative branch that the current object matches (it carries all + // of the branch's required keys) accepts this key by implicit-open or + // pattern permission, so the value may be legitimate under that branch. + continue + } + declaredDeep := false + for _, original := range originals { + if claudeOriginalDeclaresPropertyDeep(original, root, placeholder, 0, map[string]bool{}) { + declaredDeep = true + break + } + } + if declaredDeep { + continue + } + if _, ok := currentAfterChildren.Map()[placeholder]; !ok { + continue + } + injected := false + for _, translated := range translatedSchemas { + translatedInfo := analyzeClaudeObjectSchema(translated, translated) + placeholderSchemas, declaredTranslated := translatedInfo.properties[placeholder] + if !declaredTranslated { + continue + } + if placeholder == "reason" { + for _, placeholderSchema := range placeholderSchemas { + required := false + for _, req := range translated.Get("required").Array() { + if req.String() == "reason" { + required = true + } + } + if required || placeholderSchema.Get("description").String() == placeholderReasonDescription { + injected = true + } + } + // Also check required on union branches flattened into the analyzed info. + if !injected { + for _, req := range translated.Get("required").Array() { + if req.String() == "reason" { + injected = true + } + } + } + } else { + for _, req := range translated.Get("required").Array() { + if req.String() == "_" { + injected = true + } + } + // The "_" placeholder is also marked required by addEmptySchemaPlaceholder; + // when branch flattening drops required, its boolean type is the fallback signal + // only for nested objects. At the top level, the Antigravity executor wraps tool + // schemas before its second sanitization pass, which can also inject "_" after + // translation; that post-translation injection is handled below by re-running + // the same schema cleaning on the translated schema. + if !injected && !topLevel { + for _, placeholderSchema := range placeholderSchemas { + if placeholderSchema.Get("type").String() == "boolean" { + injected = true + } + } + } + } + } + // Union branches: analyzeClaudeObjectSchema only resolves single-branch unions, + // so also scan raw union branches. A required placeholder alone does not prove + // injection (the original may legitimately require it in a branch, which the + // deep-declaration check above already excluded); here the original declares the + // field nowhere, so a required occurrence in a sent branch is the injected one. + if !injected { + for _, translated := range translatedSchemas { + for _, keyword := range []string{"anyOf", "oneOf"} { + for _, branch := range translated.Get(keyword).Array() { + branchInfo := analyzeClaudeObjectSchema(branch, branch) + if _, declaredBranch := branchInfo.properties[placeholder]; !declaredBranch { + continue + } + for _, req := range branch.Get("required").Array() { + if req.String() == placeholder { + injected = true + } + } + } + } + } + } + // The Antigravity executor re-sanitizes tool schemas after translation (wrapping + // them so top-level objects become nested), which can inject placeholders that the + // translated request seen here does not yet contain. Re-running the same cleaning + // on the translated schema reproduces that final sent schema; if the placeholder + // appears there as required, it was injected post-translation. This simulation is + // only applied to closed objects (additionalProperties: false): open objects may + // legitimately carry the key as user data on routes without executor injection, + // and deleting based on a simulated injection would lose real arguments there. + if !injected && topLevel && originalInfo.forbidsDynamic { + for _, translated := range translatedSchemas { + // Mirror the executor's cleanNestedSchema wrapping: the schema is nested + // under a wrapper key before cleaning, so its top-level object counts as + // nested and can receive the "_" placeholder. + wrapped, errWrap := sjson.SetRaw("{}", "schema", translated.Raw) + if errWrap != nil { + continue + } + recleaned := gjson.Get(CleanJSONSchemaForAntigravity(wrapped), "schema") + if !recleaned.Exists() { + continue + } + recleanedInfo := analyzeClaudeObjectSchema(recleaned, recleaned) + if _, declaredRecleaned := recleanedInfo.properties[placeholder]; !declaredRecleaned { + continue + } + for _, req := range recleaned.Get("required").Array() { + if req.String() == placeholder { + injected = true + } + } + } + } + if injected { + cleaned, _ = sjson.Delete(cleaned, joinPath(path, placeholder)) + } + } + // Closed parameterless objects: if the original declared no properties and + // explicitly forbids additional properties (additionalProperties: false) without + // any candidate or union branch permitting dynamic data, normalize to "{}". + // Implicitly open objects (additionalProperties omitted) keep non-placeholder + // arguments after proven injected placeholders have been removed above. + allCandidatesDeclareObject := true + for _, original := range originals { + if !analyzeClaudeObjectSchema(original, root).declaresObject { + allCandidatesDeclareObject = false + break + } + } + currentForNormalize := gjson.Parse(cleaned) + if path != "" { + currentForNormalize = gjson.Get(cleaned, path) + } + if !originalInfo.complex && allCandidatesDeclareObject && originalInfo.forbidsDynamic && !claudeInfoAnyBranchAllowsDynamic(originalInfo) && !claudeOriginalsAnyBranchAllowsDynamic(originals, root) && !claudeOriginalsAnyCandidateAcceptsImplicitly(originals, root, currentForNormalize) && originalInfo.declaresObject && len(originalInfo.required) == 0 && (!originalInfo.hasProperties || len(originalInfo.properties) == 0) { + if topLevel { + return "{}" + } + updated, _ := sjson.SetRaw(cleaned, path, "{}") + return updated + } + parsedCleaned := gjson.Parse(cleaned) + if path != "" { + parsedCleaned = gjson.Get(cleaned, path) + } + if parsedCleaned.IsObject() && len(parsedCleaned.Map()) == 0 && path == "" { + return "{}" + } + _ = root + return cleaned +} + +func cleanClaudeTranslatedArray(argsJSON string, translatedArraySchemas []gjson.Result, originalArraySchemas []gjson.Result, root gjson.Result, path string) string { + for _, schema := range originalArraySchemas { + if claudeArraySchemaUnsafe(schema, root) { + return argsJSON + } + } + var translatedItems []gjson.Result + var originalItems []gjson.Result + deterministic := true + for _, schema := range translatedArraySchemas { + items, okItems := resolveClaudeArrayItemsSchema(schema, schema, 0, map[string]bool{}) + if !okItems { + deterministic = false + } + for _, item := range items { + translatedItems = appendUniqueSchema(translatedItems, item) + } + } + for _, schema := range originalArraySchemas { + items, okItems := resolveClaudeArrayItemsSchema(schema, root, 0, map[string]bool{}) + if !okItems { + deterministic = false + } + if okItems && len(items) == 0 { + originalItems = appendUniqueSchema(originalItems, schema) + continue + } + for _, item := range items { + originalItems = appendUniqueSchema(originalItems, item) + } + } + if !deterministic || len(translatedItems) == 0 || len(originalItems) == 0 { + return argsJSON + } + cleaned := argsJSON + elementCount := len(gjson.Get(cleaned, path).Array()) + for index := 0; index < elementCount; index++ { + elementPath := joinPath(path, strconv.Itoa(index)) + element := gjson.Get(cleaned, elementPath) + if element.IsObject() { + cleaned = cleanClaudeToolArgumentsByTranslatedSchema(cleaned, translatedItems, originalItems, root, elementPath, false) + } else if element.IsArray() { + cleaned = cleanClaudeTranslatedArray(cleaned, translatedItems, originalItems, root, elementPath) + } + } + return cleaned +} + +// CleanClaudeToolArgumentsWithTranslated strips placeholder arguments ("reason" or "_") +// only when the translated (actually-sent) tool schema shows that the placeholder was +// injected for that object (present in the translated schema with the placeholder +// description or marked required, while absent from the original declaration). When no +// translated schema is available it falls back to the original-schema inference used by +// CleanClaudeToolArguments. Parameterless tools are still normalized to "{}" because a +// strict Claude client rejects any argument for them. +func CleanClaudeToolArgumentsWithTranslated(rawArgs string, toolName string, originalRequestRawJSON []byte, translatedRequestRawJSON []byte) string { + if translatedSchemas := findTranslatedClaudeToolSchemas(translatedRequestRawJSON, toolName, originalRequestRawJSON); len(translatedSchemas) > 0 { + rawArgs = strings.TrimSpace(rawArgs) + if rawArgs == "" || rawArgs == "{}" { + return "{}" + } + if !gjson.Valid(rawArgs) || !gjson.Parse(rawArgs).IsObject() { + return rawArgs + } + schema, _, found := findClaudeToolSchema(originalRequestRawJSON, toolName) + if !found { + return rawArgs + } + if !schema.Exists() { + return "{}" + } + return cleanClaudeToolArgumentsByTranslatedSchema(rawArgs, translatedSchemas, []gjson.Result{schema}, schema, "", true) + } + return CleanClaudeToolArguments(rawArgs, toolName, originalRequestRawJSON) +} + +// CleanClaudeToolArguments strips placeholder arguments ("reason" or "_") injected during +// schema normalization for empty object schemas when returning responses to Claude clients. +// If the tool was declared with empty or missing properties and additionalProperties: false, +// the arguments are normalized to "{}". Placeholder fields are also stripped recursively from declared nested object +// properties when they were not declared there. Free-form nested schemas that do not declare +// an object type or properties are preserved because no placeholder is injected for them. +// Tools that legitimately declared those fields or use union schemas that cannot be resolved +// deterministically are left untouched. Deterministic allOf, single-branch unions, and local +// $ref schemas are resolved within the tool schema scope, matching request translation. +func CleanClaudeToolArguments(rawArgs string, toolName string, originalRequestRawJSON []byte) string { + rawArgs = strings.TrimSpace(rawArgs) + if rawArgs == "" || rawArgs == "{}" { + return "{}" + } + if !gjson.Valid(rawArgs) { + return rawArgs + } + schema, root, found := findClaudeToolSchema(originalRequestRawJSON, toolName) + if !found { + return rawArgs + } + if !schema.Exists() { + return "{}" + } + argsResult := gjson.Parse(rawArgs) + if !argsResult.IsObject() { + return rawArgs + } + _ = root + info := analyzeClaudeObjectSchema(schema, schema) + if info.complex || !info.declaresObject { + return rawArgs + } + if len(info.required) == 0 && (!info.hasProperties || len(info.properties) == 0) && info.forbidsDynamic && !claudeInfoAnyBranchAllowsDynamic(info) { + return "{}" + } + return cleanClaudeToolObject(rawArgs, []gjson.Result{schema}, schema, "", true) +} diff --git a/sdk/access/errors.go b/sdk/access/errors.go index 6f344bb0a..64082b292 100644 --- a/sdk/access/errors.go +++ b/sdk/access/errors.go @@ -13,6 +13,8 @@ const ( AuthErrorCodeNoCredentials AuthErrorCode = "no_credentials" AuthErrorCodeInvalidCredential AuthErrorCode = "invalid_credential" AuthErrorCodeNotHandled AuthErrorCode = "not_handled" + AuthErrorCodeDenied AuthErrorCode = "denied" + AuthErrorCodeRequestTooLarge AuthErrorCode = "request_too_large" AuthErrorCodeInternal AuthErrorCode = "internal_error" ) @@ -48,6 +50,9 @@ func (e *AuthError) Unwrap() error { // HTTPStatusCode returns a safe fallback for missing status codes. func (e *AuthError) HTTPStatusCode() int { if e == nil || e.StatusCode <= 0 { + if e != nil && e.Code == AuthErrorCodeDenied { + return http.StatusForbidden + } return http.StatusInternalServerError } return e.StatusCode @@ -74,6 +79,25 @@ func NewNotHandledError() *AuthError { return newAuthError(AuthErrorCodeNotHandled, "authentication provider did not handle request", 0, nil) } +func NewDeniedError(message string, statusCode int) *AuthError { + if statusCode <= 0 { + statusCode = http.StatusForbidden + } + normalizedMessage := strings.TrimSpace(message) + if normalizedMessage == "" { + normalizedMessage = http.StatusText(statusCode) + if normalizedMessage == "" { + normalizedMessage = "Access denied" + } + } + return newAuthError(AuthErrorCodeDenied, normalizedMessage, statusCode, nil) +} + +// NewRequestEntityTooLargeError builds a 413 error when the request body exceeds the configured limit. +func NewRequestEntityTooLargeError(cause error) *AuthError { + return newAuthError(AuthErrorCodeRequestTooLarge, "http: request body too large", http.StatusRequestEntityTooLarge, cause) +} + func NewInternalAuthError(message string, cause error) *AuthError { normalizedMessage := strings.TrimSpace(message) if normalizedMessage == "" { diff --git a/sdk/auth/antigravity.go b/sdk/auth/antigravity.go index 30356996f..aaafb708f 100644 --- a/sdk/auth/antigravity.go +++ b/sdk/auth/antigravity.go @@ -271,6 +271,18 @@ func AntigravityUserAgent() string { return misc.AntigravityUserAgent() } +// StartAntigravityVersionUpdater starts background refresh for the Antigravity Hub version. +// Pass a process-lifetime ctx; it runs only once and is not auto-started by SDK packages. +func StartAntigravityVersionUpdater(ctx context.Context) { + misc.StartAntigravityVersionUpdater(ctx) +} + +// AntigravityRequestUserAgent returns the User-Agent for Antigravity runtime requests, +// falling back to AntigravityUserAgent when userAgent is empty. +func AntigravityRequestUserAgent(userAgent string) string { + return misc.AntigravityRequestUserAgent(userAgent) +} + // BuildAntigravityAuthURL generates the OAuth authorization URL for Antigravity. // If redirectURI is empty, the default loopback callback URI is used. func BuildAntigravityAuthURL(state, redirectURI string) string { diff --git a/sdk/auth/codex.go b/sdk/auth/codex.go index 53b9cab1f..2d56fa079 100644 --- a/sdk/auth/codex.go +++ b/sdk/auth/codex.go @@ -32,7 +32,7 @@ func (a *CodexAuthenticator) Provider() string { } func (a *CodexAuthenticator) RefreshLead() *time.Duration { - return new(24 * time.Hour) + return new(coreauth.CodexOuterRefreshLead) } func (a *CodexAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) { diff --git a/sdk/auth/filestore.go b/sdk/auth/filestore.go index 839607534..f8e061535 100644 --- a/sdk/auth/filestore.go +++ b/sdk/auth/filestore.go @@ -110,48 +110,54 @@ func (s *FileTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (str SetMetadata(map[string]any) } - switch { - case auth.Storage != nil: - if auth.Metadata == nil { - auth.Metadata = make(map[string]any) + if handled, errCodex := cliproxyauth.PersistCodexAuthFile(ctx, path, auth); handled { + if errCodex != nil { + return "", errCodex } - auth.Metadata["disabled"] = auth.Disabled - if setter, ok := auth.Storage.(metadataSetter); ok { - setter.SetMetadata(auth.Metadata) - } - if err = auth.Storage.SaveTokenToFile(path); err != nil { - return "", err - } - case auth.Metadata != nil: - auth.Metadata["disabled"] = auth.Disabled - raw, errMarshal := json.Marshal(auth.Metadata) - if errMarshal != nil { - return "", fmt.Errorf("auth filestore: marshal metadata failed: %w", errMarshal) - } - if existing, errRead := os.ReadFile(path); errRead == nil { - if jsonEqual(existing, raw) { - break + } else { + switch { + case auth.Storage != nil: + if auth.Metadata == nil { + auth.Metadata = make(map[string]any) } - file, errOpen := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600) - if errOpen != nil { - return "", fmt.Errorf("auth filestore: open existing failed: %w", errOpen) + auth.Metadata["disabled"] = auth.Disabled + if setter, ok := auth.Storage.(metadataSetter); ok { + setter.SetMetadata(auth.Metadata) } - if _, errWrite := file.Write(raw); errWrite != nil { - _ = file.Close() - return "", fmt.Errorf("auth filestore: write existing failed: %w", errWrite) + if err = auth.Storage.SaveTokenToFile(path); err != nil { + return "", err } - if errClose := file.Close(); errClose != nil { - return "", fmt.Errorf("auth filestore: close existing failed: %w", errClose) + case auth.Metadata != nil: + auth.Metadata["disabled"] = auth.Disabled + raw, errMarshal := json.Marshal(auth.Metadata) + if errMarshal != nil { + return "", fmt.Errorf("auth filestore: marshal metadata failed: %w", errMarshal) } - break - } else if !os.IsNotExist(errRead) { - return "", fmt.Errorf("auth filestore: read existing failed: %w", errRead) - } - if errWrite := os.WriteFile(path, raw, 0o600); errWrite != nil { - return "", fmt.Errorf("auth filestore: write file failed: %w", errWrite) + if existing, errRead := os.ReadFile(path); errRead == nil { + if jsonEqual(existing, raw) { + break + } + file, errOpen := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600) + if errOpen != nil { + return "", fmt.Errorf("auth filestore: open existing failed: %w", errOpen) + } + if _, errWrite := file.Write(raw); errWrite != nil { + _ = file.Close() + return "", fmt.Errorf("auth filestore: write existing failed: %w", errWrite) + } + if errClose := file.Close(); errClose != nil { + return "", fmt.Errorf("auth filestore: close existing failed: %w", errClose) + } + } else if os.IsNotExist(errRead) { + if errWrite := os.WriteFile(path, raw, 0o600); errWrite != nil { + return "", fmt.Errorf("auth filestore: write file failed: %w", errWrite) + } + } else { + return "", fmt.Errorf("auth filestore: read existing failed: %w", errRead) + } + default: + return "", fmt.Errorf("auth filestore: nothing to persist for %s", auth.ID) } - default: - return "", fmt.Errorf("auth filestore: nothing to persist for %s", auth.ID) } if auth.Attributes == nil { diff --git a/sdk/httptransport/decorator.go b/sdk/httptransport/decorator.go new file mode 100644 index 000000000..7ef524e59 --- /dev/null +++ b/sdk/httptransport/decorator.go @@ -0,0 +1,75 @@ +// Package httptransport exposes an additive, request-scoped observation seam. +// A decorator wraps the transport already selected by the executor: it must not +// replace provider fingerprints, proxy routing, pools, or HTTP protocol policy. +package httptransport + +import ( + "context" + "net/http" +) + +type decoratorKey struct{} + +// Decorator may wrap next to observe final HTTP requests and response bodies. +// It is called during client construction; implementations must be concurrency +// safe, must delegate to next, and must not retain secrets or consume bodies. +// A decorator does not observe WebSocket messages after an upgrade. +type Decorator func(next http.RoundTripper) http.RoundTripper + +// WithDecorator attaches a decorator to one execution context. It does not +// enable request logging or modify executor credential/selection policy. +func WithDecorator(ctx context.Context, decorator Decorator) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, decoratorKey{}, decorator) +} + +// WithoutDecorator suppresses intermediate helper decoration. A specialized +// executor that further configures its helper's transport must apply Decorate +// once, after its own protocol/pool policy is complete. +func WithoutDecorator(ctx context.Context) context.Context { + return WithDecorator(ctx, nil) +} + +// InheritDecorator copies a decorator from fallback into ctx when ctx does not +// already specify a decorator or explicit suppression via WithoutDecorator. +func InheritDecorator(ctx, fallback context.Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + if fallback == nil { + return ctx + } + if _, ok := ctx.Value(decoratorKey{}).(Decorator); ok { + return ctx + } + if decorator, ok := fallback.Value(decoratorKey{}).(Decorator); ok && decorator != nil { + return WithDecorator(ctx, decorator) + } + return ctx +} + +// Decorate applies a context decorator AFTER transport selection. With no +// decorator, the exact original client is returned. Only the client value is +// copied; the original selected transport/pool and all client policy survive. +func Decorate(ctx context.Context, client *http.Client) *http.Client { + if ctx == nil || client == nil { + return client + } + decorator, _ := ctx.Value(decoratorKey{}).(Decorator) + if decorator == nil { + return client + } + next := client.Transport + if next == nil { + next = http.DefaultTransport + } + wrapped := decorator(next) + if wrapped == nil { + return client + } + clone := *client + clone.Transport = wrapped + return &clone +}