File size: 7,816 Bytes
dcedb25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
// Schema-aware repair of double-encoded tool-call arguments.
//
// Several free-tier models (GLM family prominently) emit NESTED JSON inside
// tool arguments as a string: `{"plan": "[{\"step\":...}]"}` instead of
// `{"plan": [{"step":...}]}`. Strict clients reject the call β€” observed in
// production as Codex `failed to parse function arguments: invalid type:
// string ..., expected a sequence`, which killed the agent turn right at its
// status-update call. The gateway has the request's tool schemas, so it can
// repair this principled-ly: only when the schema says a parameter is an
// array/object AND the string value parses to exactly that JSON type. A
// parameter whose schema says "string" is never touched, even if it looks
// like JSON.
//
// The walk is recursive. A model that stringifies a top-level array
// stringifies a nested one too, and a repair that stopped at the top level
// left `{"config": {"tags": "[\"a\"]"}}` broken for the client. Each level
// re-applies the identical gate, so depth adds reach without adding latitude:
// still never a guess, still never a coercion, still nothing invented.
//
// Also handles whole-arguments double encoding (the arguments field itself
// being a JSON-encoded string of a JSON object), which needs no schema.

interface JsonSchemaish {
  type?: string;
  properties?: Record<string, JsonSchemaish>;
  // `items` lets the walk descend into arrays. A tuple form (an array of
  // per-position schemas) is deliberately not followed: matching a decoded
  // element to its position is exactly the kind of inference that turns a
  // repair into a guess.
  items?: JsonSchemaish;
}

/**
 * Repair a tool call's `arguments` JSON string against the tool's parameter
 * schema. Returns the original string untouched whenever anything doesn't
 * parse or doesn't match β€” this must never corrupt a valid call.
 */
export function repairToolArguments(args: string, paramSchema?: JsonSchemaish): string {
  let parsed: unknown;
  try {
    parsed = JSON.parse(args);
  } catch {
    return args;
  }

  let changed = false;

  // Whole-arguments double encoding: `"{\"a\":1}"` parses to a string that is
  // itself JSON of an object. Unwrap one level.
  if (typeof parsed === 'string') {
    try {
      const inner = JSON.parse(parsed);
      if (inner !== null && typeof inner === 'object' && !Array.isArray(inner)) {
        parsed = inner;
        changed = true;
      } else {
        return args;
      }
    } catch {
      return args;
    }
  }

  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
    return changed ? JSON.stringify(parsed) : args;
  }

  if (repairInPlace(parsed, paramSchema)) changed = true;

  return changed ? JSON.stringify(parsed) : args;
}

/**
 * Decode `value` against the schema node that describes it, or return
 * `undefined` to mean "leave it alone".
 *
 * The gate is the whole design: the schema must say `array` or `object`, and
 * the string must parse to exactly that. A parameter whose schema says
 * `string` is never touched even when it looks like JSON, an absent or
 * type-less schema node is never guessed at, and a mismatch is left as-is
 * rather than coerced.
 */
function decodeIfSchemaSays(value: string, schema: JsonSchemaish | undefined): unknown | undefined {
  const want = schema?.type;
  if (want !== 'array' && want !== 'object') return undefined;
  const trimmed = value.trim();
  if (!(trimmed.startsWith('[') || trimmed.startsWith('{'))) return undefined;
  try {
    const inner = JSON.parse(trimmed);
    const isMatch = want === 'array'
      ? Array.isArray(inner)
      : inner !== null && typeof inner === 'object' && !Array.isArray(inner);
    return isMatch ? inner : undefined;
  } catch {
    // Not actually JSON β€” leave the string alone.
    return undefined;
  }
}

/**
 * Walk a decoded value alongside its schema, decoding double-encoded strings
 * wherever the schema is unambiguous. Mutates `node` in place (it is always a
 * fresh JSON.parse result, never a caller's object) and reports whether
 * anything changed.
 *
 * Recursion is the point: the same model that stringifies a top-level array
 * stringifies a nested one, and until now only the top level was repaired, so
 * `{"config": {"tags": "[\"a\"]"}}` reached the client still broken. Each
 * level re-applies the identical gate, so depth adds reach without loosening
 * the rule β€” a newly decoded value is itself walked, since a doubly-wrapped
 * payload is exactly what the models that do this produce.
 */
function repairInPlace(node: unknown, schema: JsonSchemaish | undefined): boolean {
  if (node === null || typeof node !== 'object') return false;

  let changed = false;

  if (Array.isArray(node)) {
    // One `items` schema describes every element; a tuple schema is skipped
    // by the type on JsonSchemaish.
    const itemSchema = schema?.items;
    if (!itemSchema) return false;
    for (let i = 0; i < node.length; i++) {
      const value = node[i];
      if (typeof value === 'string') {
        const decoded = decodeIfSchemaSays(value, itemSchema);
        if (decoded !== undefined) {
          node[i] = decoded;
          changed = true;
          repairInPlace(decoded, itemSchema);
        }
      } else if (repairInPlace(value, itemSchema)) {
        changed = true;
      }
    }
    return changed;
  }

  const props = schema?.properties;
  if (!props) return false;

  const obj = node as Record<string, unknown>;
  for (const [key, value] of Object.entries(obj)) {
    const child = props[key];
    if (!child) continue; // Unknown key β€” no schema to justify a change.
    if (typeof value === 'string') {
      const decoded = decodeIfSchemaSays(value, child);
      if (decoded !== undefined) {
        obj[key] = decoded;
        changed = true;
        repairInPlace(decoded, child);
      }
    } else if (repairInPlace(value, child)) {
      changed = true;
    }
  }

  return changed;
}

/**
 * Recursively remove the given keys from a JSON-Schema-ish value. Used to drop
 * fields a provider's tool-schema validator rejects with a 400 even though they
 * carry no meaning for the call β€” Cohere's compat endpoint, for instance, 400s
 * on `additionalProperties` (and `$schema`), which strict clients like opencode
 * and continue.dev routinely emit. Returns a NEW value; never mutates the input
 * (tools are shared across the fallback chain, so an in-place strip on one
 * provider would corrupt the schema the next provider sees). Non-object values
 * pass through unchanged. This is the provider-agnostic sibling of google.ts's
 * `sanitizeForGemini`, which strips a much larger Gemini-specific key set.
 */
export function stripSchemaKeys<T>(schema: T, keys: Set<string>): T {
  if (Array.isArray(schema)) {
    return schema.map((s) => stripSchemaKeys(s, keys)) as unknown as T;
  }
  if (schema && typeof schema === 'object') {
    const out: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(schema as Record<string, unknown>)) {
      if (keys.has(k)) continue;
      out[k] = stripSchemaKeys(v, keys);
    }
    return out as unknown as T;
  }
  return schema;
}

/**
 * Build a tool-name β†’ parameter-schema map from an OpenAI-style tools array
 * (chat-completions shape: {type:'function', function:{name, parameters}}).
 */
export function toolSchemaMap(
  tools?: Array<{ type?: string; function?: { name?: string; parameters?: unknown } }>,
): Map<string, JsonSchemaish> {
  const map = new Map<string, JsonSchemaish>();
  for (const t of tools ?? []) {
    const name = t.function?.name;
    if (t.type === 'function' && name && t.function?.parameters && typeof t.function.parameters === 'object') {
      map.set(name, t.function.parameters as JsonSchemaish);
    }
  }
  return map;
}