diff --git a/src/gemini/oauth.test.ts b/src/gemini/oauth.test.ts new file mode 100644 index 0000000..b445017 --- /dev/null +++ b/src/gemini/oauth.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +import { exchangeGeminiWithVerifier } from "./oauth"; + +describe("exchangeGeminiWithVerifier", () => { + beforeEach(() => { + mock.restore(); + }); + + it("returns a failure when code is not a string", async () => { + const result = await exchangeGeminiWithVerifier( + { code: "not-a-string" } as unknown as string, + "verifier", + ); + + expect(result.type).toBe("failed"); + if (result.type === "failed") { + expect(result.error).toContain("Missing authorization code"); + } + }); + + it("returns a failure when verifier is not a string", async () => { + const result = await exchangeGeminiWithVerifier( + "auth-code", + { verifier: "not-a-string" } as unknown as string, + ); + + expect(result.type).toBe("failed"); + if (result.type === "failed") { + expect(result.error).toContain("Missing PKCE verifier"); + } + }); + + it("allows retry after a failed token exchange", async () => { + const fetchMock = mock(async () => { + return new Response( + JSON.stringify({ error: "internal_error" }), + { status: 500, statusText: "Internal Server Error" }, + ); + }); + (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch; + + const first = await exchangeGeminiWithVerifier("retry-code-1", "retry-verifier-1"); + const second = await exchangeGeminiWithVerifier("retry-code-1", "retry-verifier-1"); + + expect(first.type).toBe("failed"); + expect(second.type).toBe("failed"); + expect(fetchMock.mock.calls.length).toBe(2); + }); + + it("marks code consumed after successful exchange", async () => { + let callCount = 0; + const fetchMock = mock(async () => { + callCount += 1; + if (callCount === 1) { + return new Response( + JSON.stringify({ + access_token: "access-token", + expires_in: 3600, + refresh_token: "refresh-token", + }), + { status: 200 }, + ); + } + + return new Response(JSON.stringify({ email: "user@example.com" }), { status: 200 }); + }); + (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch; + + const first = await exchangeGeminiWithVerifier("success-code-1", "success-verifier-1"); + const second = await exchangeGeminiWithVerifier("success-code-1", "success-verifier-1"); + + expect(first.type).toBe("success"); + expect(second.type).toBe("failed"); + if (second.type === "failed") { + expect(second.error).toContain("already submitted"); + } + expect(callCount).toBe(2); + }); +}); diff --git a/src/gemini/oauth.ts b/src/gemini/oauth.ts index 2e5ee60..c88a941 100644 --- a/src/gemini/oauth.ts +++ b/src/gemini/oauth.ts @@ -1,5 +1,5 @@ import { generatePKCE } from "@openauthjs/openauth/pkce"; -import { randomBytes } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { GEMINI_CLIENT_ID, @@ -54,6 +54,10 @@ interface GeminiUserInfo { email?: string; } +const AUTHORIZATION_CODE_REPLAY_TTL_MS = 10 * 60 * 1000; +const exchangeInFlight = new Map>(); +const consumedExchanges = new Map(); + /** * Build the Gemini OAuth authorization URL including PKCE. */ @@ -71,8 +75,6 @@ export async function authorizeGemini(): Promise { url.searchParams.set("state", state); url.searchParams.set("access_type", "offline"); url.searchParams.set("prompt", "consent"); - // Add a fragment so any stray terminal glyphs are ignored by the auth server. - url.hash = "opencode"; return { url: url.toString(), @@ -88,14 +90,59 @@ export async function exchangeGeminiWithVerifier( code: string, verifier: string, ): Promise { - try { - return await exchangeGeminiWithVerifierInternal(code, verifier); - } catch (error) { + const normalizedCode = typeof code === "string" ? code.trim() : ""; + const normalizedVerifier = typeof verifier === "string" ? verifier.trim() : ""; + if (isGeminiDebugEnabled() && (typeof code !== "string" || typeof verifier !== "string")) { + logGeminiDebugMessage( + `OAuth exchange received non-string inputs: code=${typeof code} verifier=${typeof verifier}`, + ); + } + if (!normalizedCode) { return { type: "failed", - error: error instanceof Error ? error.message : "Unknown error", + error: "Missing authorization code in exchange request", }; } + if (!normalizedVerifier) { + return { + type: "failed", + error: "Missing PKCE verifier for OAuth exchange", + }; + } + + pruneConsumedExchanges(); + const exchangeKey = buildExchangeKey(normalizedCode, normalizedVerifier); + if (consumedExchanges.has(exchangeKey)) { + return { + type: "failed", + error: "Authorization code was already submitted. Start a new login flow.", + }; + } + + const pending = exchangeInFlight.get(exchangeKey); + if (pending) { + return pending; + } + + const exchangePromise = exchangeGeminiWithVerifierInternal(normalizedCode, normalizedVerifier).catch( + (error): GeminiTokenExchangeResult => ({ + type: "failed", + error: error instanceof Error ? error.message : "Unknown error", + }), + ); + exchangeInFlight.set(exchangeKey, exchangePromise); + + let exchangeResult: GeminiTokenExchangeResult | undefined; + try { + exchangeResult = await exchangePromise; + return exchangeResult; + } finally { + exchangeInFlight.delete(exchangeKey); + if (exchangeResult?.type === "success") { + consumedExchanges.set(exchangeKey, Date.now()); + } + pruneConsumedExchanges(); + } } async function exchangeGeminiWithVerifierInternal( @@ -104,6 +151,7 @@ async function exchangeGeminiWithVerifierInternal( ): Promise { if (isGeminiDebugEnabled()) { logGeminiDebugMessage("OAuth exchange: POST https://oauth2.googleapis.com/token"); + logGeminiDebugMessage(`OAuth exchange code fingerprint: ${fingerprint(code)} len=${code.length}`); } const tokenResponse = await fetch("https://oauth2.googleapis.com/token", { method: "POST", @@ -175,3 +223,19 @@ async function exchangeGeminiWithVerifierInternal( email: userInfo.email, }; } + +function buildExchangeKey(code: string, verifier: string): string { + return createHash("sha256").update(code).update("\u0000").update(verifier).digest("hex"); +} + +function pruneConsumedExchanges(now = Date.now()): void { + for (const [key, consumedAt] of consumedExchanges.entries()) { + if (now - consumedAt > AUTHORIZATION_CODE_REPLAY_TTL_MS) { + consumedExchanges.delete(key); + } + } +} + +function fingerprint(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 12); +} diff --git a/src/plugin/oauth-authorize.test.ts b/src/plugin/oauth-authorize.test.ts new file mode 100644 index 0000000..05fcb4c --- /dev/null +++ b/src/plugin/oauth-authorize.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test"; + +import { + normalizeAuthorizationCode, + parseOAuthCallbackInput, +} from "./oauth-authorize"; + +describe("oauth authorize helpers", () => { + it("parses full callback URLs", () => { + const parsed = parseOAuthCallbackInput( + "http://localhost:8085/oauth2callback?code=4%2Fabc123&state=state-1", + ); + + expect(parsed.source).toBe("url"); + expect(parsed.code).toBe("4/abc123"); + expect(parsed.state).toBe("state-1"); + }); + + it("parses query-style callback inputs", () => { + const parsed = parseOAuthCallbackInput("code=4%2Fabc123&state=state-2"); + + expect(parsed.source).toBe("query"); + expect(parsed.code).toBe("4/abc123"); + expect(parsed.state).toBe("state-2"); + }); + + it("falls back to raw code when no query markers are present", () => { + const parsed = parseOAuthCallbackInput("4/0AbCDef"); + + expect(parsed.source).toBe("raw"); + expect(parsed.code).toBe("4/0AbCDef"); + }); + + it("normalizes encoded authorization codes", () => { + const singleEncoded = normalizeAuthorizationCode("4%2Fabc"); + const doubleEncoded = normalizeAuthorizationCode("4%252Fabc"); + + expect(singleEncoded).toBe("4/abc"); + expect(doubleEncoded).toBe("4/abc"); + }); + + it("rejects malformed authorization codes", () => { + expect(normalizeAuthorizationCode(" ")).toBeUndefined(); + expect(normalizeAuthorizationCode("4/abc 123")).toBeUndefined(); + expect(normalizeAuthorizationCode("4/abc\n123")).toBeUndefined(); + }); +}); diff --git a/src/plugin/oauth-authorize.ts b/src/plugin/oauth-authorize.ts index 70ff151..5023ec6 100644 --- a/src/plugin/oauth-authorize.ts +++ b/src/plugin/oauth-authorize.ts @@ -7,6 +7,16 @@ import { resolveProjectContextFromAccessToken } from "./project"; import { startOAuthListener, type OAuthListener } from "./server"; import type { OAuthAuthDetails } from "./types"; +const AUTHORIZATION_SESSION_TTL_MS = 10 * 60 * 1000; +const MAX_AUTHORIZATION_CODE_LENGTH = 4096; + +export interface ParsedOAuthCallbackInput { + code?: string; + state?: string; + error?: string; + source: "empty" | "url" | "query" | "raw"; +} + /** * Builds the OAuth authorize callback used by plugin auth methods. */ @@ -85,6 +95,75 @@ export function createOAuthAuthorizeMethod(): () => Promise<{ } const authorization = await authorizeGemini(); + const authorizationStartedAt = Date.now(); + let exchangePromise: Promise | null = null; + let exchangeConsumed = false; + + const finalizeExchange = async ( + parsed: ParsedOAuthCallbackInput, + ): Promise => { + if (parsed.error) { + return { + type: "failed", + error: `OAuth callback returned an error: ${parsed.error}`, + }; + } + + if (parsed.source !== "raw" && !parsed.state) { + return { + type: "failed", + error: "Missing state in callback input. Paste the full callback URL to continue.", + }; + } + + if (parsed.state && parsed.state !== authorization.state) { + return { + type: "failed", + error: "State mismatch in callback input (possible CSRF attempt)", + }; + } + + if (Date.now() - authorizationStartedAt > AUTHORIZATION_SESSION_TTL_MS) { + return { + type: "failed", + error: "OAuth authorization session expired. Start a new login flow.", + }; + } + + const normalizedCode = normalizeAuthorizationCode(parsed.code); + if (!normalizedCode) { + return { + type: "failed", + error: + "Invalid authorization code in callback input. Paste the full callback URL or a clean code value.", + }; + } + + if (exchangePromise) { + return exchangePromise; + } + + if (exchangeConsumed) { + return { + type: "failed", + error: + "Authorization code was already submitted. Start a new login flow if you need to retry.", + }; + } + + exchangeConsumed = true; + exchangePromise = (async () => { + const result = await exchangeGeminiWithVerifier(normalizedCode, authorization.verifier); + return maybeHydrateProjectId(result); + })(); + + try { + return await exchangePromise; + } finally { + exchangePromise = null; + } + }; + if (!isHeadless) { openBrowserUrl(authorization.url); } @@ -98,18 +177,12 @@ export function createOAuthAuthorizeMethod(): () => Promise<{ callback: async (): Promise => { try { const callbackUrl = await listener.waitForCallback(); - const code = callbackUrl.searchParams.get("code"); - const state = callbackUrl.searchParams.get("state"); - - if (!code || !state) { - return { type: "failed", error: "Missing code or state in callback URL" }; - } - if (state !== authorization.state) { - return { type: "failed", error: "State mismatch in callback URL (possible CSRF attempt)" }; - } - return await maybeHydrateProjectId( - await exchangeGeminiWithVerifier(code, authorization.verifier), - ); + return await finalizeExchange({ + code: callbackUrl.searchParams.get("code") ?? undefined, + state: callbackUrl.searchParams.get("state") ?? undefined, + error: callbackUrl.searchParams.get("error") ?? undefined, + source: "url", + }); } catch (error) { return { type: "failed", @@ -131,16 +204,7 @@ export function createOAuthAuthorizeMethod(): () => Promise<{ method: "code", callback: async (callbackUrl: string): Promise => { try { - const { code, state } = parseOAuthCallbackInput(callbackUrl); - if (!code) { - return { type: "failed", error: "Missing authorization code in callback input" }; - } - if (state && state !== authorization.state) { - return { type: "failed", error: "State mismatch in callback input (possible CSRF attempt)" }; - } - return await maybeHydrateProjectId( - await exchangeGeminiWithVerifier(code, authorization.verifier), - ); + return await finalizeExchange(parseOAuthCallbackInput(callbackUrl)); } catch (error) { return { type: "failed", @@ -152,35 +216,147 @@ export function createOAuthAuthorizeMethod(): () => Promise<{ }; } -function parseOAuthCallbackInput(input: string): { code?: string; state?: string } { - const trimmed = input.trim(); +export function parseOAuthCallbackInput(input: string): ParsedOAuthCallbackInput { + const trimmed = trimWrappingQuotes(input.trim()); if (!trimmed) { - return {}; + return { source: "empty" }; } - if (/^https?:\/\//i.test(trimmed)) { - try { - const url = new URL(trimmed); - return { - code: url.searchParams.get("code") || undefined, - state: url.searchParams.get("state") || undefined, - }; - } catch { - return {}; + const urlInput = normalizeUrlInput(trimmed); + if (urlInput) { + const parsedUrl = parseCallbackUrl(urlInput); + if (parsedUrl) { + return parsedUrl; } } - const candidate = trimmed.startsWith("?") ? trimmed.slice(1) : trimmed; + const candidate = extractQueryCandidate(trimmed); if (candidate.includes("=")) { const params = new URLSearchParams(candidate); const code = params.get("code") || undefined; const state = params.get("state") || undefined; - if (code || state) { - return { code, state }; + const error = params.get("error") || undefined; + if (code || state || error) { + return { source: "query", code, state, error }; } } - return { code: trimmed }; + return { source: "raw", code: trimmed }; +} + +export function normalizeAuthorizationCode(rawCode: string | undefined): string | undefined { + if (!rawCode) { + return undefined; + } + + let candidate = trimWrappingQuotes(rawCode).trim(); + if (!candidate) { + return undefined; + } + + if (/[\r\n]/.test(candidate)) { + return undefined; + } + + for (let index = 0; index < 2; index += 1) { + if (!/%[0-9A-Fa-f]{2}/.test(candidate)) { + break; + } + + try { + const decoded = decodeURIComponent(candidate); + if (decoded === candidate) { + break; + } + candidate = decoded; + } catch { + break; + } + } + + candidate = candidate.trim(); + if (!candidate || /\s/.test(candidate)) { + return undefined; + } + + if (candidate.length > MAX_AUTHORIZATION_CODE_LENGTH) { + return undefined; + } + + return candidate; +} + +function parseCallbackUrl(input: string): ParsedOAuthCallbackInput | undefined { + try { + const url = new URL(input); + const code = url.searchParams.get("code") || undefined; + const state = url.searchParams.get("state") || undefined; + const error = url.searchParams.get("error") || undefined; + if (code || state || error || url.pathname.includes("oauth2callback")) { + return { source: "url", code, state, error }; + } + + const hashParams = parseHashParams(url.hash); + if (hashParams.code || hashParams.state || hashParams.error) { + return { source: "url", ...hashParams }; + } + return undefined; + } catch { + return undefined; + } +} + +function parseHashParams(hash: string): { code?: string; state?: string; error?: string } { + const raw = hash.startsWith("#") ? hash.slice(1) : hash; + if (!raw.includes("=")) { + return {}; + } + + const params = new URLSearchParams(raw); + return { + code: params.get("code") || undefined, + state: params.get("state") || undefined, + error: params.get("error") || undefined, + }; +} + +function normalizeUrlInput(input: string): string | undefined { + if (/^https?:\/\//i.test(input)) { + return input; + } + + if (/^(localhost|127\.0\.0\.1):\d+/i.test(input)) { + return `http://${input}`; + } + + return undefined; +} + +function extractQueryCandidate(input: string): string { + const withoutQuotes = trimWrappingQuotes(input); + const queryIndex = withoutQuotes.indexOf("?"); + let candidate = queryIndex >= 0 ? withoutQuotes.slice(queryIndex + 1) : withoutQuotes; + const hashIndex = candidate.indexOf("#"); + if (hashIndex >= 0) { + const hashCandidate = candidate.slice(hashIndex + 1); + candidate = candidate.slice(0, hashIndex); + if (!candidate.includes("=") && hashCandidate.includes("=")) { + candidate = hashCandidate; + } + } + return candidate.startsWith("?") ? candidate.slice(1) : candidate; +} + +function trimWrappingQuotes(value: string): string { + if (value.length < 2) { + return value; + } + + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + return value.slice(1, -1).trim(); + } + + return value; } function openBrowserUrl(url: string): void { diff --git a/src/plugin/request.test.ts b/src/plugin/request.test.ts index 6585118..931c07d 100644 --- a/src/plugin/request.test.ts +++ b/src/plugin/request.test.ts @@ -88,4 +88,128 @@ describe("request helpers", () => { expect(payload).toContain('"responseId":"trace-456"'); expect(payload).not.toContain('"traceId"'); }); + + it("normalizes anyOf schema nodes in function declarations", () => { + const input = + "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:streamGenerateContent"; + const init: RequestInit = { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + contents: [{ role: "user", parts: [{ text: "hi" }] }], + tools: [ + { + functionDeclarations: [ + { + name: "edit", + parameters: { + type: "object", + properties: { + edits: { + description: "Array of edits", + anyOf: [ + { type: "array", items: { type: "string" } }, + { type: "null" }, + ], + }, + }, + }, + }, + ], + }, + ], + }), + }; + + const result = prepareGeminiRequest(input, init, "token-123", "project-456"); + const wrapped = JSON.parse(result.init.body as string) as { + request: { + tools: Array<{ + functionDeclarations: Array<{ + parameters: { + properties: { + edits: Record; + }; + }; + }>; + }>; + }; + }; + + const editsSchema = + wrapped.request.tools[0]?.functionDeclarations[0]?.parameters.properties.edits; + expect(editsSchema).toBeDefined(); + if (!editsSchema) { + throw new Error("Expected edits schema to be present"); + } + expect(editsSchema.anyOf).toBeDefined(); + expect(editsSchema.description).toBeUndefined(); + expect(editsSchema.type).toBeUndefined(); + }); + + it("preserves definition siblings when normalizing anyOf nodes", () => { + const input = + "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:streamGenerateContent"; + const init: RequestInit = { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + contents: [{ role: "user", parts: [{ text: "hi" }] }], + tools: [ + { + functionDeclarations: [ + { + name: "edit", + parameters: { + anyOf: [ + { $ref: "#/$defs/EditArray" }, + { type: "null" }, + ], + $defs: { + EditArray: { + type: "array", + items: { type: "string" }, + }, + }, + description: "should be dropped", + }, + }, + ], + }, + ], + }), + }; + + const result = prepareGeminiRequest(input, init, "token-123", "project-456"); + const wrapped = JSON.parse(result.init.body as string) as { + request: { + tools: Array<{ + functionDeclarations: Array<{ + parameters: Record; + }>; + }>; + }; + }; + + const parameters = wrapped.request.tools[0]?.functionDeclarations[0]?.parameters; + expect(parameters).toBeDefined(); + if (!parameters) { + throw new Error("Expected parameters schema to be present"); + } + + expect(parameters.anyOf).toBeDefined(); + expect(parameters.$defs).toBeDefined(); + expect(parameters.description).toBeUndefined(); + + const firstBranch = Array.isArray(parameters.anyOf) ? parameters.anyOf[0] : undefined; + expect(firstBranch).toBeDefined(); + if (!firstBranch || typeof firstBranch !== "object") { + throw new Error("Expected anyOf[0] branch to be present"); + } + expect((firstBranch as Record).$ref).toBe("#/$defs/EditArray"); + }); }); diff --git a/src/plugin/request/prepare.ts b/src/plugin/request/prepare.ts index a206691..8315be2 100644 --- a/src/plugin/request/prepare.ts +++ b/src/plugin/request/prepare.ts @@ -1,9 +1,11 @@ import { randomUUID } from "node:crypto"; import { CODE_ASSIST_HEADERS, GEMINI_CODE_ASSIST_ENDPOINT } from "../../constants"; +import { isGeminiDebugEnabled, logGeminiDebugMessage } from "../debug"; import { normalizeThinkingConfig } from "../request-helpers"; import { normalizeRequestPayloadIdentifiers, normalizeWrappedIdentifiers } from "./identifiers"; import { addThoughtSignaturesToFunctionCalls, transformOpenAIToolCalls } from "./openai"; +import { normalizeVertexFunctionDeclarationSchemas } from "./schema"; import { isGenerativeLanguageRequest, toRequestUrlString } from "./shared"; const STREAM_ACTION = "streamGenerateContent"; @@ -106,6 +108,17 @@ function transformRequestBody( ...parsedBody, model: effectiveModel, } as Record; + const wrappedRequest = wrappedBody.request; + if (wrappedRequest && typeof wrappedRequest === "object") { + const violations = normalizeVertexFunctionDeclarationSchemas( + wrappedRequest as Record, + ); + if (violations.length > 0 && isGeminiDebugEnabled()) { + logGeminiDebugMessage( + `Schema normalization warnings: ${violations.slice(0, 5).join("; ")}`, + ); + } + } const { userPromptId } = normalizeWrappedIdentifiers(wrappedBody); return { body: JSON.stringify(wrappedBody), userPromptId }; } @@ -116,6 +129,10 @@ function transformRequestBody( normalizeThinking(requestPayload); normalizeSystemInstruction(requestPayload); normalizeCachedContent(requestPayload); + const violations = normalizeVertexFunctionDeclarationSchemas(requestPayload); + if (violations.length > 0 && isGeminiDebugEnabled()) { + logGeminiDebugMessage(`Schema normalization warnings: ${violations.slice(0, 5).join("; ")}`); + } if ("model" in requestPayload) { delete requestPayload.model; diff --git a/src/plugin/request/schema.ts b/src/plugin/request/schema.ts new file mode 100644 index 0000000..45e636b --- /dev/null +++ b/src/plugin/request/schema.ts @@ -0,0 +1,144 @@ +import { isRecord } from "./shared"; + +type JsonRecord = Record; + +const ANY_OF_DEFINITION_KEYS = new Set(["$defs", "defs", "definitions"]); + +export function normalizeVertexFunctionDeclarationSchemas(requestPayload: JsonRecord): string[] { + const tools = requestPayload.tools; + if (!Array.isArray(tools)) { + return []; + } + + const issues: string[] = []; + for (let toolIndex = 0; toolIndex < tools.length; toolIndex += 1) { + const tool = tools[toolIndex]; + if (!isRecord(tool)) { + continue; + } + + const functionDeclarations = readFunctionDeclarations(tool); + for (let declarationIndex = 0; declarationIndex < functionDeclarations.length; declarationIndex += 1) { + const declaration = functionDeclarations[declarationIndex]; + if (!isRecord(declaration)) { + continue; + } + + const declarationName = + (typeof declaration.name === "string" && declaration.name.trim().length > 0 + ? declaration.name.trim() + : `tool_${toolIndex}_declaration_${declarationIndex}`); + + for (const schemaKey of ["parameters", "parametersJsonSchema", "parameters_json_schema"] as const) { + const schema = declaration[schemaKey]; + if (!schema) { + continue; + } + + const normalizedSchema = normalizeSchemaAnyOfNodes(schema); + declaration[schemaKey] = normalizedSchema; + const violations = collectAnyOfSiblingViolations(normalizedSchema, schemaKey); + for (const violation of violations) { + issues.push(`${declarationName}:${violation}`); + } + } + } + } + + return issues; +} + +export function collectAnyOfSiblingViolations(schema: unknown, startPath = "schema"): string[] { + const issues: string[] = []; + collectAnyOfSiblingViolationsInternal(schema, startPath, issues); + return issues; +} + +function collectAnyOfSiblingViolationsInternal( + node: unknown, + path: string, + issues: string[], +): void { + if (Array.isArray(node)) { + for (let index = 0; index < node.length; index += 1) { + collectAnyOfSiblingViolationsInternal(node[index], `${path}[${index}]`, issues); + } + return; + } + + if (!isRecord(node)) { + return; + } + + const hasAnyOf = Array.isArray(node.anyOf) || Array.isArray(node.any_of); + if (hasAnyOf) { + const siblingKeys = Object.keys(node).filter( + (key) => key !== "anyOf" && key !== "any_of" && !ANY_OF_DEFINITION_KEYS.has(key), + ); + if (siblingKeys.length > 0) { + issues.push(`${path} (extra keys: ${siblingKeys.join(",")})`); + } + } + + for (const [key, value] of Object.entries(node)) { + collectAnyOfSiblingViolationsInternal(value, `${path}.${key}`, issues); + } +} + +function readFunctionDeclarations(tool: JsonRecord): unknown[] { + if (Array.isArray(tool.functionDeclarations)) { + return tool.functionDeclarations; + } + if (Array.isArray(tool.function_declarations)) { + return tool.function_declarations; + } + return []; +} + +function normalizeSchemaAnyOfNodes(schema: unknown): unknown { + if (Array.isArray(schema)) { + return schema.map((item) => normalizeSchemaAnyOfNodes(item)); + } + + if (!isRecord(schema)) { + return schema; + } + + const anyOf = Array.isArray(schema.anyOf) + ? schema.anyOf + : Array.isArray(schema.any_of) + ? schema.any_of + : undefined; + + if (anyOf) { + const normalizedAnyOf = anyOf.map((branch) => normalizeSchemaAnyOfNodes(branch)); + const preservedDefinitions = readAnyOfDefinitionSiblings(schema); + if (Array.isArray(schema.any_of) && !Array.isArray(schema.anyOf)) { + return { + any_of: normalizedAnyOf, + ...preservedDefinitions, + }; + } + return { + anyOf: normalizedAnyOf, + ...preservedDefinitions, + }; + } + + const normalized: JsonRecord = {}; + for (const [key, value] of Object.entries(schema)) { + normalized[key] = normalizeSchemaAnyOfNodes(value); + } + return normalized; +} + +function readAnyOfDefinitionSiblings(schema: JsonRecord): JsonRecord { + const preserved: JsonRecord = {}; + for (const key of ANY_OF_DEFINITION_KEYS) { + if (!(key in schema)) { + continue; + } + preserved[key] = normalizeSchemaAnyOfNodes(schema[key]); + } + return preserved; +}