@jameslovespancakes/pi-plus 1.0.15 → 1.0.17
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/README.md +34 -4
- package/package.json +5 -5
- package/src/core/accounts/oauth-pool.ts +4 -2
- package/src/core/accounts/registry.ts +7 -2
- package/src/core/anthropic/catalog.ts +139 -0
- package/src/core/anthropic/client-identity.ts +24 -5
- package/src/core/anthropic/models.ts +111 -26
- package/src/core/config.ts +1 -1
- package/src/core/gemini/LICENSE.md +21 -0
- package/src/core/gemini/client.ts +188 -0
- package/src/core/gemini/convert.ts +239 -0
- package/src/core/gemini/credentials.ts +56 -0
- package/src/core/gemini/models.ts +362 -0
- package/src/core/gemini/oauth.ts +240 -0
- package/src/core/gemini/request.ts +243 -0
- package/src/core/gemini/schema.ts +142 -0
- package/src/core/gemini/stream.ts +557 -0
- package/src/core/oauth/callback-server.ts +110 -0
- package/src/core/policy/policy.ts +3 -1
- package/src/domains/models/catalog-tool.ts +1 -1
- package/src/domains/subscriptions/accounts.ts +2 -2
- package/src/domains/subscriptions/footer.ts +1 -1
- package/src/domains/subscriptions/index.ts +3 -1
- package/src/domains/subscriptions/provider.ts +66 -7
- package/src/domains/subscriptions/providers/builtin.ts +19 -0
- package/src/domains/subscriptions/providers/codex.ts +2 -2
- package/src/domains/subscriptions/providers/gemini.ts +111 -0
- package/src/domains/subscriptions/providers/hosted.ts +3 -4
- package/src/domains/subscriptions/providers/oauth-pool.ts +35 -9
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getCurrentSystemPrompt,
|
|
3
|
+
getCurrentTools,
|
|
4
|
+
type Api,
|
|
5
|
+
type Model,
|
|
6
|
+
type ModelThinkingLevel,
|
|
7
|
+
type Tool,
|
|
8
|
+
type ToolChoice,
|
|
9
|
+
type TranscriptContext,
|
|
10
|
+
} from "@earendil-works/pi-ai";
|
|
11
|
+
import { stableUuid } from "./client.ts";
|
|
12
|
+
import { convertMessages, sanitizeSurrogates, type Content, type Part } from "./convert.ts";
|
|
13
|
+
import { runtimeModelId, thinkingConfig } from "./models.ts";
|
|
14
|
+
import { bridgeSchema, selfContainedSchema } from "./schema.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Builds a Gemini `streamGenerateContent` request.
|
|
18
|
+
*
|
|
19
|
+
* Inside the envelope the body is ordinary Gemini, converted the way pi's
|
|
20
|
+
* own Google adapter converts it (see convert.ts). What is added here is only
|
|
21
|
+
* what this backend demands beyond the public Gemini API: the runtime model
|
|
22
|
+
* id, its thinking budget, the Claude/GPT-OSS schema bridge, a few
|
|
23
|
+
* conversation-shape repairs it enforces, and the agent envelope it expects.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export type { Content, Part };
|
|
27
|
+
|
|
28
|
+
export interface RequestOptions {
|
|
29
|
+
/** Level after pi's clamp; undefined means thinking off. */
|
|
30
|
+
reasoning?: ModelThinkingLevel;
|
|
31
|
+
temperature?: number;
|
|
32
|
+
maxTokens?: number;
|
|
33
|
+
toolChoice?: ToolChoice | "any";
|
|
34
|
+
sessionId?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface GeminiRequest {
|
|
38
|
+
project: string;
|
|
39
|
+
model: string;
|
|
40
|
+
request: Record<string, unknown>;
|
|
41
|
+
requestType: "agent";
|
|
42
|
+
userAgent: "antigravity";
|
|
43
|
+
requestId: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The only text this module ever adds to a conversation; see {@link repairContents}. */
|
|
47
|
+
export const CONTINUE_TEXT = "Continue the active task using the available instructions and context.";
|
|
48
|
+
|
|
49
|
+
const isText = (part: Part) => typeof part.text === "string" && part.text.trim().length > 0 && !part.thought;
|
|
50
|
+
const hasFunctionCall = (turn: Content) => turn.parts.some((part) => part.functionCall);
|
|
51
|
+
|
|
52
|
+
/** Gemini 3+ rejects a replayed function call that lacks its thought signature. */
|
|
53
|
+
export function requiresThoughtSignatures(runtimeId: string): boolean {
|
|
54
|
+
if (!runtimeId.startsWith("gemini-")) return false;
|
|
55
|
+
const major = /^gemini-(\d+)/.exec(runtimeId)?.[1];
|
|
56
|
+
// Unversioned agent runtimes (`gemini-pro-agent`) are current-generation.
|
|
57
|
+
return major === undefined || Number(major) >= 3;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Claude and GPT-OSS are served through the custom-tool bridge. */
|
|
61
|
+
export function usesToolBridge(runtimeId: string): boolean {
|
|
62
|
+
return runtimeId.startsWith("claude-") || runtimeId.startsWith("gpt-oss-");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function observationText(name: string, args: Record<string, unknown> | undefined, response: Part["functionResponse"]): string {
|
|
66
|
+
const argsText = args && Object.keys(args).length > 0 ? ` (${JSON.stringify(args)})` : "";
|
|
67
|
+
const payload = response?.response ?? {};
|
|
68
|
+
const failed = "error" in payload;
|
|
69
|
+
const value = failed ? payload.error : "output" in payload ? payload.output : payload;
|
|
70
|
+
const body = typeof value === "string" ? value : JSON.stringify(value);
|
|
71
|
+
return `[${failed ? "Failed observation" : "Observation"} from \`${name}\`${argsText}:\n${body}]`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* History from another model carries tool calls without this model's thought
|
|
76
|
+
* signature, which Gemini 3 rejects outright. Such a call and its result are
|
|
77
|
+
* replayed as plain text instead, so the model keeps what happened without an
|
|
78
|
+
* unverifiable call in its own voice. Gemini only checks the first call of a
|
|
79
|
+
* turn, so a signed first call keeps the whole turn intact.
|
|
80
|
+
*/
|
|
81
|
+
function observeUnsignedCalls(contents: Content[]): Content[] {
|
|
82
|
+
const pending = new Map<string, { name: string; args?: Record<string, unknown> }>();
|
|
83
|
+
const keyOf = (call: { id?: string; name?: string }) => call.id || `name:${call.name ?? ""}`;
|
|
84
|
+
|
|
85
|
+
return contents.map((turn) => {
|
|
86
|
+
if (turn.role === "model") {
|
|
87
|
+
const calls = turn.parts.filter((part) => part.functionCall);
|
|
88
|
+
if (calls.length === 0 || calls[0].thoughtSignature) return turn;
|
|
89
|
+
for (const { functionCall } of calls) {
|
|
90
|
+
pending.set(keyOf(functionCall!), { name: functionCall!.name ?? "tool", args: functionCall!.args });
|
|
91
|
+
}
|
|
92
|
+
return { ...turn, parts: turn.parts.filter((part) => !part.functionCall) };
|
|
93
|
+
}
|
|
94
|
+
if (pending.size === 0) return turn;
|
|
95
|
+
return {
|
|
96
|
+
...turn,
|
|
97
|
+
parts: turn.parts.flatMap((part): Part[] => {
|
|
98
|
+
const response = part.functionResponse;
|
|
99
|
+
const call = response && pending.get(keyOf(response));
|
|
100
|
+
if (!response || !call) return [part];
|
|
101
|
+
pending.delete(keyOf(response));
|
|
102
|
+
return [{ text: sanitizeSurrogates(observationText(call.name, call.args, response)) }, ...(response.parts ?? [])];
|
|
103
|
+
}),
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Adjacent turns with the same role become one; empty turns disappear. */
|
|
109
|
+
function mergeTurns(contents: Content[]): Content[] {
|
|
110
|
+
const merged: Content[] = [];
|
|
111
|
+
for (const turn of contents) {
|
|
112
|
+
if (turn.parts.length === 0) continue;
|
|
113
|
+
const last = merged.at(-1);
|
|
114
|
+
if (last?.role === turn.role) last.parts.push(...turn.parts);
|
|
115
|
+
else merged.push({ role: turn.role, parts: [...turn.parts] });
|
|
116
|
+
}
|
|
117
|
+
return merged;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Conversation shapes the public Gemini API tolerates but this backend
|
|
122
|
+
* rejects with a 400. Compaction and model switches produce every one of
|
|
123
|
+
* them, so they are repaired rather than surfaced:
|
|
124
|
+
*
|
|
125
|
+
* - an unsigned tool call on Gemini 3 (see {@link observeUnsignedCalls});
|
|
126
|
+
* - a conversation that does not open with a user turn;
|
|
127
|
+
* - no natural-language user text anywhere, e.g. a tool-only continuation;
|
|
128
|
+
* - a request that ends on a model turn.
|
|
129
|
+
*/
|
|
130
|
+
export function repairContents(contents: Content[], requireSignatures: boolean): Content[] {
|
|
131
|
+
const turns = mergeTurns(requireSignatures ? observeUnsignedCalls(contents) : contents);
|
|
132
|
+
const bridge = (): Part => ({ text: CONTINUE_TEXT });
|
|
133
|
+
|
|
134
|
+
if (turns.length === 0 || turns[0].role === "model") turns.unshift({ role: "user", parts: [bridge()] });
|
|
135
|
+
|
|
136
|
+
if (!turns.some((turn) => turn.role === "user" && turn.parts.some(isText))) {
|
|
137
|
+
turns.find((turn) => turn.role === "user")!.parts.push(bridge());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const last = turns.at(-1)!;
|
|
141
|
+
if (last.role === "model") {
|
|
142
|
+
if (hasFunctionCall(last)) throw new Error("Gemini request ends on a tool call with no result.");
|
|
143
|
+
turns.push({ role: "user", parts: [bridge()] });
|
|
144
|
+
}
|
|
145
|
+
return turns;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function toolDeclarations(tools: Tool[], bridge: boolean) {
|
|
149
|
+
return [{
|
|
150
|
+
functionDeclarations: tools.map((tool) => ({
|
|
151
|
+
name: tool.name,
|
|
152
|
+
description: tool.description,
|
|
153
|
+
...(bridge
|
|
154
|
+
? { parameters: bridgeSchema(tool.parameters) }
|
|
155
|
+
: { parametersJsonSchema: selfContainedSchema(tool.parameters) }),
|
|
156
|
+
})),
|
|
157
|
+
}];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** pi's tool choice as Gemini's calling mode; omitted unless asked, as pi does. */
|
|
161
|
+
function callingMode(toolChoice: RequestOptions["toolChoice"]): string | undefined {
|
|
162
|
+
if (toolChoice === "none") return "NONE";
|
|
163
|
+
if (toolChoice === "any") return "ANY";
|
|
164
|
+
return toolChoice ? "AUTO" : undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Random signed 64-bit decimal, the shape the Antigravity CLI uses for session ids. */
|
|
168
|
+
function randomSessionId(): string {
|
|
169
|
+
const bytes = crypto.getRandomValues(new Uint8Array(8));
|
|
170
|
+
return new DataView(bytes.buffer).getBigInt64(0, true).toString();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The agent envelope the Antigravity CLI sends. Ids are derived from the pi
|
|
175
|
+
* session, so one conversation keeps one trajectory across requests and
|
|
176
|
+
* restarts without any state held here.
|
|
177
|
+
*/
|
|
178
|
+
function envelope(context: TranscriptContext, contents: Content[], runtimeId: string, sessionId?: string) {
|
|
179
|
+
const first = context.messages[0];
|
|
180
|
+
const seed = sessionId ?? (first ? `${first.role}:${first.timestamp ?? ""}` : crypto.randomUUID());
|
|
181
|
+
const conversationId = stableUuid(`antigravity:conv:${seed}`);
|
|
182
|
+
const trajectoryId = stableUuid(`antigravity:traj:${seed}`);
|
|
183
|
+
const step = Math.max(1, contents.length);
|
|
184
|
+
const turn = context.messages.filter((message) =>
|
|
185
|
+
message.role === "assistant" && message.stopReason !== "error" && message.stopReason !== "aborted").length;
|
|
186
|
+
const claude = String(runtimeId.startsWith("claude-"));
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
sessionId: sessionId ?? randomSessionId(),
|
|
190
|
+
requestId: `agent/${conversationId}/${Date.now()}/${trajectoryId}/${step}`,
|
|
191
|
+
labels: {
|
|
192
|
+
last_step_index: String(step - 1),
|
|
193
|
+
request_id: `${trajectoryId}-${turn}`,
|
|
194
|
+
trajectory_id: trajectoryId,
|
|
195
|
+
used_claude: claude,
|
|
196
|
+
used_claude_conservative: claude,
|
|
197
|
+
used_non_gemini_model: String(!runtimeId.startsWith("gemini-")),
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function buildRequest(
|
|
203
|
+
model: Model<Api>,
|
|
204
|
+
context: TranscriptContext,
|
|
205
|
+
projectId: string,
|
|
206
|
+
options: RequestOptions = {},
|
|
207
|
+
): GeminiRequest {
|
|
208
|
+
const runtimeId = runtimeModelId(model, options.reasoning);
|
|
209
|
+
const contents = repairContents(convertMessages(model, context), requiresThoughtSignatures(runtimeId));
|
|
210
|
+
|
|
211
|
+
// The system prompt and tools live in the transcript's system messages,
|
|
212
|
+
// never on the context object; reading them any other way sends neither.
|
|
213
|
+
const systemPrompt = getCurrentSystemPrompt(context.messages);
|
|
214
|
+
const tools = getCurrentTools(context.messages);
|
|
215
|
+
// Strict tool sampling (Gemini's VALIDATED mode) is not offered by this backend.
|
|
216
|
+
const mode = tools.length > 0 ? callingMode(options.toolChoice) : undefined;
|
|
217
|
+
|
|
218
|
+
const thinking = thinkingConfig(runtimeId, options.reasoning);
|
|
219
|
+
const generationConfig = {
|
|
220
|
+
...(options.temperature !== undefined && { temperature: options.temperature }),
|
|
221
|
+
maxOutputTokens: Math.min(options.maxTokens ?? model.maxTokens, model.maxTokens),
|
|
222
|
+
...(thinking && { thinkingConfig: thinking }),
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const { sessionId, requestId, labels } = envelope(context, contents, runtimeId, options.sessionId);
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
project: projectId,
|
|
229
|
+
model: runtimeId,
|
|
230
|
+
request: {
|
|
231
|
+
contents,
|
|
232
|
+
...(systemPrompt && { systemInstruction: { role: "user", parts: [{ text: sanitizeSurrogates(systemPrompt) }] } }),
|
|
233
|
+
generationConfig,
|
|
234
|
+
...(tools.length > 0 && { tools: toolDeclarations(tools, usesToolBridge(runtimeId)) }),
|
|
235
|
+
...(mode !== undefined && { toolConfig: { functionCallingConfig: { mode } } }),
|
|
236
|
+
sessionId,
|
|
237
|
+
labels,
|
|
238
|
+
},
|
|
239
|
+
requestType: "agent",
|
|
240
|
+
userAgent: "antigravity",
|
|
241
|
+
requestId,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -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
|
+
}
|