opencode-antigravity-auth-mf 1.3.0-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +630 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/src/antigravity/oauth.d.ts +31 -0
- package/dist/src/antigravity/oauth.d.ts.map +1 -0
- package/dist/src/antigravity/oauth.js +168 -0
- package/dist/src/antigravity/oauth.js.map +1 -0
- package/dist/src/constants.d.ts +99 -0
- package/dist/src/constants.d.ts.map +1 -0
- package/dist/src/constants.js +135 -0
- package/dist/src/constants.js.map +1 -0
- package/dist/src/hooks/auto-update-checker/cache.d.ts +3 -0
- package/dist/src/hooks/auto-update-checker/cache.d.ts.map +1 -0
- package/dist/src/hooks/auto-update-checker/cache.js +71 -0
- package/dist/src/hooks/auto-update-checker/cache.js.map +1 -0
- package/dist/src/hooks/auto-update-checker/checker.d.ts +16 -0
- package/dist/src/hooks/auto-update-checker/checker.d.ts.map +1 -0
- package/dist/src/hooks/auto-update-checker/checker.js +237 -0
- package/dist/src/hooks/auto-update-checker/checker.js.map +1 -0
- package/dist/src/hooks/auto-update-checker/constants.d.ts +9 -0
- package/dist/src/hooks/auto-update-checker/constants.d.ts.map +1 -0
- package/dist/src/hooks/auto-update-checker/constants.js +23 -0
- package/dist/src/hooks/auto-update-checker/constants.js.map +1 -0
- package/dist/src/hooks/auto-update-checker/index.d.ts +34 -0
- package/dist/src/hooks/auto-update-checker/index.d.ts.map +1 -0
- package/dist/src/hooks/auto-update-checker/index.js +125 -0
- package/dist/src/hooks/auto-update-checker/index.js.map +1 -0
- package/dist/src/hooks/auto-update-checker/types.d.ts +25 -0
- package/dist/src/hooks/auto-update-checker/types.d.ts.map +1 -0
- package/dist/src/hooks/auto-update-checker/types.js +1 -0
- package/dist/src/hooks/auto-update-checker/types.js.map +1 -0
- package/dist/src/plugin/accounts.d.ts +86 -0
- package/dist/src/plugin/accounts.d.ts.map +1 -0
- package/dist/src/plugin/accounts.js +609 -0
- package/dist/src/plugin/accounts.js.map +1 -0
- package/dist/src/plugin/auth.d.ts +21 -0
- package/dist/src/plugin/auth.d.ts.map +1 -0
- package/dist/src/plugin/auth.js +46 -0
- package/dist/src/plugin/auth.js.map +1 -0
- package/dist/src/plugin/cache/index.d.ts +5 -0
- package/dist/src/plugin/cache/index.d.ts.map +1 -0
- package/dist/src/plugin/cache/index.js +5 -0
- package/dist/src/plugin/cache/index.js.map +1 -0
- package/dist/src/plugin/cache/signature-cache.d.ts +111 -0
- package/dist/src/plugin/cache/signature-cache.d.ts.map +1 -0
- package/dist/src/plugin/cache/signature-cache.js +375 -0
- package/dist/src/plugin/cache/signature-cache.js.map +1 -0
- package/dist/src/plugin/cache.d.ts +44 -0
- package/dist/src/plugin/cache.d.ts.map +1 -0
- package/dist/src/plugin/cache.js +200 -0
- package/dist/src/plugin/cache.js.map +1 -0
- package/dist/src/plugin/cli.d.ts +19 -0
- package/dist/src/plugin/cli.d.ts.map +1 -0
- package/dist/src/plugin/cli.js +59 -0
- package/dist/src/plugin/cli.js.map +1 -0
- package/dist/src/plugin/config/index.d.ts +16 -0
- package/dist/src/plugin/config/index.d.ts.map +1 -0
- package/dist/src/plugin/config/index.js +16 -0
- package/dist/src/plugin/config/index.js.map +1 -0
- package/dist/src/plugin/config/loader.d.ts +37 -0
- package/dist/src/plugin/config/loader.d.ts.map +1 -0
- package/dist/src/plugin/config/loader.js +206 -0
- package/dist/src/plugin/config/loader.js.map +1 -0
- package/dist/src/plugin/config/schema.d.ts +411 -0
- package/dist/src/plugin/config/schema.d.ts.map +1 -0
- package/dist/src/plugin/config/schema.js +339 -0
- package/dist/src/plugin/config/schema.js.map +1 -0
- package/dist/src/plugin/core/streaming/index.d.ts +3 -0
- package/dist/src/plugin/core/streaming/index.d.ts.map +1 -0
- package/dist/src/plugin/core/streaming/index.js +3 -0
- package/dist/src/plugin/core/streaming/index.js.map +1 -0
- package/dist/src/plugin/core/streaming/transformer.d.ts +10 -0
- package/dist/src/plugin/core/streaming/transformer.d.ts.map +1 -0
- package/dist/src/plugin/core/streaming/transformer.js +255 -0
- package/dist/src/plugin/core/streaming/transformer.js.map +1 -0
- package/dist/src/plugin/core/streaming/types.d.ts +35 -0
- package/dist/src/plugin/core/streaming/types.d.ts.map +1 -0
- package/dist/src/plugin/core/streaming/types.js +1 -0
- package/dist/src/plugin/core/streaming/types.js.map +1 -0
- package/dist/src/plugin/debug.d.ts +68 -0
- package/dist/src/plugin/debug.d.ts.map +1 -0
- package/dist/src/plugin/debug.js +325 -0
- package/dist/src/plugin/debug.js.map +1 -0
- package/dist/src/plugin/errors.d.ts +28 -0
- package/dist/src/plugin/errors.d.ts.map +1 -0
- package/dist/src/plugin/errors.js +42 -0
- package/dist/src/plugin/errors.js.map +1 -0
- package/dist/src/plugin/image-saver.d.ts +25 -0
- package/dist/src/plugin/image-saver.d.ts.map +1 -0
- package/dist/src/plugin/image-saver.js +86 -0
- package/dist/src/plugin/image-saver.js.map +1 -0
- package/dist/src/plugin/logger.d.ts +54 -0
- package/dist/src/plugin/logger.d.ts.map +1 -0
- package/dist/src/plugin/logger.js +120 -0
- package/dist/src/plugin/logger.js.map +1 -0
- package/dist/src/plugin/project.d.ts +33 -0
- package/dist/src/plugin/project.d.ts.map +1 -0
- package/dist/src/plugin/project.js +234 -0
- package/dist/src/plugin/project.js.map +1 -0
- package/dist/src/plugin/recovery/constants.d.ts +22 -0
- package/dist/src/plugin/recovery/constants.d.ts.map +1 -0
- package/dist/src/plugin/recovery/constants.js +43 -0
- package/dist/src/plugin/recovery/constants.js.map +1 -0
- package/dist/src/plugin/recovery/index.d.ts +12 -0
- package/dist/src/plugin/recovery/index.d.ts.map +1 -0
- package/dist/src/plugin/recovery/index.js +12 -0
- package/dist/src/plugin/recovery/index.js.map +1 -0
- package/dist/src/plugin/recovery/storage.d.ts +24 -0
- package/dist/src/plugin/recovery/storage.d.ts.map +1 -0
- package/dist/src/plugin/recovery/storage.js +354 -0
- package/dist/src/plugin/recovery/storage.js.map +1 -0
- package/dist/src/plugin/recovery/types.d.ts +116 -0
- package/dist/src/plugin/recovery/types.d.ts.map +1 -0
- package/dist/src/plugin/recovery/types.js +6 -0
- package/dist/src/plugin/recovery/types.js.map +1 -0
- package/dist/src/plugin/recovery.d.ts +61 -0
- package/dist/src/plugin/recovery.d.ts.map +1 -0
- package/dist/src/plugin/recovery.js +376 -0
- package/dist/src/plugin/recovery.js.map +1 -0
- package/dist/src/plugin/refresh-queue.d.ts +101 -0
- package/dist/src/plugin/refresh-queue.d.ts.map +1 -0
- package/dist/src/plugin/refresh-queue.js +244 -0
- package/dist/src/plugin/refresh-queue.js.map +1 -0
- package/dist/src/plugin/request-helpers.d.ts +278 -0
- package/dist/src/plugin/request-helpers.d.ts.map +1 -0
- package/dist/src/plugin/request-helpers.js +2268 -0
- package/dist/src/plugin/request-helpers.js.map +1 -0
- package/dist/src/plugin/request.d.ts +91 -0
- package/dist/src/plugin/request.d.ts.map +1 -0
- package/dist/src/plugin/request.js +1302 -0
- package/dist/src/plugin/request.js.map +1 -0
- package/dist/src/plugin/rotation.d.ts +168 -0
- package/dist/src/plugin/rotation.d.ts.map +1 -0
- package/dist/src/plugin/rotation.js +302 -0
- package/dist/src/plugin/rotation.js.map +1 -0
- package/dist/src/plugin/server.d.ts +23 -0
- package/dist/src/plugin/server.d.ts.map +1 -0
- package/dist/src/plugin/server.js +324 -0
- package/dist/src/plugin/server.js.map +1 -0
- package/dist/src/plugin/storage.d.ts +92 -0
- package/dist/src/plugin/storage.d.ts.map +1 -0
- package/dist/src/plugin/storage.js +417 -0
- package/dist/src/plugin/storage.js.map +1 -0
- package/dist/src/plugin/stores/signature-store.d.ts +5 -0
- package/dist/src/plugin/stores/signature-store.d.ts.map +1 -0
- package/dist/src/plugin/stores/signature-store.js +25 -0
- package/dist/src/plugin/stores/signature-store.js.map +1 -0
- package/dist/src/plugin/thinking-recovery.d.ts +90 -0
- package/dist/src/plugin/thinking-recovery.d.ts.map +1 -0
- package/dist/src/plugin/thinking-recovery.js +316 -0
- package/dist/src/plugin/thinking-recovery.js.map +1 -0
- package/dist/src/plugin/token.d.ts +19 -0
- package/dist/src/plugin/token.d.ts.map +1 -0
- package/dist/src/plugin/token.js +128 -0
- package/dist/src/plugin/token.js.map +1 -0
- package/dist/src/plugin/transform/claude.d.ts +80 -0
- package/dist/src/plugin/transform/claude.d.ts.map +1 -0
- package/dist/src/plugin/transform/claude.js +265 -0
- package/dist/src/plugin/transform/claude.js.map +1 -0
- package/dist/src/plugin/transform/cross-model-sanitizer.d.ts +35 -0
- package/dist/src/plugin/transform/cross-model-sanitizer.d.ts.map +1 -0
- package/dist/src/plugin/transform/cross-model-sanitizer.js +225 -0
- package/dist/src/plugin/transform/cross-model-sanitizer.js.map +1 -0
- package/dist/src/plugin/transform/gemini.d.ts +112 -0
- package/dist/src/plugin/transform/gemini.d.ts.map +1 -0
- package/dist/src/plugin/transform/gemini.js +409 -0
- package/dist/src/plugin/transform/gemini.js.map +1 -0
- package/dist/src/plugin/transform/index.d.ts +15 -0
- package/dist/src/plugin/transform/index.d.ts.map +1 -0
- package/dist/src/plugin/transform/index.js +14 -0
- package/dist/src/plugin/transform/index.js.map +1 -0
- package/dist/src/plugin/transform/model-resolver.d.ts +101 -0
- package/dist/src/plugin/transform/model-resolver.d.ts.map +1 -0
- package/dist/src/plugin/transform/model-resolver.js +356 -0
- package/dist/src/plugin/transform/model-resolver.js.map +1 -0
- package/dist/src/plugin/transform/types.d.ts +106 -0
- package/dist/src/plugin/transform/types.d.ts.map +1 -0
- package/dist/src/plugin/transform/types.js +1 -0
- package/dist/src/plugin/transform/types.js.map +1 -0
- package/dist/src/plugin/types.d.ts +96 -0
- package/dist/src/plugin/types.d.ts.map +1 -0
- package/dist/src/plugin/types.js +1 -0
- package/dist/src/plugin/types.js.map +1 -0
- package/dist/src/plugin/usage-reporter.d.ts +23 -0
- package/dist/src/plugin/usage-reporter.d.ts.map +1 -0
- package/dist/src/plugin/usage-reporter.js +43 -0
- package/dist/src/plugin/usage-reporter.js.map +1 -0
- package/dist/src/plugin.d.ts +8 -0
- package/dist/src/plugin.d.ts.map +1 -0
- package/dist/src/plugin.js +1708 -0
- package/dist/src/plugin.js.map +1 -0
- package/package.json +67 -0
|
@@ -0,0 +1,2268 @@
|
|
|
1
|
+
import { getKeepThinking } from "./config";
|
|
2
|
+
import { createLogger } from "./logger";
|
|
3
|
+
import { EMPTY_SCHEMA_PLACEHOLDER_NAME, EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION, SKIP_THOUGHT_SIGNATURE, } from "../constants";
|
|
4
|
+
import { processImageData } from "./image-saver";
|
|
5
|
+
const log = createLogger("request-helpers");
|
|
6
|
+
const ANTIGRAVITY_PREVIEW_LINK = "https://goo.gle/enable-preview-features"; // TODO: Update to Antigravity link if available
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// JSON SCHEMA CLEANING FOR ANTIGRAVITY API
|
|
9
|
+
// Ported from CLIProxyAPI's CleanJSONSchemaForAntigravity (gemini_schema.go)
|
|
10
|
+
// ============================================================================
|
|
11
|
+
/**
|
|
12
|
+
* Unsupported constraint keywords that should be moved to description hints.
|
|
13
|
+
* Claude/Gemini reject these in VALIDATED mode.
|
|
14
|
+
*/
|
|
15
|
+
const UNSUPPORTED_CONSTRAINTS = [
|
|
16
|
+
"minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum",
|
|
17
|
+
"pattern", "minItems", "maxItems", "format",
|
|
18
|
+
"default", "examples",
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* Keywords that should be removed after hint extraction.
|
|
22
|
+
*/
|
|
23
|
+
const UNSUPPORTED_KEYWORDS = [
|
|
24
|
+
...UNSUPPORTED_CONSTRAINTS,
|
|
25
|
+
"$schema", "$defs", "definitions", "const", "$ref", "additionalProperties",
|
|
26
|
+
"propertyNames", "title", "$id", "$comment",
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* Appends a hint to a schema's description field.
|
|
30
|
+
*/
|
|
31
|
+
function appendDescriptionHint(schema, hint) {
|
|
32
|
+
if (!schema || typeof schema !== "object") {
|
|
33
|
+
return schema;
|
|
34
|
+
}
|
|
35
|
+
const existing = typeof schema.description === "string" ? schema.description : "";
|
|
36
|
+
const newDescription = existing ? `${existing} (${hint})` : hint;
|
|
37
|
+
return { ...schema, description: newDescription };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Phase 1a: Converts $ref to description hints.
|
|
41
|
+
* $ref: "#/$defs/Foo" → { type: "object", description: "See: Foo" }
|
|
42
|
+
*/
|
|
43
|
+
function convertRefsToHints(schema) {
|
|
44
|
+
if (!schema || typeof schema !== "object") {
|
|
45
|
+
return schema;
|
|
46
|
+
}
|
|
47
|
+
if (Array.isArray(schema)) {
|
|
48
|
+
return schema.map(item => convertRefsToHints(item));
|
|
49
|
+
}
|
|
50
|
+
// If this object has $ref, replace it with a hint
|
|
51
|
+
if (typeof schema.$ref === "string") {
|
|
52
|
+
const refVal = schema.$ref;
|
|
53
|
+
const defName = refVal.includes("/") ? refVal.split("/").pop() : refVal;
|
|
54
|
+
const hint = `See: ${defName}`;
|
|
55
|
+
const existingDesc = typeof schema.description === "string" ? schema.description : "";
|
|
56
|
+
const newDescription = existingDesc ? `${existingDesc} (${hint})` : hint;
|
|
57
|
+
return { type: "object", description: newDescription };
|
|
58
|
+
}
|
|
59
|
+
// Recursively process all properties
|
|
60
|
+
const result = {};
|
|
61
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
62
|
+
result[key] = convertRefsToHints(value);
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Phase 1b: Converts const to enum.
|
|
68
|
+
* { const: "foo" } → { enum: ["foo"] }
|
|
69
|
+
*/
|
|
70
|
+
function convertConstToEnum(schema) {
|
|
71
|
+
if (!schema || typeof schema !== "object") {
|
|
72
|
+
return schema;
|
|
73
|
+
}
|
|
74
|
+
if (Array.isArray(schema)) {
|
|
75
|
+
return schema.map(item => convertConstToEnum(item));
|
|
76
|
+
}
|
|
77
|
+
const result = {};
|
|
78
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
79
|
+
if (key === "const" && !schema.enum) {
|
|
80
|
+
result.enum = [value];
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
result[key] = convertConstToEnum(value);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Phase 1c: Adds enum hints to description.
|
|
90
|
+
* { enum: ["a", "b", "c"] } → adds "(Allowed: a, b, c)" to description
|
|
91
|
+
*/
|
|
92
|
+
function addEnumHints(schema) {
|
|
93
|
+
if (!schema || typeof schema !== "object") {
|
|
94
|
+
return schema;
|
|
95
|
+
}
|
|
96
|
+
if (Array.isArray(schema)) {
|
|
97
|
+
return schema.map(item => addEnumHints(item));
|
|
98
|
+
}
|
|
99
|
+
let result = { ...schema };
|
|
100
|
+
// Add enum hint if enum has 2-10 items
|
|
101
|
+
if (Array.isArray(result.enum) && result.enum.length > 1 && result.enum.length <= 10) {
|
|
102
|
+
const vals = result.enum.map((v) => String(v)).join(", ");
|
|
103
|
+
result = appendDescriptionHint(result, `Allowed: ${vals}`);
|
|
104
|
+
}
|
|
105
|
+
// Recursively process nested objects
|
|
106
|
+
for (const [key, value] of Object.entries(result)) {
|
|
107
|
+
if (key !== "enum" && typeof value === "object" && value !== null) {
|
|
108
|
+
result[key] = addEnumHints(value);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Phase 1d: Adds additionalProperties hints.
|
|
115
|
+
* { additionalProperties: false } → adds "(No extra properties allowed)" to description
|
|
116
|
+
*/
|
|
117
|
+
function addAdditionalPropertiesHints(schema) {
|
|
118
|
+
if (!schema || typeof schema !== "object") {
|
|
119
|
+
return schema;
|
|
120
|
+
}
|
|
121
|
+
if (Array.isArray(schema)) {
|
|
122
|
+
return schema.map(item => addAdditionalPropertiesHints(item));
|
|
123
|
+
}
|
|
124
|
+
let result = { ...schema };
|
|
125
|
+
if (result.additionalProperties === false) {
|
|
126
|
+
result = appendDescriptionHint(result, "No extra properties allowed");
|
|
127
|
+
}
|
|
128
|
+
// Recursively process nested objects
|
|
129
|
+
for (const [key, value] of Object.entries(result)) {
|
|
130
|
+
if (key !== "additionalProperties" && typeof value === "object" && value !== null) {
|
|
131
|
+
result[key] = addAdditionalPropertiesHints(value);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Phase 1e: Moves unsupported constraints to description hints.
|
|
138
|
+
* { minLength: 1, maxLength: 100 } → adds "(minLength: 1) (maxLength: 100)" to description
|
|
139
|
+
*/
|
|
140
|
+
function moveConstraintsToDescription(schema) {
|
|
141
|
+
if (!schema || typeof schema !== "object") {
|
|
142
|
+
return schema;
|
|
143
|
+
}
|
|
144
|
+
if (Array.isArray(schema)) {
|
|
145
|
+
return schema.map(item => moveConstraintsToDescription(item));
|
|
146
|
+
}
|
|
147
|
+
let result = { ...schema };
|
|
148
|
+
// Move constraint values to description
|
|
149
|
+
for (const constraint of UNSUPPORTED_CONSTRAINTS) {
|
|
150
|
+
if (result[constraint] !== undefined && typeof result[constraint] !== "object") {
|
|
151
|
+
result = appendDescriptionHint(result, `${constraint}: ${result[constraint]}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Recursively process nested objects
|
|
155
|
+
for (const [key, value] of Object.entries(result)) {
|
|
156
|
+
if (typeof value === "object" && value !== null) {
|
|
157
|
+
result[key] = moveConstraintsToDescription(value);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Phase 2a: Merges allOf schemas into a single object.
|
|
164
|
+
* { allOf: [{ properties: { a: ... } }, { properties: { b: ... } }] }
|
|
165
|
+
* → { properties: { a: ..., b: ... } }
|
|
166
|
+
*/
|
|
167
|
+
function mergeAllOf(schema) {
|
|
168
|
+
if (!schema || typeof schema !== "object") {
|
|
169
|
+
return schema;
|
|
170
|
+
}
|
|
171
|
+
if (Array.isArray(schema)) {
|
|
172
|
+
return schema.map(item => mergeAllOf(item));
|
|
173
|
+
}
|
|
174
|
+
let result = { ...schema };
|
|
175
|
+
// If this object has allOf, merge its contents
|
|
176
|
+
if (Array.isArray(result.allOf)) {
|
|
177
|
+
const merged = {};
|
|
178
|
+
const mergedRequired = [];
|
|
179
|
+
for (const item of result.allOf) {
|
|
180
|
+
if (!item || typeof item !== "object")
|
|
181
|
+
continue;
|
|
182
|
+
// Merge properties
|
|
183
|
+
if (item.properties && typeof item.properties === "object") {
|
|
184
|
+
merged.properties = { ...merged.properties, ...item.properties };
|
|
185
|
+
}
|
|
186
|
+
// Merge required arrays
|
|
187
|
+
if (Array.isArray(item.required)) {
|
|
188
|
+
for (const req of item.required) {
|
|
189
|
+
if (!mergedRequired.includes(req)) {
|
|
190
|
+
mergedRequired.push(req);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Copy other fields from allOf items
|
|
195
|
+
for (const [key, value] of Object.entries(item)) {
|
|
196
|
+
if (key !== "properties" && key !== "required" && merged[key] === undefined) {
|
|
197
|
+
merged[key] = value;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Apply merged content to result
|
|
202
|
+
if (merged.properties) {
|
|
203
|
+
result.properties = { ...result.properties, ...merged.properties };
|
|
204
|
+
}
|
|
205
|
+
if (mergedRequired.length > 0) {
|
|
206
|
+
const existingRequired = Array.isArray(result.required) ? result.required : [];
|
|
207
|
+
result.required = Array.from(new Set([...existingRequired, ...mergedRequired]));
|
|
208
|
+
}
|
|
209
|
+
// Copy other merged fields
|
|
210
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
211
|
+
if (key !== "properties" && key !== "required" && result[key] === undefined) {
|
|
212
|
+
result[key] = value;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
delete result.allOf;
|
|
216
|
+
}
|
|
217
|
+
// Recursively process nested objects
|
|
218
|
+
for (const [key, value] of Object.entries(result)) {
|
|
219
|
+
if (typeof value === "object" && value !== null) {
|
|
220
|
+
result[key] = mergeAllOf(value);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Scores a schema option for selection in anyOf/oneOf flattening.
|
|
227
|
+
* Higher score = more preferred.
|
|
228
|
+
*/
|
|
229
|
+
function scoreSchemaOption(schema) {
|
|
230
|
+
if (!schema || typeof schema !== "object") {
|
|
231
|
+
return { score: 0, typeName: "unknown" };
|
|
232
|
+
}
|
|
233
|
+
const type = schema.type;
|
|
234
|
+
// Object or has properties = highest priority
|
|
235
|
+
if (type === "object" || schema.properties) {
|
|
236
|
+
return { score: 3, typeName: "object" };
|
|
237
|
+
}
|
|
238
|
+
// Array or has items = second priority
|
|
239
|
+
if (type === "array" || schema.items) {
|
|
240
|
+
return { score: 2, typeName: "array" };
|
|
241
|
+
}
|
|
242
|
+
// Any other non-null type
|
|
243
|
+
if (type && type !== "null") {
|
|
244
|
+
return { score: 1, typeName: type };
|
|
245
|
+
}
|
|
246
|
+
// Null or no type
|
|
247
|
+
return { score: 0, typeName: type || "null" };
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Checks if an anyOf/oneOf array represents enum choices.
|
|
251
|
+
* Returns the merged enum values if so, otherwise null.
|
|
252
|
+
*
|
|
253
|
+
* Handles patterns like:
|
|
254
|
+
* - anyOf: [{ const: "a" }, { const: "b" }]
|
|
255
|
+
* - anyOf: [{ enum: ["a"] }, { enum: ["b"] }]
|
|
256
|
+
* - anyOf: [{ type: "string", const: "a" }, { type: "string", const: "b" }]
|
|
257
|
+
*/
|
|
258
|
+
function tryMergeEnumFromUnion(options) {
|
|
259
|
+
if (!Array.isArray(options) || options.length === 0) {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
const enumValues = [];
|
|
263
|
+
for (const option of options) {
|
|
264
|
+
if (!option || typeof option !== "object") {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
// Check for const value
|
|
268
|
+
if (option.const !== undefined) {
|
|
269
|
+
enumValues.push(String(option.const));
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
// Check for single-value enum
|
|
273
|
+
if (Array.isArray(option.enum) && option.enum.length === 1) {
|
|
274
|
+
enumValues.push(String(option.enum[0]));
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
// Check for multi-value enum (merge all values)
|
|
278
|
+
if (Array.isArray(option.enum) && option.enum.length > 0) {
|
|
279
|
+
for (const val of option.enum) {
|
|
280
|
+
enumValues.push(String(val));
|
|
281
|
+
}
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
// If option has complex structure (properties, items, etc.), it's not a simple enum
|
|
285
|
+
if (option.properties || option.items || option.anyOf || option.oneOf || option.allOf) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
// If option has only type (no const/enum), it's not an enum pattern
|
|
289
|
+
if (option.type && !option.const && !option.enum) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// Only return if we found actual enum values
|
|
294
|
+
return enumValues.length > 0 ? enumValues : null;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Phase 2b: Flattens anyOf/oneOf to the best option with type hints.
|
|
298
|
+
* { anyOf: [{ type: "string" }, { type: "number" }] }
|
|
299
|
+
* → { type: "string", description: "(Accepts: string | number)" }
|
|
300
|
+
*
|
|
301
|
+
* Special handling for enum patterns:
|
|
302
|
+
* { anyOf: [{ const: "a" }, { const: "b" }] }
|
|
303
|
+
* → { type: "string", enum: ["a", "b"] }
|
|
304
|
+
*/
|
|
305
|
+
function flattenAnyOfOneOf(schema) {
|
|
306
|
+
if (!schema || typeof schema !== "object") {
|
|
307
|
+
return schema;
|
|
308
|
+
}
|
|
309
|
+
if (Array.isArray(schema)) {
|
|
310
|
+
return schema.map(item => flattenAnyOfOneOf(item));
|
|
311
|
+
}
|
|
312
|
+
let result = { ...schema };
|
|
313
|
+
// Process anyOf or oneOf
|
|
314
|
+
for (const unionKey of ["anyOf", "oneOf"]) {
|
|
315
|
+
if (Array.isArray(result[unionKey]) && result[unionKey].length > 0) {
|
|
316
|
+
const options = result[unionKey];
|
|
317
|
+
const parentDesc = typeof result.description === "string" ? result.description : "";
|
|
318
|
+
// First, check if this is an enum pattern (anyOf with const/enum values)
|
|
319
|
+
// This is crucial for tools like WebFetch where format: anyOf[{const:"text"},{const:"markdown"},{const:"html"}]
|
|
320
|
+
const mergedEnum = tryMergeEnumFromUnion(options);
|
|
321
|
+
if (mergedEnum !== null) {
|
|
322
|
+
// This is an enum pattern - merge all values into a single enum
|
|
323
|
+
const { [unionKey]: _, ...rest } = result;
|
|
324
|
+
result = {
|
|
325
|
+
...rest,
|
|
326
|
+
type: "string",
|
|
327
|
+
enum: mergedEnum,
|
|
328
|
+
};
|
|
329
|
+
// Preserve parent description
|
|
330
|
+
if (parentDesc) {
|
|
331
|
+
result.description = parentDesc;
|
|
332
|
+
}
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
// Not an enum pattern - use standard flattening logic
|
|
336
|
+
// Score each option and find the best
|
|
337
|
+
let bestIdx = 0;
|
|
338
|
+
let bestScore = -1;
|
|
339
|
+
const allTypes = [];
|
|
340
|
+
for (let i = 0; i < options.length; i++) {
|
|
341
|
+
const { score, typeName } = scoreSchemaOption(options[i]);
|
|
342
|
+
if (typeName) {
|
|
343
|
+
allTypes.push(typeName);
|
|
344
|
+
}
|
|
345
|
+
if (score > bestScore) {
|
|
346
|
+
bestScore = score;
|
|
347
|
+
bestIdx = i;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// Select the best option and flatten it recursively
|
|
351
|
+
let selected = flattenAnyOfOneOf(options[bestIdx]) || { type: "string" };
|
|
352
|
+
// Preserve parent description
|
|
353
|
+
if (parentDesc) {
|
|
354
|
+
const childDesc = typeof selected.description === "string" ? selected.description : "";
|
|
355
|
+
if (childDesc && childDesc !== parentDesc) {
|
|
356
|
+
selected = { ...selected, description: `${parentDesc} (${childDesc})` };
|
|
357
|
+
}
|
|
358
|
+
else if (!childDesc) {
|
|
359
|
+
selected = { ...selected, description: parentDesc };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (allTypes.length > 1) {
|
|
363
|
+
const uniqueTypes = Array.from(new Set(allTypes));
|
|
364
|
+
const hint = `Accepts: ${uniqueTypes.join(" | ")}`;
|
|
365
|
+
selected = appendDescriptionHint(selected, hint);
|
|
366
|
+
}
|
|
367
|
+
// Replace result with selected schema, preserving other fields
|
|
368
|
+
const { [unionKey]: _, description: __, ...rest } = result;
|
|
369
|
+
result = { ...rest, ...selected };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// Recursively process nested objects
|
|
373
|
+
for (const [key, value] of Object.entries(result)) {
|
|
374
|
+
if (typeof value === "object" && value !== null) {
|
|
375
|
+
result[key] = flattenAnyOfOneOf(value);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return result;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Phase 2c: Flattens type arrays to single type with nullable hint.
|
|
382
|
+
* { type: ["string", "null"] } → { type: "string", description: "(nullable)" }
|
|
383
|
+
*/
|
|
384
|
+
function flattenTypeArrays(schema, nullableFields, currentPath) {
|
|
385
|
+
if (!schema || typeof schema !== "object") {
|
|
386
|
+
return schema;
|
|
387
|
+
}
|
|
388
|
+
if (Array.isArray(schema)) {
|
|
389
|
+
return schema.map((item, idx) => flattenTypeArrays(item, nullableFields, `${currentPath || ""}[${idx}]`));
|
|
390
|
+
}
|
|
391
|
+
let result = { ...schema };
|
|
392
|
+
const localNullableFields = nullableFields || new Map();
|
|
393
|
+
// Handle type array
|
|
394
|
+
if (Array.isArray(result.type)) {
|
|
395
|
+
const types = result.type;
|
|
396
|
+
const hasNull = types.includes("null");
|
|
397
|
+
const nonNullTypes = types.filter(t => t !== "null" && t);
|
|
398
|
+
// Select first non-null type, or "string" as fallback
|
|
399
|
+
const firstType = nonNullTypes.length > 0 ? nonNullTypes[0] : "string";
|
|
400
|
+
result.type = firstType;
|
|
401
|
+
// Add hint for multiple types
|
|
402
|
+
if (nonNullTypes.length > 1) {
|
|
403
|
+
result = appendDescriptionHint(result, `Accepts: ${nonNullTypes.join(" | ")}`);
|
|
404
|
+
}
|
|
405
|
+
// Add nullable hint
|
|
406
|
+
if (hasNull) {
|
|
407
|
+
result = appendDescriptionHint(result, "nullable");
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// Recursively process properties
|
|
411
|
+
if (result.properties && typeof result.properties === "object") {
|
|
412
|
+
const newProps = {};
|
|
413
|
+
for (const [propKey, propValue] of Object.entries(result.properties)) {
|
|
414
|
+
const propPath = currentPath ? `${currentPath}.properties.${propKey}` : `properties.${propKey}`;
|
|
415
|
+
const processed = flattenTypeArrays(propValue, localNullableFields, propPath);
|
|
416
|
+
newProps[propKey] = processed;
|
|
417
|
+
// Track nullable fields for required array cleanup
|
|
418
|
+
if (processed && typeof processed === "object" &&
|
|
419
|
+
typeof processed.description === "string" &&
|
|
420
|
+
processed.description.includes("nullable")) {
|
|
421
|
+
const objectPath = currentPath || "";
|
|
422
|
+
const existing = localNullableFields.get(objectPath) || [];
|
|
423
|
+
existing.push(propKey);
|
|
424
|
+
localNullableFields.set(objectPath, existing);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
result.properties = newProps;
|
|
428
|
+
}
|
|
429
|
+
// Remove nullable fields from required array
|
|
430
|
+
if (Array.isArray(result.required) && !nullableFields) {
|
|
431
|
+
// Only at root level, filter out nullable fields
|
|
432
|
+
const nullableAtRoot = localNullableFields.get("") || [];
|
|
433
|
+
if (nullableAtRoot.length > 0) {
|
|
434
|
+
result.required = result.required.filter((r) => !nullableAtRoot.includes(r));
|
|
435
|
+
if (result.required.length === 0) {
|
|
436
|
+
delete result.required;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// Recursively process other nested objects
|
|
441
|
+
for (const [key, value] of Object.entries(result)) {
|
|
442
|
+
if (key !== "properties" && typeof value === "object" && value !== null) {
|
|
443
|
+
result[key] = flattenTypeArrays(value, localNullableFields, `${currentPath || ""}.${key}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return result;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Phase 3: Removes unsupported keywords after hints have been extracted.
|
|
450
|
+
* @param insideProperties - When true, keys are property NAMES (preserve); when false, keys are JSON Schema keywords (filter).
|
|
451
|
+
*/
|
|
452
|
+
function removeUnsupportedKeywords(schema, insideProperties = false) {
|
|
453
|
+
if (!schema || typeof schema !== "object") {
|
|
454
|
+
return schema;
|
|
455
|
+
}
|
|
456
|
+
if (Array.isArray(schema)) {
|
|
457
|
+
return schema.map(item => removeUnsupportedKeywords(item, false));
|
|
458
|
+
}
|
|
459
|
+
const result = {};
|
|
460
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
461
|
+
if (!insideProperties && UNSUPPORTED_KEYWORDS.includes(key)) {
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (typeof value === "object" && value !== null) {
|
|
465
|
+
if (key === "properties") {
|
|
466
|
+
const propertiesResult = {};
|
|
467
|
+
for (const [propName, propSchema] of Object.entries(value)) {
|
|
468
|
+
propertiesResult[propName] = removeUnsupportedKeywords(propSchema, false);
|
|
469
|
+
}
|
|
470
|
+
result[key] = propertiesResult;
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
result[key] = removeUnsupportedKeywords(value, false);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
result[key] = value;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return result;
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Phase 3b: Cleans up required fields - removes entries that don't exist in properties.
|
|
484
|
+
*/
|
|
485
|
+
function cleanupRequiredFields(schema) {
|
|
486
|
+
if (!schema || typeof schema !== "object") {
|
|
487
|
+
return schema;
|
|
488
|
+
}
|
|
489
|
+
if (Array.isArray(schema)) {
|
|
490
|
+
return schema.map(item => cleanupRequiredFields(item));
|
|
491
|
+
}
|
|
492
|
+
let result = { ...schema };
|
|
493
|
+
// Clean up required array if properties exist
|
|
494
|
+
if (Array.isArray(result.required) && result.properties && typeof result.properties === "object") {
|
|
495
|
+
const validRequired = result.required.filter((req) => Object.prototype.hasOwnProperty.call(result.properties, req));
|
|
496
|
+
if (validRequired.length === 0) {
|
|
497
|
+
delete result.required;
|
|
498
|
+
}
|
|
499
|
+
else if (validRequired.length !== result.required.length) {
|
|
500
|
+
result.required = validRequired;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
// Recursively process nested objects
|
|
504
|
+
for (const [key, value] of Object.entries(result)) {
|
|
505
|
+
if (typeof value === "object" && value !== null) {
|
|
506
|
+
result[key] = cleanupRequiredFields(value);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return result;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Phase 4: Adds placeholder property for empty object schemas.
|
|
513
|
+
* Claude VALIDATED mode requires at least one property.
|
|
514
|
+
*/
|
|
515
|
+
function addEmptySchemaPlaceholder(schema) {
|
|
516
|
+
if (!schema || typeof schema !== "object") {
|
|
517
|
+
return schema;
|
|
518
|
+
}
|
|
519
|
+
if (Array.isArray(schema)) {
|
|
520
|
+
return schema.map(item => addEmptySchemaPlaceholder(item));
|
|
521
|
+
}
|
|
522
|
+
let result = { ...schema };
|
|
523
|
+
// Check if this is an empty object schema
|
|
524
|
+
const isObjectType = result.type === "object";
|
|
525
|
+
if (isObjectType) {
|
|
526
|
+
const hasProperties = result.properties &&
|
|
527
|
+
typeof result.properties === "object" &&
|
|
528
|
+
Object.keys(result.properties).length > 0;
|
|
529
|
+
if (!hasProperties) {
|
|
530
|
+
result.properties = {
|
|
531
|
+
[EMPTY_SCHEMA_PLACEHOLDER_NAME]: {
|
|
532
|
+
type: "boolean",
|
|
533
|
+
description: EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION,
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
result.required = [EMPTY_SCHEMA_PLACEHOLDER_NAME];
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
// Recursively process nested objects
|
|
540
|
+
for (const [key, value] of Object.entries(result)) {
|
|
541
|
+
if (typeof value === "object" && value !== null) {
|
|
542
|
+
result[key] = addEmptySchemaPlaceholder(value);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return result;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Cleans a JSON schema for Antigravity API compatibility.
|
|
549
|
+
* Transforms unsupported features into description hints while preserving semantic information.
|
|
550
|
+
*
|
|
551
|
+
* Ported from CLIProxyAPI's CleanJSONSchemaForAntigravity (gemini_schema.go)
|
|
552
|
+
*/
|
|
553
|
+
export function cleanJSONSchemaForAntigravity(schema) {
|
|
554
|
+
if (!schema || typeof schema !== "object") {
|
|
555
|
+
return schema;
|
|
556
|
+
}
|
|
557
|
+
let result = schema;
|
|
558
|
+
// Phase 1: Convert and add hints
|
|
559
|
+
result = convertRefsToHints(result);
|
|
560
|
+
result = convertConstToEnum(result);
|
|
561
|
+
result = addEnumHints(result);
|
|
562
|
+
result = addAdditionalPropertiesHints(result);
|
|
563
|
+
result = moveConstraintsToDescription(result);
|
|
564
|
+
// Phase 2: Flatten complex structures
|
|
565
|
+
result = mergeAllOf(result);
|
|
566
|
+
result = flattenAnyOfOneOf(result);
|
|
567
|
+
result = flattenTypeArrays(result);
|
|
568
|
+
// Phase 3: Cleanup
|
|
569
|
+
result = removeUnsupportedKeywords(result);
|
|
570
|
+
result = cleanupRequiredFields(result);
|
|
571
|
+
// Phase 4: Add placeholder for empty object schemas
|
|
572
|
+
result = addEmptySchemaPlaceholder(result);
|
|
573
|
+
return result;
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Default token budget for thinking/reasoning. 16000 tokens provides sufficient
|
|
577
|
+
* space for complex reasoning while staying within typical model limits.
|
|
578
|
+
*/
|
|
579
|
+
export const DEFAULT_THINKING_BUDGET = 16000;
|
|
580
|
+
/**
|
|
581
|
+
* Checks if a model name indicates thinking/reasoning capability.
|
|
582
|
+
* Models with "thinking", "gemini-3", or "opus" in their name support extended thinking.
|
|
583
|
+
*/
|
|
584
|
+
export function isThinkingCapableModel(modelName) {
|
|
585
|
+
const lowerModel = modelName.toLowerCase();
|
|
586
|
+
return lowerModel.includes("thinking")
|
|
587
|
+
|| lowerModel.includes("gemini-3")
|
|
588
|
+
|| lowerModel.includes("opus");
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Extracts thinking configuration from various possible request locations.
|
|
592
|
+
* Supports both Gemini-style thinkingConfig and Anthropic-style thinking options.
|
|
593
|
+
*/
|
|
594
|
+
export function extractThinkingConfig(requestPayload, rawGenerationConfig, extraBody) {
|
|
595
|
+
const thinkingConfig = rawGenerationConfig?.thinkingConfig
|
|
596
|
+
?? extraBody?.thinkingConfig
|
|
597
|
+
?? requestPayload.thinkingConfig;
|
|
598
|
+
if (thinkingConfig && typeof thinkingConfig === "object") {
|
|
599
|
+
const config = thinkingConfig;
|
|
600
|
+
return {
|
|
601
|
+
includeThoughts: Boolean(config.includeThoughts),
|
|
602
|
+
thinkingBudget: typeof config.thinkingBudget === "number" ? config.thinkingBudget : DEFAULT_THINKING_BUDGET,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
// Convert Anthropic-style "thinking" option: { type: "enabled", budgetTokens: N }
|
|
606
|
+
const anthropicThinking = extraBody?.thinking ?? requestPayload.thinking;
|
|
607
|
+
if (anthropicThinking && typeof anthropicThinking === "object") {
|
|
608
|
+
const thinking = anthropicThinking;
|
|
609
|
+
if (thinking.type === "enabled" || thinking.budgetTokens) {
|
|
610
|
+
return {
|
|
611
|
+
includeThoughts: true,
|
|
612
|
+
thinkingBudget: typeof thinking.budgetTokens === "number" ? thinking.budgetTokens : DEFAULT_THINKING_BUDGET,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return undefined;
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Extracts variant thinking config from OpenCode's providerOptions.
|
|
620
|
+
*
|
|
621
|
+
* All Antigravity models route through the Google provider, so we only check
|
|
622
|
+
* providerOptions.google. Supports two formats:
|
|
623
|
+
*
|
|
624
|
+
* 1. Gemini 3 native: { google: { thinkingLevel: "high", includeThoughts: true } }
|
|
625
|
+
* 2. Budget-based (Claude/Gemini 2.5): { google: { thinkingConfig: { thinkingBudget: 32000 } } }
|
|
626
|
+
*/
|
|
627
|
+
export function extractVariantThinkingConfig(providerOptions) {
|
|
628
|
+
if (!providerOptions)
|
|
629
|
+
return undefined;
|
|
630
|
+
const google = providerOptions.google;
|
|
631
|
+
if (!google)
|
|
632
|
+
return undefined;
|
|
633
|
+
const result = {};
|
|
634
|
+
// Gemini 3 native format: { google: { thinkingLevel: "high", includeThoughts: true } }
|
|
635
|
+
// thinkingLevel takes priority over thinkingBudget - they are mutually exclusive
|
|
636
|
+
if (typeof google.thinkingLevel === "string") {
|
|
637
|
+
result.thinkingLevel = google.thinkingLevel;
|
|
638
|
+
result.includeThoughts = typeof google.includeThoughts === "boolean" ? google.includeThoughts : undefined;
|
|
639
|
+
}
|
|
640
|
+
else if (google.thinkingConfig && typeof google.thinkingConfig === "object") {
|
|
641
|
+
// Budget-based format (Claude/Gemini 2.5): { google: { thinkingConfig: { thinkingBudget } } }
|
|
642
|
+
// Only used when thinkingLevel is not present
|
|
643
|
+
const tc = google.thinkingConfig;
|
|
644
|
+
if (typeof tc.thinkingBudget === "number") {
|
|
645
|
+
result.thinkingBudget = tc.thinkingBudget;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
// Extract Google Search config
|
|
649
|
+
if (google.googleSearch && typeof google.googleSearch === "object") {
|
|
650
|
+
const search = google.googleSearch;
|
|
651
|
+
result.googleSearch = {
|
|
652
|
+
mode: search.mode === 'auto' || search.mode === 'off' ? search.mode : undefined,
|
|
653
|
+
threshold: typeof search.threshold === 'number' ? search.threshold : undefined,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Determines the final thinking configuration based on model capabilities and user settings.
|
|
660
|
+
* For Claude thinking models, we keep thinking enabled even in multi-turn conversations.
|
|
661
|
+
* The filterUnsignedThinkingBlocks function will handle signature validation/restoration.
|
|
662
|
+
*/
|
|
663
|
+
export function resolveThinkingConfig(userConfig, isThinkingModel, _isClaudeModel, _hasAssistantHistory) {
|
|
664
|
+
// For thinking-capable models (including Claude thinking models), enable thinking by default
|
|
665
|
+
// The signature validation/restoration is handled by filterUnsignedThinkingBlocks
|
|
666
|
+
if (isThinkingModel && !userConfig) {
|
|
667
|
+
return { includeThoughts: true, thinkingBudget: DEFAULT_THINKING_BUDGET };
|
|
668
|
+
}
|
|
669
|
+
return userConfig;
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Checks if a part is a thinking/reasoning block (Anthropic or Gemini style).
|
|
673
|
+
*/
|
|
674
|
+
function isThinkingPart(part) {
|
|
675
|
+
return part.type === "thinking"
|
|
676
|
+
|| part.type === "redacted_thinking"
|
|
677
|
+
|| part.type === "reasoning"
|
|
678
|
+
|| part.thinking !== undefined
|
|
679
|
+
|| part.thought === true;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Checks if a part has a signature field (thinking block signature).
|
|
683
|
+
* Used to detect foreign thinking blocks that might have unknown type values.
|
|
684
|
+
*/
|
|
685
|
+
function hasSignatureField(part) {
|
|
686
|
+
return part.signature !== undefined || part.thoughtSignature !== undefined;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Checks if a part is a tool block (tool_use or tool_result).
|
|
690
|
+
* Tool blocks must never be filtered - they're required for tool call/result pairing.
|
|
691
|
+
* Handles multiple formats:
|
|
692
|
+
* - Anthropic: { type: "tool_use" }, { type: "tool_result", tool_use_id }
|
|
693
|
+
* - Nested: { tool_result: { tool_use_id } }, { tool_use: { id } }
|
|
694
|
+
* - Gemini: { functionCall }, { functionResponse }
|
|
695
|
+
*/
|
|
696
|
+
function isToolBlock(part) {
|
|
697
|
+
return part.type === "tool_use"
|
|
698
|
+
|| part.type === "tool_result"
|
|
699
|
+
|| part.tool_use_id !== undefined
|
|
700
|
+
|| part.tool_call_id !== undefined
|
|
701
|
+
|| part.tool_result !== undefined
|
|
702
|
+
|| part.tool_use !== undefined
|
|
703
|
+
|| part.toolUse !== undefined
|
|
704
|
+
|| part.functionCall !== undefined
|
|
705
|
+
|| part.functionResponse !== undefined;
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Unconditionally strips ALL thinking/reasoning blocks from a content array.
|
|
709
|
+
* Used for Claude models to avoid signature validation errors entirely.
|
|
710
|
+
* Claude will generate fresh thinking for each turn.
|
|
711
|
+
*/
|
|
712
|
+
function stripAllThinkingBlocks(contentArray) {
|
|
713
|
+
return contentArray.filter(item => {
|
|
714
|
+
if (!item || typeof item !== "object")
|
|
715
|
+
return true;
|
|
716
|
+
if (isToolBlock(item))
|
|
717
|
+
return true;
|
|
718
|
+
if (isThinkingPart(item))
|
|
719
|
+
return false;
|
|
720
|
+
if (hasSignatureField(item))
|
|
721
|
+
return false;
|
|
722
|
+
return true;
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Removes trailing thinking blocks from a content array.
|
|
727
|
+
* Claude API requires that assistant messages don't end with thinking blocks.
|
|
728
|
+
* Only removes unsigned thinking blocks; preserves those with valid signatures.
|
|
729
|
+
*/
|
|
730
|
+
function removeTrailingThinkingBlocks(contentArray, sessionId, getCachedSignatureFn) {
|
|
731
|
+
const result = [...contentArray];
|
|
732
|
+
while (result.length > 0 && isThinkingPart(result[result.length - 1])) {
|
|
733
|
+
const part = result[result.length - 1];
|
|
734
|
+
const isValid = sessionId && getCachedSignatureFn
|
|
735
|
+
? isOurCachedSignature(part, sessionId, getCachedSignatureFn)
|
|
736
|
+
: hasValidSignature(part);
|
|
737
|
+
if (isValid) {
|
|
738
|
+
break;
|
|
739
|
+
}
|
|
740
|
+
result.pop();
|
|
741
|
+
}
|
|
742
|
+
return result;
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Checks if a thinking part has a valid signature.
|
|
746
|
+
* A valid signature is a non-empty string with at least 50 characters.
|
|
747
|
+
*/
|
|
748
|
+
function hasValidSignature(part) {
|
|
749
|
+
const signature = part.thought === true ? part.thoughtSignature : part.signature;
|
|
750
|
+
return typeof signature === "string" && signature.length >= 50;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Gets the signature from a thinking part, if present.
|
|
754
|
+
*/
|
|
755
|
+
function getSignature(part) {
|
|
756
|
+
const signature = part.thought === true ? part.thoughtSignature : part.signature;
|
|
757
|
+
return typeof signature === "string" ? signature : undefined;
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Checks if a thinking part's signature was generated by our plugin (exists in our cache).
|
|
761
|
+
* This prevents accepting signatures from other providers (e.g., direct Anthropic API, OpenAI)
|
|
762
|
+
* which would cause "Invalid signature" errors when sent to Antigravity Claude.
|
|
763
|
+
*/
|
|
764
|
+
function isOurCachedSignature(part, sessionId, getCachedSignatureFn) {
|
|
765
|
+
if (!sessionId || !getCachedSignatureFn) {
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
const text = getThinkingText(part);
|
|
769
|
+
if (!text) {
|
|
770
|
+
return false;
|
|
771
|
+
}
|
|
772
|
+
const partSignature = getSignature(part);
|
|
773
|
+
if (!partSignature) {
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
const cachedSignature = getCachedSignatureFn(sessionId, text);
|
|
777
|
+
return cachedSignature === partSignature;
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Gets the text content from a thinking part.
|
|
781
|
+
*/
|
|
782
|
+
function getThinkingText(part) {
|
|
783
|
+
if (typeof part.text === "string")
|
|
784
|
+
return part.text;
|
|
785
|
+
if (typeof part.thinking === "string")
|
|
786
|
+
return part.thinking;
|
|
787
|
+
if (part.text && typeof part.text === "object") {
|
|
788
|
+
const maybeText = part.text.text;
|
|
789
|
+
if (typeof maybeText === "string")
|
|
790
|
+
return maybeText;
|
|
791
|
+
}
|
|
792
|
+
if (part.thinking && typeof part.thinking === "object") {
|
|
793
|
+
const maybeText = part.thinking.text ?? part.thinking.thinking;
|
|
794
|
+
if (typeof maybeText === "string")
|
|
795
|
+
return maybeText;
|
|
796
|
+
}
|
|
797
|
+
return "";
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Recursively strips cache_control and providerOptions from any object.
|
|
801
|
+
* These fields can be injected by SDKs, but Claude rejects them inside thinking blocks.
|
|
802
|
+
*/
|
|
803
|
+
function stripCacheControlRecursively(obj) {
|
|
804
|
+
if (obj === null || obj === undefined)
|
|
805
|
+
return obj;
|
|
806
|
+
if (typeof obj !== "object")
|
|
807
|
+
return obj;
|
|
808
|
+
if (Array.isArray(obj))
|
|
809
|
+
return obj.map(item => stripCacheControlRecursively(item));
|
|
810
|
+
const result = {};
|
|
811
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
812
|
+
if (key === "cache_control" || key === "providerOptions")
|
|
813
|
+
continue;
|
|
814
|
+
result[key] = stripCacheControlRecursively(value);
|
|
815
|
+
}
|
|
816
|
+
return result;
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Sanitizes a thinking part by keeping only the allowed fields.
|
|
820
|
+
* In particular, ensures `thinking` is a string (not an object with cache_control).
|
|
821
|
+
* Returns null if the thinking block has no valid content.
|
|
822
|
+
*/
|
|
823
|
+
function sanitizeThinkingPart(part) {
|
|
824
|
+
// Gemini-style thought blocks: { thought: true, text, thoughtSignature }
|
|
825
|
+
if (part.thought === true) {
|
|
826
|
+
let textContent = part.text;
|
|
827
|
+
if (typeof textContent === "object" && textContent !== null) {
|
|
828
|
+
const maybeText = textContent.text;
|
|
829
|
+
textContent = typeof maybeText === "string" ? maybeText : undefined;
|
|
830
|
+
}
|
|
831
|
+
const hasContent = typeof textContent === "string" && textContent.trim().length > 0;
|
|
832
|
+
if (!hasContent && !part.thoughtSignature) {
|
|
833
|
+
return null;
|
|
834
|
+
}
|
|
835
|
+
const sanitized = { thought: true };
|
|
836
|
+
if (textContent !== undefined)
|
|
837
|
+
sanitized.text = textContent;
|
|
838
|
+
if (part.thoughtSignature !== undefined)
|
|
839
|
+
sanitized.thoughtSignature = part.thoughtSignature;
|
|
840
|
+
return sanitized;
|
|
841
|
+
}
|
|
842
|
+
// Anthropic-style thinking/redacted_thinking blocks: { type: "thinking"|"redacted_thinking", thinking, signature }
|
|
843
|
+
if (part.type === "thinking" || part.type === "redacted_thinking" || part.thinking !== undefined) {
|
|
844
|
+
let thinkingContent = part.thinking ?? part.text;
|
|
845
|
+
if (thinkingContent !== undefined && typeof thinkingContent === "object" && thinkingContent !== null) {
|
|
846
|
+
const maybeText = thinkingContent.text ?? thinkingContent.thinking;
|
|
847
|
+
thinkingContent = typeof maybeText === "string" ? maybeText : undefined;
|
|
848
|
+
}
|
|
849
|
+
const hasContent = typeof thinkingContent === "string" && thinkingContent.trim().length > 0;
|
|
850
|
+
if (!hasContent && !part.signature) {
|
|
851
|
+
return null;
|
|
852
|
+
}
|
|
853
|
+
const sanitized = { type: part.type === "redacted_thinking" ? "redacted_thinking" : "thinking" };
|
|
854
|
+
if (thinkingContent !== undefined)
|
|
855
|
+
sanitized.thinking = thinkingContent;
|
|
856
|
+
if (part.signature !== undefined)
|
|
857
|
+
sanitized.signature = part.signature;
|
|
858
|
+
return sanitized;
|
|
859
|
+
}
|
|
860
|
+
// Reasoning blocks (OpenCode format): { type: "reasoning", text, signature }
|
|
861
|
+
if (part.type === "reasoning") {
|
|
862
|
+
let textContent = part.text;
|
|
863
|
+
if (typeof textContent === "object" && textContent !== null) {
|
|
864
|
+
const maybeText = textContent.text;
|
|
865
|
+
textContent = typeof maybeText === "string" ? maybeText : undefined;
|
|
866
|
+
}
|
|
867
|
+
const hasContent = typeof textContent === "string" && textContent.trim().length > 0;
|
|
868
|
+
if (!hasContent && !part.signature) {
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
const sanitized = { type: "reasoning" };
|
|
872
|
+
if (textContent !== undefined)
|
|
873
|
+
sanitized.text = textContent;
|
|
874
|
+
if (part.signature !== undefined)
|
|
875
|
+
sanitized.signature = part.signature;
|
|
876
|
+
return sanitized;
|
|
877
|
+
}
|
|
878
|
+
// Fallback: strip cache_control recursively.
|
|
879
|
+
return stripCacheControlRecursively(part);
|
|
880
|
+
}
|
|
881
|
+
function findLastAssistantIndex(contents, roleValue) {
|
|
882
|
+
for (let i = contents.length - 1; i >= 0; i--) {
|
|
883
|
+
const content = contents[i];
|
|
884
|
+
if (content && typeof content === "object" && content.role === roleValue) {
|
|
885
|
+
return i;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return -1;
|
|
889
|
+
}
|
|
890
|
+
function filterContentArray(contentArray, sessionId, getCachedSignatureFn, isClaudeModel, isLastAssistantMessage = false) {
|
|
891
|
+
// For Claude models, strip thinking blocks by default for reliability
|
|
892
|
+
// User can opt-in to keep thinking via config: { "keep_thinking": true }
|
|
893
|
+
if (isClaudeModel && !getKeepThinking()) {
|
|
894
|
+
return stripAllThinkingBlocks(contentArray);
|
|
895
|
+
}
|
|
896
|
+
const filtered = [];
|
|
897
|
+
for (const item of contentArray) {
|
|
898
|
+
if (!item || typeof item !== "object") {
|
|
899
|
+
filtered.push(item);
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
if (isToolBlock(item)) {
|
|
903
|
+
filtered.push(item);
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
const isThinking = isThinkingPart(item);
|
|
907
|
+
const hasSignature = hasSignatureField(item);
|
|
908
|
+
if (!isThinking && !hasSignature) {
|
|
909
|
+
filtered.push(item);
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
// For the LAST assistant message with thinking blocks:
|
|
913
|
+
// - If signature is valid (length >= 50), pass through unchanged
|
|
914
|
+
// - If signature is invalid/missing, inject sentinel to bypass validation
|
|
915
|
+
if (isLastAssistantMessage && (isThinking || hasSignature)) {
|
|
916
|
+
const existingSignature = item.signature || item.thoughtSignature;
|
|
917
|
+
const hasValidSignature = typeof existingSignature === "string" && existingSignature.length >= 50;
|
|
918
|
+
if (hasValidSignature) {
|
|
919
|
+
filtered.push(item);
|
|
920
|
+
}
|
|
921
|
+
else {
|
|
922
|
+
// Invalid or missing signature - inject sentinel
|
|
923
|
+
const thinkingText = getThinkingText(item) || "";
|
|
924
|
+
log.debug("Injecting sentinel signature for invalid last-message thinking block");
|
|
925
|
+
const sentinelPart = {
|
|
926
|
+
type: item.type || "thinking",
|
|
927
|
+
thinking: thinkingText,
|
|
928
|
+
signature: SKIP_THOUGHT_SIGNATURE,
|
|
929
|
+
};
|
|
930
|
+
filtered.push(sentinelPart);
|
|
931
|
+
}
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
if (isOurCachedSignature(item, sessionId, getCachedSignatureFn)) {
|
|
935
|
+
const sanitized = sanitizeThinkingPart(item);
|
|
936
|
+
if (sanitized)
|
|
937
|
+
filtered.push(sanitized);
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
if (sessionId && getCachedSignatureFn) {
|
|
941
|
+
const text = getThinkingText(item);
|
|
942
|
+
if (text) {
|
|
943
|
+
const cachedSignature = getCachedSignatureFn(sessionId, text);
|
|
944
|
+
if (cachedSignature && cachedSignature.length >= 50) {
|
|
945
|
+
const restoredPart = { ...item };
|
|
946
|
+
if (item.thought === true) {
|
|
947
|
+
restoredPart.thoughtSignature = cachedSignature;
|
|
948
|
+
}
|
|
949
|
+
else {
|
|
950
|
+
restoredPart.signature = cachedSignature;
|
|
951
|
+
}
|
|
952
|
+
const sanitized = sanitizeThinkingPart(restoredPart);
|
|
953
|
+
if (sanitized)
|
|
954
|
+
filtered.push(sanitized);
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
return filtered;
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Filters thinking blocks from contents unless the signature matches our cache.
|
|
964
|
+
* Attempts to restore signatures from cache for thinking blocks that lack signatures.
|
|
965
|
+
*
|
|
966
|
+
* @param contents - The contents array from the request
|
|
967
|
+
* @param sessionId - Optional session ID for signature cache lookup
|
|
968
|
+
* @param getCachedSignatureFn - Optional function to retrieve cached signatures
|
|
969
|
+
*/
|
|
970
|
+
export function filterUnsignedThinkingBlocks(contents, sessionId, getCachedSignatureFn, isClaudeModel) {
|
|
971
|
+
const lastAssistantIdx = findLastAssistantIndex(contents, "model");
|
|
972
|
+
return contents.map((content, idx) => {
|
|
973
|
+
if (!content || typeof content !== "object") {
|
|
974
|
+
return content;
|
|
975
|
+
}
|
|
976
|
+
const isLastAssistant = idx === lastAssistantIdx;
|
|
977
|
+
if (Array.isArray(content.parts)) {
|
|
978
|
+
const filteredParts = filterContentArray(content.parts, sessionId, getCachedSignatureFn, isClaudeModel, isLastAssistant);
|
|
979
|
+
const trimmedParts = content.role === "model" && !isClaudeModel
|
|
980
|
+
? removeTrailingThinkingBlocks(filteredParts, sessionId, getCachedSignatureFn)
|
|
981
|
+
: filteredParts;
|
|
982
|
+
return { ...content, parts: trimmedParts };
|
|
983
|
+
}
|
|
984
|
+
if (Array.isArray(content.content)) {
|
|
985
|
+
const isAssistantRole = content.role === "assistant";
|
|
986
|
+
const isLastAssistantContent = idx === lastAssistantIdx ||
|
|
987
|
+
(isAssistantRole && idx === findLastAssistantIndex(contents, "assistant"));
|
|
988
|
+
const filteredContent = filterContentArray(content.content, sessionId, getCachedSignatureFn, isClaudeModel, isLastAssistantContent);
|
|
989
|
+
const trimmedContent = isAssistantRole && !isClaudeModel
|
|
990
|
+
? removeTrailingThinkingBlocks(filteredContent, sessionId, getCachedSignatureFn)
|
|
991
|
+
: filteredContent;
|
|
992
|
+
return { ...content, content: trimmedContent };
|
|
993
|
+
}
|
|
994
|
+
return content;
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Filters thinking blocks from Anthropic-style messages[] payloads using cached signatures.
|
|
999
|
+
*/
|
|
1000
|
+
export function filterMessagesThinkingBlocks(messages, sessionId, getCachedSignatureFn, isClaudeModel) {
|
|
1001
|
+
const lastAssistantIdx = findLastAssistantIndex(messages, "assistant");
|
|
1002
|
+
return messages.map((message, idx) => {
|
|
1003
|
+
if (!message || typeof message !== "object") {
|
|
1004
|
+
return message;
|
|
1005
|
+
}
|
|
1006
|
+
if (Array.isArray(message.content)) {
|
|
1007
|
+
const isAssistantRole = message.role === "assistant";
|
|
1008
|
+
const isLastAssistant = isAssistantRole && idx === lastAssistantIdx;
|
|
1009
|
+
const filteredContent = filterContentArray(message.content, sessionId, getCachedSignatureFn, isClaudeModel, isLastAssistant);
|
|
1010
|
+
const trimmedContent = isAssistantRole && !isClaudeModel
|
|
1011
|
+
? removeTrailingThinkingBlocks(filteredContent, sessionId, getCachedSignatureFn)
|
|
1012
|
+
: filteredContent;
|
|
1013
|
+
return { ...message, content: trimmedContent };
|
|
1014
|
+
}
|
|
1015
|
+
return message;
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
export function deepFilterThinkingBlocks(payload, sessionId, getCachedSignatureFn, isClaudeModel) {
|
|
1019
|
+
const visited = new WeakSet();
|
|
1020
|
+
const walk = (value) => {
|
|
1021
|
+
if (!value || typeof value !== "object") {
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
if (visited.has(value)) {
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
visited.add(value);
|
|
1028
|
+
if (Array.isArray(value)) {
|
|
1029
|
+
value.forEach((item) => walk(item));
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
const obj = value;
|
|
1033
|
+
if (Array.isArray(obj.contents)) {
|
|
1034
|
+
obj.contents = filterUnsignedThinkingBlocks(obj.contents, sessionId, getCachedSignatureFn, isClaudeModel);
|
|
1035
|
+
}
|
|
1036
|
+
if (Array.isArray(obj.messages)) {
|
|
1037
|
+
obj.messages = filterMessagesThinkingBlocks(obj.messages, sessionId, getCachedSignatureFn, isClaudeModel);
|
|
1038
|
+
}
|
|
1039
|
+
Object.keys(obj).forEach((key) => walk(obj[key]));
|
|
1040
|
+
};
|
|
1041
|
+
walk(payload);
|
|
1042
|
+
return payload;
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Transforms Gemini-style thought parts (thought: true) and Anthropic-style
|
|
1046
|
+
* thinking parts (type: "thinking") to reasoning format.
|
|
1047
|
+
* Claude responses through Antigravity may use candidates structure with Anthropic-style parts.
|
|
1048
|
+
*/
|
|
1049
|
+
function transformGeminiCandidate(candidate) {
|
|
1050
|
+
if (!candidate || typeof candidate !== "object") {
|
|
1051
|
+
return candidate;
|
|
1052
|
+
}
|
|
1053
|
+
const content = candidate.content;
|
|
1054
|
+
if (!content || typeof content !== "object" || !Array.isArray(content.parts)) {
|
|
1055
|
+
return candidate;
|
|
1056
|
+
}
|
|
1057
|
+
const thinkingTexts = [];
|
|
1058
|
+
const transformedParts = content.parts.map((part) => {
|
|
1059
|
+
if (!part || typeof part !== "object") {
|
|
1060
|
+
return part;
|
|
1061
|
+
}
|
|
1062
|
+
// Handle Gemini-style: thought: true
|
|
1063
|
+
if (part.thought === true) {
|
|
1064
|
+
thinkingTexts.push(part.text || "");
|
|
1065
|
+
const transformed = { ...part, type: "reasoning" };
|
|
1066
|
+
if (part.cache_control)
|
|
1067
|
+
transformed.cache_control = part.cache_control;
|
|
1068
|
+
return transformed;
|
|
1069
|
+
}
|
|
1070
|
+
// Handle Anthropic-style in candidates: type: "thinking"
|
|
1071
|
+
if (part.type === "thinking") {
|
|
1072
|
+
const thinkingText = part.thinking || part.text || "";
|
|
1073
|
+
thinkingTexts.push(thinkingText);
|
|
1074
|
+
const transformed = {
|
|
1075
|
+
...part,
|
|
1076
|
+
type: "reasoning",
|
|
1077
|
+
text: thinkingText,
|
|
1078
|
+
thought: true,
|
|
1079
|
+
};
|
|
1080
|
+
if (part.cache_control)
|
|
1081
|
+
transformed.cache_control = part.cache_control;
|
|
1082
|
+
return transformed;
|
|
1083
|
+
}
|
|
1084
|
+
// Handle functionCall: parse JSON strings in args
|
|
1085
|
+
// (Ported from LLM-API-Key-Proxy's _extract_tool_call)
|
|
1086
|
+
if (part.functionCall && part.functionCall.args) {
|
|
1087
|
+
const parsedArgs = recursivelyParseJsonStrings(part.functionCall.args);
|
|
1088
|
+
return {
|
|
1089
|
+
...part,
|
|
1090
|
+
functionCall: {
|
|
1091
|
+
...part.functionCall,
|
|
1092
|
+
args: parsedArgs,
|
|
1093
|
+
},
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
// Handle image data (inlineData) - save to disk and return file path
|
|
1097
|
+
if (part.inlineData) {
|
|
1098
|
+
const result = processImageData({
|
|
1099
|
+
mimeType: part.inlineData.mimeType,
|
|
1100
|
+
data: part.inlineData.data,
|
|
1101
|
+
});
|
|
1102
|
+
if (result) {
|
|
1103
|
+
return { text: result };
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return part;
|
|
1107
|
+
});
|
|
1108
|
+
return {
|
|
1109
|
+
...candidate,
|
|
1110
|
+
content: { ...content, parts: transformedParts },
|
|
1111
|
+
...(thinkingTexts.length > 0 ? { reasoning_content: thinkingTexts.join("\n\n") } : {}),
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Transforms thinking/reasoning content in response parts to OpenCode's expected format.
|
|
1116
|
+
* Handles both Gemini-style (thought: true) and Anthropic-style (type: "thinking") formats.
|
|
1117
|
+
* Also extracts reasoning_content for Anthropic-style responses.
|
|
1118
|
+
*/
|
|
1119
|
+
export function transformThinkingParts(response) {
|
|
1120
|
+
if (!response || typeof response !== "object") {
|
|
1121
|
+
return response;
|
|
1122
|
+
}
|
|
1123
|
+
const resp = response;
|
|
1124
|
+
const result = { ...resp };
|
|
1125
|
+
const reasoningTexts = [];
|
|
1126
|
+
// Handle Anthropic-style content array (type: "thinking")
|
|
1127
|
+
if (Array.isArray(resp.content)) {
|
|
1128
|
+
const transformedContent = [];
|
|
1129
|
+
for (const block of resp.content) {
|
|
1130
|
+
if (block && typeof block === "object" && block.type === "thinking") {
|
|
1131
|
+
const thinkingText = block.thinking || block.text || "";
|
|
1132
|
+
reasoningTexts.push(thinkingText);
|
|
1133
|
+
transformedContent.push({
|
|
1134
|
+
...block,
|
|
1135
|
+
type: "reasoning",
|
|
1136
|
+
text: thinkingText,
|
|
1137
|
+
thought: true,
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
else {
|
|
1141
|
+
transformedContent.push(block);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
result.content = transformedContent;
|
|
1145
|
+
}
|
|
1146
|
+
// Handle Gemini-style candidates array
|
|
1147
|
+
if (Array.isArray(resp.candidates)) {
|
|
1148
|
+
result.candidates = resp.candidates.map(transformGeminiCandidate);
|
|
1149
|
+
}
|
|
1150
|
+
// Add reasoning_content if we found any thinking blocks (for Anthropic-style)
|
|
1151
|
+
if (reasoningTexts.length > 0 && !result.reasoning_content) {
|
|
1152
|
+
result.reasoning_content = reasoningTexts.join("\n\n");
|
|
1153
|
+
}
|
|
1154
|
+
return result;
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Ensures thinkingConfig is valid: includeThoughts only allowed when budget > 0.
|
|
1158
|
+
*/
|
|
1159
|
+
export function normalizeThinkingConfig(config) {
|
|
1160
|
+
if (!config || typeof config !== "object") {
|
|
1161
|
+
return undefined;
|
|
1162
|
+
}
|
|
1163
|
+
const record = config;
|
|
1164
|
+
const budgetRaw = record.thinkingBudget ?? record.thinking_budget;
|
|
1165
|
+
const includeRaw = record.includeThoughts ?? record.include_thoughts;
|
|
1166
|
+
const thinkingBudget = typeof budgetRaw === "number" && Number.isFinite(budgetRaw) ? budgetRaw : undefined;
|
|
1167
|
+
const includeThoughts = typeof includeRaw === "boolean" ? includeRaw : undefined;
|
|
1168
|
+
const enableThinking = thinkingBudget !== undefined && thinkingBudget > 0;
|
|
1169
|
+
const finalInclude = enableThinking ? includeThoughts ?? false : false;
|
|
1170
|
+
if (!enableThinking && finalInclude === false && thinkingBudget === undefined && includeThoughts === undefined) {
|
|
1171
|
+
return undefined;
|
|
1172
|
+
}
|
|
1173
|
+
const normalized = {};
|
|
1174
|
+
if (thinkingBudget !== undefined) {
|
|
1175
|
+
normalized.thinkingBudget = thinkingBudget;
|
|
1176
|
+
}
|
|
1177
|
+
if (finalInclude !== undefined) {
|
|
1178
|
+
normalized.includeThoughts = finalInclude;
|
|
1179
|
+
}
|
|
1180
|
+
return normalized;
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Parses an Antigravity API body; handles array-wrapped responses the API sometimes returns.
|
|
1184
|
+
*/
|
|
1185
|
+
export function parseAntigravityApiBody(rawText) {
|
|
1186
|
+
try {
|
|
1187
|
+
const parsed = JSON.parse(rawText);
|
|
1188
|
+
if (Array.isArray(parsed)) {
|
|
1189
|
+
const firstObject = parsed.find((item) => typeof item === "object" && item !== null);
|
|
1190
|
+
if (firstObject && typeof firstObject === "object") {
|
|
1191
|
+
return firstObject;
|
|
1192
|
+
}
|
|
1193
|
+
return null;
|
|
1194
|
+
}
|
|
1195
|
+
if (parsed && typeof parsed === "object") {
|
|
1196
|
+
return parsed;
|
|
1197
|
+
}
|
|
1198
|
+
return null;
|
|
1199
|
+
}
|
|
1200
|
+
catch {
|
|
1201
|
+
return null;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Extracts usageMetadata from a response object, guarding types.
|
|
1206
|
+
*/
|
|
1207
|
+
export function extractUsageMetadata(body) {
|
|
1208
|
+
const usage = (body.response && typeof body.response === "object"
|
|
1209
|
+
? body.response.usageMetadata
|
|
1210
|
+
: undefined);
|
|
1211
|
+
if (!usage || typeof usage !== "object") {
|
|
1212
|
+
return null;
|
|
1213
|
+
}
|
|
1214
|
+
const asRecord = usage;
|
|
1215
|
+
const toNumber = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
1216
|
+
return {
|
|
1217
|
+
totalTokenCount: toNumber(asRecord.totalTokenCount),
|
|
1218
|
+
promptTokenCount: toNumber(asRecord.promptTokenCount),
|
|
1219
|
+
candidatesTokenCount: toNumber(asRecord.candidatesTokenCount),
|
|
1220
|
+
cachedContentTokenCount: toNumber(asRecord.cachedContentTokenCount),
|
|
1221
|
+
thoughtsTokenCount: toNumber(asRecord.thoughtsTokenCount),
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Walks SSE lines to find a usage-bearing response chunk.
|
|
1226
|
+
*/
|
|
1227
|
+
export function extractUsageFromSsePayload(payload) {
|
|
1228
|
+
const lines = payload.split("\n");
|
|
1229
|
+
for (const line of lines) {
|
|
1230
|
+
if (!line.startsWith("data:")) {
|
|
1231
|
+
continue;
|
|
1232
|
+
}
|
|
1233
|
+
const jsonText = line.slice(5).trim();
|
|
1234
|
+
if (!jsonText) {
|
|
1235
|
+
continue;
|
|
1236
|
+
}
|
|
1237
|
+
try {
|
|
1238
|
+
const parsed = JSON.parse(jsonText);
|
|
1239
|
+
if (parsed && typeof parsed === "object") {
|
|
1240
|
+
const usage = extractUsageMetadata({ response: parsed.response });
|
|
1241
|
+
if (usage) {
|
|
1242
|
+
return usage;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
catch {
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
return null;
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* Enhances 404 errors for Antigravity models with a direct preview-access message.
|
|
1254
|
+
*/
|
|
1255
|
+
export function rewriteAntigravityPreviewAccessError(body, status, requestedModel) {
|
|
1256
|
+
if (!needsPreviewAccessOverride(status, body, requestedModel)) {
|
|
1257
|
+
return null;
|
|
1258
|
+
}
|
|
1259
|
+
const error = body.error ?? {};
|
|
1260
|
+
const trimmedMessage = typeof error.message === "string" ? error.message.trim() : "";
|
|
1261
|
+
const messagePrefix = trimmedMessage.length > 0
|
|
1262
|
+
? trimmedMessage
|
|
1263
|
+
: "Antigravity preview features are not enabled for this account.";
|
|
1264
|
+
const enhancedMessage = `${messagePrefix} Request preview access at ${ANTIGRAVITY_PREVIEW_LINK} before using this model.`;
|
|
1265
|
+
return {
|
|
1266
|
+
...body,
|
|
1267
|
+
error: {
|
|
1268
|
+
...error,
|
|
1269
|
+
message: enhancedMessage,
|
|
1270
|
+
},
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
function needsPreviewAccessOverride(status, body, requestedModel) {
|
|
1274
|
+
if (status !== 404) {
|
|
1275
|
+
return false;
|
|
1276
|
+
}
|
|
1277
|
+
if (isAntigravityModel(requestedModel)) {
|
|
1278
|
+
return true;
|
|
1279
|
+
}
|
|
1280
|
+
const errorMessage = typeof body.error?.message === "string" ? body.error.message : "";
|
|
1281
|
+
return isAntigravityModel(errorMessage);
|
|
1282
|
+
}
|
|
1283
|
+
function isAntigravityModel(target) {
|
|
1284
|
+
if (!target) {
|
|
1285
|
+
return false;
|
|
1286
|
+
}
|
|
1287
|
+
// Check for Antigravity models instead of Gemini 3
|
|
1288
|
+
return /antigravity/i.test(target) || /opus/i.test(target) || /claude/i.test(target);
|
|
1289
|
+
}
|
|
1290
|
+
// ============================================================================
|
|
1291
|
+
// EMPTY RESPONSE DETECTION (Ported from LLM-API-Key-Proxy)
|
|
1292
|
+
// ============================================================================
|
|
1293
|
+
/**
|
|
1294
|
+
* Checks if a JSON response body represents an empty response.
|
|
1295
|
+
*
|
|
1296
|
+
* Empty responses occur when:
|
|
1297
|
+
* - No candidates in Gemini format
|
|
1298
|
+
* - No choices in OpenAI format
|
|
1299
|
+
* - Candidates/choices exist but have no content
|
|
1300
|
+
*
|
|
1301
|
+
* @param text - The response body text (should be valid JSON)
|
|
1302
|
+
* @returns true if the response is empty
|
|
1303
|
+
*/
|
|
1304
|
+
export function isEmptyResponseBody(text) {
|
|
1305
|
+
if (!text || !text.trim()) {
|
|
1306
|
+
return true;
|
|
1307
|
+
}
|
|
1308
|
+
try {
|
|
1309
|
+
const parsed = JSON.parse(text);
|
|
1310
|
+
// Check for empty candidates (Gemini/Antigravity format)
|
|
1311
|
+
if (parsed.candidates !== undefined) {
|
|
1312
|
+
if (!Array.isArray(parsed.candidates) || parsed.candidates.length === 0) {
|
|
1313
|
+
return true;
|
|
1314
|
+
}
|
|
1315
|
+
// Check if first candidate has empty content
|
|
1316
|
+
const firstCandidate = parsed.candidates[0];
|
|
1317
|
+
if (!firstCandidate) {
|
|
1318
|
+
return true;
|
|
1319
|
+
}
|
|
1320
|
+
// Check for empty parts in content
|
|
1321
|
+
const content = firstCandidate.content;
|
|
1322
|
+
if (!content || typeof content !== "object") {
|
|
1323
|
+
return true;
|
|
1324
|
+
}
|
|
1325
|
+
const parts = content.parts;
|
|
1326
|
+
if (!Array.isArray(parts) || parts.length === 0) {
|
|
1327
|
+
return true;
|
|
1328
|
+
}
|
|
1329
|
+
// Check if all parts are empty (no text, no functionCall)
|
|
1330
|
+
const hasContent = parts.some((part) => {
|
|
1331
|
+
if (!part || typeof part !== "object")
|
|
1332
|
+
return false;
|
|
1333
|
+
if (typeof part.text === "string" && part.text.length > 0)
|
|
1334
|
+
return true;
|
|
1335
|
+
if (part.functionCall)
|
|
1336
|
+
return true;
|
|
1337
|
+
if (part.thought === true && typeof part.text === "string")
|
|
1338
|
+
return true;
|
|
1339
|
+
return false;
|
|
1340
|
+
});
|
|
1341
|
+
if (!hasContent) {
|
|
1342
|
+
return true;
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
// Check for empty choices (OpenAI format - shouldn't occur but handle it)
|
|
1346
|
+
if (parsed.choices !== undefined) {
|
|
1347
|
+
if (!Array.isArray(parsed.choices) || parsed.choices.length === 0) {
|
|
1348
|
+
return true;
|
|
1349
|
+
}
|
|
1350
|
+
const firstChoice = parsed.choices[0];
|
|
1351
|
+
if (!firstChoice) {
|
|
1352
|
+
return true;
|
|
1353
|
+
}
|
|
1354
|
+
// Check for empty message/delta
|
|
1355
|
+
const message = firstChoice.message || firstChoice.delta;
|
|
1356
|
+
if (!message) {
|
|
1357
|
+
return true;
|
|
1358
|
+
}
|
|
1359
|
+
// Check if message has content or tool_calls
|
|
1360
|
+
if (!message.content && !message.tool_calls && !message.reasoning_content) {
|
|
1361
|
+
return true;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
// Check response wrapper (Antigravity envelope)
|
|
1365
|
+
if (parsed.response !== undefined) {
|
|
1366
|
+
const response = parsed.response;
|
|
1367
|
+
if (!response || typeof response !== "object") {
|
|
1368
|
+
return true;
|
|
1369
|
+
}
|
|
1370
|
+
return isEmptyResponseBody(JSON.stringify(response));
|
|
1371
|
+
}
|
|
1372
|
+
return false;
|
|
1373
|
+
}
|
|
1374
|
+
catch {
|
|
1375
|
+
// JSON parse error - treat as empty
|
|
1376
|
+
return true;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
export function createStreamingChunkCounter() {
|
|
1380
|
+
let count = 0;
|
|
1381
|
+
let hasRealContent = false;
|
|
1382
|
+
return {
|
|
1383
|
+
increment: () => {
|
|
1384
|
+
count++;
|
|
1385
|
+
},
|
|
1386
|
+
getCount: () => count,
|
|
1387
|
+
hasContent: () => hasRealContent || count > 0,
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* Checks if an SSE line contains meaningful content.
|
|
1392
|
+
*
|
|
1393
|
+
* @param line - A single SSE line (e.g., "data: {...}")
|
|
1394
|
+
* @returns true if the line contains content worth counting
|
|
1395
|
+
*/
|
|
1396
|
+
export function isMeaningfulSseLine(line) {
|
|
1397
|
+
if (!line.startsWith("data: ")) {
|
|
1398
|
+
return false;
|
|
1399
|
+
}
|
|
1400
|
+
const data = line.slice(6).trim();
|
|
1401
|
+
if (data === "[DONE]") {
|
|
1402
|
+
return false;
|
|
1403
|
+
}
|
|
1404
|
+
if (!data) {
|
|
1405
|
+
return false;
|
|
1406
|
+
}
|
|
1407
|
+
try {
|
|
1408
|
+
const parsed = JSON.parse(data);
|
|
1409
|
+
// Check for candidates with content
|
|
1410
|
+
if (parsed.candidates && Array.isArray(parsed.candidates)) {
|
|
1411
|
+
for (const candidate of parsed.candidates) {
|
|
1412
|
+
const parts = candidate?.content?.parts;
|
|
1413
|
+
if (Array.isArray(parts) && parts.length > 0) {
|
|
1414
|
+
for (const part of parts) {
|
|
1415
|
+
if (typeof part?.text === "string" && part.text.length > 0)
|
|
1416
|
+
return true;
|
|
1417
|
+
if (part?.functionCall)
|
|
1418
|
+
return true;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
// Check response wrapper
|
|
1424
|
+
if (parsed.response?.candidates) {
|
|
1425
|
+
return isMeaningfulSseLine(`data: ${JSON.stringify(parsed.response)}`);
|
|
1426
|
+
}
|
|
1427
|
+
return false;
|
|
1428
|
+
}
|
|
1429
|
+
catch {
|
|
1430
|
+
return false;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
// ============================================================================
|
|
1434
|
+
// RECURSIVE JSON STRING AUTO-PARSING (Ported from LLM-API-Key-Proxy)
|
|
1435
|
+
// ============================================================================
|
|
1436
|
+
/**
|
|
1437
|
+
* Recursively parses JSON strings in nested data structures.
|
|
1438
|
+
*
|
|
1439
|
+
* This is a port of LLM-API-Key-Proxy's _recursively_parse_json_strings() function.
|
|
1440
|
+
*
|
|
1441
|
+
* Handles:
|
|
1442
|
+
* - JSON-stringified values: {"files": "[{...}]"} → {"files": [{...}]}
|
|
1443
|
+
* - Malformed double-encoded JSON (extra trailing chars)
|
|
1444
|
+
* - Escaped control characters (\\n → \n, \\t → \t)
|
|
1445
|
+
*
|
|
1446
|
+
* This is useful because Antigravity sometimes returns JSON-stringified values
|
|
1447
|
+
* in tool arguments, which can cause downstream parsing issues.
|
|
1448
|
+
*
|
|
1449
|
+
* @param obj - The object to recursively parse
|
|
1450
|
+
* @param skipParseKeys - Set of keys whose values should NOT be parsed as JSON (preserved as strings)
|
|
1451
|
+
* @param currentKey - The current key being processed (internal use)
|
|
1452
|
+
* @returns The parsed object with JSON strings expanded
|
|
1453
|
+
*/
|
|
1454
|
+
// Keys whose string values should NOT be parsed as JSON - they contain literal text content
|
|
1455
|
+
const SKIP_PARSE_KEYS = new Set([
|
|
1456
|
+
"oldString",
|
|
1457
|
+
"newString",
|
|
1458
|
+
"content",
|
|
1459
|
+
"filePath",
|
|
1460
|
+
"path",
|
|
1461
|
+
"text",
|
|
1462
|
+
"code",
|
|
1463
|
+
"source",
|
|
1464
|
+
"data",
|
|
1465
|
+
"body",
|
|
1466
|
+
"message",
|
|
1467
|
+
"prompt",
|
|
1468
|
+
"input",
|
|
1469
|
+
"output",
|
|
1470
|
+
"result",
|
|
1471
|
+
"value",
|
|
1472
|
+
"query",
|
|
1473
|
+
"pattern",
|
|
1474
|
+
"replacement",
|
|
1475
|
+
"template",
|
|
1476
|
+
"script",
|
|
1477
|
+
"command",
|
|
1478
|
+
"snippet",
|
|
1479
|
+
]);
|
|
1480
|
+
export function recursivelyParseJsonStrings(obj, skipParseKeys = SKIP_PARSE_KEYS, currentKey) {
|
|
1481
|
+
if (obj === null || obj === undefined) {
|
|
1482
|
+
return obj;
|
|
1483
|
+
}
|
|
1484
|
+
if (Array.isArray(obj)) {
|
|
1485
|
+
return obj.map((item) => recursivelyParseJsonStrings(item, skipParseKeys));
|
|
1486
|
+
}
|
|
1487
|
+
if (typeof obj === "object") {
|
|
1488
|
+
const result = {};
|
|
1489
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
1490
|
+
result[key] = recursivelyParseJsonStrings(value, skipParseKeys, key);
|
|
1491
|
+
}
|
|
1492
|
+
return result;
|
|
1493
|
+
}
|
|
1494
|
+
if (typeof obj !== "string") {
|
|
1495
|
+
return obj;
|
|
1496
|
+
}
|
|
1497
|
+
if (currentKey && skipParseKeys.has(currentKey)) {
|
|
1498
|
+
return obj;
|
|
1499
|
+
}
|
|
1500
|
+
const stripped = obj.trim();
|
|
1501
|
+
// Check if string contains control character escape sequences
|
|
1502
|
+
// that need unescaping (\\n, \\t but NOT \\" or \\\\)
|
|
1503
|
+
const hasControlCharEscapes = obj.includes("\\n") || obj.includes("\\t");
|
|
1504
|
+
const hasIntentionalEscapes = obj.includes('\\"') || obj.includes("\\\\");
|
|
1505
|
+
if (hasControlCharEscapes && !hasIntentionalEscapes) {
|
|
1506
|
+
try {
|
|
1507
|
+
// Use JSON.parse with quotes to unescape the string
|
|
1508
|
+
return JSON.parse(`"${obj}"`);
|
|
1509
|
+
}
|
|
1510
|
+
catch {
|
|
1511
|
+
// Continue with original processing
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
// Check if it looks like JSON (starts with { or [)
|
|
1515
|
+
if (stripped && (stripped[0] === "{" || stripped[0] === "[")) {
|
|
1516
|
+
// Try standard parsing first
|
|
1517
|
+
if ((stripped.startsWith("{") && stripped.endsWith("}")) ||
|
|
1518
|
+
(stripped.startsWith("[") && stripped.endsWith("]"))) {
|
|
1519
|
+
try {
|
|
1520
|
+
const parsed = JSON.parse(obj);
|
|
1521
|
+
return recursivelyParseJsonStrings(parsed);
|
|
1522
|
+
}
|
|
1523
|
+
catch {
|
|
1524
|
+
// Continue
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
// Handle malformed JSON: array that doesn't end with ]
|
|
1528
|
+
if (stripped.startsWith("[") && !stripped.endsWith("]")) {
|
|
1529
|
+
try {
|
|
1530
|
+
const lastBracket = stripped.lastIndexOf("]");
|
|
1531
|
+
if (lastBracket > 0) {
|
|
1532
|
+
const cleaned = stripped.slice(0, lastBracket + 1);
|
|
1533
|
+
const parsed = JSON.parse(cleaned);
|
|
1534
|
+
log.debug("Auto-corrected malformed JSON array", {
|
|
1535
|
+
truncatedChars: stripped.length - cleaned.length,
|
|
1536
|
+
});
|
|
1537
|
+
return recursivelyParseJsonStrings(parsed);
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
catch {
|
|
1541
|
+
// Continue
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
// Handle malformed JSON: object that doesn't end with }
|
|
1545
|
+
if (stripped.startsWith("{") && !stripped.endsWith("}")) {
|
|
1546
|
+
try {
|
|
1547
|
+
const lastBrace = stripped.lastIndexOf("}");
|
|
1548
|
+
if (lastBrace > 0) {
|
|
1549
|
+
const cleaned = stripped.slice(0, lastBrace + 1);
|
|
1550
|
+
const parsed = JSON.parse(cleaned);
|
|
1551
|
+
log.debug("Auto-corrected malformed JSON object", {
|
|
1552
|
+
truncatedChars: stripped.length - cleaned.length,
|
|
1553
|
+
});
|
|
1554
|
+
return recursivelyParseJsonStrings(parsed);
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
catch {
|
|
1558
|
+
// Continue
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
return obj;
|
|
1563
|
+
}
|
|
1564
|
+
// ============================================================================
|
|
1565
|
+
// TOOL ID ORPHAN RECOVERY (Ported from LLM-API-Key-Proxy)
|
|
1566
|
+
// ============================================================================
|
|
1567
|
+
/**
|
|
1568
|
+
* Groups function calls with their responses, handling ID mismatches.
|
|
1569
|
+
*
|
|
1570
|
+
* This is a port of LLM-API-Key-Proxy's _fix_tool_response_grouping() function.
|
|
1571
|
+
*
|
|
1572
|
+
* When context compaction or other processes strip tool responses, the tool call
|
|
1573
|
+
* IDs become orphaned. This function attempts to recover by:
|
|
1574
|
+
*
|
|
1575
|
+
* 1. Pass 1: Match by exact ID (normal case)
|
|
1576
|
+
* 2. Pass 2: Match by function name (for ID mismatches)
|
|
1577
|
+
* 3. Pass 3: Match "unknown_function" orphans or take first available
|
|
1578
|
+
* 4. Fallback: Create placeholder responses for missing tool results
|
|
1579
|
+
*
|
|
1580
|
+
* @param contents - Array of Gemini-style content messages
|
|
1581
|
+
* @returns Fixed contents array with matched tool responses
|
|
1582
|
+
*/
|
|
1583
|
+
export function fixToolResponseGrouping(contents) {
|
|
1584
|
+
if (!Array.isArray(contents) || contents.length === 0) {
|
|
1585
|
+
return contents;
|
|
1586
|
+
}
|
|
1587
|
+
const newContents = [];
|
|
1588
|
+
// Track pending tool call groups that need responses
|
|
1589
|
+
const pendingGroups = [];
|
|
1590
|
+
// Collected orphan responses (by ID)
|
|
1591
|
+
const collectedResponses = new Map();
|
|
1592
|
+
for (const content of contents) {
|
|
1593
|
+
const role = content.role;
|
|
1594
|
+
const parts = content.parts || [];
|
|
1595
|
+
// Check if this is a tool response message
|
|
1596
|
+
const responseParts = parts.filter((p) => p?.functionResponse);
|
|
1597
|
+
if (responseParts.length > 0) {
|
|
1598
|
+
// Collect responses by ID (skip duplicates)
|
|
1599
|
+
for (const resp of responseParts) {
|
|
1600
|
+
const respId = resp.functionResponse?.id || "";
|
|
1601
|
+
if (respId && !collectedResponses.has(respId)) {
|
|
1602
|
+
collectedResponses.set(respId, resp);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
// Try to satisfy the most recent pending group
|
|
1606
|
+
for (let i = pendingGroups.length - 1; i >= 0; i--) {
|
|
1607
|
+
const group = pendingGroups[i];
|
|
1608
|
+
if (group.ids.every(id => collectedResponses.has(id))) {
|
|
1609
|
+
// All IDs found - build the response group
|
|
1610
|
+
const groupResponses = group.ids.map(id => {
|
|
1611
|
+
const resp = collectedResponses.get(id);
|
|
1612
|
+
collectedResponses.delete(id);
|
|
1613
|
+
return resp;
|
|
1614
|
+
});
|
|
1615
|
+
newContents.push({ parts: groupResponses, role: "user" });
|
|
1616
|
+
pendingGroups.splice(i, 1);
|
|
1617
|
+
break; // Only satisfy one group at a time
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
continue; // Don't add the original response message
|
|
1621
|
+
}
|
|
1622
|
+
if (role === "model") {
|
|
1623
|
+
// Check for function calls in this model message
|
|
1624
|
+
const funcCalls = parts.filter((p) => p?.functionCall);
|
|
1625
|
+
newContents.push(content);
|
|
1626
|
+
if (funcCalls.length > 0) {
|
|
1627
|
+
const callIds = funcCalls
|
|
1628
|
+
.map((fc) => fc.functionCall?.id || "")
|
|
1629
|
+
.filter(Boolean);
|
|
1630
|
+
const funcNames = funcCalls
|
|
1631
|
+
.map((fc) => fc.functionCall?.name || "");
|
|
1632
|
+
if (callIds.length > 0) {
|
|
1633
|
+
pendingGroups.push({
|
|
1634
|
+
ids: callIds,
|
|
1635
|
+
funcNames,
|
|
1636
|
+
insertAfterIdx: newContents.length - 1,
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
else {
|
|
1642
|
+
newContents.push(content);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
// Handle remaining pending groups with orphan recovery
|
|
1646
|
+
// Process in reverse order so insertions don't shift indices
|
|
1647
|
+
pendingGroups.sort((a, b) => b.insertAfterIdx - a.insertAfterIdx);
|
|
1648
|
+
for (const group of pendingGroups) {
|
|
1649
|
+
const groupResponses = [];
|
|
1650
|
+
for (let i = 0; i < group.ids.length; i++) {
|
|
1651
|
+
const expectedId = group.ids[i];
|
|
1652
|
+
const expectedName = group.funcNames[i] || "";
|
|
1653
|
+
if (collectedResponses.has(expectedId)) {
|
|
1654
|
+
// Direct ID match - ideal case
|
|
1655
|
+
groupResponses.push(collectedResponses.get(expectedId));
|
|
1656
|
+
collectedResponses.delete(expectedId);
|
|
1657
|
+
}
|
|
1658
|
+
else if (collectedResponses.size > 0) {
|
|
1659
|
+
// Need to find an orphan response
|
|
1660
|
+
let matchedId = null;
|
|
1661
|
+
// Pass 1: Match by function name
|
|
1662
|
+
for (const [orphanId, orphanResp] of collectedResponses) {
|
|
1663
|
+
const orphanName = orphanResp.functionResponse?.name || "";
|
|
1664
|
+
if (orphanName === expectedName) {
|
|
1665
|
+
matchedId = orphanId;
|
|
1666
|
+
break;
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
// Pass 2: Match "unknown_function" orphans
|
|
1670
|
+
if (!matchedId) {
|
|
1671
|
+
for (const [orphanId, orphanResp] of collectedResponses) {
|
|
1672
|
+
if (orphanResp.functionResponse?.name === "unknown_function") {
|
|
1673
|
+
matchedId = orphanId;
|
|
1674
|
+
break;
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
// Pass 3: Take first available
|
|
1679
|
+
if (!matchedId) {
|
|
1680
|
+
matchedId = collectedResponses.keys().next().value ?? null;
|
|
1681
|
+
}
|
|
1682
|
+
if (matchedId) {
|
|
1683
|
+
const orphanResp = collectedResponses.get(matchedId);
|
|
1684
|
+
collectedResponses.delete(matchedId);
|
|
1685
|
+
// Fix the ID and name to match expected
|
|
1686
|
+
orphanResp.functionResponse.id = expectedId;
|
|
1687
|
+
if (orphanResp.functionResponse.name === "unknown_function" && expectedName) {
|
|
1688
|
+
orphanResp.functionResponse.name = expectedName;
|
|
1689
|
+
}
|
|
1690
|
+
log.debug("Auto-repaired tool ID mismatch", {
|
|
1691
|
+
mappedFrom: matchedId,
|
|
1692
|
+
mappedTo: expectedId,
|
|
1693
|
+
functionName: expectedName,
|
|
1694
|
+
});
|
|
1695
|
+
groupResponses.push(orphanResp);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
else {
|
|
1699
|
+
// No responses available - create placeholder
|
|
1700
|
+
const placeholder = {
|
|
1701
|
+
functionResponse: {
|
|
1702
|
+
name: expectedName || "unknown_function",
|
|
1703
|
+
response: {
|
|
1704
|
+
result: {
|
|
1705
|
+
error: "Tool response was lost during context processing. " +
|
|
1706
|
+
"This is a recovered placeholder.",
|
|
1707
|
+
recovered: true,
|
|
1708
|
+
},
|
|
1709
|
+
},
|
|
1710
|
+
id: expectedId,
|
|
1711
|
+
},
|
|
1712
|
+
};
|
|
1713
|
+
log.debug("Created placeholder response for missing tool", {
|
|
1714
|
+
id: expectedId,
|
|
1715
|
+
name: expectedName,
|
|
1716
|
+
});
|
|
1717
|
+
groupResponses.push(placeholder);
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
if (groupResponses.length > 0) {
|
|
1721
|
+
// Insert at correct position (after the model message that made the calls)
|
|
1722
|
+
newContents.splice(group.insertAfterIdx + 1, 0, {
|
|
1723
|
+
parts: groupResponses,
|
|
1724
|
+
role: "user",
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
return newContents;
|
|
1729
|
+
}
|
|
1730
|
+
/**
|
|
1731
|
+
* Checks if contents have any tool call/response ID mismatches.
|
|
1732
|
+
*
|
|
1733
|
+
* @param contents - Array of Gemini-style content messages
|
|
1734
|
+
* @returns Object with mismatch details
|
|
1735
|
+
*/
|
|
1736
|
+
export function detectToolIdMismatches(contents) {
|
|
1737
|
+
const expectedIds = [];
|
|
1738
|
+
const foundIds = [];
|
|
1739
|
+
for (const content of contents) {
|
|
1740
|
+
const parts = content.parts || [];
|
|
1741
|
+
for (const part of parts) {
|
|
1742
|
+
if (part?.functionCall?.id) {
|
|
1743
|
+
expectedIds.push(part.functionCall.id);
|
|
1744
|
+
}
|
|
1745
|
+
if (part?.functionResponse?.id) {
|
|
1746
|
+
foundIds.push(part.functionResponse.id);
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
const expectedSet = new Set(expectedIds);
|
|
1751
|
+
const foundSet = new Set(foundIds);
|
|
1752
|
+
const missingIds = expectedIds.filter(id => !foundSet.has(id));
|
|
1753
|
+
const orphanIds = foundIds.filter(id => !expectedSet.has(id));
|
|
1754
|
+
return {
|
|
1755
|
+
hasMismatches: missingIds.length > 0 || orphanIds.length > 0,
|
|
1756
|
+
expectedIds,
|
|
1757
|
+
foundIds,
|
|
1758
|
+
missingIds,
|
|
1759
|
+
orphanIds,
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
// ============================================================================
|
|
1763
|
+
// CLAUDE FORMAT TOOL PAIRING (Defense in Depth)
|
|
1764
|
+
// ============================================================================
|
|
1765
|
+
/**
|
|
1766
|
+
* Find orphaned tool_use IDs (tool_use without matching tool_result).
|
|
1767
|
+
* Works on Claude format messages.
|
|
1768
|
+
*/
|
|
1769
|
+
export function findOrphanedToolUseIds(messages) {
|
|
1770
|
+
const toolUseIds = new Set();
|
|
1771
|
+
const toolResultIds = new Set();
|
|
1772
|
+
for (const msg of messages) {
|
|
1773
|
+
if (Array.isArray(msg.content)) {
|
|
1774
|
+
for (const block of msg.content) {
|
|
1775
|
+
if (block.type === "tool_use" && block.id) {
|
|
1776
|
+
toolUseIds.add(block.id);
|
|
1777
|
+
}
|
|
1778
|
+
if (block.type === "tool_result" && block.tool_use_id) {
|
|
1779
|
+
toolResultIds.add(block.tool_use_id);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
return new Set([...toolUseIds].filter((id) => !toolResultIds.has(id)));
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* Fix orphaned tool_use blocks in Claude format messages.
|
|
1788
|
+
* Mirrors fixToolResponseGrouping() but for Claude's messages[] format.
|
|
1789
|
+
*
|
|
1790
|
+
* Claude format:
|
|
1791
|
+
* - assistant message with content[]: { type: 'tool_use', id, name, input }
|
|
1792
|
+
* - user message with content[]: { type: 'tool_result', tool_use_id, content }
|
|
1793
|
+
*
|
|
1794
|
+
* @param messages - Claude format messages array
|
|
1795
|
+
* @returns Fixed messages with placeholder tool_results for orphans
|
|
1796
|
+
*/
|
|
1797
|
+
export function fixClaudeToolPairing(messages) {
|
|
1798
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
1799
|
+
return messages;
|
|
1800
|
+
}
|
|
1801
|
+
// 1. Collect all tool_use IDs from assistant messages
|
|
1802
|
+
const toolUseMap = new Map();
|
|
1803
|
+
for (let i = 0; i < messages.length; i++) {
|
|
1804
|
+
const msg = messages[i];
|
|
1805
|
+
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
|
1806
|
+
for (const block of msg.content) {
|
|
1807
|
+
if (block.type === "tool_use" && block.id) {
|
|
1808
|
+
toolUseMap.set(block.id, { name: block.name || `tool-${toolUseMap.size}`, msgIndex: i });
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
// 2. Collect all tool_result IDs from user messages
|
|
1814
|
+
const toolResultIds = new Set();
|
|
1815
|
+
for (const msg of messages) {
|
|
1816
|
+
if (msg.role === "user" && Array.isArray(msg.content)) {
|
|
1817
|
+
for (const block of msg.content) {
|
|
1818
|
+
if (block.type === "tool_result" && block.tool_use_id) {
|
|
1819
|
+
toolResultIds.add(block.tool_use_id);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
// 3. Find orphaned tool_use (no matching tool_result)
|
|
1825
|
+
const orphans = [];
|
|
1826
|
+
for (const [id, info] of toolUseMap) {
|
|
1827
|
+
if (!toolResultIds.has(id)) {
|
|
1828
|
+
orphans.push({ id, ...info });
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
if (orphans.length === 0) {
|
|
1832
|
+
return messages;
|
|
1833
|
+
}
|
|
1834
|
+
// 4. Group orphans by message index (insert after each assistant message)
|
|
1835
|
+
const orphansByMsgIndex = new Map();
|
|
1836
|
+
for (const orphan of orphans) {
|
|
1837
|
+
const existing = orphansByMsgIndex.get(orphan.msgIndex) || [];
|
|
1838
|
+
existing.push(orphan);
|
|
1839
|
+
orphansByMsgIndex.set(orphan.msgIndex, existing);
|
|
1840
|
+
}
|
|
1841
|
+
// 5. Build new messages array with injected tool_results
|
|
1842
|
+
const result = [];
|
|
1843
|
+
for (let i = 0; i < messages.length; i++) {
|
|
1844
|
+
result.push(messages[i]);
|
|
1845
|
+
const orphansForMsg = orphansByMsgIndex.get(i);
|
|
1846
|
+
if (orphansForMsg && orphansForMsg.length > 0) {
|
|
1847
|
+
// Check if next message is user with tool_result - if so, merge into it
|
|
1848
|
+
const nextMsg = messages[i + 1];
|
|
1849
|
+
if (nextMsg?.role === "user" && Array.isArray(nextMsg.content)) {
|
|
1850
|
+
// Will be handled when we push nextMsg - add to its content
|
|
1851
|
+
const placeholders = orphansForMsg.map((o) => ({
|
|
1852
|
+
type: "tool_result",
|
|
1853
|
+
tool_use_id: o.id,
|
|
1854
|
+
content: `[Tool "${o.name}" execution was cancelled or failed]`,
|
|
1855
|
+
is_error: true,
|
|
1856
|
+
}));
|
|
1857
|
+
// Prepend placeholders to next message's content
|
|
1858
|
+
nextMsg.content = [...placeholders, ...nextMsg.content];
|
|
1859
|
+
}
|
|
1860
|
+
else {
|
|
1861
|
+
// Inject new user message with placeholder tool_results
|
|
1862
|
+
result.push({
|
|
1863
|
+
role: "user",
|
|
1864
|
+
content: orphansForMsg.map((o) => ({
|
|
1865
|
+
type: "tool_result",
|
|
1866
|
+
tool_use_id: o.id,
|
|
1867
|
+
content: `[Tool "${o.name}" execution was cancelled or failed]`,
|
|
1868
|
+
is_error: true,
|
|
1869
|
+
})),
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
return result;
|
|
1875
|
+
}
|
|
1876
|
+
/**
|
|
1877
|
+
* Nuclear option: Remove orphaned tool_use blocks entirely.
|
|
1878
|
+
* Called when fixClaudeToolPairing() fails to pair all tools.
|
|
1879
|
+
*/
|
|
1880
|
+
function removeOrphanedToolUse(messages, orphanIds) {
|
|
1881
|
+
return messages
|
|
1882
|
+
.map((msg) => {
|
|
1883
|
+
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
|
1884
|
+
return {
|
|
1885
|
+
...msg,
|
|
1886
|
+
content: msg.content.filter((block) => block.type !== "tool_use" || !orphanIds.has(block.id)),
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
return msg;
|
|
1890
|
+
})
|
|
1891
|
+
.filter((msg) =>
|
|
1892
|
+
// Remove empty assistant messages
|
|
1893
|
+
!(msg.role === "assistant" && Array.isArray(msg.content) && msg.content.length === 0));
|
|
1894
|
+
}
|
|
1895
|
+
/**
|
|
1896
|
+
* Validate and fix tool pairing with fallback nuclear option.
|
|
1897
|
+
* Defense in depth: tries gentle fix first, then nuclear removal.
|
|
1898
|
+
*/
|
|
1899
|
+
export function validateAndFixClaudeToolPairing(messages) {
|
|
1900
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
1901
|
+
return messages;
|
|
1902
|
+
}
|
|
1903
|
+
// First: Try gentle fix (inject placeholder tool_results)
|
|
1904
|
+
let fixed = fixClaudeToolPairing(messages);
|
|
1905
|
+
// Second: Validate - find any remaining orphans
|
|
1906
|
+
const orphanIds = findOrphanedToolUseIds(fixed);
|
|
1907
|
+
if (orphanIds.size === 0) {
|
|
1908
|
+
return fixed;
|
|
1909
|
+
}
|
|
1910
|
+
// Third: Nuclear option - remove orphaned tool_use entirely
|
|
1911
|
+
// This should rarely happen, but provides defense in depth
|
|
1912
|
+
console.warn("[antigravity] fixClaudeToolPairing left orphans, applying nuclear option", {
|
|
1913
|
+
orphanIds: [...orphanIds],
|
|
1914
|
+
});
|
|
1915
|
+
return removeOrphanedToolUse(fixed, orphanIds);
|
|
1916
|
+
}
|
|
1917
|
+
// ============================================================================
|
|
1918
|
+
// TOOL HALLUCINATION PREVENTION (Ported from LLM-API-Key-Proxy)
|
|
1919
|
+
// ============================================================================
|
|
1920
|
+
/**
|
|
1921
|
+
* Formats a type hint for a property schema.
|
|
1922
|
+
* Port of LLM-API-Key-Proxy's _format_type_hint()
|
|
1923
|
+
*/
|
|
1924
|
+
function formatTypeHint(propData, depth = 0) {
|
|
1925
|
+
const type = propData.type ?? "unknown";
|
|
1926
|
+
// Handle enum values
|
|
1927
|
+
if (propData.enum && Array.isArray(propData.enum)) {
|
|
1928
|
+
const enumVals = propData.enum;
|
|
1929
|
+
if (enumVals.length <= 5) {
|
|
1930
|
+
return `string ENUM[${enumVals.map(v => JSON.stringify(v)).join(", ")}]`;
|
|
1931
|
+
}
|
|
1932
|
+
return `string ENUM[${enumVals.length} options]`;
|
|
1933
|
+
}
|
|
1934
|
+
// Handle const values
|
|
1935
|
+
if (propData.const !== undefined) {
|
|
1936
|
+
return `string CONST=${JSON.stringify(propData.const)}`;
|
|
1937
|
+
}
|
|
1938
|
+
if (type === "array") {
|
|
1939
|
+
const items = propData.items;
|
|
1940
|
+
if (items && typeof items === "object") {
|
|
1941
|
+
const itemType = items.type ?? "unknown";
|
|
1942
|
+
if (itemType === "object") {
|
|
1943
|
+
const nestedProps = items.properties;
|
|
1944
|
+
const nestedReq = items.required ?? [];
|
|
1945
|
+
if (nestedProps && depth < 1) {
|
|
1946
|
+
const nestedList = Object.entries(nestedProps).map(([n, d]) => {
|
|
1947
|
+
const t = d.type ?? "unknown";
|
|
1948
|
+
const req = nestedReq.includes(n) ? " REQUIRED" : "";
|
|
1949
|
+
return `${n}: ${t}${req}`;
|
|
1950
|
+
});
|
|
1951
|
+
return `ARRAY_OF_OBJECTS[${nestedList.join(", ")}]`;
|
|
1952
|
+
}
|
|
1953
|
+
return "ARRAY_OF_OBJECTS";
|
|
1954
|
+
}
|
|
1955
|
+
return `ARRAY_OF_${itemType.toUpperCase()}`;
|
|
1956
|
+
}
|
|
1957
|
+
return "ARRAY";
|
|
1958
|
+
}
|
|
1959
|
+
if (type === "object") {
|
|
1960
|
+
const nestedProps = propData.properties;
|
|
1961
|
+
const nestedReq = propData.required ?? [];
|
|
1962
|
+
if (nestedProps && depth < 1) {
|
|
1963
|
+
const nestedList = Object.entries(nestedProps).map(([n, d]) => {
|
|
1964
|
+
const t = d.type ?? "unknown";
|
|
1965
|
+
const req = nestedReq.includes(n) ? " REQUIRED" : "";
|
|
1966
|
+
return `${n}: ${t}${req}`;
|
|
1967
|
+
});
|
|
1968
|
+
return `object{${nestedList.join(", ")}}`;
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
return type;
|
|
1972
|
+
}
|
|
1973
|
+
/**
|
|
1974
|
+
* Injects parameter signatures into tool descriptions.
|
|
1975
|
+
* Port of LLM-API-Key-Proxy's _inject_signature_into_descriptions()
|
|
1976
|
+
*
|
|
1977
|
+
* This helps prevent tool hallucination by explicitly listing parameters
|
|
1978
|
+
* in the description, making it harder for the model to hallucinate
|
|
1979
|
+
* parameters from its training data.
|
|
1980
|
+
*
|
|
1981
|
+
* @param tools - Array of tool definitions (Gemini format)
|
|
1982
|
+
* @param promptTemplate - Template for the signature (default: "\\n\\nSTRICT PARAMETERS: {params}.")
|
|
1983
|
+
* @returns Modified tools array with signatures injected
|
|
1984
|
+
*/
|
|
1985
|
+
export function injectParameterSignatures(tools, promptTemplate = "\n\n⚠️ STRICT PARAMETERS: {params}.") {
|
|
1986
|
+
if (!tools || !Array.isArray(tools))
|
|
1987
|
+
return tools;
|
|
1988
|
+
return tools.map((tool) => {
|
|
1989
|
+
const declarations = tool.functionDeclarations;
|
|
1990
|
+
if (!Array.isArray(declarations))
|
|
1991
|
+
return tool;
|
|
1992
|
+
const newDeclarations = declarations.map((decl) => {
|
|
1993
|
+
// Skip if signature already injected (avoids duplicate injection)
|
|
1994
|
+
if (decl.description?.includes("STRICT PARAMETERS:")) {
|
|
1995
|
+
return decl;
|
|
1996
|
+
}
|
|
1997
|
+
const schema = decl.parameters || decl.parametersJsonSchema;
|
|
1998
|
+
if (!schema)
|
|
1999
|
+
return decl;
|
|
2000
|
+
const required = schema.required ?? [];
|
|
2001
|
+
const properties = schema.properties ?? {};
|
|
2002
|
+
if (Object.keys(properties).length === 0)
|
|
2003
|
+
return decl;
|
|
2004
|
+
const paramList = Object.entries(properties).map(([propName, propData]) => {
|
|
2005
|
+
const typeHint = formatTypeHint(propData);
|
|
2006
|
+
const isRequired = required.includes(propName);
|
|
2007
|
+
return `${propName} (${typeHint}${isRequired ? ", REQUIRED" : ""})`;
|
|
2008
|
+
});
|
|
2009
|
+
const sigStr = promptTemplate.replace("{params}", paramList.join(", "));
|
|
2010
|
+
return {
|
|
2011
|
+
...decl,
|
|
2012
|
+
description: (decl.description || "") + sigStr,
|
|
2013
|
+
};
|
|
2014
|
+
});
|
|
2015
|
+
return { ...tool, functionDeclarations: newDeclarations };
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
/**
|
|
2019
|
+
* Injects a tool hardening system instruction into the request payload.
|
|
2020
|
+
* Port of LLM-API-Key-Proxy's _inject_tool_hardening_instruction()
|
|
2021
|
+
*
|
|
2022
|
+
* @param payload - The Gemini request payload
|
|
2023
|
+
* @param instructionText - The instruction text to inject
|
|
2024
|
+
*/
|
|
2025
|
+
export function injectToolHardeningInstruction(payload, instructionText) {
|
|
2026
|
+
if (!instructionText)
|
|
2027
|
+
return;
|
|
2028
|
+
// Skip if instruction already present (avoids duplicate injection)
|
|
2029
|
+
const existing = payload.systemInstruction;
|
|
2030
|
+
if (existing && typeof existing === "object" && "parts" in existing) {
|
|
2031
|
+
const parts = existing.parts;
|
|
2032
|
+
if (Array.isArray(parts) && parts.some(p => p.text?.includes("CRITICAL TOOL USAGE INSTRUCTIONS"))) {
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
const instructionPart = { text: instructionText };
|
|
2037
|
+
if (payload.systemInstruction) {
|
|
2038
|
+
if (existing && typeof existing === "object" && "parts" in existing) {
|
|
2039
|
+
const parts = existing.parts;
|
|
2040
|
+
if (Array.isArray(parts)) {
|
|
2041
|
+
parts.unshift(instructionPart);
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
else if (typeof existing === "string") {
|
|
2045
|
+
payload.systemInstruction = {
|
|
2046
|
+
role: "user",
|
|
2047
|
+
parts: [instructionPart, { text: existing }],
|
|
2048
|
+
};
|
|
2049
|
+
}
|
|
2050
|
+
else {
|
|
2051
|
+
payload.systemInstruction = {
|
|
2052
|
+
role: "user",
|
|
2053
|
+
parts: [instructionPart],
|
|
2054
|
+
};
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
else {
|
|
2058
|
+
payload.systemInstruction = {
|
|
2059
|
+
role: "user",
|
|
2060
|
+
parts: [instructionPart],
|
|
2061
|
+
};
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
// ============================================================================
|
|
2065
|
+
// TOOL PROCESSING FOR WRAPPED REQUESTS
|
|
2066
|
+
// Shared logic for assigning tool IDs and fixing tool pairing
|
|
2067
|
+
// ============================================================================
|
|
2068
|
+
/**
|
|
2069
|
+
* Assigns IDs to functionCall parts and returns the pending call IDs by name.
|
|
2070
|
+
* This is the first pass of tool ID assignment.
|
|
2071
|
+
*
|
|
2072
|
+
* @param contents - Gemini-style contents array
|
|
2073
|
+
* @returns Object with modified contents and pending call IDs map
|
|
2074
|
+
*/
|
|
2075
|
+
export function assignToolIdsToContents(contents) {
|
|
2076
|
+
if (!Array.isArray(contents)) {
|
|
2077
|
+
return { contents, pendingCallIdsByName: new Map(), toolCallCounter: 0 };
|
|
2078
|
+
}
|
|
2079
|
+
let toolCallCounter = 0;
|
|
2080
|
+
const pendingCallIdsByName = new Map();
|
|
2081
|
+
const newContents = contents.map((content) => {
|
|
2082
|
+
if (!content || !Array.isArray(content.parts)) {
|
|
2083
|
+
return content;
|
|
2084
|
+
}
|
|
2085
|
+
const newParts = content.parts.map((part) => {
|
|
2086
|
+
if (part && typeof part === "object" && part.functionCall) {
|
|
2087
|
+
const call = { ...part.functionCall };
|
|
2088
|
+
if (!call.id) {
|
|
2089
|
+
call.id = `tool-call-${++toolCallCounter}`;
|
|
2090
|
+
}
|
|
2091
|
+
const nameKey = typeof call.name === "string" ? call.name : `tool-${toolCallCounter}`;
|
|
2092
|
+
const queue = pendingCallIdsByName.get(nameKey) || [];
|
|
2093
|
+
queue.push(call.id);
|
|
2094
|
+
pendingCallIdsByName.set(nameKey, queue);
|
|
2095
|
+
return { ...part, functionCall: call };
|
|
2096
|
+
}
|
|
2097
|
+
return part;
|
|
2098
|
+
});
|
|
2099
|
+
return { ...content, parts: newParts };
|
|
2100
|
+
});
|
|
2101
|
+
return { contents: newContents, pendingCallIdsByName, toolCallCounter };
|
|
2102
|
+
}
|
|
2103
|
+
/**
|
|
2104
|
+
* Matches functionResponse IDs to their corresponding functionCall IDs.
|
|
2105
|
+
* This is the second pass of tool ID assignment.
|
|
2106
|
+
*
|
|
2107
|
+
* @param contents - Gemini-style contents array
|
|
2108
|
+
* @param pendingCallIdsByName - Map of function names to pending call IDs
|
|
2109
|
+
* @returns Modified contents with matched response IDs
|
|
2110
|
+
*/
|
|
2111
|
+
export function matchResponseIdsToContents(contents, pendingCallIdsByName) {
|
|
2112
|
+
if (!Array.isArray(contents)) {
|
|
2113
|
+
return contents;
|
|
2114
|
+
}
|
|
2115
|
+
return contents.map((content) => {
|
|
2116
|
+
if (!content || !Array.isArray(content.parts)) {
|
|
2117
|
+
return content;
|
|
2118
|
+
}
|
|
2119
|
+
const newParts = content.parts.map((part) => {
|
|
2120
|
+
if (part && typeof part === "object" && part.functionResponse) {
|
|
2121
|
+
const resp = { ...part.functionResponse };
|
|
2122
|
+
if (!resp.id && typeof resp.name === "string") {
|
|
2123
|
+
const queue = pendingCallIdsByName.get(resp.name);
|
|
2124
|
+
if (queue && queue.length > 0) {
|
|
2125
|
+
resp.id = queue.shift();
|
|
2126
|
+
pendingCallIdsByName.set(resp.name, queue);
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
return { ...part, functionResponse: resp };
|
|
2130
|
+
}
|
|
2131
|
+
return part;
|
|
2132
|
+
});
|
|
2133
|
+
return { ...content, parts: newParts };
|
|
2134
|
+
});
|
|
2135
|
+
}
|
|
2136
|
+
/**
|
|
2137
|
+
* Applies all tool fixes to a request payload for Claude models.
|
|
2138
|
+
* This includes:
|
|
2139
|
+
* 1. Tool ID assignment for functionCalls
|
|
2140
|
+
* 2. Response ID matching for functionResponses
|
|
2141
|
+
* 3. Orphan recovery via fixToolResponseGrouping
|
|
2142
|
+
* 4. Claude format pairing fix via validateAndFixClaudeToolPairing
|
|
2143
|
+
*
|
|
2144
|
+
* @param payload - Request payload object
|
|
2145
|
+
* @param isClaude - Whether this is a Claude model request
|
|
2146
|
+
* @returns Object with fix applied status
|
|
2147
|
+
*/
|
|
2148
|
+
export function applyToolPairingFixes(payload, isClaude) {
|
|
2149
|
+
let contentsFixed = false;
|
|
2150
|
+
let messagesFixed = false;
|
|
2151
|
+
if (!isClaude) {
|
|
2152
|
+
return { contentsFixed, messagesFixed };
|
|
2153
|
+
}
|
|
2154
|
+
// Fix Gemini format (contents[])
|
|
2155
|
+
if (Array.isArray(payload.contents)) {
|
|
2156
|
+
// First pass: assign IDs to functionCalls
|
|
2157
|
+
const { contents: contentsWithIds, pendingCallIdsByName } = assignToolIdsToContents(payload.contents);
|
|
2158
|
+
// Second pass: match functionResponse IDs
|
|
2159
|
+
const contentsWithMatchedIds = matchResponseIdsToContents(contentsWithIds, pendingCallIdsByName);
|
|
2160
|
+
// Third pass: fix orphan recovery
|
|
2161
|
+
payload.contents = fixToolResponseGrouping(contentsWithMatchedIds);
|
|
2162
|
+
contentsFixed = true;
|
|
2163
|
+
log.debug("Applied tool pairing fixes to contents[]", {
|
|
2164
|
+
originalLength: payload.contents.length,
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
2167
|
+
// Fix Claude format (messages[])
|
|
2168
|
+
if (Array.isArray(payload.messages)) {
|
|
2169
|
+
payload.messages = validateAndFixClaudeToolPairing(payload.messages);
|
|
2170
|
+
messagesFixed = true;
|
|
2171
|
+
log.debug("Applied tool pairing fixes to messages[]", {
|
|
2172
|
+
originalLength: payload.messages.length,
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
return { contentsFixed, messagesFixed };
|
|
2176
|
+
}
|
|
2177
|
+
// ============================================================================
|
|
2178
|
+
// SYNTHETIC CLAUDE SSE RESPONSE
|
|
2179
|
+
// Used to return error messages as "successful" responses to avoid locking
|
|
2180
|
+
// the OpenCode session when unrecoverable errors (like 400 Prompt Too Long) occur.
|
|
2181
|
+
// ============================================================================
|
|
2182
|
+
/**
|
|
2183
|
+
* Creates a synthetic Claude SSE streaming response with error content.
|
|
2184
|
+
*
|
|
2185
|
+
* When returning HTTP 400/500 errors to OpenCode, the session becomes locked
|
|
2186
|
+
* and the user cannot use /compact or other commands. This function creates
|
|
2187
|
+
* a fake "successful" SSE response (200 OK) with the error message as text content,
|
|
2188
|
+
* allowing the user to continue using the session.
|
|
2189
|
+
*
|
|
2190
|
+
* @param errorMessage - The error message to include in the response
|
|
2191
|
+
* @param requestedModel - The model that was requested
|
|
2192
|
+
* @returns A Response object with synthetic SSE stream
|
|
2193
|
+
*/
|
|
2194
|
+
export function createSyntheticErrorResponse(errorMessage, requestedModel = "unknown") {
|
|
2195
|
+
// Generate a unique message ID
|
|
2196
|
+
const messageId = `msg_synthetic_${Date.now()}`;
|
|
2197
|
+
// Build Claude SSE events that represent a complete message with error text
|
|
2198
|
+
const events = [];
|
|
2199
|
+
// 1. message_start event
|
|
2200
|
+
events.push(`event: message_start
|
|
2201
|
+
data: ${JSON.stringify({
|
|
2202
|
+
type: "message_start",
|
|
2203
|
+
message: {
|
|
2204
|
+
id: messageId,
|
|
2205
|
+
type: "message",
|
|
2206
|
+
role: "assistant",
|
|
2207
|
+
content: [],
|
|
2208
|
+
model: requestedModel,
|
|
2209
|
+
stop_reason: null,
|
|
2210
|
+
stop_sequence: null,
|
|
2211
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
2212
|
+
},
|
|
2213
|
+
})}
|
|
2214
|
+
|
|
2215
|
+
`);
|
|
2216
|
+
// 2. content_block_start event
|
|
2217
|
+
events.push(`event: content_block_start
|
|
2218
|
+
data: ${JSON.stringify({
|
|
2219
|
+
type: "content_block_start",
|
|
2220
|
+
index: 0,
|
|
2221
|
+
content_block: { type: "text", text: "" },
|
|
2222
|
+
})}
|
|
2223
|
+
|
|
2224
|
+
`);
|
|
2225
|
+
// 3. content_block_delta event with the error message
|
|
2226
|
+
events.push(`event: content_block_delta
|
|
2227
|
+
data: ${JSON.stringify({
|
|
2228
|
+
type: "content_block_delta",
|
|
2229
|
+
index: 0,
|
|
2230
|
+
delta: { type: "text_delta", text: errorMessage },
|
|
2231
|
+
})}
|
|
2232
|
+
|
|
2233
|
+
`);
|
|
2234
|
+
// 4. content_block_stop event
|
|
2235
|
+
events.push(`event: content_block_stop
|
|
2236
|
+
data: ${JSON.stringify({
|
|
2237
|
+
type: "content_block_stop",
|
|
2238
|
+
index: 0,
|
|
2239
|
+
})}
|
|
2240
|
+
|
|
2241
|
+
`);
|
|
2242
|
+
// 5. message_delta event (end_turn)
|
|
2243
|
+
events.push(`event: message_delta
|
|
2244
|
+
data: ${JSON.stringify({
|
|
2245
|
+
type: "message_delta",
|
|
2246
|
+
delta: { stop_reason: "end_turn", stop_sequence: null },
|
|
2247
|
+
usage: { output_tokens: Math.ceil(errorMessage.length / 4) },
|
|
2248
|
+
})}
|
|
2249
|
+
|
|
2250
|
+
`);
|
|
2251
|
+
// 6. message_stop event
|
|
2252
|
+
events.push(`event: message_stop
|
|
2253
|
+
data: ${JSON.stringify({ type: "message_stop" })}
|
|
2254
|
+
|
|
2255
|
+
`);
|
|
2256
|
+
const body = events.join("");
|
|
2257
|
+
return new Response(body, {
|
|
2258
|
+
status: 200,
|
|
2259
|
+
headers: {
|
|
2260
|
+
"Content-Type": "text/event-stream",
|
|
2261
|
+
"Cache-Control": "no-cache",
|
|
2262
|
+
"Connection": "keep-alive",
|
|
2263
|
+
"X-Antigravity-Synthetic": "true",
|
|
2264
|
+
"X-Antigravity-Error-Type": "prompt_too_long",
|
|
2265
|
+
},
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
//# sourceMappingURL=request-helpers.js.map
|