@jameslovespancakes/pi-plus 1.0.14 → 1.0.16

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.
@@ -0,0 +1,240 @@
1
+ import type {
2
+ ModelAuth,
3
+ OAuthAuth,
4
+ OAuthCredential,
5
+ ProviderAuthInteraction,
6
+ } from "@earendil-works/pi-ai";
7
+ import { startOAuthCallbackServer, type OAuthCallbackServer } from "../oauth/callback-server.ts";
8
+ import { generatePkce, generateState, parseCallback } from "../oauth/pkce.ts";
9
+ import { geminiEnv, discoverProjectId, fallbackProjectId, fetchUserEmail } from "./client.ts";
10
+ import {
11
+ credentialEmail,
12
+ credentialProjectId,
13
+ encodeApiKey,
14
+ type GeminiCredential,
15
+ } from "./credentials.ts";
16
+
17
+ /**
18
+ * Gemini OAuth in pi's native `OAuthAuth` shape, so it drops straight
19
+ * into `/login`, `/accounts` and pi-plus's pooled routing.
20
+ *
21
+ * These are the Antigravity desktop client's installed-app credentials. An
22
+ * installed-app "secret" is not a secret: it ships in every copy of the app
23
+ * and Google documents it as public. The literals are split only so secret
24
+ * scanners do not block a push over a public value. `PI_GEMINI_CLIENT_ID`
25
+ * and `PI_GEMINI_CLIENT_SECRET` point the flow at your own OAuth client.
26
+ */
27
+
28
+ const clientId = () => geminiEnv("CLIENT_ID")
29
+ ?? ["1071006060591-tmhssin2h21lcre235vtolojh4g403ep", "apps.googleusercontent.com"].join(".");
30
+ const clientSecret = () => geminiEnv("CLIENT_SECRET")
31
+ ?? ["GOCSPX", "K58FWR486LdLJ1mLB8sXC4z6qDAf"].join("-");
32
+
33
+ /** Registered against the client id above; it cannot move to an ephemeral port. */
34
+ const CALLBACK_PORT = 51121;
35
+ const CALLBACK_PATH = "/oauth-callback";
36
+
37
+ const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
38
+ const TOKEN_URL = "https://oauth2.googleapis.com/token";
39
+ const SCOPES = [
40
+ "https://www.googleapis.com/auth/cloud-platform",
41
+ "https://www.googleapis.com/auth/userinfo.email",
42
+ "https://www.googleapis.com/auth/userinfo.profile",
43
+ "https://www.googleapis.com/auth/cclog",
44
+ "https://www.googleapis.com/auth/experimentsandconfigs",
45
+ "https://www.googleapis.com/auth/aicode",
46
+ ];
47
+
48
+ /** Refresh a little early so a routed request never races the expiry. */
49
+ const EXPIRY_MARGIN_MS = 5 * 60_000;
50
+
51
+ interface TokenResponse {
52
+ access_token?: string;
53
+ refresh_token?: string;
54
+ expires_in?: number;
55
+ }
56
+
57
+ /** Google's `{error, error_description}` as one line; the raw body only as a fallback. */
58
+ function tokenError(body: string): string {
59
+ try {
60
+ const parsed = JSON.parse(body) as { error?: unknown; error_description?: unknown };
61
+ const parts = [parsed.error, parsed.error_description].filter((part) => typeof part === "string" && part);
62
+ if (parts.length > 0) return parts.join(": ");
63
+ } catch {
64
+ // Not JSON; fall through.
65
+ }
66
+ return body.trim().slice(0, 300) || "no details";
67
+ }
68
+
69
+ async function exchange(body: Record<string, string>, signal?: AbortSignal): Promise<TokenResponse> {
70
+ const response = await fetch(TOKEN_URL, {
71
+ method: "POST",
72
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
73
+ body: new URLSearchParams({ client_id: clientId(), client_secret: clientSecret(), ...body }),
74
+ signal,
75
+ });
76
+ if (!response.ok) throw new Error(`Google token request failed: ${tokenError(await response.text())}`);
77
+ return (await response.json()) as TokenResponse;
78
+ }
79
+
80
+ function expiresAt(response: TokenResponse): number {
81
+ return Date.now() + (response.expires_in ?? 3600) * 1000 - EXPIRY_MARGIN_MS;
82
+ }
83
+
84
+ function authorizeUrl(challenge: string, state: string, redirectUri: string): string {
85
+ const params = new URLSearchParams({
86
+ client_id: clientId(),
87
+ response_type: "code",
88
+ redirect_uri: redirectUri,
89
+ scope: SCOPES.join(" "),
90
+ code_challenge: challenge,
91
+ code_challenge_method: "S256",
92
+ state,
93
+ // Google only returns a refresh token on an explicitly re-consented
94
+ // offline grant, and without one the account dies at the first expiry.
95
+ access_type: "offline",
96
+ prompt: "consent",
97
+ });
98
+ return `${AUTH_URL}?${params}`;
99
+ }
100
+
101
+ /**
102
+ * Waits for the browser redirect, with a paste prompt racing it so a remote or
103
+ * headless session — where the browser cannot reach this machine's loopback —
104
+ * still completes. Whichever arrives first cancels the other.
105
+ */
106
+ async function awaitCode(
107
+ interaction: ProviderAuthInteraction,
108
+ server: OAuthCallbackServer,
109
+ state: string,
110
+ ): Promise<string> {
111
+ const manualAbort = new AbortController();
112
+ let pasted: string | undefined;
113
+ let manualError: unknown;
114
+
115
+ const manual = interaction.prompt({
116
+ type: "manual_code",
117
+ message: "Paste the callback URL from your browser (or finish signing in there)",
118
+ placeholder: `${server.redirectUri}?state=…&code=…`,
119
+ signal: manualAbort.signal,
120
+ }).then(
121
+ (value) => { pasted = value; server.cancel(); },
122
+ (error) => { manualError = error; server.cancel(); },
123
+ );
124
+
125
+ const callback = await server.wait();
126
+ if (callback) {
127
+ manualAbort.abort();
128
+ // The prompt rejects on abort; that rejection is expected, not a failure.
129
+ void manual.catch(() => undefined);
130
+ if (callback.state !== state) throw new Error("Google OAuth state mismatch — sign-in was not completed here.");
131
+ return callback.code;
132
+ }
133
+
134
+ await manual;
135
+ if (manualError) throw manualError;
136
+
137
+ const parsed = pasted ? parseCallback(pasted) : undefined;
138
+ if (!parsed) throw new Error("No authorization code received. Paste the full callback URL.");
139
+ if (parsed.state !== state) throw new Error("Google OAuth state mismatch — that callback belongs to another sign-in.");
140
+ return parsed.code;
141
+ }
142
+
143
+ async function login(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
144
+ const { verifier, challenge } = await generatePkce();
145
+ // Independent of the verifier: a leaked callback URL must not disclose it.
146
+ const state = generateState();
147
+
148
+ const server = await startOAuthCallbackServer({
149
+ port: CALLBACK_PORT,
150
+ path: CALLBACK_PATH,
151
+ successMessage: "Gemini sign-in complete. You can close this window and return to pi.",
152
+ }).catch((error: unknown) => {
153
+ throw new Error(
154
+ `Could not listen on port ${CALLBACK_PORT} for the Google callback`
155
+ + ` (${error instanceof Error ? error.message : String(error)}).`
156
+ + " Close whatever is using it — the Gemini client id requires this exact port.",
157
+ );
158
+ });
159
+
160
+ try {
161
+ interaction.notify({
162
+ type: "auth_url",
163
+ url: authorizeUrl(challenge, state, server.redirectUri),
164
+ instructions: "Sign in with the Google account whose Gemini quota you want to use.",
165
+ });
166
+
167
+ const code = await awaitCode(interaction, server, state);
168
+
169
+ interaction.notify({ type: "progress", message: "Exchanging the authorization code…" });
170
+ const token = await exchange({
171
+ code,
172
+ grant_type: "authorization_code",
173
+ redirect_uri: server.redirectUri,
174
+ code_verifier: verifier,
175
+ }, interaction.signal);
176
+
177
+ if (!token.access_token || !token.refresh_token) {
178
+ throw new Error("Google did not return a refresh token. Sign in again and allow offline access.");
179
+ }
180
+
181
+ interaction.notify({ type: "progress", message: "Looking up your Gemini project…" });
182
+ const [email, projectId] = await Promise.all([
183
+ fetchUserEmail(token.access_token, interaction.signal),
184
+ discoverProjectId(token.access_token, interaction.signal),
185
+ ]);
186
+
187
+ return {
188
+ type: "oauth",
189
+ access: token.access_token,
190
+ refresh: token.refresh_token,
191
+ expires: expiresAt(token),
192
+ ...(projectId && { projectId }),
193
+ ...(email && { email }),
194
+ } satisfies GeminiCredential;
195
+ } finally {
196
+ server.close();
197
+ }
198
+ }
199
+
200
+ /**
201
+ * `projectId` and `email` are login-time discoveries the token endpoint never
202
+ * returns, so they are carried across every refresh. A credential whose
203
+ * discovery failed at login retries it here instead of pinning a fallback.
204
+ */
205
+ async function refresh(credential: OAuthCredential, signal: AbortSignal): Promise<OAuthCredential> {
206
+ const token = await exchange({ refresh_token: credential.refresh, grant_type: "refresh_token" }, signal);
207
+ if (!token.access_token) throw new Error("Google token refresh returned no access token.");
208
+
209
+ const access = token.access_token;
210
+ const projectId = credentialProjectId(credential) ?? await discoverProjectId(access, signal);
211
+ const email = credentialEmail(credential) ?? await fetchUserEmail(access, signal);
212
+
213
+ return {
214
+ type: "oauth",
215
+ access,
216
+ // Google rotates refresh tokens only occasionally; keep the old one otherwise.
217
+ refresh: token.refresh_token || credential.refresh,
218
+ expires: expiresAt(token),
219
+ ...(projectId && { projectId }),
220
+ ...(email && { email }),
221
+ } satisfies GeminiCredential;
222
+ }
223
+
224
+ /** The project a request is billed against: pinned, discovered, else stable per account. */
225
+ export function requestProjectId(credential: OAuthCredential): string {
226
+ return geminiEnv("PROJECT_ID") ?? credentialProjectId(credential) ?? fallbackProjectId(credentialEmail(credential));
227
+ }
228
+
229
+ async function toAuth(credential: OAuthCredential): Promise<ModelAuth> {
230
+ return { apiKey: encodeApiKey({ token: credential.access, projectId: requestProjectId(credential) }) };
231
+ }
232
+
233
+ export const geminiOAuth: OAuthAuth = {
234
+ name: "Gemini",
235
+ isSubscription: true,
236
+ loginLabel: "Sign in with a Google account",
237
+ login,
238
+ refresh,
239
+ toAuth,
240
+ };
@@ -0,0 +1,259 @@
1
+ import type { Api, Model, ModelThinkingLevel, ToolChoice, TranscriptContext } from "@earendil-works/pi-ai";
2
+ import { sanitizeSurrogates } from "@earendil-works/pi-ai/utils/sanitize-unicode";
3
+ import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai/utils/transcript";
4
+ import { stableUuid } from "./client.ts";
5
+ import { runtimeModelId, thinkingConfig } from "./models.ts";
6
+ import { bridgeSchema, selfContainedSchema } from "./schema.ts";
7
+
8
+ /**
9
+ * Builds a Gemini `streamGenerateContent` request.
10
+ *
11
+ * Inside the envelope the body is ordinary Gemini, so message conversion,
12
+ * thought-signature validation, tool-call ids and function-calling mode all
13
+ * come from pi's own Google adapter. What is added here is only what this
14
+ * backend demands beyond the public Gemini API: the runtime model id, its
15
+ * thinking budget, the Claude/GPT-OSS schema bridge, a few conversation-shape
16
+ * repairs it enforces, and the agent envelope it expects.
17
+ */
18
+
19
+ type GoogleShared = typeof import("@earendil-works/pi-ai/api/google-shared");
20
+ let googleSharedModule: Promise<GoogleShared> | undefined;
21
+
22
+ /** Loaded on first request, as pi loads its own Google adapter: it pulls in `@google/genai`. */
23
+ export function googleShared(): Promise<GoogleShared> {
24
+ return googleSharedModule ??= import("@earendil-works/pi-ai/api/google-shared");
25
+ }
26
+
27
+ export interface Part {
28
+ text?: string;
29
+ thought?: boolean;
30
+ thoughtSignature?: string;
31
+ inlineData?: { mimeType?: string; data?: string };
32
+ functionCall?: { name?: string; args?: Record<string, unknown>; id?: string };
33
+ functionResponse?: { name?: string; id?: string; response?: Record<string, unknown>; parts?: Part[] };
34
+ }
35
+
36
+ export interface Content {
37
+ role: "user" | "model";
38
+ parts: Part[];
39
+ }
40
+
41
+ export interface RequestOptions {
42
+ /** Level after pi's clamp; undefined means thinking off. */
43
+ reasoning?: ModelThinkingLevel;
44
+ temperature?: number;
45
+ maxTokens?: number;
46
+ toolChoice?: ToolChoice | "any";
47
+ sessionId?: string;
48
+ }
49
+
50
+ export interface GeminiRequest {
51
+ project: string;
52
+ model: string;
53
+ request: Record<string, unknown>;
54
+ requestType: "agent";
55
+ userAgent: "antigravity";
56
+ requestId: string;
57
+ }
58
+
59
+ /** The only text this module ever adds to a conversation; see {@link repairContents}. */
60
+ export const CONTINUE_TEXT = "Continue the active task using the available instructions and context.";
61
+
62
+ const isText = (part: Part) => typeof part.text === "string" && part.text.trim().length > 0 && !part.thought;
63
+ const hasFunctionCall = (turn: Content) => turn.parts.some((part) => part.functionCall);
64
+
65
+ /** Gemini 3+ rejects a replayed function call that lacks its thought signature. */
66
+ export function requiresThoughtSignatures(runtimeId: string): boolean {
67
+ if (!runtimeId.startsWith("gemini-")) return false;
68
+ const major = /^gemini-(\d+)/.exec(runtimeId)?.[1];
69
+ // Unversioned agent runtimes (`gemini-pro-agent`) are current-generation.
70
+ return major === undefined || Number(major) >= 3;
71
+ }
72
+
73
+ /** Claude and GPT-OSS are served through the custom-tool bridge. */
74
+ export function usesToolBridge(runtimeId: string): boolean {
75
+ return runtimeId.startsWith("claude-") || runtimeId.startsWith("gpt-oss-");
76
+ }
77
+
78
+ function observationText(name: string, args: Record<string, unknown> | undefined, response: Part["functionResponse"]): string {
79
+ const argsText = args && Object.keys(args).length > 0 ? ` (${JSON.stringify(args)})` : "";
80
+ const payload = response?.response ?? {};
81
+ const failed = "error" in payload;
82
+ const value = failed ? payload.error : "output" in payload ? payload.output : payload;
83
+ const body = typeof value === "string" ? value : JSON.stringify(value);
84
+ return `[${failed ? "Failed observation" : "Observation"} from \`${name}\`${argsText}:\n${body}]`;
85
+ }
86
+
87
+ /**
88
+ * History from another model carries tool calls without this model's thought
89
+ * signature, which Gemini 3 rejects outright. Such a call and its result are
90
+ * replayed as plain text instead, so the model keeps what happened without an
91
+ * unverifiable call in its own voice. Gemini only checks the first call of a
92
+ * turn, so a signed first call keeps the whole turn intact.
93
+ */
94
+ function observeUnsignedCalls(contents: Content[]): Content[] {
95
+ const pending = new Map<string, { name: string; args?: Record<string, unknown> }>();
96
+ const keyOf = (call: { id?: string; name?: string }) => call.id || `name:${call.name ?? ""}`;
97
+
98
+ return contents.map((turn) => {
99
+ if (turn.role === "model") {
100
+ const calls = turn.parts.filter((part) => part.functionCall);
101
+ if (calls.length === 0 || calls[0].thoughtSignature) return turn;
102
+ for (const { functionCall } of calls) {
103
+ pending.set(keyOf(functionCall!), { name: functionCall!.name ?? "tool", args: functionCall!.args });
104
+ }
105
+ return { ...turn, parts: turn.parts.filter((part) => !part.functionCall) };
106
+ }
107
+ if (pending.size === 0) return turn;
108
+ return {
109
+ ...turn,
110
+ parts: turn.parts.flatMap((part): Part[] => {
111
+ const response = part.functionResponse;
112
+ const call = response && pending.get(keyOf(response));
113
+ if (!response || !call) return [part];
114
+ pending.delete(keyOf(response));
115
+ return [{ text: sanitizeSurrogates(observationText(call.name, call.args, response)) }, ...(response.parts ?? [])];
116
+ }),
117
+ };
118
+ });
119
+ }
120
+
121
+ /** Adjacent turns with the same role become one; empty turns disappear. */
122
+ function mergeTurns(contents: Content[]): Content[] {
123
+ const merged: Content[] = [];
124
+ for (const turn of contents) {
125
+ if (turn.parts.length === 0) continue;
126
+ const last = merged.at(-1);
127
+ if (last?.role === turn.role) last.parts.push(...turn.parts);
128
+ else merged.push({ role: turn.role, parts: [...turn.parts] });
129
+ }
130
+ return merged;
131
+ }
132
+
133
+ /**
134
+ * Conversation shapes the public Gemini API tolerates but this backend
135
+ * rejects with a 400. Compaction and model switches produce every one of
136
+ * them, so they are repaired rather than surfaced:
137
+ *
138
+ * - an unsigned tool call on Gemini 3 (see {@link observeUnsignedCalls});
139
+ * - a conversation that does not open with a user turn;
140
+ * - no natural-language user text anywhere, e.g. a tool-only continuation;
141
+ * - a request that ends on a model turn.
142
+ */
143
+ export function repairContents(contents: Content[], requireSignatures: boolean): Content[] {
144
+ const turns = mergeTurns(requireSignatures ? observeUnsignedCalls(contents) : contents);
145
+ const bridge = (): Part => ({ text: CONTINUE_TEXT });
146
+
147
+ if (turns.length === 0 || turns[0].role === "model") turns.unshift({ role: "user", parts: [bridge()] });
148
+
149
+ if (!turns.some((turn) => turn.role === "user" && turn.parts.some(isText))) {
150
+ turns.find((turn) => turn.role === "user")!.parts.push(bridge());
151
+ }
152
+
153
+ const last = turns.at(-1)!;
154
+ if (last.role === "model") {
155
+ if (hasFunctionCall(last)) throw new Error("Gemini request ends on a tool call with no result.");
156
+ turns.push({ role: "user", parts: [bridge()] });
157
+ }
158
+ return turns;
159
+ }
160
+
161
+ function toolDeclarations(declared: { functionDeclarations: Record<string, unknown>[] }[], bridge: boolean) {
162
+ return declared.map((group) => ({
163
+ functionDeclarations: group.functionDeclarations.map(({ parametersJsonSchema, parameters, ...rest }) => ({
164
+ ...rest,
165
+ ...(bridge
166
+ ? { parameters: bridgeSchema(parametersJsonSchema ?? parameters) }
167
+ : { parametersJsonSchema: selfContainedSchema(parametersJsonSchema ?? parameters) }),
168
+ })),
169
+ }));
170
+ }
171
+
172
+ /** Random signed 64-bit decimal, the shape the Antigravity CLI uses for session ids. */
173
+ function randomSessionId(): string {
174
+ const bytes = crypto.getRandomValues(new Uint8Array(8));
175
+ return new DataView(bytes.buffer).getBigInt64(0, true).toString();
176
+ }
177
+
178
+ /**
179
+ * The agent envelope the Antigravity CLI sends. Ids are derived from the pi
180
+ * session, so one conversation keeps one trajectory across requests and
181
+ * restarts without any state held here.
182
+ */
183
+ function envelope(context: TranscriptContext, contents: Content[], runtimeId: string, sessionId?: string) {
184
+ const first = context.messages[0];
185
+ const seed = sessionId ?? (first ? `${first.role}:${first.timestamp ?? ""}` : crypto.randomUUID());
186
+ const conversationId = stableUuid(`antigravity:conv:${seed}`);
187
+ const trajectoryId = stableUuid(`antigravity:traj:${seed}`);
188
+ const step = Math.max(1, contents.length);
189
+ const turn = context.messages.filter((message) =>
190
+ message.role === "assistant" && message.stopReason !== "error" && message.stopReason !== "aborted").length;
191
+ const claude = String(runtimeId.startsWith("claude-"));
192
+
193
+ return {
194
+ sessionId: sessionId ?? randomSessionId(),
195
+ requestId: `agent/${conversationId}/${Date.now()}/${trajectoryId}/${step}`,
196
+ labels: {
197
+ last_step_index: String(step - 1),
198
+ request_id: `${trajectoryId}-${turn}`,
199
+ trajectory_id: trajectoryId,
200
+ used_claude: claude,
201
+ used_claude_conservative: claude,
202
+ used_non_gemini_model: String(!runtimeId.startsWith("gemini-")),
203
+ },
204
+ };
205
+ }
206
+
207
+ export async function buildRequest(
208
+ model: Model<Api>,
209
+ context: TranscriptContext,
210
+ projectId: string,
211
+ options: RequestOptions = {},
212
+ ): Promise<GeminiRequest> {
213
+ const google = await googleShared();
214
+ // pi's Google helpers are typed against its own Google APIs; the body inside
215
+ // the envelope has exactly the same model semantics.
216
+ const googleModel = model as unknown as Model<"google-generative-ai">;
217
+ const runtimeId = runtimeModelId(model, options.reasoning);
218
+
219
+ const contents = repairContents(
220
+ google.convertMessages(googleModel, context) as unknown as Content[],
221
+ requiresThoughtSignatures(runtimeId),
222
+ );
223
+
224
+ // The system prompt and tools live in the transcript's system messages,
225
+ // never on the context object; reading them any other way sends neither.
226
+ const systemPrompt = getCurrentSystemPrompt(context.messages);
227
+ const tools = getCurrentTools(context.messages);
228
+ // Strict tool sampling (Gemini's VALIDATED mode) is not offered by this backend.
229
+ const declared = google.convertTools(tools, false, false);
230
+ const mode = tools.length > 0
231
+ ? google.resolveGoogleFunctionCallingMode(tools, options.toolChoice, false)
232
+ : undefined;
233
+
234
+ const thinking = thinkingConfig(runtimeId, options.reasoning);
235
+ const generationConfig = {
236
+ ...(options.temperature !== undefined && { temperature: options.temperature }),
237
+ maxOutputTokens: Math.min(options.maxTokens ?? model.maxTokens, model.maxTokens),
238
+ ...(thinking && { thinkingConfig: thinking }),
239
+ };
240
+
241
+ const { sessionId, requestId, labels } = envelope(context, contents, runtimeId, options.sessionId);
242
+
243
+ return {
244
+ project: projectId,
245
+ model: runtimeId,
246
+ request: {
247
+ contents,
248
+ ...(systemPrompt && { systemInstruction: { role: "user", parts: [{ text: sanitizeSurrogates(systemPrompt) }] } }),
249
+ generationConfig,
250
+ ...(declared && { tools: toolDeclarations(declared, usesToolBridge(runtimeId)) }),
251
+ ...(mode !== undefined && { toolConfig: { functionCallingConfig: { mode } } }),
252
+ sessionId,
253
+ labels,
254
+ },
255
+ requestType: "agent",
256
+ userAgent: "antigravity",
257
+ requestId,
258
+ };
259
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Tool schemas in the two shapes the Antigravity backend accepts.
3
+ *
4
+ * - Gemini reads full JSON Schema from `parametersJsonSchema`, but only
5
+ * self-contained: `$ref` must already be resolved and `$defs` removed.
6
+ * - Claude and GPT-OSS go through a custom-tool bridge that reads the
7
+ * legacy protobuf `parameters` field. It rejects every keyword it does not
8
+ * know (`nullable`, `anyOf`, `format`, `const`, …) with a 400, so the
9
+ * schema is reduced to an allowlist rather than a denylist: a new JSON
10
+ * Schema keyword can never break a request. pi still validates the
11
+ * arguments the model returns against the original schema.
12
+ *
13
+ * Tool schemas can come from MCP servers, so expansion is bounded. A
14
+ * reference that cannot be resolved — missing, circular, or past a bound —
15
+ * becomes the unconstrained schema `{}` instead of failing the whole request.
16
+ */
17
+
18
+ type Json = Record<string, unknown>;
19
+
20
+ const MAX_DEPTH = 32;
21
+ const MAX_NODES = 10_000;
22
+
23
+ const METADATA = new Set(["$schema", "$id", "$anchor", "$dynamicAnchor", "$vocabulary", "$comment", "$defs", "definitions"]);
24
+ /** Keywords whose value is a map of *names* to schemas; the names are never keywords. */
25
+ const SCHEMA_MAPS = new Set(["properties", "patternProperties", "dependentSchemas"]);
26
+ /** Keywords whose value is a schema, or an array of schemas. */
27
+ const SCHEMA_VALUES = new Set([
28
+ "items", "prefixItems", "additionalItems", "additionalProperties", "unevaluatedItems", "unevaluatedProperties",
29
+ "contains", "propertyNames", "not", "if", "then", "else", "contentSchema", "allOf", "anyOf", "oneOf",
30
+ ]);
31
+
32
+ const BRIDGE_KEYWORDS = new Set(["type", "description", "properties", "required", "items", "enum"]);
33
+
34
+ function isRecord(value: unknown): value is Json {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36
+ }
37
+
38
+ /** RFC 6901 pointer into the root schema; only local (`#…`) references resolve. */
39
+ function resolvePointer(root: unknown, ref: string): unknown {
40
+ if (ref === "#") return root;
41
+ if (!ref.startsWith("#/")) return undefined;
42
+ let node: unknown = root;
43
+ for (const raw of ref.slice(2).split("/")) {
44
+ const key = raw.replace(/~1/g, "/").replace(/~0/g, "~");
45
+ if (Array.isArray(node)) {
46
+ if (!/^(0|[1-9]\d*)$/.test(key)) return undefined;
47
+ node = node[Number(key)];
48
+ } else if (isRecord(node) && Object.hasOwn(node, key)) {
49
+ node = node[key];
50
+ } else {
51
+ return undefined;
52
+ }
53
+ }
54
+ return node;
55
+ }
56
+
57
+ /** A JSON Schema with every local `$ref` inlined and schema metadata removed. */
58
+ export function selfContainedSchema(schema: unknown): Json {
59
+ let nodes = 0;
60
+
61
+ const walk = (node: unknown, refs: ReadonlySet<string>, depth: number): unknown => {
62
+ if (++nodes > MAX_NODES || depth > MAX_DEPTH) return {};
63
+ if (Array.isArray(node)) return node.map((item) => walk(item, refs, depth + 1));
64
+ if (!isRecord(node)) return node;
65
+
66
+ if (typeof node.$ref === "string") {
67
+ const { $ref: ref, ...siblings } = node;
68
+ const target = refs.has(ref) ? undefined : resolvePointer(schema, ref);
69
+ const resolved = target === undefined ? {} : walk(target, new Set([...refs, ref]), depth + 1);
70
+ const rest = walk(siblings, refs, depth + 1);
71
+ return { ...(isRecord(resolved) ? resolved : {}), ...(isRecord(rest) ? rest : {}) };
72
+ }
73
+
74
+ const out: Json = {};
75
+ for (const [key, value] of Object.entries(node)) {
76
+ if (METADATA.has(key)) continue;
77
+ if (SCHEMA_MAPS.has(key) && isRecord(value)) {
78
+ out[key] = Object.fromEntries(Object.entries(value).map(([name, sub]) => [name, walk(sub, refs, depth + 1)]));
79
+ } else if (SCHEMA_VALUES.has(key)) {
80
+ out[key] = walk(value, refs, depth + 1);
81
+ } else {
82
+ // `enum`, `default`, `examples`… are data, not schemas: copied verbatim.
83
+ out[key] = value;
84
+ }
85
+ }
86
+ return out;
87
+ };
88
+
89
+ return asObjectRoot(walk(schema, new Set(), 0));
90
+ }
91
+
92
+ /** Function declarations must describe an object, even for a tool with no parameters. */
93
+ function asObjectRoot(schema: unknown): Json {
94
+ if (!isRecord(schema)) return { type: "object", properties: {} };
95
+ return schema.type ? schema : { ...schema, type: "object", properties: schema.properties ?? {} };
96
+ }
97
+
98
+ /** `["string", "null"]` → `"string"`: the bridge takes a single type. */
99
+ function singleType(value: unknown): string | undefined {
100
+ if (typeof value === "string") return value;
101
+ return Array.isArray(value) ? value.find((entry): entry is string => typeof entry === "string" && entry !== "null") : undefined;
102
+ }
103
+
104
+ /**
105
+ * A union the bridge cannot express is narrowed to its first non-null branch,
106
+ * which keeps the common `T | null` shape typed instead of unconstrained.
107
+ */
108
+ function firstBranch(node: Json): Json | undefined {
109
+ const branches = Array.isArray(node.anyOf) ? node.anyOf : Array.isArray(node.oneOf) ? node.oneOf : undefined;
110
+ return branches?.find((branch): branch is Json => isRecord(branch) && branch.type !== "null");
111
+ }
112
+
113
+ function toBridge(node: unknown): unknown {
114
+ if (!isRecord(node)) return node;
115
+ const branch = node.type === undefined ? firstBranch(node) : undefined;
116
+ const source = branch ? { ...branch, ...(node.description !== undefined && { description: node.description }) } : node;
117
+
118
+ const out: Json = {};
119
+ for (const [key, value] of Object.entries(source)) {
120
+ if (!BRIDGE_KEYWORDS.has(key)) continue;
121
+ if (key === "type") {
122
+ const type = singleType(value);
123
+ if (type) out.type = type;
124
+ } else if (key === "properties" && isRecord(value)) {
125
+ out.properties = Object.fromEntries(Object.entries(value).map(([name, sub]) => [name, toBridge(sub)]));
126
+ } else if (key === "enum") {
127
+ // The bridge's enum is string-only; a mixed enum is dropped rather than coerced.
128
+ if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) out.enum = value;
129
+ } else if (key === "items") {
130
+ out.items = toBridge(Array.isArray(value) ? value[0] : value);
131
+ } else {
132
+ out[key] = value;
133
+ }
134
+ }
135
+ if (typeof source.const === "string" && out.enum === undefined) out.enum = [source.const];
136
+ return out;
137
+ }
138
+
139
+ /** The subset of a tool schema Gemini's Claude/GPT-OSS bridge accepts. */
140
+ export function bridgeSchema(schema: unknown): Json {
141
+ return asObjectRoot(toBridge(selfContainedSchema(schema)));
142
+ }