pi-antigravity-rotator 2.2.1 → 2.3.0
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/CHANGELOG.md +23 -4
- package/README.md +14 -5
- package/package.json +3 -1
- package/src/account-store.ts +109 -168
- package/src/admin-auth.ts +74 -83
- package/src/cli.ts +62 -46
- package/src/compat/cache.ts +42 -0
- package/src/compat/model-specs.ts +78 -0
- package/src/compat/schema-sanitizer.ts +277 -0
- package/src/compat/translators.ts +1538 -0
- package/src/compat.ts +1535 -2444
- package/src/config-defaults.ts +64 -0
- package/src/config-storage.ts +21 -0
- package/src/dashboard.ts +310 -3193
- package/src/db-store.ts +155 -0
- package/src/doctor.ts +96 -78
- package/src/index.ts +200 -149
- package/src/notification-poller.ts +1 -1
- package/src/onboarding.ts +352 -113
- package/src/paths.ts +34 -31
- package/src/proxy.ts +1751 -1270
- package/src/responses-store.ts +150 -178
- package/src/rotator.ts +3041 -2310
- package/src/settings-repository.ts +314 -0
- package/src/static/dashboard.css +1224 -0
- package/src/static/dashboard.js +1834 -0
- package/src/telemetry.ts +1 -1
- package/src/types.ts +501 -397
package/src/cli.ts
CHANGED
|
@@ -8,56 +8,72 @@
|
|
|
8
8
|
|
|
9
9
|
import { getConfigDir } from "./paths.js";
|
|
10
10
|
|
|
11
|
-
const args = process.argv
|
|
11
|
+
const args = process.argv
|
|
12
|
+
.slice(2)
|
|
13
|
+
.filter(
|
|
14
|
+
(a) =>
|
|
15
|
+
!a.startsWith("--config-dir") &&
|
|
16
|
+
a !== process.argv[process.argv.indexOf("--config-dir") + 1],
|
|
17
|
+
);
|
|
12
18
|
const command = args[0] || "start";
|
|
13
19
|
|
|
14
20
|
console.log(`Config dir: ${getConfigDir()}`);
|
|
15
21
|
console.log();
|
|
16
22
|
|
|
17
23
|
switch (command) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
24
|
+
case "start": {
|
|
25
|
+
// Dynamic import to avoid loading everything for help
|
|
26
|
+
const { main } = await import("./index.js");
|
|
27
|
+
await main();
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
case "login": {
|
|
31
|
+
const { initDb } = await import("./db-store.js");
|
|
32
|
+
await initDb();
|
|
33
|
+
const { runLogin } = await import("./login.js");
|
|
34
|
+
await runLogin();
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
case "status": {
|
|
38
|
+
try {
|
|
39
|
+
const port = 51200;
|
|
40
|
+
const res = await fetch(`http://localhost:${port}/api/status`);
|
|
41
|
+
const data = await res.json();
|
|
42
|
+
console.log(JSON.stringify(data, null, 2));
|
|
43
|
+
} catch {
|
|
44
|
+
console.error("Rotator is not running or unreachable on port 51200");
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
case "doctor": {
|
|
50
|
+
const { initDb } = await import("./db-store.js");
|
|
51
|
+
await initDb();
|
|
52
|
+
const { printDoctorReport, runDoctor } = await import("./doctor.js");
|
|
53
|
+
const result = await runDoctor();
|
|
54
|
+
printDoctorReport(result);
|
|
55
|
+
process.exit(result.ok ? 0 : 1);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
default:
|
|
59
|
+
console.log("Pi Antigravity Rotator");
|
|
60
|
+
console.log();
|
|
61
|
+
console.log("Usage:");
|
|
62
|
+
console.log(" pi-antigravity-rotator start Start the proxy (default)");
|
|
63
|
+
console.log(" pi-antigravity-rotator login Add a new Google account");
|
|
64
|
+
console.log(
|
|
65
|
+
" pi-antigravity-rotator status Show account status (JSON)",
|
|
66
|
+
);
|
|
67
|
+
console.log(
|
|
68
|
+
" pi-antigravity-rotator doctor Validate config and local state",
|
|
69
|
+
);
|
|
70
|
+
console.log();
|
|
71
|
+
console.log("Options:");
|
|
72
|
+
console.log(
|
|
73
|
+
" --config-dir <path> Config directory (default: ~/.pi-antigravity-rotator/)",
|
|
74
|
+
);
|
|
75
|
+
console.log();
|
|
76
|
+
console.log("Environment:");
|
|
77
|
+
console.log(" PI_ROTATOR_DIR Config directory override");
|
|
78
|
+
process.exit(command === "help" || command === "--help" ? 0 : 1);
|
|
63
79
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ResponsesStore, type StoredResponseEntry } from "../responses-store.js";
|
|
2
|
+
|
|
3
|
+
export const thoughtSignatureCache = new Map<string, string>();
|
|
4
|
+
const THOUGHT_SIGNATURE_CACHE_MAX = 500;
|
|
5
|
+
export const responsesStore = new ResponsesStore();
|
|
6
|
+
|
|
7
|
+
export function makeCompatId(prefix: string): string {
|
|
8
|
+
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getStoredResponse(id: string): StoredResponseEntry | null {
|
|
12
|
+
return responsesStore.get(id);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function setStoredResponse(id: string, entry: StoredResponseEntry): void {
|
|
16
|
+
responsesStore.set(id, entry);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resetResponsesStoreForTests(): void {
|
|
20
|
+
responsesStore.clear();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function loadResponsesStore(): Promise<void> {
|
|
24
|
+
await responsesStore.load();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function flushResponsesStore(): Promise<void> {
|
|
28
|
+
await responsesStore.flush();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function flushResponsesStoreSync(): void {
|
|
32
|
+
responsesStore.flushSync();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function cacheThoughtSignature(callId: string, signature: string): void {
|
|
36
|
+
if (thoughtSignatureCache.size >= THOUGHT_SIGNATURE_CACHE_MAX) {
|
|
37
|
+
// Evict the oldest entry
|
|
38
|
+
const firstKey = thoughtSignatureCache.keys().next().value;
|
|
39
|
+
if (firstKey !== undefined) thoughtSignatureCache.delete(firstKey);
|
|
40
|
+
}
|
|
41
|
+
thoughtSignatureCache.set(callId, signature);
|
|
42
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export interface ModelSpec {
|
|
2
|
+
maxOutputTokens: number;
|
|
3
|
+
thinkingBudget: number; // -1 = adaptive (model decides), >=0 = fixed
|
|
4
|
+
isThinking: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_MODEL_SPECS: Record<string, ModelSpec> = {
|
|
8
|
+
"gemini-pro-agent": { maxOutputTokens: 65535, thinkingBudget: 10001, isThinking: true },
|
|
9
|
+
"gemini-3-flash-agent": { maxOutputTokens: 65536, thinkingBudget: 10000, isThinking: true },
|
|
10
|
+
"gemini-3-pro-high": { maxOutputTokens: 65535, thinkingBudget: 10001, isThinking: true },
|
|
11
|
+
"gemini-3-pro-low": { maxOutputTokens: 65535, thinkingBudget: 1001, isThinking: true },
|
|
12
|
+
"gemini-3.1-pro": { maxOutputTokens: 65535, thinkingBudget: 10001, isThinking: true },
|
|
13
|
+
"gemini-3.1-pro-high": { maxOutputTokens: 65535, thinkingBudget: 10001, isThinking: true },
|
|
14
|
+
"gemini-3.1-pro-low": { maxOutputTokens: 65535, thinkingBudget: 1001, isThinking: true },
|
|
15
|
+
"gemini-3.1-pro-preview": { maxOutputTokens: 65535, thinkingBudget: 10001, isThinking: true },
|
|
16
|
+
"gemini-3.5-flash": { maxOutputTokens: 65536, thinkingBudget: 10000, isThinking: true },
|
|
17
|
+
"gemini-3.5-flash-medium": { maxOutputTokens: 65536, thinkingBudget: 4000, isThinking: true },
|
|
18
|
+
"gemini-3.5-flash-low": { maxOutputTokens: 65536, thinkingBudget: 4000, isThinking: true },
|
|
19
|
+
"gemini-3.5-flash-high": { maxOutputTokens: 65536, thinkingBudget: 10000, isThinking: true },
|
|
20
|
+
"gemini-3-flash": { maxOutputTokens: 65536, thinkingBudget: 4000, isThinking: true },
|
|
21
|
+
"gemini-2.5-flash": { maxOutputTokens: 65535, thinkingBudget: 24576, isThinking: true },
|
|
22
|
+
"gemini-2.5-pro": { maxOutputTokens: 65535, thinkingBudget: 1024, isThinking: true },
|
|
23
|
+
"claude-sonnet-4-6": { maxOutputTokens: 64000, thinkingBudget: 32768, isThinking: true },
|
|
24
|
+
"claude-sonnet-4-6-thinking":{ maxOutputTokens: 64000, thinkingBudget: 32768, isThinking: true },
|
|
25
|
+
"claude-opus-4-6-thinking": { maxOutputTokens: 64000, thinkingBudget: 32768, isThinking: true },
|
|
26
|
+
"gpt-oss-120b-medium": { maxOutputTokens: 32768, thinkingBudget: 8192, isThinking: true },
|
|
27
|
+
"gpt-oss-120b": { maxOutputTokens: 32768, thinkingBudget: 8192, isThinking: true },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
let modelSpecsOverride: Record<string, ModelSpec> | null = null;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Replace the bundled model spec table with operator-provided overrides.
|
|
34
|
+
* Pass `null` to restore defaults. Called once at startup from index.ts.
|
|
35
|
+
*/
|
|
36
|
+
export function setModelSpecsOverride(specs: Record<string, ModelSpec> | null): void {
|
|
37
|
+
modelSpecsOverride = specs && Object.keys(specs).length > 0 ? specs : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getActiveModelSpecs(): Record<string, ModelSpec> {
|
|
41
|
+
return modelSpecsOverride ?? DEFAULT_MODEL_SPECS;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const GEMINI_MAX_OUTPUT_TOKENS = 65536;
|
|
45
|
+
const CLAUDE_MAX_OUTPUT_TOKENS = 64000;
|
|
46
|
+
const FALLBACK_THINKING_BUDGET = 24576;
|
|
47
|
+
const CLAUDE_DEFAULT_THINKING_BUDGET = 32768;
|
|
48
|
+
|
|
49
|
+
export function getModelFamily(model: string): "claude" | "gemini" | "unknown" {
|
|
50
|
+
const l = model.toLowerCase();
|
|
51
|
+
if (l.includes("claude")) return "claude";
|
|
52
|
+
if (l.includes("gemini")) return "gemini";
|
|
53
|
+
return "unknown";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getModelSpec(model: string): ModelSpec {
|
|
57
|
+
const specs = getActiveModelSpecs();
|
|
58
|
+
const lower = model.toLowerCase();
|
|
59
|
+
if (specs[lower]) return specs[lower];
|
|
60
|
+
for (const [key, spec] of Object.entries(specs)) {
|
|
61
|
+
if (lower.includes(key)) return spec;
|
|
62
|
+
}
|
|
63
|
+
const family = getModelFamily(model);
|
|
64
|
+
if (family === "claude") return { maxOutputTokens: CLAUDE_MAX_OUTPUT_TOKENS, thinkingBudget: CLAUDE_DEFAULT_THINKING_BUDGET, isThinking: true };
|
|
65
|
+
if (family === "gemini") return { maxOutputTokens: GEMINI_MAX_OUTPUT_TOKENS, thinkingBudget: FALLBACK_THINKING_BUDGET, isThinking: true };
|
|
66
|
+
return { maxOutputTokens: 65536, thinkingBudget: FALLBACK_THINKING_BUDGET, isThinking: false };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isThinkingModel(model: string): boolean {
|
|
70
|
+
const spec = getModelSpec(model);
|
|
71
|
+
if (spec.isThinking) return true;
|
|
72
|
+
const l = model.toLowerCase();
|
|
73
|
+
if (l.includes("gemini")) {
|
|
74
|
+
const m = l.match(/gemini-(\d+)/);
|
|
75
|
+
if (m && parseInt(m[1], 10) >= 3) return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
2
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Gemini's function_declarations accept a restricted subset of JSON Schema.
|
|
7
|
+
* Keywords like `const`, `$schema`, `$ref`, `$defs`, `if/then/else`, `not`,
|
|
8
|
+
* `patternProperties`, etc. are not supported and will cause a 400.
|
|
9
|
+
* This function recursively strips those unsupported keywords.
|
|
10
|
+
*/
|
|
11
|
+
export function sanitizeGeminiSchema(schema: unknown): unknown {
|
|
12
|
+
if (!isRecord(schema)) return schema;
|
|
13
|
+
|
|
14
|
+
// Keywords Gemini does not support
|
|
15
|
+
const UNSUPPORTED = new Set([
|
|
16
|
+
"const", "$schema", "$id", "$ref", "$defs", "definitions",
|
|
17
|
+
"if", "then", "else", "not",
|
|
18
|
+
"patternProperties", "unevaluatedProperties", "unevaluatedItems",
|
|
19
|
+
"contentEncoding", "contentMediaType", "examples",
|
|
20
|
+
"exclusiveMinimum", "exclusiveMaximum", "minimum", "maximum",
|
|
21
|
+
"multipleOf", "minLength", "maxLength", "pattern",
|
|
22
|
+
"minItems", "maxItems", "uniqueItems",
|
|
23
|
+
"minProperties", "maxProperties", "title", "default",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const out: Record<string, unknown> = {};
|
|
27
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
28
|
+
if (UNSUPPORTED.has(key)) continue;
|
|
29
|
+
|
|
30
|
+
if (key === "anyOf" || key === "oneOf" || key === "allOf") {
|
|
31
|
+
if (Array.isArray(value)) {
|
|
32
|
+
// Special case: all items are pure {const: value} — this is the
|
|
33
|
+
// JSON Schema way of writing an enum. Convert to Gemini's `enum` array.
|
|
34
|
+
const allConst = value.every(
|
|
35
|
+
(item) => isRecord(item) && Object.keys(item).length === 1 && "const" in item,
|
|
36
|
+
);
|
|
37
|
+
if (allConst) {
|
|
38
|
+
out["enum"] = value.map((item) => (item as Record<string, unknown>)["const"]);
|
|
39
|
+
// Infer type:string when all const values are strings (covers most tool params)
|
|
40
|
+
if (value.every((item) => typeof (item as Record<string, unknown>)["const"] === "string")) {
|
|
41
|
+
if (!out["type"]) out["type"] = "string";
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
const cleaned = value.map(sanitizeGeminiSchema).filter(
|
|
45
|
+
// Drop entries that become empty objects after sanitisation
|
|
46
|
+
(v) => isRecord(v) && Object.keys(v).length > 0,
|
|
47
|
+
);
|
|
48
|
+
// If only one variant remains, unwrap it (Gemini prefers flat schemas)
|
|
49
|
+
if (cleaned.length === 1) {
|
|
50
|
+
Object.assign(out, cleaned[0]);
|
|
51
|
+
} else if (cleaned.length > 1) {
|
|
52
|
+
out[key] = cleaned;
|
|
53
|
+
}
|
|
54
|
+
// cleaned.length === 0: skip entirely
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Inline union type: `type: ["number","null"]`. Gemini's proto `type`
|
|
61
|
+
// field is a single enum, not repeating — collapse to the first non-null
|
|
62
|
+
// type and lift nullability into the proto-supported `nullable` flag.
|
|
63
|
+
if (key === "type" && Array.isArray(value)) {
|
|
64
|
+
const nonNull = (value as unknown[]).filter((t) => t !== "null");
|
|
65
|
+
if ((value as unknown[]).includes("null")) out["nullable"] = true;
|
|
66
|
+
out["type"] = (nonNull[0] ?? "string");
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (key === "properties" && isRecord(value)) {
|
|
71
|
+
out[key] = Object.fromEntries(
|
|
72
|
+
Object.entries(value).map(([k, v]) => [k, sanitizeGeminiSchema(v)]),
|
|
73
|
+
);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (key === "items") {
|
|
78
|
+
out[key] = sanitizeGeminiSchema(value);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
out[key] = isRecord(value) ? sanitizeGeminiSchema(value) : value;
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Lighter sanitization for Claude models routed through Gemini's API.
|
|
89
|
+
* Gemini's outer API still validates schemas before routing to Claude, so
|
|
90
|
+
* we must remove fields Gemini's protobuf doesn't know about (like `const`,
|
|
91
|
+
* `$ref`, etc.). However, unlike the Gemini-native sanitizer, we KEEP
|
|
92
|
+
* standard JSON Schema Draft 2020-12 keywords (minimum, maximum, pattern,
|
|
93
|
+
* etc.) that Claude requires and that Gemini's API does pass through.
|
|
94
|
+
*/
|
|
95
|
+
export function sanitizeClaudeViaGeminiSchema(schema: unknown): unknown {
|
|
96
|
+
if (!isRecord(schema)) return schema;
|
|
97
|
+
|
|
98
|
+
// Only remove fields that Gemini's API layer truly rejects at the network level.
|
|
99
|
+
// We keep standard Draft 2020-12 keywords but must strip exclusiveMinimum/exclusiveMaximum
|
|
100
|
+
// as boolean values (Draft 4) — the API layer rejects them even for Claude-bound requests.
|
|
101
|
+
const UNSUPPORTED = new Set([
|
|
102
|
+
"$schema", "$id", "$ref", "$defs", "definitions",
|
|
103
|
+
"if", "then", "else", "not",
|
|
104
|
+
"patternProperties", "unevaluatedProperties", "unevaluatedItems",
|
|
105
|
+
"contentEncoding", "contentMediaType",
|
|
106
|
+
// Gemini's protobuf layer rejects these regardless of target model
|
|
107
|
+
"exclusiveMinimum", "exclusiveMaximum",
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
const out: Record<string, unknown> = {};
|
|
111
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
112
|
+
if (UNSUPPORTED.has(key)) continue;
|
|
113
|
+
|
|
114
|
+
// `const` is not supported by Gemini's API — convert to a single-value enum
|
|
115
|
+
if (key === "const") {
|
|
116
|
+
out["enum"] = [value];
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (key === "anyOf" || key === "oneOf" || key === "allOf") {
|
|
121
|
+
if (Array.isArray(value)) {
|
|
122
|
+
// Case 1: all items are pure {const: value} — convert to flat enum.
|
|
123
|
+
const allPureConst = value.every(
|
|
124
|
+
(item) => isRecord(item) && Object.keys(item).length === 1 && "const" in item,
|
|
125
|
+
);
|
|
126
|
+
if (allPureConst) {
|
|
127
|
+
out["enum"] = value.map((item) => (item as Record<string, unknown>)["const"]);
|
|
128
|
+
if (value.every((item) => typeof (item as Record<string, unknown>)["const"] === "string")) {
|
|
129
|
+
if (!out["type"]) out["type"] = "string";
|
|
130
|
+
}
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Case 2: all items are {type: T, const: V} (same type, each with a const).
|
|
135
|
+
// e.g. [{type:"string",const:"fact"},{type:"string",const:"lesson"}]
|
|
136
|
+
// Merge into a single flat {type: T, enum: [V1, V2, ...]} — avoids
|
|
137
|
+
// the redundant anyOf-with-single-enum pattern that Claude rejects.
|
|
138
|
+
const allTypeConst = value.every(
|
|
139
|
+
(item) =>
|
|
140
|
+
isRecord(item) &&
|
|
141
|
+
Object.keys(item).length === 2 &&
|
|
142
|
+
"type" in item &&
|
|
143
|
+
"const" in item,
|
|
144
|
+
);
|
|
145
|
+
if (allTypeConst) {
|
|
146
|
+
const firstType = (value[0] as Record<string, unknown>)["type"];
|
|
147
|
+
const allSameType = value.every((item) => (item as Record<string, unknown>)["type"] === firstType);
|
|
148
|
+
if (allSameType) {
|
|
149
|
+
if (!out["type"]) out["type"] = firstType;
|
|
150
|
+
out["enum"] = value.map((item) => (item as Record<string, unknown>)["const"]);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Sanitize all variants first.
|
|
156
|
+
const cleaned = value.map(sanitizeClaudeViaGeminiSchema).filter(
|
|
157
|
+
(v) => isRecord(v) && Object.keys(v).length > 0,
|
|
158
|
+
) as Record<string, unknown>[];
|
|
159
|
+
|
|
160
|
+
if (cleaned.length === 0) {
|
|
161
|
+
// All variants collapsed to nothing — skip entirely.
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Case 3: nullable pattern — anyOf/oneOf with exactly one {type:"null"}
|
|
166
|
+
// variant and one or more real variants. Convert to the real variant
|
|
167
|
+
// with nullable:true. This is lossless — Gemini's proto supports nullable.
|
|
168
|
+
// e.g. anyOf:[{type:"string"},{type:"null"}] → {type:"string",nullable:true}
|
|
169
|
+
if (key !== "allOf") {
|
|
170
|
+
const nullIdx = cleaned.findIndex((v) => v.type === "null" && Object.keys(v).length === 1);
|
|
171
|
+
if (nullIdx !== -1) {
|
|
172
|
+
const nonNull = cleaned.filter((_, i) => i !== nullIdx);
|
|
173
|
+
if (nonNull.length === 1) {
|
|
174
|
+
Object.assign(out, nonNull[0], { nullable: true });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (nonNull.length > 1) {
|
|
178
|
+
// Multiple non-null variants + null → collapse non-null variants,
|
|
179
|
+
// then mark nullable. Still lossy but preserves nullability.
|
|
180
|
+
Object.assign(out, nonNull[0], { nullable: true });
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Case 4: allOf — deep merge all variants (allOf = intersection).
|
|
187
|
+
// Merging properties from all variants is semantically correct.
|
|
188
|
+
if (key === "allOf") {
|
|
189
|
+
const merged: Record<string, unknown> = {};
|
|
190
|
+
let mergedProperties: Record<string, unknown> = {};
|
|
191
|
+
let mergedRequired: string[] = [];
|
|
192
|
+
for (const variant of cleaned) {
|
|
193
|
+
for (const [vk, vv] of Object.entries(variant)) {
|
|
194
|
+
if (vk === "properties" && isRecord(vv)) {
|
|
195
|
+
mergedProperties = { ...mergedProperties, ...vv };
|
|
196
|
+
} else if (vk === "required" && Array.isArray(vv)) {
|
|
197
|
+
mergedRequired = [...new Set([...mergedRequired, ...vv])];
|
|
198
|
+
} else {
|
|
199
|
+
merged[vk] = vv;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (Object.keys(mergedProperties).length > 0) merged["properties"] = mergedProperties;
|
|
204
|
+
if (mergedRequired.length > 0) merged["required"] = mergedRequired;
|
|
205
|
+
Object.assign(out, merged);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Case 5: anyOf/oneOf where all variants are objects with properties —
|
|
210
|
+
// merge all properties together, making all optional (union of shapes).
|
|
211
|
+
// This is mildly lossy (accepts wider input) but doesn't reject valid inputs.
|
|
212
|
+
const allObjects = cleaned.every(
|
|
213
|
+
(v) => v.type === "object" && isRecord(v.properties),
|
|
214
|
+
);
|
|
215
|
+
if (allObjects && cleaned.length > 1) {
|
|
216
|
+
const unionProperties: Record<string, unknown> = {};
|
|
217
|
+
for (const variant of cleaned) {
|
|
218
|
+
const props = variant.properties as Record<string, unknown>;
|
|
219
|
+
for (const [pk, pv] of Object.entries(props)) {
|
|
220
|
+
if (!(pk in unionProperties)) unionProperties[pk] = pv;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Only keep required fields that exist in ALL variants
|
|
224
|
+
const allRequired = cleaned.map((v) =>
|
|
225
|
+
Array.isArray(v.required) ? new Set(v.required as string[]) : new Set<string>(),
|
|
226
|
+
);
|
|
227
|
+
const commonRequired = [...allRequired[0]].filter((r) =>
|
|
228
|
+
allRequired.every((s) => s.has(r)),
|
|
229
|
+
);
|
|
230
|
+
const base = { ...cleaned[0] };
|
|
231
|
+
base["properties"] = unionProperties;
|
|
232
|
+
if (commonRequired.length > 0) {
|
|
233
|
+
base["required"] = commonRequired;
|
|
234
|
+
} else {
|
|
235
|
+
delete base["required"];
|
|
236
|
+
}
|
|
237
|
+
Object.assign(out, base);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Fallback: collapse to the first valid variant.
|
|
242
|
+
// Gemini's Schema proto serialization corrupts complex anyOf/oneOf
|
|
243
|
+
// during the round-trip to Claude, causing JSON Schema draft 2020-12
|
|
244
|
+
// validation failures. Collapsing is lossy but functional — the tool
|
|
245
|
+
// still works, just with a narrower accepted input type.
|
|
246
|
+
Object.assign(out, cleaned[0]);
|
|
247
|
+
}
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Inline union type: `type: ["number","null"]`. Gemini's proto `type`
|
|
252
|
+
// field is a single enum, not repeating — an array value triggers a 400
|
|
253
|
+
// ('Proto field is not repeating'). Collapse to the first non-null type
|
|
254
|
+
// and lift nullability into the proto-supported `nullable` flag.
|
|
255
|
+
if (key === "type" && Array.isArray(value)) {
|
|
256
|
+
const nonNull = (value as unknown[]).filter((t) => t !== "null");
|
|
257
|
+
if ((value as unknown[]).includes("null")) out["nullable"] = true;
|
|
258
|
+
out["type"] = (nonNull[0] ?? "string");
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (key === "properties" && isRecord(value)) {
|
|
263
|
+
out[key] = Object.fromEntries(
|
|
264
|
+
Object.entries(value).map(([k, v]) => [k, sanitizeClaudeViaGeminiSchema(v)]),
|
|
265
|
+
);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (key === "items") {
|
|
270
|
+
out[key] = sanitizeClaudeViaGeminiSchema(value);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
out[key] = isRecord(value) ? sanitizeClaudeViaGeminiSchema(value) : value;
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}
|