From 79fbcdcad1b055b4d2927abeb3ff05805f979cdb Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Wed, 8 Jul 2026 09:22:27 -0700 Subject: [PATCH 01/21] feat: add firebase ailogic:providers CLI commands Add `firebase ailogic:providers:{enable,disable,list}` to manage the Gemini API providers (Gemini Developer API and Agent Platform Gemini API) for Firebase AI Logic from the CLI. - providers:enable enables the Firebase AI Logic API and the selected provider's underlying API; agent-platform-gemini-api requires the Blaze (pay-as-you-go) plan. - providers:disable prompts for confirmation (--force to skip) and turns off the proxy when no providers remain enabled. - providers:list reports which providers are enabled. - Adds a shared interactive enablement flow (used when the API is not yet enabled) that checks enable permission up front before prompting. - Adds `firebase help ` listing of subcommands under a prefix so ailogic commands are discoverable. Requires the firebasevertexai and serviceusage IAM permissions; commands support --json and --non-interactive. --- CHANGELOG.md | 6 +- src/commands/ailogic-providers-disable.ts | 40 ++++ src/commands/ailogic-providers-enable.ts | 23 +++ src/commands/ailogic-providers-list.ts | 38 ++++ src/commands/help.spec.ts | 59 ++++++ src/commands/help.ts | 42 ++++- src/commands/index.ts | 6 + src/ensureApiEnabled.ts | 15 ++ src/gcp/ailogic.spec.ts | 120 ++++++++++++ src/gcp/ailogic.ts | 211 +++++++++++++++++++++- src/gcp/serviceusage.spec.ts | 18 ++ src/gcp/serviceusage.ts | 42 +++++ 12 files changed, 614 insertions(+), 6 deletions(-) create mode 100644 src/commands/ailogic-providers-disable.ts create mode 100644 src/commands/ailogic-providers-enable.ts create mode 100644 src/commands/ailogic-providers-list.ts create mode 100644 src/commands/help.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e01550d0d53..0d442c6dbac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,2 @@ -- Fixed an issue in `apps:create` where App Store ID was always prompted for even when unnecessary. -- Add `functions:lifecycle:list` and `functions:lifecycle:run` commands to view and run - lifecycle hooks in isolation. -- Updated the Firebase SQL Connect local toolkit to v3.4.15, which supports for 1:1 nested mutations. (#10773) +- Added `firebase ailogic:providers:*` CLI commands to enable, disable, and list Gemini API providers. +- Updated Pub/Sub emulator to version 0.8.34 diff --git a/src/commands/ailogic-providers-disable.ts b/src/commands/ailogic-providers-disable.ts new file mode 100644 index 00000000000..aa126b323a1 --- /dev/null +++ b/src/commands/ailogic-providers-disable.ts @@ -0,0 +1,40 @@ +import { Command } from "../command"; +import { requirePermissions } from "../requirePermissions"; +import { needProjectId } from "../projectUtils"; +import * as ailogic from "../gcp/ailogic"; +import * as clc from "colorette"; +import { logger } from "../logger"; +import { FirebaseError } from "../error"; +import { confirm } from "../prompt"; + +import { Options } from "../options"; + +export const command = new Command("ailogic:providers:disable ") + .description("disable a Gemini API provider service") + .option("-f, --force", "bypass confirmation prompt") + .before(requirePermissions, ["serviceusage.services.disable", "firebasevertexai.config.update"]) + .action(async (providerType: string, options: Options) => { + const projectId = needProjectId(options); + if (providerType !== "gemini-developer-api" && providerType !== "agent-platform-gemini-api") { + throw new FirebaseError( + `Invalid provider type: ${clc.bold(providerType)}. Must be 'gemini-developer-api' or 'agent-platform-gemini-api'.`, + ); + } + if (options.nonInteractive && !options.force) { + throw new FirebaseError( + `Disabling provider ${clc.bold(providerType)} requires confirmation.\n\n` + + `To proceed in non-interactive mode, rerun with --force:\n\n` + + ` firebase ailogic:providers:disable ${providerType} --force`, + ); + } + const confirmed = await confirm({ + message: `You are about to disable ${clc.bold(providerType)}. This will stop running apps from invoking it. Are you sure?`, + force: options.force, + nonInteractive: options.nonInteractive, + }); + if (!confirmed) { + throw new FirebaseError("Command aborted.", { exit: 1 }); + } + await ailogic.disableProvider(projectId, providerType as ailogic.ProviderType); + logger.info(clc.green(`Successfully disabled provider: ${clc.bold(providerType)}`)); + }); diff --git a/src/commands/ailogic-providers-enable.ts b/src/commands/ailogic-providers-enable.ts new file mode 100644 index 00000000000..244661495cb --- /dev/null +++ b/src/commands/ailogic-providers-enable.ts @@ -0,0 +1,23 @@ +import { Command } from "../command"; +import { requirePermissions } from "../requirePermissions"; +import { needProjectId } from "../projectUtils"; +import * as ailogic from "../gcp/ailogic"; +import * as clc from "colorette"; +import { logger } from "../logger"; +import { FirebaseError } from "../error"; + +import { Options } from "../options"; + +export const command = new Command("ailogic:providers:enable ") + .description("enable a Gemini API provider service") + .before(requirePermissions, ["serviceusage.services.enable", "firebasevertexai.config.update"]) + .action(async (providerType: string, options: Options) => { + const projectId = needProjectId(options); + if (providerType !== "gemini-developer-api" && providerType !== "agent-platform-gemini-api") { + throw new FirebaseError( + `Invalid provider type: ${clc.bold(providerType)}. Must be 'gemini-developer-api' or 'agent-platform-gemini-api'.`, + ); + } + await ailogic.enableProvider(projectId, providerType as ailogic.ProviderType); + logger.info(clc.green(`Successfully enabled provider: ${clc.bold(providerType)}`)); + }); diff --git a/src/commands/ailogic-providers-list.ts b/src/commands/ailogic-providers-list.ts new file mode 100644 index 00000000000..fe850408ff9 --- /dev/null +++ b/src/commands/ailogic-providers-list.ts @@ -0,0 +1,38 @@ +import { Command } from "../command"; +import { requirePermissions } from "../requirePermissions"; +import { needProjectId } from "../projectUtils"; +import * as ailogic from "../gcp/ailogic"; +import * as clc from "colorette"; +import { logger } from "../logger"; +import * as Table from "cli-table3"; + +import { Options } from "../options"; + +export const command = new Command("ailogic:providers:list") + .description("list which Gemini API providers are enabled") + .before(requirePermissions, ["serviceusage.services.get"]) + .action(async (options: Options) => { + const projectId = needProjectId(options); + const enabledProviders = await ailogic.listProviders(projectId); + + if (enabledProviders.length === 0) { + logger.info(clc.bold("No Gemini API providers are enabled.")); + return enabledProviders; + } + + const tableHead = ["Provider", "Status"]; + const table = new Table({ head: tableHead, style: { head: ["green"] } }); + + // Show both possible providers, indicating if they are enabled or disabled + const allProviders: ailogic.ProviderType[] = [ + "gemini-developer-api", + "agent-platform-gemini-api", + ]; + for (const provider of allProviders) { + const isEnabled = enabledProviders.includes(provider); + table.push([clc.bold(provider), isEnabled ? clc.green("Enabled") : clc.red("Disabled")]); + } + + logger.info(table.toString()); + return enabledProviders; + }); diff --git a/src/commands/help.spec.ts b/src/commands/help.spec.ts new file mode 100644 index 00000000000..18f755caac9 --- /dev/null +++ b/src/commands/help.spec.ts @@ -0,0 +1,59 @@ +import * as sinon from "sinon"; +import { expect } from "chai"; +import { command as helpCommand } from "./help"; +import { logger } from "../logger"; + +describe("help command namespace listing", () => { + let loggerStub: sinon.SinonStub; + + beforeEach(() => { + loggerStub = sinon.stub(logger, "info"); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should show help for namespace subcommands", async () => { + const mockEnableCommand = { + name: () => "ailogic:providers:enable", + description: () => "enable a provider", + outputHelp: sinon.stub(), + }; + const mockDisableCommand = { + name: () => "ailogic:providers:disable", + description: () => "disable a provider", + outputHelp: sinon.stub(), + }; + + const mockClient = { + cli: { + commands: [mockEnableCommand, mockDisableCommand], + outputHelp: sinon.stub(), + }, + getCommand: sinon.stub().returns(undefined), + ailogic: { + providers: {}, + }, + }; + + // Run help command action with mock context + // `actionFn` is a private member of Command; cast through `unknown` to invoke it + // directly with a mocked command context. The target type is fully specified (no `any`). + const actionFn = ( + helpCommand as unknown as { + actionFn: (this: { client: typeof mockClient }, commandName: string) => Promise; + } + ).actionFn; + await actionFn.call({ client: mockClient }, "ailogic:providers"); + + // It should log the subcommands and descriptions + expect(loggerStub).to.have.been.called; + const allArgs = loggerStub.args.map((a) => a.join(" ")).join("\n"); + expect(allArgs).to.include("Commands under ailogic:providers:"); + expect(allArgs).to.include("ailogic:providers:enable"); + expect(allArgs).to.include("enable a provider"); + expect(allArgs).to.include("ailogic:providers:disable"); + expect(allArgs).to.include("disable a provider"); + }); +}); diff --git a/src/commands/help.ts b/src/commands/help.ts index 7d8067ca410..ee39fcd290a 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import * as clc from "colorette"; +import type { Command as CommanderCommand } from "commander"; import { Command } from "../command"; import { logger } from "../logger"; @@ -14,7 +15,46 @@ export const command = new Command("help [command]") const cmd = commandName ? client.getCommand(commandName) : undefined; if (cmd) { cmd.outputHelp(); - } else if (commandName) { + return; + } + + if (commandName) { + const keys = commandName.split(":"); + let current = client; + let matched = true; + for (const key of keys) { + if (!current || typeof current !== "object") { + matched = false; + break; + } + const nextKey = Object.keys(current).find((k) => k.toLowerCase() === key.toLowerCase()); + if (nextKey) { + current = current[nextKey]; + } else { + matched = false; + break; + } + } + + if (matched && current && typeof current === "object") { + const prefix = commandName + ":"; + const subcmds = (client.cli.commands as CommanderCommand[]).filter((c) => + c.name().startsWith(prefix), + ); + if (subcmds.length > 0) { + logger.info(); + logger.info(clc.bold(`Commands under ${clc.green(commandName)}:`)); + logger.info(); + for (const subcmd of subcmds) { + logger.info(` ${clc.bold(subcmd.name().padEnd(45))} ${subcmd.description()}`); + } + logger.info(); + return; + } + } + } + + if (commandName) { logger.warn(); utils.logWarning( clc.bold(commandName) + " is not a valid command. See below for valid commands", diff --git a/src/commands/index.ts b/src/commands/index.ts index f393031da67..9d0c3013e1b 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -224,6 +224,12 @@ export function load(client: CLIClient): CLIClient { client.apphosting.rollouts.list = loadCommand("apphosting-rollouts-list"); } } + client.ailogic = {}; + client.ailogic.providers = {}; + client.ailogic.providers.enable = loadCommand("ailogic-providers-enable"); + client.ailogic.providers.disable = loadCommand("ailogic-providers-disable"); + client.ailogic.providers.list = loadCommand("ailogic-providers-list"); + client.login = loadCommand("login"); client.login.add = loadCommand("login-add"); client.login.ci = loadCommand("login-ci"); diff --git a/src/ensureApiEnabled.ts b/src/ensureApiEnabled.ts index 91276866270..b2965cb1abf 100644 --- a/src/ensureApiEnabled.ts +++ b/src/ensureApiEnabled.ts @@ -239,3 +239,18 @@ function cacheEnabledAPI(projectId: string, apiName: string) { cache[projectId][apiName] = true; configstore.set(API_ENABLEMENT_CACHE_KEY, cache); } + +/** + * Removes a single API from the local "enabled" cache so the next check re-queries the server. + * Call this after enabling or disabling an API to keep the cache consistent with the server state. + */ +export function uncacheEnabledAPI(projectId: string, apiName: string): void { + const cache = (configstore.get(API_ENABLEMENT_CACHE_KEY) || {}) as Record< + string, + Record + >; + if (cache[projectId]) { + delete cache[projectId][apiName]; + configstore.set(API_ENABLEMENT_CACHE_KEY, cache); + } +} diff --git a/src/gcp/ailogic.spec.ts b/src/gcp/ailogic.spec.ts index 696ca201772..d69347e1d6f 100644 --- a/src/gcp/ailogic.spec.ts +++ b/src/gcp/ailogic.spec.ts @@ -1,11 +1,15 @@ import { expect } from "chai"; import * as sinon from "sinon"; import * as ailogic from "./ailogic"; +import * as ensureApiEnabled from "../ensureApiEnabled"; +import * as serviceUsage from "./serviceusage"; +import * as cloudbilling from "./cloudbilling"; import { AI_LOGIC_BEFORE_GENERATE_CONTENT, AI_LOGIC_AFTER_GENERATE_CONTENT, AILogicEndpoint, } from "../deploy/functions/services/ailogic"; +import { FirebaseError } from "../error"; describe("ailogic", () => { const mockEndpointBase = { @@ -142,4 +146,120 @@ describe("ailogic", () => { ); }); }); + + describe("providers", () => { + let ensureStub: sinon.SinonStub; + let disableStub: sinon.SinonStub; + let uncacheStub: sinon.SinonStub; + let checkStub: sinon.SinonStub; + let billingStub: sinon.SinonStub; + + beforeEach(() => { + ensureStub = sinon.stub(ensureApiEnabled, "ensure"); + disableStub = sinon.stub(serviceUsage, "disableServiceAndPoll"); + uncacheStub = sinon.stub(ensureApiEnabled, "uncacheEnabledAPI"); + checkStub = sinon.stub(ensureApiEnabled, "check"); + billingStub = sinon.stub(cloudbilling, "checkBillingEnabled"); + }); + + afterEach(() => { + ensureStub.restore(); + disableStub.restore(); + uncacheStub.restore(); + checkStub.restore(); + billingStub.restore(); + }); + + it("should enable gemini-developer-api", async () => { + ensureStub.resolves(); + + await ailogic.enableProvider("my-project", "gemini-developer-api"); + + expect(ensureStub).to.have.been.calledTwice; + expect(ensureStub.firstCall).to.have.been.calledWith( + "my-project", + "generativelanguage.googleapis.com", + "ailogic", + ); + expect(ensureStub.secondCall).to.have.been.calledWith( + "my-project", + "firebasevertexai.googleapis.com", + "ailogic", + ); + }); + + it("should enable agent-platform-gemini-api if billing is enabled", async () => { + ensureStub.resolves(); + billingStub.resolves(true); + + await ailogic.enableProvider("my-project", "agent-platform-gemini-api"); + + expect(ensureStub).to.have.been.calledTwice; + expect(ensureStub.firstCall).to.have.been.calledWith( + "my-project", + "aiplatform.googleapis.com", + "ailogic", + ); + expect(ensureStub.secondCall).to.have.been.calledWith( + "my-project", + "firebasevertexai.googleapis.com", + "ailogic", + ); + }); + + it("should reject enabling agent-platform-gemini-api if billing is disabled", async () => { + ensureStub.resolves(); + billingStub.resolves(false); + + await expect( + ailogic.enableProvider("my-project", "agent-platform-gemini-api"), + ).to.be.rejectedWith(FirebaseError, /must be on the Blaze/); + + expect(ensureStub).to.not.have.been.called; + }); + + it("should disable gemini-developer-api and disable proxy if agent-platform-gemini-api is also disabled", async () => { + disableStub.resolves(); + checkStub.resolves(false); // agent-platform-gemini-api is disabled + + await ailogic.disableProvider("my-project", "gemini-developer-api"); + + expect(disableStub).to.have.been.calledTwice; + expect(disableStub.firstCall).to.have.been.calledWith( + "my-project", + "generativelanguage.googleapis.com", + "ailogic", + ); + expect(disableStub.secondCall).to.have.been.calledWith( + "my-project", + "firebasevertexai.googleapis.com", + "ailogic", + ); + expect(uncacheStub).to.have.been.calledTwice; + }); + + it("should disable gemini-developer-api but NOT disable proxy if agent-platform-gemini-api is enabled", async () => { + disableStub.resolves(); + checkStub.resolves(true); // agent-platform-gemini-api is enabled + + await ailogic.disableProvider("my-project", "gemini-developer-api"); + + expect(disableStub).to.have.been.calledOnce; + expect(disableStub.firstCall).to.have.been.calledWith( + "my-project", + "generativelanguage.googleapis.com", + "ailogic", + ); + expect(uncacheStub).to.have.been.calledOnce; + }); + + it("should list enabled providers", async () => { + checkStub.onFirstCall().resolves(true); // gemini-developer-api is enabled + checkStub.onSecondCall().resolves(true); // agent-platform-gemini-api API is enabled + + const enabled = await ailogic.listProviders("my-project"); + + expect(enabled).to.deep.equal(["gemini-developer-api", "agent-platform-gemini-api"]); + }); + }); }); diff --git a/src/gcp/ailogic.ts b/src/gcp/ailogic.ts index 1ea5e1751f8..67d8c383fae 100644 --- a/src/gcp/ailogic.ts +++ b/src/gcp/ailogic.ts @@ -2,7 +2,14 @@ import { Client } from "../apiv2"; import { aiLogicProxyOrigin } from "../api"; import { DeepOmit } from "../metaprogramming"; import type { AILogicEndpoint } from "../deploy/functions/services/ailogic"; -import { getErrStatus } from "../error"; +import { FirebaseError, getErrStatus } from "../error"; +import * as ensureApiEnabled from "../ensureApiEnabled"; +import * as serviceUsage from "./serviceusage"; +import { bold } from "colorette"; +import * as cloudbilling from "./cloudbilling"; +import * as iam from "./iam"; +import { logger } from "../logger"; +import { confirm, select } from "../prompt"; export const API_VERSION = "v1beta"; @@ -167,6 +174,9 @@ export async function listTriggers( return triggers; } +/** + * + */ export async function upsertBlockingFunction(endpoint: AILogicEndpoint): Promise { const eventType = endpoint.blockingTrigger.eventType; const triggerId = AI_LOGIC_EVENTS_TO_TRIGGER[eventType]; @@ -191,6 +201,9 @@ export async function upsertBlockingFunction(endpoint: AILogicEndpoint): Promise } } +/** + * + */ export async function deleteBlockingFunction(endpoint: AILogicEndpoint): Promise { const eventType = endpoint.blockingTrigger.eventType; const triggerId = AI_LOGIC_EVENTS_TO_TRIGGER[eventType]; @@ -198,3 +211,199 @@ export async function deleteBlockingFunction(endpoint: AILogicEndpoint): Promise await deleteTrigger(endpoint.project, location, triggerId, true); } + +export type ProviderType = "gemini-developer-api" | "agent-platform-gemini-api"; + +/** + * Enables a Gemini API provider service. + */ +export async function enableProvider(projectId: string, providerType: ProviderType): Promise { + const prefix = "ailogic"; + if (providerType === "gemini-developer-api") { + await ensureApiEnabled.ensure(projectId, "generativelanguage.googleapis.com", prefix); + await ensureApiEnabled.ensure(projectId, "firebasevertexai.googleapis.com", prefix); + } else if (providerType === "agent-platform-gemini-api") { + const billingEnabled = await cloudbilling.checkBillingEnabled(projectId); + if (!billingEnabled) { + throw new FirebaseError( + `Your project ${bold( + projectId, + )} must be on the Blaze (pay-as-you-go) plan to enable the Agent Platform. To upgrade, visit the following URL:\n\nhttps://console.firebase.google.com/project/${projectId}/usage/details`, + ); + } + await ensureApiEnabled.ensure(projectId, "aiplatform.googleapis.com", prefix); + await ensureApiEnabled.ensure(projectId, "firebasevertexai.googleapis.com", prefix); + } else { + throw new FirebaseError(`Invalid provider type: ${providerType as string}`); + } +} + +/** + * Disables a Gemini API provider service. + */ +export async function disableProvider( + projectId: string, + providerType: ProviderType, +): Promise { + const prefix = "ailogic"; + if (providerType === "gemini-developer-api") { + await serviceUsage.disableServiceAndPoll( + projectId, + "generativelanguage.googleapis.com", + prefix, + ); + ensureApiEnabled.uncacheEnabledAPI(projectId, "generativelanguage.googleapis.com"); + + const isVertexEnabled = await ensureApiEnabled.check( + projectId, + "aiplatform.googleapis.com", + prefix, + true, + ); + if (!isVertexEnabled) { + await serviceUsage.disableServiceAndPoll( + projectId, + "firebasevertexai.googleapis.com", + prefix, + ); + ensureApiEnabled.uncacheEnabledAPI(projectId, "firebasevertexai.googleapis.com"); + } + } else if (providerType === "agent-platform-gemini-api") { + await serviceUsage.disableServiceAndPoll(projectId, "aiplatform.googleapis.com", prefix); + ensureApiEnabled.uncacheEnabledAPI(projectId, "aiplatform.googleapis.com"); + + const isDeveloperEnabled = await ensureApiEnabled.check( + projectId, + "generativelanguage.googleapis.com", + prefix, + true, + ); + if (!isDeveloperEnabled) { + await serviceUsage.disableServiceAndPoll( + projectId, + "firebasevertexai.googleapis.com", + prefix, + ); + ensureApiEnabled.uncacheEnabledAPI(projectId, "firebasevertexai.googleapis.com"); + } + } else { + throw new FirebaseError(`Invalid provider type: ${providerType as string}`); + } +} + +/** + * + */ +export async function listProviders(projectId: string): Promise { + const prefix = "ailogic"; + const enabled: ProviderType[] = []; + + const isDeveloperEnabled = await ensureApiEnabled.check( + projectId, + "generativelanguage.googleapis.com", + prefix, + true, + ); + if (isDeveloperEnabled) { + enabled.push("gemini-developer-api"); + } + + const isVertexEnabled = await ensureApiEnabled.check( + projectId, + "aiplatform.googleapis.com", + prefix, + true, + ); + // aiplatform.googleapis.com cannot be enabled without billing (the Blaze plan), + // so an enabled Vertex API already implies the agent-platform provider is available. + if (isVertexEnabled) { + enabled.push("agent-platform-gemini-api"); + } + + return enabled; +} + +/** + * Ensures that the Firebase AI Logic API is enabled. If not enabled: + * - In non-interactive mode: throws an error with instructions. + * - In interactive mode: prompts to enable, and guides the user to choose a provider to enable. + */ +export async function ensureAILogicApiEnabled( + projectId: string, + options: { nonInteractive?: boolean; force?: boolean }, +): Promise { + const isEnabled = await ensureApiEnabled.check( + projectId, + "firebasevertexai.googleapis.com", + "ailogic", + true, + ); + if (isEnabled) { + return; + } + + if (options.nonInteractive) { + throw new FirebaseError( + `The Firebase AI Logic API (firebasevertexai.googleapis.com) is not enabled on project ${projectId}.\n\n` + + `Enable Firebase AI Logic with one of the Gemini API providers by running:\n\n` + + ` firebase ailogic:providers:enable gemini-developer-api\n` + + ` firebase ailogic:providers:enable agent-platform-gemini-api\n\n` + + `Then run this command again.`, + ); + } + + // Verify the caller can actually enable the API before prompting, so we fail + // with a clear permission error up front instead of midway through the flow. + const { missing } = await iam.testIamPermissions(projectId, ["serviceusage.services.enable"]); + if (missing.length > 0) { + throw new FirebaseError( + `You do not have permission to enable the Firebase AI Logic API on project ${projectId}.\n\n` + + `Missing permission: ${missing.join(", ")}\n\n` + + `This permission is included in the Owner and Editor roles. Ask a project ` + + `administrator to enable the API or grant you the permission, then run this command again.`, + ); + } + + logger.info( + `The Firebase AI Logic API (firebasevertexai.googleapis.com) is not enabled on project ${projectId}.`, + ); + const proceed = await confirm({ + message: "Would you like to enable it now?", + default: true, + }); + if (!proceed) { + throw new FirebaseError("Command aborted.", { exit: 1 }); + } + + for (;;) { + const provider = await select({ + message: "Which Gemini API provider do you want to enable?", + choices: [ + { name: "gemini-developer-api", value: "gemini-developer-api" }, + { + name: "agent-platform-gemini-api (requires the Blaze plan)", + value: "agent-platform-gemini-api", + }, + ], + }); + + if (provider === "agent-platform-gemini-api") { + const billingEnabled = await cloudbilling.checkBillingEnabled(projectId); + if (!billingEnabled) { + logger.info( + `\n${bold("Error:")} The agent-platform-gemini-api provider requires the pay-as-you-go (Blaze) plan.\n` + + `Project ${projectId} is on the Spark plan.\n\n` + + `Upgrade your plan at:\n\n` + + ` https://console.firebase.google.com/project/${projectId}/usage/details\n`, + ); + continue; + } + } + + logger.info(`Enabling firebasevertexai.googleapis.com...`); + logger.info(`Enabling provider ${provider}...`); + await enableProvider(projectId, provider); + logger.info(bold(`Successfully enabled Firebase AI Logic with provider: ${provider}`)); + break; + } +} diff --git a/src/gcp/serviceusage.spec.ts b/src/gcp/serviceusage.spec.ts index a7360e515f0..ae54140f7f0 100644 --- a/src/gcp/serviceusage.spec.ts +++ b/src/gcp/serviceusage.spec.ts @@ -28,4 +28,22 @@ describe("serviceusage", () => { expect(pollerStub).to.not.be.called; }); }); + + describe("disableServiceAndPoll", () => { + it("does not poll if disableService responds with a completed operation", async () => { + postStub.onFirstCall().resolves({ body: { done: true } }); + await serviceUsage.disableServiceAndPoll(projectNumber, service, prefix); + expect(pollerStub).to.not.be.called; + }); + + it("polls if disableService responds with an uncompleted operation", async () => { + postStub.onFirstCall().resolves({ body: { done: false, name: "operation-name" } }); + pollerStub.onFirstCall().resolves({}); + await serviceUsage.disableServiceAndPoll(projectNumber, service, prefix); + expect(pollerStub).to.have.been.calledOnce; + expect(pollerStub).to.have.been.calledWithMatch({ + operationResourceName: "operation-name", + }); + }); + }); }); diff --git a/src/gcp/serviceusage.ts b/src/gcp/serviceusage.ts index 5aa8ca5dbd8..7c5b15b303a 100644 --- a/src/gcp/serviceusage.ts +++ b/src/gcp/serviceusage.ts @@ -70,3 +70,45 @@ export async function generateServiceIdentityAndPoll( headers: { "x-goog-user-project": `${projectNumber}` }, }); } + +/** + * Disables a service on the project. + */ +export async function disableService( + projectId: string, + service: string, +): Promise> { + try { + const res = await apiClient.post( + `projects/${projectId}/services/${service}:disable`, + /* body=*/ {}, + { headers: { "x-goog-user-project": `${projectId}` } }, + ); + return res.body as LongRunningOperation; + } catch (err: unknown) { + throw new FirebaseError(`Error disabling service ${service}.`, { + original: err as Error, + }); + } +} + +/** + * Calls disableService and polls till the operation is complete. + */ +export async function disableServiceAndPoll( + projectId: string, + service: string, + prefix: string, +): Promise { + utils.logLabeledBullet(prefix, `disabling service ${bold(service)}...`); + const op = await disableService(projectId, service); + if (op.done) { + return; + } + + await poller.pollOperation({ + ...serviceUsagePollerOptions, + operationResourceName: op.name, + headers: { "x-goog-user-project": `${projectId}` }, + }); +} From dc501fea7911c410c690c5e48c27efcc993b9c41 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Sat, 18 Jul 2026 09:05:29 -0700 Subject: [PATCH 02/21] fix(ailogic): address PR review feedback - Rename provider id agent-platform-gemini-api -> gemini-agent-platform-api for symmetry with gemini-developer-api (paulb777). - Centralize provider-type validation in ailogic.parseProviderType/isProviderType and reuse it in the enable/disable commands instead of copying the union (christhompsongoogle). - Add AILOGIC_LOGGING_PREFIX constant; replace the repeated 'ailogic' literals. - Move enablement-cache invalidation into serviceusage.disableServiceAndPoll and drop the per-callsite uncache calls; rename its 'prefix' param to loggingPrefix. - Drop the redundant non-interactive guard in disable; confirm() already enforces --force in non-interactive mode. - Gate the ailogic commands behind a new 'ailogic' experiment until API-council approval. - Document the help.ts namespace-listing blocks. - Update/extend unit tests accordingly. --- src/commands/ailogic-providers-disable.ts | 21 ++--- src/commands/ailogic-providers-enable.ts | 11 +-- src/commands/ailogic-providers-list.ts | 8 +- src/commands/help.ts | 5 ++ src/commands/index.ts | 14 ++-- src/experiments.ts | 8 ++ src/gcp/ailogic.spec.ts | 48 +++++++---- src/gcp/ailogic.ts | 98 +++++++++++++++-------- src/gcp/serviceusage.spec.ts | 10 +++ src/gcp/serviceusage.ts | 22 ++--- 10 files changed, 151 insertions(+), 94 deletions(-) diff --git a/src/commands/ailogic-providers-disable.ts b/src/commands/ailogic-providers-disable.ts index aa126b323a1..6a4e81e399f 100644 --- a/src/commands/ailogic-providers-disable.ts +++ b/src/commands/ailogic-providers-disable.ts @@ -15,26 +15,17 @@ export const command = new Command("ailogic:providers:disable ") .before(requirePermissions, ["serviceusage.services.disable", "firebasevertexai.config.update"]) .action(async (providerType: string, options: Options) => { const projectId = needProjectId(options); - if (providerType !== "gemini-developer-api" && providerType !== "agent-platform-gemini-api") { - throw new FirebaseError( - `Invalid provider type: ${clc.bold(providerType)}. Must be 'gemini-developer-api' or 'agent-platform-gemini-api'.`, - ); - } - if (options.nonInteractive && !options.force) { - throw new FirebaseError( - `Disabling provider ${clc.bold(providerType)} requires confirmation.\n\n` + - `To proceed in non-interactive mode, rerun with --force:\n\n` + - ` firebase ailogic:providers:disable ${providerType} --force`, - ); - } + const provider = ailogic.parseProviderType(providerType); + // confirm() aborts (throws) in non-interactive mode unless --force is set, so a + // separate non-interactive guard is unnecessary here. const confirmed = await confirm({ - message: `You are about to disable ${clc.bold(providerType)}. This will stop running apps from invoking it. Are you sure?`, + message: `You are about to disable ${clc.bold(provider)}. This will stop running apps from invoking it. Are you sure?`, force: options.force, nonInteractive: options.nonInteractive, }); if (!confirmed) { throw new FirebaseError("Command aborted.", { exit: 1 }); } - await ailogic.disableProvider(projectId, providerType as ailogic.ProviderType); - logger.info(clc.green(`Successfully disabled provider: ${clc.bold(providerType)}`)); + await ailogic.disableProvider(projectId, provider); + logger.info(clc.green(`Successfully disabled provider: ${clc.bold(provider)}`)); }); diff --git a/src/commands/ailogic-providers-enable.ts b/src/commands/ailogic-providers-enable.ts index 244661495cb..45b14600813 100644 --- a/src/commands/ailogic-providers-enable.ts +++ b/src/commands/ailogic-providers-enable.ts @@ -4,7 +4,6 @@ import { needProjectId } from "../projectUtils"; import * as ailogic from "../gcp/ailogic"; import * as clc from "colorette"; import { logger } from "../logger"; -import { FirebaseError } from "../error"; import { Options } from "../options"; @@ -13,11 +12,7 @@ export const command = new Command("ailogic:providers:enable ") .before(requirePermissions, ["serviceusage.services.enable", "firebasevertexai.config.update"]) .action(async (providerType: string, options: Options) => { const projectId = needProjectId(options); - if (providerType !== "gemini-developer-api" && providerType !== "agent-platform-gemini-api") { - throw new FirebaseError( - `Invalid provider type: ${clc.bold(providerType)}. Must be 'gemini-developer-api' or 'agent-platform-gemini-api'.`, - ); - } - await ailogic.enableProvider(projectId, providerType as ailogic.ProviderType); - logger.info(clc.green(`Successfully enabled provider: ${clc.bold(providerType)}`)); + const provider = ailogic.parseProviderType(providerType); + await ailogic.enableProvider(projectId, provider); + logger.info(clc.green(`Successfully enabled provider: ${clc.bold(provider)}`)); }); diff --git a/src/commands/ailogic-providers-list.ts b/src/commands/ailogic-providers-list.ts index fe850408ff9..fbd968fd7c7 100644 --- a/src/commands/ailogic-providers-list.ts +++ b/src/commands/ailogic-providers-list.ts @@ -23,12 +23,8 @@ export const command = new Command("ailogic:providers:list") const tableHead = ["Provider", "Status"]; const table = new Table({ head: tableHead, style: { head: ["green"] } }); - // Show both possible providers, indicating if they are enabled or disabled - const allProviders: ailogic.ProviderType[] = [ - "gemini-developer-api", - "agent-platform-gemini-api", - ]; - for (const provider of allProviders) { + // Show every possible provider, indicating whether it is enabled or disabled. + for (const provider of ailogic.PROVIDER_TYPES) { const isEnabled = enabledProviders.includes(provider); table.push([clc.bold(provider), isEnabled ? clc.green("Enabled") : clc.red("Disabled")]); } diff --git a/src/commands/help.ts b/src/commands/help.ts index ee39fcd290a..5191c596d25 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -19,6 +19,9 @@ export const command = new Command("help [command]") } if (commandName) { + // Treat the argument as a command namespace (e.g. "ailogic:providers") and walk + // the nested client command tree segment by segment ("ailogic" -> "providers") to + // check whether it resolves to a group of subcommands rather than a leaf command. const keys = commandName.split(":"); let current = client; let matched = true; @@ -36,6 +39,8 @@ export const command = new Command("help [command]") } } + // If it resolved to a namespace, print every registered command under that prefix + // (e.g. `firebase help ailogic:providers` lists enable/disable/list). if (matched && current && typeof current === "object") { const prefix = commandName + ":"; const subcmds = (client.cli.commands as CommanderCommand[]).filter((c) => diff --git a/src/commands/index.ts b/src/commands/index.ts index 9d0c3013e1b..eaf6d1da5d1 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -224,11 +224,15 @@ export function load(client: CLIClient): CLIClient { client.apphosting.rollouts.list = loadCommand("apphosting-rollouts-list"); } } - client.ailogic = {}; - client.ailogic.providers = {}; - client.ailogic.providers.enable = loadCommand("ailogic-providers-enable"); - client.ailogic.providers.disable = loadCommand("ailogic-providers-disable"); - client.ailogic.providers.list = loadCommand("ailogic-providers-list"); + // Gated behind the `ailogic` experiment until the underlying API is API-council + // approved, since the surface may still change. + if (experiments.isEnabled("ailogic")) { + client.ailogic = {}; + client.ailogic.providers = {}; + client.ailogic.providers.enable = loadCommand("ailogic-providers-enable"); + client.ailogic.providers.disable = loadCommand("ailogic-providers-disable"); + client.ailogic.providers.list = loadCommand("ailogic-providers-list"); + } client.login = loadCommand("login"); client.login.add = loadCommand("login-add"); diff --git a/src/experiments.ts b/src/experiments.ts index d5d33cc14e3..3b165d2d66d 100644 --- a/src/experiments.ts +++ b/src/experiments.ts @@ -136,6 +136,14 @@ export const ALL_EXPERIMENTS = experiments({ "without a notice.", }, + ailogic: { + shortDescription: "Manage Firebase AI Logic from the CLI.", + fullDescription: + "Enables the `firebase ailogic` command surface for managing Firebase AI Logic, " + + "starting with the Gemini API providers. These commands are in preview and may " + + "change until the underlying API is finalized.", + }, + apphosting: { shortDescription: "Allow CLI option for Frameworks", default: true, diff --git a/src/gcp/ailogic.spec.ts b/src/gcp/ailogic.spec.ts index d69347e1d6f..0b03921e8d3 100644 --- a/src/gcp/ailogic.spec.ts +++ b/src/gcp/ailogic.spec.ts @@ -150,14 +150,14 @@ describe("ailogic", () => { describe("providers", () => { let ensureStub: sinon.SinonStub; let disableStub: sinon.SinonStub; - let uncacheStub: sinon.SinonStub; let checkStub: sinon.SinonStub; let billingStub: sinon.SinonStub; beforeEach(() => { ensureStub = sinon.stub(ensureApiEnabled, "ensure"); + // disableServiceAndPoll now owns cache invalidation, so it is stubbed here and + // that behavior is verified in serviceusage.spec.ts. disableStub = sinon.stub(serviceUsage, "disableServiceAndPoll"); - uncacheStub = sinon.stub(ensureApiEnabled, "uncacheEnabledAPI"); checkStub = sinon.stub(ensureApiEnabled, "check"); billingStub = sinon.stub(cloudbilling, "checkBillingEnabled"); }); @@ -165,7 +165,6 @@ describe("ailogic", () => { afterEach(() => { ensureStub.restore(); disableStub.restore(); - uncacheStub.restore(); checkStub.restore(); billingStub.restore(); }); @@ -188,11 +187,11 @@ describe("ailogic", () => { ); }); - it("should enable agent-platform-gemini-api if billing is enabled", async () => { + it("should enable gemini-agent-platform-api if billing is enabled", async () => { ensureStub.resolves(); billingStub.resolves(true); - await ailogic.enableProvider("my-project", "agent-platform-gemini-api"); + await ailogic.enableProvider("my-project", "gemini-agent-platform-api"); expect(ensureStub).to.have.been.calledTwice; expect(ensureStub.firstCall).to.have.been.calledWith( @@ -207,20 +206,20 @@ describe("ailogic", () => { ); }); - it("should reject enabling agent-platform-gemini-api if billing is disabled", async () => { + it("should reject enabling gemini-agent-platform-api if billing is disabled", async () => { ensureStub.resolves(); billingStub.resolves(false); await expect( - ailogic.enableProvider("my-project", "agent-platform-gemini-api"), + ailogic.enableProvider("my-project", "gemini-agent-platform-api"), ).to.be.rejectedWith(FirebaseError, /must be on the Blaze/); expect(ensureStub).to.not.have.been.called; }); - it("should disable gemini-developer-api and disable proxy if agent-platform-gemini-api is also disabled", async () => { + it("should disable gemini-developer-api and disable proxy if gemini-agent-platform-api is also disabled", async () => { disableStub.resolves(); - checkStub.resolves(false); // agent-platform-gemini-api is disabled + checkStub.resolves(false); // gemini-agent-platform-api is disabled await ailogic.disableProvider("my-project", "gemini-developer-api"); @@ -235,12 +234,11 @@ describe("ailogic", () => { "firebasevertexai.googleapis.com", "ailogic", ); - expect(uncacheStub).to.have.been.calledTwice; }); - it("should disable gemini-developer-api but NOT disable proxy if agent-platform-gemini-api is enabled", async () => { + it("should disable gemini-developer-api but NOT disable proxy if gemini-agent-platform-api is enabled", async () => { disableStub.resolves(); - checkStub.resolves(true); // agent-platform-gemini-api is enabled + checkStub.resolves(true); // gemini-agent-platform-api is enabled await ailogic.disableProvider("my-project", "gemini-developer-api"); @@ -250,16 +248,36 @@ describe("ailogic", () => { "generativelanguage.googleapis.com", "ailogic", ); - expect(uncacheStub).to.have.been.calledOnce; }); it("should list enabled providers", async () => { checkStub.onFirstCall().resolves(true); // gemini-developer-api is enabled - checkStub.onSecondCall().resolves(true); // agent-platform-gemini-api API is enabled + checkStub.onSecondCall().resolves(true); // gemini-agent-platform-api API is enabled const enabled = await ailogic.listProviders("my-project"); - expect(enabled).to.deep.equal(["gemini-developer-api", "agent-platform-gemini-api"]); + expect(enabled).to.deep.equal(["gemini-developer-api", "gemini-agent-platform-api"]); + }); + }); + + describe("parseProviderType", () => { + it("returns the provider for a valid value", () => { + expect(ailogic.parseProviderType("gemini-developer-api")).to.equal("gemini-developer-api"); + expect(ailogic.parseProviderType("gemini-agent-platform-api")).to.equal( + "gemini-agent-platform-api", + ); + }); + + it("throws a FirebaseError listing the valid providers for an invalid value", () => { + expect(() => ailogic.parseProviderType("agent-platform-gemini-api")).to.throw( + FirebaseError, + /Invalid provider type/, + ); + }); + + it("isProviderType narrows valid values only", () => { + expect(ailogic.isProviderType("gemini-developer-api")).to.be.true; + expect(ailogic.isProviderType("nope")).to.be.false; }); }); }); diff --git a/src/gcp/ailogic.ts b/src/gcp/ailogic.ts index 67d8c383fae..5e48cede180 100644 --- a/src/gcp/ailogic.ts +++ b/src/gcp/ailogic.ts @@ -13,6 +13,9 @@ import { confirm, select } from "../prompt"; export const API_VERSION = "v1beta"; +/** Label used as the prefix for this module's user-facing progress logging. */ +export const AILOGIC_LOGGING_PREFIX = "ailogic"; + export const AI_LOGIC_BEFORE_GENERATE_CONTENT = "google.firebase.ailogic.v1.beforeGenerate" as const; export const AI_LOGIC_AFTER_GENERATE_CONTENT = "google.firebase.ailogic.v1.afterGenerate" as const; @@ -212,17 +215,43 @@ export async function deleteBlockingFunction(endpoint: AILogicEndpoint): Promise await deleteTrigger(endpoint.project, location, triggerId, true); } -export type ProviderType = "gemini-developer-api" | "agent-platform-gemini-api"; +export type ProviderType = "gemini-developer-api" | "gemini-agent-platform-api"; + +export const PROVIDER_TYPES: ProviderType[] = ["gemini-developer-api", "gemini-agent-platform-api"]; + +/** Whether the given string is a known Gemini API provider type. */ +export function isProviderType(value: string): value is ProviderType { + return PROVIDER_TYPES.some((p) => p === value); +} + +/** Validates and narrows a string to a ProviderType, throwing a FirebaseError otherwise. */ +export function parseProviderType(value: string): ProviderType { + if (!isProviderType(value)) { + throw new FirebaseError( + `Invalid provider type: ${bold(value)}. Must be one of: ${PROVIDER_TYPES.map( + (p) => `'${p}'`, + ).join(", ")}.`, + ); + } + return value; +} /** * Enables a Gemini API provider service. */ export async function enableProvider(projectId: string, providerType: ProviderType): Promise { - const prefix = "ailogic"; if (providerType === "gemini-developer-api") { - await ensureApiEnabled.ensure(projectId, "generativelanguage.googleapis.com", prefix); - await ensureApiEnabled.ensure(projectId, "firebasevertexai.googleapis.com", prefix); - } else if (providerType === "agent-platform-gemini-api") { + await ensureApiEnabled.ensure( + projectId, + "generativelanguage.googleapis.com", + AILOGIC_LOGGING_PREFIX, + ); + await ensureApiEnabled.ensure( + projectId, + "firebasevertexai.googleapis.com", + AILOGIC_LOGGING_PREFIX, + ); + } else if (providerType === "gemini-agent-platform-api") { const billingEnabled = await cloudbilling.checkBillingEnabled(projectId); if (!billingEnabled) { throw new FirebaseError( @@ -231,77 +260,76 @@ export async function enableProvider(projectId: string, providerType: ProviderTy )} must be on the Blaze (pay-as-you-go) plan to enable the Agent Platform. To upgrade, visit the following URL:\n\nhttps://console.firebase.google.com/project/${projectId}/usage/details`, ); } - await ensureApiEnabled.ensure(projectId, "aiplatform.googleapis.com", prefix); - await ensureApiEnabled.ensure(projectId, "firebasevertexai.googleapis.com", prefix); - } else { - throw new FirebaseError(`Invalid provider type: ${providerType as string}`); + await ensureApiEnabled.ensure(projectId, "aiplatform.googleapis.com", AILOGIC_LOGGING_PREFIX); + await ensureApiEnabled.ensure( + projectId, + "firebasevertexai.googleapis.com", + AILOGIC_LOGGING_PREFIX, + ); } } /** - * Disables a Gemini API provider service. + * Disables a Gemini API provider service. `disableServiceAndPoll` invalidates the + * enablement cache for each disabled service, so no explicit uncaching is needed here. */ export async function disableProvider( projectId: string, providerType: ProviderType, ): Promise { - const prefix = "ailogic"; if (providerType === "gemini-developer-api") { await serviceUsage.disableServiceAndPoll( projectId, "generativelanguage.googleapis.com", - prefix, + AILOGIC_LOGGING_PREFIX, ); - ensureApiEnabled.uncacheEnabledAPI(projectId, "generativelanguage.googleapis.com"); const isVertexEnabled = await ensureApiEnabled.check( projectId, "aiplatform.googleapis.com", - prefix, + AILOGIC_LOGGING_PREFIX, true, ); if (!isVertexEnabled) { await serviceUsage.disableServiceAndPoll( projectId, "firebasevertexai.googleapis.com", - prefix, + AILOGIC_LOGGING_PREFIX, ); - ensureApiEnabled.uncacheEnabledAPI(projectId, "firebasevertexai.googleapis.com"); } - } else if (providerType === "agent-platform-gemini-api") { - await serviceUsage.disableServiceAndPoll(projectId, "aiplatform.googleapis.com", prefix); - ensureApiEnabled.uncacheEnabledAPI(projectId, "aiplatform.googleapis.com"); + } else if (providerType === "gemini-agent-platform-api") { + await serviceUsage.disableServiceAndPoll( + projectId, + "aiplatform.googleapis.com", + AILOGIC_LOGGING_PREFIX, + ); const isDeveloperEnabled = await ensureApiEnabled.check( projectId, "generativelanguage.googleapis.com", - prefix, + AILOGIC_LOGGING_PREFIX, true, ); if (!isDeveloperEnabled) { await serviceUsage.disableServiceAndPoll( projectId, "firebasevertexai.googleapis.com", - prefix, + AILOGIC_LOGGING_PREFIX, ); - ensureApiEnabled.uncacheEnabledAPI(projectId, "firebasevertexai.googleapis.com"); } - } else { - throw new FirebaseError(`Invalid provider type: ${providerType as string}`); } } /** - * + * Lists which Gemini API providers are enabled, derived from underlying API enablement state. */ export async function listProviders(projectId: string): Promise { - const prefix = "ailogic"; const enabled: ProviderType[] = []; const isDeveloperEnabled = await ensureApiEnabled.check( projectId, "generativelanguage.googleapis.com", - prefix, + AILOGIC_LOGGING_PREFIX, true, ); if (isDeveloperEnabled) { @@ -311,13 +339,13 @@ export async function listProviders(projectId: string): Promise const isVertexEnabled = await ensureApiEnabled.check( projectId, "aiplatform.googleapis.com", - prefix, + AILOGIC_LOGGING_PREFIX, true, ); // aiplatform.googleapis.com cannot be enabled without billing (the Blaze plan), // so an enabled Vertex API already implies the agent-platform provider is available. if (isVertexEnabled) { - enabled.push("agent-platform-gemini-api"); + enabled.push("gemini-agent-platform-api"); } return enabled; @@ -335,7 +363,7 @@ export async function ensureAILogicApiEnabled( const isEnabled = await ensureApiEnabled.check( projectId, "firebasevertexai.googleapis.com", - "ailogic", + AILOGIC_LOGGING_PREFIX, true, ); if (isEnabled) { @@ -347,7 +375,7 @@ export async function ensureAILogicApiEnabled( `The Firebase AI Logic API (firebasevertexai.googleapis.com) is not enabled on project ${projectId}.\n\n` + `Enable Firebase AI Logic with one of the Gemini API providers by running:\n\n` + ` firebase ailogic:providers:enable gemini-developer-api\n` + - ` firebase ailogic:providers:enable agent-platform-gemini-api\n\n` + + ` firebase ailogic:providers:enable gemini-agent-platform-api\n\n` + `Then run this command again.`, ); } @@ -381,17 +409,17 @@ export async function ensureAILogicApiEnabled( choices: [ { name: "gemini-developer-api", value: "gemini-developer-api" }, { - name: "agent-platform-gemini-api (requires the Blaze plan)", - value: "agent-platform-gemini-api", + name: "gemini-agent-platform-api (requires the Blaze plan)", + value: "gemini-agent-platform-api", }, ], }); - if (provider === "agent-platform-gemini-api") { + if (provider === "gemini-agent-platform-api") { const billingEnabled = await cloudbilling.checkBillingEnabled(projectId); if (!billingEnabled) { logger.info( - `\n${bold("Error:")} The agent-platform-gemini-api provider requires the pay-as-you-go (Blaze) plan.\n` + + `\n${bold("Error:")} The gemini-agent-platform-api provider requires the pay-as-you-go (Blaze) plan.\n` + `Project ${projectId} is on the Spark plan.\n\n` + `Upgrade your plan at:\n\n` + ` https://console.firebase.google.com/project/${projectId}/usage/details\n`, diff --git a/src/gcp/serviceusage.spec.ts b/src/gcp/serviceusage.spec.ts index ae54140f7f0..37aacacf168 100644 --- a/src/gcp/serviceusage.spec.ts +++ b/src/gcp/serviceusage.spec.ts @@ -2,10 +2,12 @@ import { expect } from "chai"; import * as sinon from "sinon"; import * as serviceUsage from "./serviceusage"; import * as poller from "../operation-poller"; +import * as ensureApiEnabled from "../ensureApiEnabled"; describe("serviceusage", () => { let postStub: sinon.SinonStub; let pollerStub: sinon.SinonStub; + let uncacheStub: sinon.SinonStub; const projectNumber = "projectNumber"; const service = "service"; @@ -14,11 +16,13 @@ describe("serviceusage", () => { beforeEach(() => { postStub = sinon.stub(serviceUsage.apiClient, "post").throws("unexpected post call"); pollerStub = sinon.stub(poller, "pollOperation").throws("unexpected pollOperation call"); + uncacheStub = sinon.stub(ensureApiEnabled, "uncacheEnabledAPI"); }); afterEach(() => { postStub.restore(); pollerStub.restore(); + uncacheStub.restore(); }); describe("generateServiceIdentityAndPoll", () => { @@ -36,6 +40,12 @@ describe("serviceusage", () => { expect(pollerStub).to.not.be.called; }); + it("invalidates the enablement cache for the disabled service", async () => { + postStub.onFirstCall().resolves({ body: { done: true } }); + await serviceUsage.disableServiceAndPoll(projectNumber, service, prefix); + expect(uncacheStub).to.have.been.calledOnceWith(projectNumber, service); + }); + it("polls if disableService responds with an uncompleted operation", async () => { postStub.onFirstCall().resolves({ body: { done: false, name: "operation-name" } }); pollerStub.onFirstCall().resolves({}); diff --git a/src/gcp/serviceusage.ts b/src/gcp/serviceusage.ts index 7c5b15b303a..e4e3f4d28eb 100644 --- a/src/gcp/serviceusage.ts +++ b/src/gcp/serviceusage.ts @@ -5,6 +5,7 @@ import { FirebaseError } from "../error"; import * as utils from "../utils"; import * as poller from "../operation-poller"; import { LongRunningOperation } from "../operation-poller"; +import * as ensureApiEnabled from "../ensureApiEnabled"; const API_VERSION = "v1beta1"; const SERVICE_USAGE_ORIGIN = serviceUsageOrigin(); @@ -98,17 +99,18 @@ export async function disableService( export async function disableServiceAndPoll( projectId: string, service: string, - prefix: string, + loggingPrefix: string, ): Promise { - utils.logLabeledBullet(prefix, `disabling service ${bold(service)}...`); + utils.logLabeledBullet(loggingPrefix, `disabling service ${bold(service)}...`); const op = await disableService(projectId, service); - if (op.done) { - return; + if (!op.done) { + await poller.pollOperation({ + ...serviceUsagePollerOptions, + operationResourceName: op.name, + headers: { "x-goog-user-project": `${projectId}` }, + }); } - - await poller.pollOperation({ - ...serviceUsagePollerOptions, - operationResourceName: op.name, - headers: { "x-goog-user-project": `${projectId}` }, - }); + // The service is now disabled; invalidate the cached enablement status so that + // subsequent checks reflect reality without waiting for the cache to expire. + ensureApiEnabled.uncacheEnabledAPI(projectId, service); } From 4273fe02f7c73165a085f3bdcb030855b40fd3f2 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Wed, 22 Jul 2026 13:18:57 -0700 Subject: [PATCH 03/21] style: format CHANGELOG.md with prettier --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20f9815bba5..a0e76d3c131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,4 +2,3 @@ - Add `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` HTTP headers to `OneMcpServer` requests per the MCP 0728 standard release candidate (https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/ and https://modelcontextprotocol.io/seps/2243-http-standardization). - Fixes Storage Emulator to support JSON uploads larger than 100KB without hanging or throwing 413 error (#8355) - Add `extdeprecationwarnings` experiment to display phased deprecation notices and guidance across `ext:*` CLI commands. - From f06ed0859bfbc029278aa875da23c2729fafface Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Wed, 8 Jul 2026 15:44:13 -0700 Subject: [PATCH 04/21] feat: add firebase ailogic:config CLI commands Add `firebase ailogic:config:get [path]` and `ailogic:config:set ` to read and write Firebase AI Logic service configuration from the CLI. - config:get prints the full config (providers, security, monitoring) or a single value by path; it is read-only and reports "not enabled" gracefully rather than forcing API enablement. - config:set updates one setting. Tightening security.auth-only or security.template-only from false to true prompts for confirmation (--force to skip); monitoring.sample-rate-percentage is validated as an integer 1-100. - Developer-facing paths map onto the underlying config resource (trafficFilter.*, telemetryConfig.*), with sample rate stored as a fraction. Supports --json and --non-interactive. --- CHANGELOG.md | 1 + src/commands/ailogic-config-get.ts | 85 +++++++++++++++++++ src/commands/ailogic-config-set.ts | 130 ++++++++++++++++++++++++++++ src/commands/index.ts | 4 + src/gcp/ailogic.spec.ts | 132 +++++++++++++++++++++++++++++ src/gcp/ailogic.ts | 119 ++++++++++++++++++++++++++ 6 files changed, 471 insertions(+) create mode 100644 src/commands/ailogic-config-get.ts create mode 100644 src/commands/ailogic-config-set.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a0e76d3c131..9c326c62b94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ - Added `firebase ailogic:providers:*` CLI commands to enable, disable, and list Gemini API providers. +- Added `firebase ailogic:config:*` CLI commands to read and modify AI Logic configuration settings. - Add `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` HTTP headers to `OneMcpServer` requests per the MCP 0728 standard release candidate (https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/ and https://modelcontextprotocol.io/seps/2243-http-standardization). - Fixes Storage Emulator to support JSON uploads larger than 100KB without hanging or throwing 413 error (#8355) - Add `extdeprecationwarnings` experiment to display phased deprecation notices and guidance across `ext:*` CLI commands. diff --git a/src/commands/ailogic-config-get.ts b/src/commands/ailogic-config-get.ts new file mode 100644 index 00000000000..9d1c1571841 --- /dev/null +++ b/src/commands/ailogic-config-get.ts @@ -0,0 +1,85 @@ +import { Command } from "../command"; +import { requirePermissions } from "../requirePermissions"; +import { needProjectId } from "../projectUtils"; +import * as ailogic from "../gcp/ailogic"; +import { logger } from "../logger"; +import { FirebaseError } from "../error"; + +import { Options } from "../options"; + +export const command = new Command("ailogic:config:get [path]") + .description("read AI Logic configuration") + .before(requirePermissions, ["firebasevertexai.config.get", "serviceusage.services.get"]) + .action(async (path: string | undefined, options: Options) => { + const projectId = needProjectId(options); + + if (!(await ailogic.isAILogicApiEnabled(projectId))) { + logger.info("Firebase AI Logic is not enabled on this project."); + return; + } + const config = await ailogic.getConfig(projectId); + + const authOnly = config.trafficFilter?.firebaseAuthRequired ?? false; + const templateOnly = config.trafficFilter?.templateOnly ?? false; + const monitoringState = config.telemetryConfig?.mode === "ALL"; + const sampleRatePercent = + config.telemetryConfig?.samplingRate !== undefined + ? Math.round(config.telemetryConfig.samplingRate * 100) + : 100; + + const enabledProviders = await ailogic.listProviders(projectId); + const hasDeveloperApi = enabledProviders.includes("gemini-developer-api"); + const hasAgentPlatform = enabledProviders.includes("agent-platform-gemini-api"); + + const configObj = { + providers: { + "gemini-developer-api": hasDeveloperApi, + "agent-platform-gemini-api": hasAgentPlatform, + }, + security: { + "auth-only": authOnly, + "template-only": templateOnly, + }, + monitoring: { + state: monitoringState, + "sample-rate-percentage": sampleRatePercent, + }, + }; + + if (path) { + const validPaths = [ + "providers", + "providers.gemini-developer-api", + "providers.agent-platform-gemini-api", + "security", + "security.auth-only", + "security.template-only", + "monitoring", + "monitoring.state", + "monitoring.sample-rate-percentage", + ]; + if (!validPaths.includes(path)) { + throw new FirebaseError( + `Unknown configuration path: ${path}\n\nValid paths:\n\n` + + [ + "security.auth-only", + "security.template-only", + "monitoring.state", + "monitoring.sample-rate-percentage", + ] + .map((p) => ` ${p}`) + .join("\n"), + ); + } + const parts = path.split("."); + let val: unknown = configObj; + for (const part of parts) { + val = val && typeof val === "object" ? (val as Record)[part] : undefined; + } + logger.info(typeof val === "object" ? JSON.stringify(val, null, 2) : String(val)); + return val; + } else { + logger.info(JSON.stringify(configObj, null, 2)); + return configObj; + } + }); diff --git a/src/commands/ailogic-config-set.ts b/src/commands/ailogic-config-set.ts new file mode 100644 index 00000000000..224ed9ac9d9 --- /dev/null +++ b/src/commands/ailogic-config-set.ts @@ -0,0 +1,130 @@ +import { Command } from "../command"; +import { requirePermissions } from "../requirePermissions"; +import { needProjectId } from "../projectUtils"; +import * as ailogic from "../gcp/ailogic"; +import * as clc from "colorette"; +import { logger } from "../logger"; +import { FirebaseError } from "../error"; +import { confirm } from "../prompt"; + +import { Options } from "../options"; + +export const command = new Command("ailogic:config:set ") + .description("set one configuration value") + .option("-f, --force", "bypass confirmation prompt") + .before(requirePermissions, ["firebasevertexai.config.update", "firebasevertexai.config.get"]) + .action(async (pathStr: string, value: string, options: Options) => { + const projectId = needProjectId(options); + + await ailogic.ensureAILogicApiEnabled(projectId, options); + + const validPaths = [ + "security.auth-only", + "security.template-only", + "monitoring.state", + "monitoring.sample-rate-percentage", + ]; + + if (!validPaths.includes(pathStr)) { + throw new FirebaseError( + `Unknown configuration path: ${pathStr}\n\nValid paths:\n\n` + + validPaths.map((p) => ` ${p}`).join("\n"), + ); + } + + // Tightening check + if (pathStr === "security.auth-only" || pathStr === "security.template-only") { + if (value !== "true" && value !== "false") { + throw new FirebaseError(`Value for ${clc.bold(pathStr)} must be 'true' or 'false'.`); + } + const boolVal = value === "true"; + + if (boolVal) { + // Fetch current config to check if it's currently false + const currentConfig = await ailogic.getConfig(projectId); + const currentVal = + pathStr === "security.auth-only" + ? currentConfig.trafficFilter?.firebaseAuthRequired ?? false + : currentConfig.trafficFilter?.templateOnly ?? false; + + if (!currentVal) { + const rejectMsg = + pathStr === "security.auth-only" + ? "reject requests from unauthenticated users" + : "reject requests not using templates"; + + if (options.nonInteractive && !options.force) { + throw new FirebaseError( + `Updating ${clc.bold(pathStr)} requires confirmation.\n\n` + + `To proceed in non-interactive mode, rerun with --force:\n\n` + + ` firebase ailogic:config:set ${pathStr} ${value} --force`, + ); + } + + const confirmed = await confirm({ + message: `Enabling ${clc.bold(pathStr)} will ${rejectMsg}. Continue?`, + force: options.force, + nonInteractive: options.nonInteractive, + }); + + if (!confirmed) { + throw new FirebaseError("Command aborted.", { exit: 1 }); + } + } + } + + if (pathStr === "security.auth-only") { + await ailogic.updateConfig( + projectId, + { + trafficFilter: { firebaseAuthRequired: boolVal }, + }, + ["trafficFilter.firebaseAuthRequired"], + ); + } else { + await ailogic.updateConfig( + projectId, + { + trafficFilter: { templateOnly: boolVal }, + }, + ["trafficFilter.templateOnly"], + ); + } + logger.info( + clc.green(`Successfully updated security setting: ${clc.bold(pathStr)} = ${value}`), + ); + } else if (pathStr === "monitoring.state") { + if (value !== "true" && value !== "false") { + throw new FirebaseError(`Value for ${clc.bold(pathStr)} must be 'true' or 'false'.`); + } + const boolVal = value === "true"; + await ailogic.updateConfig( + projectId, + { + telemetryConfig: { mode: boolVal ? "ALL" : "NONE" }, + }, + ["telemetryConfig.mode"], + ); + logger.info( + clc.green(`Successfully updated monitoring state: ${clc.bold(pathStr)} = ${value}`), + ); + } else if (pathStr === "monitoring.sample-rate-percentage") { + const numVal = Number(value); + if (!Number.isInteger(numVal) || numVal < 1 || numVal > 100) { + throw new FirebaseError( + `Value for ${clc.bold(pathStr)} must be an integer in the range 1-100.`, + ); + } + const samplingRate = numVal / 100; + await ailogic.updateConfig( + projectId, + { + telemetryConfig: { samplingRate }, + }, + ["telemetryConfig.samplingRate"], + ); + logger.info( + clc.green(`Successfully updated monitoring sample rate: ${clc.bold(pathStr)} = ${value}%`), + ); + } + }); diff --git a/src/commands/index.ts b/src/commands/index.ts index eaf6d1da5d1..7e529aab794 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -234,6 +234,10 @@ export function load(client: CLIClient): CLIClient { client.ailogic.providers.list = loadCommand("ailogic-providers-list"); } + client.ailogic.config = {}; + client.ailogic.config.get = loadCommand("ailogic-config-get"); + client.ailogic.config.set = loadCommand("ailogic-config-set"); + client.login = loadCommand("login"); client.login.add = loadCommand("login-add"); client.login.ci = loadCommand("login-ci"); diff --git a/src/gcp/ailogic.spec.ts b/src/gcp/ailogic.spec.ts index 0b03921e8d3..54945919c49 100644 --- a/src/gcp/ailogic.spec.ts +++ b/src/gcp/ailogic.spec.ts @@ -3,6 +3,7 @@ import * as sinon from "sinon"; import * as ailogic from "./ailogic"; import * as ensureApiEnabled from "../ensureApiEnabled"; import * as serviceUsage from "./serviceusage"; +import * as rules from "./rules"; import * as cloudbilling from "./cloudbilling"; import { AI_LOGIC_BEFORE_GENERATE_CONTENT, @@ -147,6 +148,69 @@ describe("ailogic", () => { }); }); + describe("getConfig", () => { + let getStub: sinon.SinonStub; + + beforeEach(() => { + getStub = sinon.stub(ailogic.client, "get"); + }); + + afterEach(() => { + getStub.restore(); + }); + + it("should fetch config", async () => { + const mockConfig: ailogic.Config = { + name: "projects/my-project/locations/global/config", + generativeLanguageConfig: { apiKey: "key" }, + }; + getStub.resolves({ body: mockConfig }); + + const config = await ailogic.getConfig("my-project"); + + expect(getStub).to.have.been.calledWithMatch("projects/my-project/locations/global/config"); + expect(config).to.deep.equal(mockConfig); + }); + }); + + describe("updateConfig", () => { + let patchStub: sinon.SinonStub; + + beforeEach(() => { + patchStub = sinon.stub(ailogic.client, "patch"); + }); + + afterEach(() => { + patchStub.restore(); + }); + + it("should update config", async () => { + const patchConfig: Partial = { + generativeLanguageConfig: { apiKey: "new-key" }, + }; + const mockConfig: ailogic.Config = { + name: "projects/my-project/locations/global/config", + generativeLanguageConfig: { apiKey: "new-key" }, + }; + patchStub.resolves({ body: mockConfig }); + + const config = await ailogic.updateConfig("my-project", patchConfig, [ + "generativeLanguageConfig", + ]); + + expect(patchStub).to.have.been.calledWithMatch( + "projects/my-project/locations/global/config", + patchConfig, + { + queryParams: { + updateMask: "generativeLanguageConfig", + }, + }, + ); + expect(config).to.deep.equal(mockConfig); + }); + }); + describe("providers", () => { let ensureStub: sinon.SinonStub; let disableStub: sinon.SinonStub; @@ -280,4 +344,72 @@ describe("ailogic", () => { expect(ailogic.isProviderType("nope")).to.be.false; }); }); + + describe("securityRules", () => { + let listReleasesStub: sinon.SinonStub; + let getLatestRulesetNameStub: sinon.SinonStub; + let getRulesetContentStub: sinon.SinonStub; + let createRulesetStub: sinon.SinonStub; + let updateOrCreateReleaseStub: sinon.SinonStub; + + beforeEach(() => { + listReleasesStub = sinon.stub(rules, "listAllReleases"); + getLatestRulesetNameStub = sinon.stub(rules, "getLatestRulesetName"); + getRulesetContentStub = sinon.stub(rules, "getRulesetContent"); + createRulesetStub = sinon.stub(rules, "createRuleset"); + updateOrCreateReleaseStub = sinon.stub(rules, "updateOrCreateRelease"); + }); + + afterEach(() => { + listReleasesStub.restore(); + getLatestRulesetNameStub.restore(); + getRulesetContentStub.restore(); + createRulesetStub.restore(); + updateOrCreateReleaseStub.restore(); + }); + + it("should get rules and parse authOnly and templateOnly", async () => { + listReleasesStub.resolves([]); + getLatestRulesetNameStub.resolves("ruleset-name"); + getRulesetContentStub.resolves([ + { + name: "vertexai.rules", + content: `rules_version = '2'; +service firebase.vertexai { + match /projects/{project}/locations/{location} { + match /templates/{template} { + allow read: if request.auth != null; + } + match /models/{model} { + allow read: if false; + } + } +}`, + }, + ]); + + const config = await ailogic.getSecurityRules("my-project"); + + expect(config).to.deep.equal({ authOnly: true, templateOnly: true }); + }); + + it("should deploy rules with generateRulesContent", async () => { + createRulesetStub.resolves("new-ruleset"); + updateOrCreateReleaseStub.resolves("release-name"); + + await ailogic.updateSecurityRules("my-project", true, false); + + expect(createRulesetStub).to.have.been.calledWith("my-project", [ + { + name: "vertexai.rules", + content: ailogic.generateRulesContent(true, false), + }, + ]); + expect(updateOrCreateReleaseStub).to.have.been.calledWith( + "my-project", + "new-ruleset", + "firebase.vertexai", + ); + }); + }); }); diff --git a/src/gcp/ailogic.ts b/src/gcp/ailogic.ts index 5e48cede180..9c1fc200b8e 100644 --- a/src/gcp/ailogic.ts +++ b/src/gcp/ailogic.ts @@ -5,6 +5,7 @@ import type { AILogicEndpoint } from "../deploy/functions/services/ailogic"; import { FirebaseError, getErrStatus } from "../error"; import * as ensureApiEnabled from "../ensureApiEnabled"; import * as serviceUsage from "./serviceusage"; +import * as rules from "./rules"; import { bold } from "colorette"; import * as cloudbilling from "./cloudbilling"; import * as iam from "./iam"; @@ -215,6 +216,27 @@ export async function deleteBlockingFunction(endpoint: AILogicEndpoint): Promise await deleteTrigger(endpoint.project, location, triggerId, true); } +export interface GenerativeLanguageConfig { + apiKey?: string; +} + +export interface TrafficFilter { + templateOnly?: boolean; + firebaseAuthRequired?: boolean; +} + +export interface TelemetryConfig { + mode?: "MODE_UNSPECIFIED" | "NONE" | "ALL"; + samplingRate?: number; +} + +export interface Config { + name: string; + generativeLanguageConfig?: GenerativeLanguageConfig; + trafficFilter?: TrafficFilter; + telemetryConfig?: TelemetryConfig; +} + export type ProviderType = "gemini-developer-api" | "gemini-agent-platform-api"; export const PROVIDER_TYPES: ProviderType[] = ["gemini-developer-api", "gemini-agent-platform-api"]; @@ -236,6 +258,32 @@ export function parseProviderType(value: string): ProviderType { return value; } +/** + * Gets the AI Logic Config singleton. + */ +export async function getConfig(projectId: string): Promise { + const name = `projects/${projectId}/locations/global/config`; + const res = await client.get(name); + return res.body; +} + +/** + * Updates the AI Logic Config singleton. + */ +export async function updateConfig( + projectId: string, + config: Partial, + updateMask?: string[], +): Promise { + const name = `projects/${projectId}/locations/global/config`; + const queryParams: Record = {}; + if (updateMask && updateMask.length > 0) { + queryParams.updateMask = updateMask.join(","); + } + const res = await client.patch, Config>(name, config, { queryParams }); + return res.body; +} + /** * Enables a Gemini API provider service. */ @@ -351,6 +399,77 @@ export async function listProviders(projectId: string): Promise return enabled; } +/** + * + */ +export function generateRulesContent(authOnly: boolean, templateOnly: boolean): string { + const condition = authOnly ? "request.auth != null" : "true"; + + return `rules_version = '2'; +service firebase.vertexai { + match /projects/{project}/locations/{location} { + match /templates/{template} { + allow read: if ${condition}; + } + match /models/{model} { + allow read: if ${templateOnly ? "false" : condition}; + } + } +}`; +} + +export interface SecurityRulesConfig { + authOnly: boolean; + templateOnly: boolean; +} + +/** + * Gets security rules settings by fetching and parsing the active release. + */ +export async function getSecurityRules(projectId: string): Promise { + const releases = await rules.listAllReleases(projectId); + const rulesetName = await rules.getLatestRulesetName(projectId, "firebase.vertexai", releases); + if (!rulesetName) { + return { authOnly: false, templateOnly: false }; + } + const files = await rules.getRulesetContent(rulesetName); + const vertexFile = files.find((f) => f.name === "vertexai.rules"); + if (!vertexFile) { + return { authOnly: false, templateOnly: false }; + } + const content = vertexFile.content; + const authOnly = content.includes("request.auth != null"); + const templateOnly = content.includes("allow read: if false"); + return { authOnly, templateOnly }; +} + +/** + * Deploys new security rules. + */ +export async function updateSecurityRules( + projectId: string, + authOnly: boolean, + templateOnly: boolean, +): Promise { + const content = generateRulesContent(authOnly, templateOnly); + const files = [ + { + name: "vertexai.rules", + content, + }, + ]; + const rulesetName = await rules.createRuleset(projectId, files); + await rules.updateOrCreateRelease(projectId, rulesetName, "firebase.vertexai"); +} + +/** + * Returns whether the Firebase AI Logic API is enabled on the project, without prompting to enable it. + * Read-only commands use this to report state instead of forcing enablement. + */ +export async function isAILogicApiEnabled(projectId: string): Promise { + return ensureApiEnabled.check(projectId, "firebasevertexai.googleapis.com", "ailogic", true); +} + /** * Ensures that the Firebase AI Logic API is enabled. If not enabled: * - In non-interactive mode: throws an error with instructions. From 847f078f05f99532d866e64159b0d1673392cd55 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Sat, 18 Jul 2026 13:09:18 -0700 Subject: [PATCH 05/21] fix(ailogic): address review feedback on config commands - Bring config commands under the ailogic experiment gate (via cherry-pick of the providers review fixes) and inherit the provider rename + centralized validation. - config:get/config:set: single source of valid paths (fixes get's validate-vs-error mismatch); rename provider id to gemini-agent-platform-api. - config:set: drop the redundant non-interactive guard (confirm() enforces --force), use utils.logSuccess, extract a bool parser, validate the path before the API-enablement flow. - gcp: GLOBAL_LOCATION constant for the config resource; AILOGIC_LOGGING_PREFIX in isAILogicApiEnabled. - Add ailogic-config-get.spec.ts and ailogic-config-set.spec.ts. --- src/commands/ailogic-config-get.spec.ts | 52 +++++++++ src/commands/ailogic-config-get.ts | 68 ++++++----- src/commands/ailogic-config-set.spec.ts | 110 ++++++++++++++++++ src/commands/ailogic-config-set.ts | 145 ++++++++++-------------- src/gcp/ailogic.ts | 17 ++- 5 files changed, 269 insertions(+), 123 deletions(-) create mode 100644 src/commands/ailogic-config-get.spec.ts create mode 100644 src/commands/ailogic-config-set.spec.ts diff --git a/src/commands/ailogic-config-get.spec.ts b/src/commands/ailogic-config-get.spec.ts new file mode 100644 index 00000000000..c344df940f0 --- /dev/null +++ b/src/commands/ailogic-config-get.spec.ts @@ -0,0 +1,52 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; + +import { command } from "./ailogic-config-get"; +import * as ailogic from "../gcp/ailogic"; +import * as projectUtils from "../projectUtils"; +import { FirebaseError } from "../error"; + +const PROJECT_ID = "test-project"; + +describe("ailogic:config:get", () => { + beforeEach(() => { + (command as unknown as { befores: unknown[] }).befores = []; // bypass pre-action hooks + sinon.stub(projectUtils, "needProjectId").returns(PROJECT_ID); + sinon.stub(ailogic, "isAILogicApiEnabled").resolves(true); + sinon.stub(ailogic, "listProviders").resolves(["gemini-developer-api"]); + sinon.stub(ailogic, "getConfig").resolves({ + name: "config", + trafficFilter: { firebaseAuthRequired: true, templateOnly: false }, + telemetryConfig: { mode: "ALL", samplingRate: 0.5 }, + }); + }); + + afterEach(() => sinon.restore()); + + it("returns a structured config with mapped values", async () => { + expect(await command.runner()(undefined, { project: PROJECT_ID })).to.deep.equal({ + providers: { + "gemini-developer-api": true, + "gemini-agent-platform-api": false, + }, + security: { "auth-only": true, "template-only": false }, + monitoring: { state: true, "sample-rate-percentage": 50 }, + }); + }); + + it("returns a single value for a valid path", async () => { + expect(await command.runner()("security.auth-only", { project: PROJECT_ID })).to.equal(true); + }); + + it("throws on an unknown path", async () => { + await expect(command.runner()("security.authonly", { project: PROJECT_ID })).to.be.rejectedWith( + FirebaseError, + /Unknown configuration path/, + ); + }); + + it("returns early when AI Logic is not enabled", async () => { + (ailogic.isAILogicApiEnabled as sinon.SinonStub).resolves(false); + expect(await command.runner()(undefined, { project: PROJECT_ID })).to.be.undefined; + }); +}); diff --git a/src/commands/ailogic-config-get.ts b/src/commands/ailogic-config-get.ts index 9d1c1571841..34edd175165 100644 --- a/src/commands/ailogic-config-get.ts +++ b/src/commands/ailogic-config-get.ts @@ -7,6 +7,20 @@ import { FirebaseError } from "../error"; import { Options } from "../options"; +// Developer-facing config paths that `config:get` can read. Used both to validate +// the requested path and to list the valid paths in the error message. +const READABLE_CONFIG_PATHS = [ + "providers", + "providers.gemini-developer-api", + "providers.gemini-agent-platform-api", + "security", + "security.auth-only", + "security.template-only", + "monitoring", + "monitoring.state", + "monitoring.sample-rate-percentage", +]; + export const command = new Command("ailogic:config:get [path]") .description("read AI Logic configuration") .before(requirePermissions, ["firebasevertexai.config.get", "serviceusage.services.get"]) @@ -22,6 +36,8 @@ export const command = new Command("ailogic:config:get [path]") const authOnly = config.trafficFilter?.firebaseAuthRequired ?? false; const templateOnly = config.trafficFilter?.templateOnly ?? false; const monitoringState = config.telemetryConfig?.mode === "ALL"; + // The API stores the sampling rate as a fraction in (0,1]; the CLI exposes it + // as an integer percentage (1-100). const sampleRatePercent = config.telemetryConfig?.samplingRate !== undefined ? Math.round(config.telemetryConfig.samplingRate * 100) @@ -29,12 +45,12 @@ export const command = new Command("ailogic:config:get [path]") const enabledProviders = await ailogic.listProviders(projectId); const hasDeveloperApi = enabledProviders.includes("gemini-developer-api"); - const hasAgentPlatform = enabledProviders.includes("agent-platform-gemini-api"); + const hasAgentPlatform = enabledProviders.includes("gemini-agent-platform-api"); const configObj = { providers: { "gemini-developer-api": hasDeveloperApi, - "agent-platform-gemini-api": hasAgentPlatform, + "gemini-agent-platform-api": hasAgentPlatform, }, security: { "auth-only": authOnly, @@ -46,40 +62,22 @@ export const command = new Command("ailogic:config:get [path]") }, }; - if (path) { - const validPaths = [ - "providers", - "providers.gemini-developer-api", - "providers.agent-platform-gemini-api", - "security", - "security.auth-only", - "security.template-only", - "monitoring", - "monitoring.state", - "monitoring.sample-rate-percentage", - ]; - if (!validPaths.includes(path)) { - throw new FirebaseError( - `Unknown configuration path: ${path}\n\nValid paths:\n\n` + - [ - "security.auth-only", - "security.template-only", - "monitoring.state", - "monitoring.sample-rate-percentage", - ] - .map((p) => ` ${p}`) - .join("\n"), - ); - } - const parts = path.split("."); - let val: unknown = configObj; - for (const part of parts) { - val = val && typeof val === "object" ? (val as Record)[part] : undefined; - } - logger.info(typeof val === "object" ? JSON.stringify(val, null, 2) : String(val)); - return val; - } else { + if (!path) { logger.info(JSON.stringify(configObj, null, 2)); return configObj; } + + if (!READABLE_CONFIG_PATHS.includes(path)) { + throw new FirebaseError( + `Unknown configuration path: ${path}\n\nValid paths:\n\n` + + READABLE_CONFIG_PATHS.map((p) => ` ${p}`).join("\n"), + ); + } + + let val: unknown = configObj; + for (const part of path.split(".")) { + val = val && typeof val === "object" ? (val as Record)[part] : undefined; + } + logger.info(typeof val === "object" ? JSON.stringify(val, null, 2) : String(val)); + return val; }); diff --git a/src/commands/ailogic-config-set.spec.ts b/src/commands/ailogic-config-set.spec.ts new file mode 100644 index 00000000000..4187e309cd2 --- /dev/null +++ b/src/commands/ailogic-config-set.spec.ts @@ -0,0 +1,110 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; + +import { command } from "./ailogic-config-set"; +import * as ailogic from "../gcp/ailogic"; +import * as projectUtils from "../projectUtils"; +import * as prompt from "../prompt"; +import * as utils from "../utils"; +import { FirebaseError } from "../error"; + +const PROJECT_ID = "test-project"; + +describe("ailogic:config:set", () => { + let updateStub: sinon.SinonStub; + let getConfigStub: sinon.SinonStub; + let confirmStub: sinon.SinonStub; + + beforeEach(() => { + (command as unknown as { befores: unknown[] }).befores = []; // bypass pre-action hooks + sinon.stub(projectUtils, "needProjectId").returns(PROJECT_ID); + sinon.stub(ailogic, "ensureAILogicApiEnabled").resolves(); + sinon.stub(utils, "logSuccess"); + getConfigStub = sinon.stub(ailogic, "getConfig").resolves({ name: "config" }); + updateStub = sinon.stub(ailogic, "updateConfig").resolves({ name: "config" }); + confirmStub = sinon.stub(prompt, "confirm").resolves(true); + }); + + afterEach(() => sinon.restore()); + + it("throws on an unknown path listing the writable paths", async () => { + await expect( + command.runner()("security.authonly", "true", { project: PROJECT_ID }), + ).to.be.rejectedWith(FirebaseError, /Unknown configuration path/); + }); + + it("rejects a non-boolean value for a security path", async () => { + await expect( + command.runner()("security.auth-only", "yes", { project: PROJECT_ID }), + ).to.be.rejectedWith(FirebaseError, /must be 'true' or 'false'/); + }); + + it("prompts when tightening auth-only from false to true, then updates", async () => { + getConfigStub.resolves({ name: "config", trafficFilter: { firebaseAuthRequired: false } }); + await command.runner()("security.auth-only", "true", { + project: PROJECT_ID, + interactive: true, + }); + expect(confirmStub).to.have.been.calledOnce; + expect(updateStub).to.have.been.calledWith( + PROJECT_ID, + { trafficFilter: { firebaseAuthRequired: true } }, + ["trafficFilter.firebaseAuthRequired"], + ); + }); + + it("does not prompt when auth-only is already true", async () => { + getConfigStub.resolves({ name: "config", trafficFilter: { firebaseAuthRequired: true } }); + await command.runner()("security.auth-only", "true", { + project: PROJECT_ID, + interactive: true, + }); + expect(confirmStub).to.not.have.been.called; + expect(updateStub).to.have.been.calledOnce; + }); + + it("does not prompt when relaxing auth-only to false", async () => { + await command.runner()("security.auth-only", "false", { project: PROJECT_ID }); + expect(confirmStub).to.not.have.been.called; + expect(updateStub).to.have.been.calledWith( + PROJECT_ID, + { trafficFilter: { firebaseAuthRequired: false } }, + ["trafficFilter.firebaseAuthRequired"], + ); + }); + + it("propagates confirm() aborting in non-interactive mode without --force", async () => { + // confirm() throws in non-interactive mode when no --force is given; the command + // must surface that and not proceed to write. + getConfigStub.resolves({ name: "config", trafficFilter: { firebaseAuthRequired: false } }); + confirmStub.rejects(new FirebaseError("cannot be answered in non-interactive mode")); + await expect( + command.runner()("security.auth-only", "true", { project: PROJECT_ID, nonInteractive: true }), + ).to.be.rejectedWith(FirebaseError, /non-interactive/); + expect(updateStub).to.not.have.been.called; + }); + + it("maps monitoring.state true to telemetryConfig.mode ALL", async () => { + await command.runner()("monitoring.state", "true", { project: PROJECT_ID }); + expect(updateStub).to.have.been.calledWith(PROJECT_ID, { telemetryConfig: { mode: "ALL" } }, [ + "telemetryConfig.mode", + ]); + }); + + it("maps a sample-rate percentage to a (0,1] sampling fraction", async () => { + await command.runner()("monitoring.sample-rate-percentage", "50", { project: PROJECT_ID }); + expect(updateStub).to.have.been.calledWith( + PROJECT_ID, + { telemetryConfig: { samplingRate: 0.5 } }, + ["telemetryConfig.samplingRate"], + ); + }); + + it("rejects an out-of-range or non-integer sample rate", async () => { + for (const bad of ["0", "101", "1.5", "abc"]) { + await expect( + command.runner()("monitoring.sample-rate-percentage", bad, { project: PROJECT_ID }), + ).to.be.rejectedWith(FirebaseError, /integer in the range 1-100/); + } + }); +}); diff --git a/src/commands/ailogic-config-set.ts b/src/commands/ailogic-config-set.ts index 224ed9ac9d9..3e1cbf54484 100644 --- a/src/commands/ailogic-config-set.ts +++ b/src/commands/ailogic-config-set.ts @@ -3,12 +3,28 @@ import { requirePermissions } from "../requirePermissions"; import { needProjectId } from "../projectUtils"; import * as ailogic from "../gcp/ailogic"; import * as clc from "colorette"; -import { logger } from "../logger"; +import * as utils from "../utils"; import { FirebaseError } from "../error"; import { confirm } from "../prompt"; import { Options } from "../options"; +// Developer-facing config paths that `config:set` can write. +const WRITABLE_CONFIG_PATHS = [ + "security.auth-only", + "security.template-only", + "monitoring.state", + "monitoring.sample-rate-percentage", +]; + +/** Parses a "true"/"false" flag value, throwing a FirebaseError otherwise. */ +function parseBool(pathStr: string, value: string): boolean { + if (value !== "true" && value !== "false") { + throw new FirebaseError(`Value for ${clc.bold(pathStr)} must be 'true' or 'false'.`); + } + return value === "true"; +} + export const command = new Command("ailogic:config:set ") .description("set one configuration value") .option("-f, --force", "bypass confirmation prompt") @@ -16,115 +32,74 @@ export const command = new Command("ailogic:config:set ") .action(async (pathStr: string, value: string, options: Options) => { const projectId = needProjectId(options); - await ailogic.ensureAILogicApiEnabled(projectId, options); - - const validPaths = [ - "security.auth-only", - "security.template-only", - "monitoring.state", - "monitoring.sample-rate-percentage", - ]; - - if (!validPaths.includes(pathStr)) { + // Validate the path up front so bad input fails fast, before the API-enablement flow. + if (!WRITABLE_CONFIG_PATHS.includes(pathStr)) { throw new FirebaseError( `Unknown configuration path: ${pathStr}\n\nValid paths:\n\n` + - validPaths.map((p) => ` ${p}`).join("\n"), + WRITABLE_CONFIG_PATHS.map((p) => ` ${p}`).join("\n"), ); } - // Tightening check + await ailogic.ensureAILogicApiEnabled(projectId, options); + if (pathStr === "security.auth-only" || pathStr === "security.template-only") { - if (value !== "true" && value !== "false") { - throw new FirebaseError(`Value for ${clc.bold(pathStr)} must be 'true' or 'false'.`); - } - const boolVal = value === "true"; + const boolVal = parseBool(pathStr, value); + const isAuthOnly = pathStr === "security.auth-only"; + const trafficFilter: ailogic.TrafficFilter = isAuthOnly + ? { firebaseAuthRequired: boolVal } + : { templateOnly: boolVal }; + const mask = isAuthOnly ? "trafficFilter.firebaseAuthRequired" : "trafficFilter.templateOnly"; + // Tightening security from false to true is client-breaking, so confirm first. if (boolVal) { - // Fetch current config to check if it's currently false - const currentConfig = await ailogic.getConfig(projectId); + const current = await ailogic.getConfig(projectId); const currentVal = - pathStr === "security.auth-only" - ? currentConfig.trafficFilter?.firebaseAuthRequired ?? false - : currentConfig.trafficFilter?.templateOnly ?? false; - + (isAuthOnly + ? current.trafficFilter?.firebaseAuthRequired + : current.trafficFilter?.templateOnly) ?? false; if (!currentVal) { - const rejectMsg = - pathStr === "security.auth-only" - ? "reject requests from unauthenticated users" - : "reject requests not using templates"; - - if (options.nonInteractive && !options.force) { - throw new FirebaseError( - `Updating ${clc.bold(pathStr)} requires confirmation.\n\n` + - `To proceed in non-interactive mode, rerun with --force:\n\n` + - ` firebase ailogic:config:set ${pathStr} ${value} --force`, - ); - } - + const rejectMsg = isAuthOnly + ? "reject requests from unauthenticated users" + : "reject requests not using templates"; + // confirm() aborts in non-interactive mode unless --force is set. const confirmed = await confirm({ message: `Enabling ${clc.bold(pathStr)} will ${rejectMsg}. Continue?`, force: options.force, nonInteractive: options.nonInteractive, }); - if (!confirmed) { throw new FirebaseError("Command aborted.", { exit: 1 }); } } } - if (pathStr === "security.auth-only") { - await ailogic.updateConfig( - projectId, - { - trafficFilter: { firebaseAuthRequired: boolVal }, - }, - ["trafficFilter.firebaseAuthRequired"], - ); - } else { - await ailogic.updateConfig( - projectId, - { - trafficFilter: { templateOnly: boolVal }, - }, - ["trafficFilter.templateOnly"], - ); - } - logger.info( - clc.green(`Successfully updated security setting: ${clc.bold(pathStr)} = ${value}`), - ); - } else if (pathStr === "monitoring.state") { - if (value !== "true" && value !== "false") { - throw new FirebaseError(`Value for ${clc.bold(pathStr)} must be 'true' or 'false'.`); - } - const boolVal = value === "true"; + await ailogic.updateConfig(projectId, { trafficFilter }, [mask]); + utils.logSuccess(`Updated security setting: ${clc.bold(pathStr)} = ${value}`); + return; + } + + if (pathStr === "monitoring.state") { + const boolVal = parseBool(pathStr, value); await ailogic.updateConfig( projectId, - { - telemetryConfig: { mode: boolVal ? "ALL" : "NONE" }, - }, + { telemetryConfig: { mode: boolVal ? "ALL" : "NONE" } }, ["telemetryConfig.mode"], ); - logger.info( - clc.green(`Successfully updated monitoring state: ${clc.bold(pathStr)} = ${value}`), - ); - } else if (pathStr === "monitoring.sample-rate-percentage") { - const numVal = Number(value); - if (!Number.isInteger(numVal) || numVal < 1 || numVal > 100) { - throw new FirebaseError( - `Value for ${clc.bold(pathStr)} must be an integer in the range 1-100.`, - ); - } - const samplingRate = numVal / 100; - await ailogic.updateConfig( - projectId, - { - telemetryConfig: { samplingRate }, - }, - ["telemetryConfig.samplingRate"], - ); - logger.info( - clc.green(`Successfully updated monitoring sample rate: ${clc.bold(pathStr)} = ${value}%`), + utils.logSuccess(`Updated monitoring state: ${clc.bold(pathStr)} = ${value}`); + return; + } + + // monitoring.sample-rate-percentage + const numVal = Number(value); + if (!Number.isInteger(numVal) || numVal < 1 || numVal > 100) { + throw new FirebaseError( + `Value for ${clc.bold(pathStr)} must be an integer in the range 1-100.`, ); } + // The API stores the sampling rate as a fraction in (0,1]; the CLI accepts 1-100 percent. + const samplingRate = numVal / 100; + await ailogic.updateConfig(projectId, { telemetryConfig: { samplingRate } }, [ + "telemetryConfig.samplingRate", + ]); + utils.logSuccess(`Updated monitoring sample rate: ${clc.bold(pathStr)} = ${value}%`); }); diff --git a/src/gcp/ailogic.ts b/src/gcp/ailogic.ts index 9c1fc200b8e..55fd5ee7da2 100644 --- a/src/gcp/ailogic.ts +++ b/src/gcp/ailogic.ts @@ -17,6 +17,12 @@ export const API_VERSION = "v1beta"; /** Label used as the prefix for this module's user-facing progress logging. */ export const AILOGIC_LOGGING_PREFIX = "ailogic"; +/** + * All AI Logic management resources live at the fixed `global` location; there is + * no per-region configuration surface for these commands. + */ +export const GLOBAL_LOCATION = "global"; + export const AI_LOGIC_BEFORE_GENERATE_CONTENT = "google.firebase.ailogic.v1.beforeGenerate" as const; export const AI_LOGIC_AFTER_GENERATE_CONTENT = "google.firebase.ailogic.v1.afterGenerate" as const; @@ -262,7 +268,7 @@ export function parseProviderType(value: string): ProviderType { * Gets the AI Logic Config singleton. */ export async function getConfig(projectId: string): Promise { - const name = `projects/${projectId}/locations/global/config`; + const name = `projects/${projectId}/locations/${GLOBAL_LOCATION}/config`; const res = await client.get(name); return res.body; } @@ -275,7 +281,7 @@ export async function updateConfig( config: Partial, updateMask?: string[], ): Promise { - const name = `projects/${projectId}/locations/global/config`; + const name = `projects/${projectId}/locations/${GLOBAL_LOCATION}/config`; const queryParams: Record = {}; if (updateMask && updateMask.length > 0) { queryParams.updateMask = updateMask.join(","); @@ -467,7 +473,12 @@ export async function updateSecurityRules( * Read-only commands use this to report state instead of forcing enablement. */ export async function isAILogicApiEnabled(projectId: string): Promise { - return ensureApiEnabled.check(projectId, "firebasevertexai.googleapis.com", "ailogic", true); + return ensureApiEnabled.check( + projectId, + "firebasevertexai.googleapis.com", + AILOGIC_LOGGING_PREFIX, + true, + ); } /** From dd7c215c2149a2632975866451f40869958c6bfd Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Wed, 22 Jul 2026 18:19:29 -0700 Subject: [PATCH 06/21] fix(ailogic): polish config commands per pre-PR review - config:set validates the value before the API-enablement flow (fail-fast) and returns a {path, value} result so --json produces output. - config:get path traversal uses an isRecord type guard instead of an `as` cast; provider keys/paths derive from ailogic.PROVIDER_TYPES (no duplicated literals). - Add tests: monitoring.state=false -> NONE, template-only tightening, nested-path get, and fail-fast-before-enablement. --- src/commands/ailogic-config-get.spec.ts | 7 ++ src/commands/ailogic-config-get.ts | 27 +++-- src/commands/ailogic-config-set.spec.ts | 32 +++++- src/commands/ailogic-config-set.ts | 131 +++++++++++++----------- 4 files changed, 127 insertions(+), 70 deletions(-) diff --git a/src/commands/ailogic-config-get.spec.ts b/src/commands/ailogic-config-get.spec.ts index c344df940f0..23725f8afd6 100644 --- a/src/commands/ailogic-config-get.spec.ts +++ b/src/commands/ailogic-config-get.spec.ts @@ -38,6 +38,13 @@ describe("ailogic:config:get", () => { expect(await command.runner()("security.auth-only", { project: PROJECT_ID })).to.equal(true); }); + it("returns a nested object for a group path", async () => { + expect(await command.runner()("monitoring", { project: PROJECT_ID })).to.deep.equal({ + state: true, + "sample-rate-percentage": 50, + }); + }); + it("throws on an unknown path", async () => { await expect(command.runner()("security.authonly", { project: PROJECT_ID })).to.be.rejectedWith( FirebaseError, diff --git a/src/commands/ailogic-config-get.ts b/src/commands/ailogic-config-get.ts index 34edd175165..292afd92a13 100644 --- a/src/commands/ailogic-config-get.ts +++ b/src/commands/ailogic-config-get.ts @@ -7,12 +7,12 @@ import { FirebaseError } from "../error"; import { Options } from "../options"; -// Developer-facing config paths that `config:get` can read. Used both to validate -// the requested path and to list the valid paths in the error message. +// Developer-facing config paths that `config:get` can read. Provider sub-paths are +// derived from the canonical provider list so they stay in sync. Used both to +// validate the requested path and to list the valid paths in the error message. const READABLE_CONFIG_PATHS = [ "providers", - "providers.gemini-developer-api", - "providers.gemini-agent-platform-api", + ...ailogic.PROVIDER_TYPES.map((p) => `providers.${p}`), "security", "security.auth-only", "security.template-only", @@ -21,6 +21,10 @@ const READABLE_CONFIG_PATHS = [ "monitoring.sample-rate-percentage", ]; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + export const command = new Command("ailogic:config:get [path]") .description("read AI Logic configuration") .before(requirePermissions, ["firebasevertexai.config.get", "serviceusage.services.get"]) @@ -44,14 +48,11 @@ export const command = new Command("ailogic:config:get [path]") : 100; const enabledProviders = await ailogic.listProviders(projectId); - const hasDeveloperApi = enabledProviders.includes("gemini-developer-api"); - const hasAgentPlatform = enabledProviders.includes("gemini-agent-platform-api"); const configObj = { - providers: { - "gemini-developer-api": hasDeveloperApi, - "gemini-agent-platform-api": hasAgentPlatform, - }, + providers: Object.fromEntries( + ailogic.PROVIDER_TYPES.map((p) => [p, enabledProviders.includes(p)]), + ), security: { "auth-only": authOnly, "template-only": templateOnly, @@ -76,7 +77,11 @@ export const command = new Command("ailogic:config:get [path]") let val: unknown = configObj; for (const part of path.split(".")) { - val = val && typeof val === "object" ? (val as Record)[part] : undefined; + if (!isRecord(val)) { + val = undefined; + break; + } + val = val[part]; } logger.info(typeof val === "object" ? JSON.stringify(val, null, 2) : String(val)); return val; diff --git a/src/commands/ailogic-config-set.spec.ts b/src/commands/ailogic-config-set.spec.ts index 4187e309cd2..08b352d4ee6 100644 --- a/src/commands/ailogic-config-set.spec.ts +++ b/src/commands/ailogic-config-set.spec.ts @@ -14,11 +14,12 @@ describe("ailogic:config:set", () => { let updateStub: sinon.SinonStub; let getConfigStub: sinon.SinonStub; let confirmStub: sinon.SinonStub; + let ensureStub: sinon.SinonStub; beforeEach(() => { (command as unknown as { befores: unknown[] }).befores = []; // bypass pre-action hooks sinon.stub(projectUtils, "needProjectId").returns(PROJECT_ID); - sinon.stub(ailogic, "ensureAILogicApiEnabled").resolves(); + ensureStub = sinon.stub(ailogic, "ensureAILogicApiEnabled").resolves(); sinon.stub(utils, "logSuccess"); getConfigStub = sinon.stub(ailogic, "getConfig").resolves({ name: "config" }); updateStub = sinon.stub(ailogic, "updateConfig").resolves({ name: "config" }); @@ -39,6 +40,13 @@ describe("ailogic:config:set", () => { ).to.be.rejectedWith(FirebaseError, /must be 'true' or 'false'/); }); + it("validates input before triggering the API-enablement flow (fail-fast)", async () => { + await expect( + command.runner()("monitoring.sample-rate-percentage", "500", { project: PROJECT_ID }), + ).to.be.rejectedWith(FirebaseError, /integer in the range 1-100/); + expect(ensureStub).to.not.have.been.called; + }); + it("prompts when tightening auth-only from false to true, then updates", async () => { getConfigStub.resolves({ name: "config", trafficFilter: { firebaseAuthRequired: false } }); await command.runner()("security.auth-only", "true", { @@ -84,6 +92,20 @@ describe("ailogic:config:set", () => { expect(updateStub).to.not.have.been.called; }); + it("prompts when tightening template-only from false to true, then updates", async () => { + getConfigStub.resolves({ name: "config", trafficFilter: { templateOnly: false } }); + await command.runner()("security.template-only", "true", { + project: PROJECT_ID, + interactive: true, + }); + expect(confirmStub).to.have.been.calledOnce; + expect(updateStub).to.have.been.calledWith( + PROJECT_ID, + { trafficFilter: { templateOnly: true } }, + ["trafficFilter.templateOnly"], + ); + }); + it("maps monitoring.state true to telemetryConfig.mode ALL", async () => { await command.runner()("monitoring.state", "true", { project: PROJECT_ID }); expect(updateStub).to.have.been.calledWith(PROJECT_ID, { telemetryConfig: { mode: "ALL" } }, [ @@ -91,6 +113,14 @@ describe("ailogic:config:set", () => { ]); }); + it("maps monitoring.state false to telemetryConfig.mode NONE without prompting", async () => { + await command.runner()("monitoring.state", "false", { project: PROJECT_ID }); + expect(confirmStub).to.not.have.been.called; + expect(updateStub).to.have.been.calledWith(PROJECT_ID, { telemetryConfig: { mode: "NONE" } }, [ + "telemetryConfig.mode", + ]); + }); + it("maps a sample-rate percentage to a (0,1] sampling fraction", async () => { await command.runner()("monitoring.sample-rate-percentage", "50", { project: PROJECT_ID }); expect(updateStub).to.have.been.calledWith( diff --git a/src/commands/ailogic-config-set.ts b/src/commands/ailogic-config-set.ts index 3e1cbf54484..3152450d683 100644 --- a/src/commands/ailogic-config-set.ts +++ b/src/commands/ailogic-config-set.ts @@ -25,6 +25,59 @@ function parseBool(pathStr: string, value: string): boolean { return value === "true"; } +// The parsed, validated change to apply: the partial Config, its updateMask, and +// whether this is a security tightening (true) that requires confirmation. +interface ConfigUpdate { + config: Partial; + updateMask: string; + securityTightening: boolean; +} + +/** Validates the path/value pair and builds the update, throwing on bad input. */ +function buildUpdate(pathStr: string, value: string): ConfigUpdate { + if (pathStr === "security.auth-only" || pathStr === "security.template-only") { + const boolVal = parseBool(pathStr, value); + const isAuthOnly = pathStr === "security.auth-only"; + return { + config: { + trafficFilter: isAuthOnly ? { firebaseAuthRequired: boolVal } : { templateOnly: boolVal }, + }, + updateMask: isAuthOnly ? "trafficFilter.firebaseAuthRequired" : "trafficFilter.templateOnly", + securityTightening: boolVal, + }; + } + if (pathStr === "monitoring.state") { + const boolVal = parseBool(pathStr, value); + return { + config: { telemetryConfig: { mode: boolVal ? "ALL" : "NONE" } }, + updateMask: "telemetryConfig.mode", + securityTightening: false, + }; + } + // monitoring.sample-rate-percentage + const numVal = Number(value); + if (!Number.isInteger(numVal) || numVal < 1 || numVal > 100) { + throw new FirebaseError( + `Value for ${clc.bold(pathStr)} must be an integer in the range 1-100.`, + ); + } + // The API stores the sampling rate as a fraction in (0,1]; the CLI accepts 1-100 percent. + return { + config: { telemetryConfig: { samplingRate: numVal / 100 } }, + updateMask: "telemetryConfig.samplingRate", + securityTightening: false, + }; +} + +/** Whether tightening `pathStr` to `true` changes it from a currently-false value. */ +function isTightening(pathStr: string, current: ailogic.Config): boolean { + const currentVal = + pathStr === "security.auth-only" + ? current.trafficFilter?.firebaseAuthRequired + : current.trafficFilter?.templateOnly; + return !(currentVal ?? false); +} + export const command = new Command("ailogic:config:set ") .description("set one configuration value") .option("-f, --force", "bypass confirmation prompt") @@ -32,74 +85,36 @@ export const command = new Command("ailogic:config:set ") .action(async (pathStr: string, value: string, options: Options) => { const projectId = needProjectId(options); - // Validate the path up front so bad input fails fast, before the API-enablement flow. + // Validate the path and value up front so bad input fails fast, before the + // API-enablement flow. if (!WRITABLE_CONFIG_PATHS.includes(pathStr)) { throw new FirebaseError( `Unknown configuration path: ${pathStr}\n\nValid paths:\n\n` + WRITABLE_CONFIG_PATHS.map((p) => ` ${p}`).join("\n"), ); } + const update = buildUpdate(pathStr, value); await ailogic.ensureAILogicApiEnabled(projectId, options); - if (pathStr === "security.auth-only" || pathStr === "security.template-only") { - const boolVal = parseBool(pathStr, value); - const isAuthOnly = pathStr === "security.auth-only"; - const trafficFilter: ailogic.TrafficFilter = isAuthOnly - ? { firebaseAuthRequired: boolVal } - : { templateOnly: boolVal }; - const mask = isAuthOnly ? "trafficFilter.firebaseAuthRequired" : "trafficFilter.templateOnly"; - - // Tightening security from false to true is client-breaking, so confirm first. - if (boolVal) { - const current = await ailogic.getConfig(projectId); - const currentVal = - (isAuthOnly - ? current.trafficFilter?.firebaseAuthRequired - : current.trafficFilter?.templateOnly) ?? false; - if (!currentVal) { - const rejectMsg = isAuthOnly - ? "reject requests from unauthenticated users" - : "reject requests not using templates"; - // confirm() aborts in non-interactive mode unless --force is set. - const confirmed = await confirm({ - message: `Enabling ${clc.bold(pathStr)} will ${rejectMsg}. Continue?`, - force: options.force, - nonInteractive: options.nonInteractive, - }); - if (!confirmed) { - throw new FirebaseError("Command aborted.", { exit: 1 }); - } - } + // Tightening a security setting from false to true is client-breaking, so confirm first. + if (update.securityTightening && isTightening(pathStr, await ailogic.getConfig(projectId))) { + const rejectMsg = + pathStr === "security.auth-only" + ? "reject requests from unauthenticated users" + : "reject requests not using templates"; + // confirm() aborts in non-interactive mode unless --force is set. + const confirmed = await confirm({ + message: `Enabling ${clc.bold(pathStr)} will ${rejectMsg}. Continue?`, + force: options.force, + nonInteractive: options.nonInteractive, + }); + if (!confirmed) { + throw new FirebaseError("Command aborted.", { exit: 1 }); } - - await ailogic.updateConfig(projectId, { trafficFilter }, [mask]); - utils.logSuccess(`Updated security setting: ${clc.bold(pathStr)} = ${value}`); - return; - } - - if (pathStr === "monitoring.state") { - const boolVal = parseBool(pathStr, value); - await ailogic.updateConfig( - projectId, - { telemetryConfig: { mode: boolVal ? "ALL" : "NONE" } }, - ["telemetryConfig.mode"], - ); - utils.logSuccess(`Updated monitoring state: ${clc.bold(pathStr)} = ${value}`); - return; } - // monitoring.sample-rate-percentage - const numVal = Number(value); - if (!Number.isInteger(numVal) || numVal < 1 || numVal > 100) { - throw new FirebaseError( - `Value for ${clc.bold(pathStr)} must be an integer in the range 1-100.`, - ); - } - // The API stores the sampling rate as a fraction in (0,1]; the CLI accepts 1-100 percent. - const samplingRate = numVal / 100; - await ailogic.updateConfig(projectId, { telemetryConfig: { samplingRate } }, [ - "telemetryConfig.samplingRate", - ]); - utils.logSuccess(`Updated monitoring sample rate: ${clc.bold(pathStr)} = ${value}%`); + await ailogic.updateConfig(projectId, update.config, [update.updateMask]); + utils.logSuccess(`Updated ${clc.bold(pathStr)} = ${value}`); + return { path: pathStr, value }; }); From 6eb7b69ec0a0e806d61093f5d1f685259ee44827 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Wed, 22 Jul 2026 22:17:39 -0700 Subject: [PATCH 07/21] fix(ailogic): address code review on config PR - Critical: register ailogic:config commands INSIDE the experiment gate; a rebase had left them outside, which crashed CLI startup for users without the ailogic experiment (client.ailogic was undefined). - parseBool accepts case-insensitive true/false. - listProviders runs its two independent enablement checks in parallel. --- src/commands/ailogic-config-set.spec.ts | 7 +++++++ src/commands/ailogic-config-set.ts | 7 ++++--- src/commands/index.ts | 7 +++---- src/gcp/ailogic.ts | 26 ++++++++++++------------- 4 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/commands/ailogic-config-set.spec.ts b/src/commands/ailogic-config-set.spec.ts index 08b352d4ee6..68f8162a6bf 100644 --- a/src/commands/ailogic-config-set.spec.ts +++ b/src/commands/ailogic-config-set.spec.ts @@ -106,6 +106,13 @@ describe("ailogic:config:set", () => { ); }); + it("accepts a case-insensitive boolean value", async () => { + await command.runner()("monitoring.state", "TRUE", { project: PROJECT_ID }); + expect(updateStub).to.have.been.calledWith(PROJECT_ID, { telemetryConfig: { mode: "ALL" } }, [ + "telemetryConfig.mode", + ]); + }); + it("maps monitoring.state true to telemetryConfig.mode ALL", async () => { await command.runner()("monitoring.state", "true", { project: PROJECT_ID }); expect(updateStub).to.have.been.calledWith(PROJECT_ID, { telemetryConfig: { mode: "ALL" } }, [ diff --git a/src/commands/ailogic-config-set.ts b/src/commands/ailogic-config-set.ts index 3152450d683..4fc8416af52 100644 --- a/src/commands/ailogic-config-set.ts +++ b/src/commands/ailogic-config-set.ts @@ -17,12 +17,13 @@ const WRITABLE_CONFIG_PATHS = [ "monitoring.sample-rate-percentage", ]; -/** Parses a "true"/"false" flag value, throwing a FirebaseError otherwise. */ +/** Parses a "true"/"false" flag value (case-insensitive), throwing a FirebaseError otherwise. */ function parseBool(pathStr: string, value: string): boolean { - if (value !== "true" && value !== "false") { + const normalized = value.toLowerCase(); + if (normalized !== "true" && normalized !== "false") { throw new FirebaseError(`Value for ${clc.bold(pathStr)} must be 'true' or 'false'.`); } - return value === "true"; + return normalized === "true"; } // The parsed, validated change to apply: the partial Config, its updateMask, and diff --git a/src/commands/index.ts b/src/commands/index.ts index 7e529aab794..41f5fc612a9 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -232,12 +232,11 @@ export function load(client: CLIClient): CLIClient { client.ailogic.providers.enable = loadCommand("ailogic-providers-enable"); client.ailogic.providers.disable = loadCommand("ailogic-providers-disable"); client.ailogic.providers.list = loadCommand("ailogic-providers-list"); + client.ailogic.config = {}; + client.ailogic.config.get = loadCommand("ailogic-config-get"); + client.ailogic.config.set = loadCommand("ailogic-config-set"); } - client.ailogic.config = {}; - client.ailogic.config.get = loadCommand("ailogic-config-get"); - client.ailogic.config.set = loadCommand("ailogic-config-set"); - client.login = loadCommand("login"); client.login.add = loadCommand("login-add"); client.login.ci = loadCommand("login-ci"); diff --git a/src/gcp/ailogic.ts b/src/gcp/ailogic.ts index 55fd5ee7da2..00691c3638d 100644 --- a/src/gcp/ailogic.ts +++ b/src/gcp/ailogic.ts @@ -378,24 +378,22 @@ export async function disableProvider( * Lists which Gemini API providers are enabled, derived from underlying API enablement state. */ export async function listProviders(projectId: string): Promise { - const enabled: ProviderType[] = []; + // The two enablement checks are independent, so run them in parallel to keep + // read commands (providers:list, config:get) responsive on a cold cache. + const [isDeveloperEnabled, isVertexEnabled] = await Promise.all([ + ensureApiEnabled.check( + projectId, + "generativelanguage.googleapis.com", + AILOGIC_LOGGING_PREFIX, + true, + ), + ensureApiEnabled.check(projectId, "aiplatform.googleapis.com", AILOGIC_LOGGING_PREFIX, true), + ]); - const isDeveloperEnabled = await ensureApiEnabled.check( - projectId, - "generativelanguage.googleapis.com", - AILOGIC_LOGGING_PREFIX, - true, - ); + const enabled: ProviderType[] = []; if (isDeveloperEnabled) { enabled.push("gemini-developer-api"); } - - const isVertexEnabled = await ensureApiEnabled.check( - projectId, - "aiplatform.googleapis.com", - AILOGIC_LOGGING_PREFIX, - true, - ); // aiplatform.googleapis.com cannot be enabled without billing (the Blaze plan), // so an enabled Vertex API already implies the agent-platform provider is available. if (isVertexEnabled) { From 86e99a052e5b23edf6fe69e9dc787c70e1e9dbe7 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Wed, 22 Jul 2026 22:35:50 -0700 Subject: [PATCH 08/21] refactor(ailogic): reuse shared config helpers and remove unused code - Remove the unused security-rules layer (generateRulesContent, getSecurityRules, updateSecurityRules, the rules import, and their tests); nothing references it. - Single source for config paths: WRITABLE_CONFIG_PATHS exported from gcp/ailogic, READABLE derived from it; shared assertKnownConfigPath error helper; shared samplingRateToPercent/percentToSamplingRate codec (both directions were hand-coded in each command). - config:get validates the path before any API call and only checks provider enablement when the requested path needs it. - config:set: buildUpdate is now the single per-path decision site (folds in the confirmation message and current-value read), the switch is exhaustive so a new path cannot silently fall into the sample-rate branch, the percentage requires a plain decimal integer (Number() also accepted hex and scientific notation), and the --json result echoes the normalized value. - ensureAILogicApiEnabled reuses isAILogicApiEnabled instead of inlining the check. --- src/commands/ailogic-config-get.spec.ts | 25 ++++- src/commands/ailogic-config-get.ts | 48 ++++---- src/commands/ailogic-config-set.spec.ts | 11 +- src/commands/ailogic-config-set.ts | 140 ++++++++++++------------ src/gcp/ailogic.spec.ts | 69 ------------ src/gcp/ailogic.ts | 103 ++++++----------- 6 files changed, 157 insertions(+), 239 deletions(-) diff --git a/src/commands/ailogic-config-get.spec.ts b/src/commands/ailogic-config-get.spec.ts index 23725f8afd6..9566afa478d 100644 --- a/src/commands/ailogic-config-get.spec.ts +++ b/src/commands/ailogic-config-get.spec.ts @@ -9,12 +9,16 @@ import { FirebaseError } from "../error"; const PROJECT_ID = "test-project"; describe("ailogic:config:get", () => { + let enabledStub: sinon.SinonStub; + let listProvidersStub: sinon.SinonStub; + let getConfigStub: sinon.SinonStub; + beforeEach(() => { (command as unknown as { befores: unknown[] }).befores = []; // bypass pre-action hooks sinon.stub(projectUtils, "needProjectId").returns(PROJECT_ID); - sinon.stub(ailogic, "isAILogicApiEnabled").resolves(true); - sinon.stub(ailogic, "listProviders").resolves(["gemini-developer-api"]); - sinon.stub(ailogic, "getConfig").resolves({ + enabledStub = sinon.stub(ailogic, "isAILogicApiEnabled").resolves(true); + listProvidersStub = sinon.stub(ailogic, "listProviders").resolves(["gemini-developer-api"]); + getConfigStub = sinon.stub(ailogic, "getConfig").resolves({ name: "config", trafficFilter: { firebaseAuthRequired: true, templateOnly: false }, telemetryConfig: { mode: "ALL", samplingRate: 0.5 }, @@ -45,15 +49,26 @@ describe("ailogic:config:get", () => { }); }); - it("throws on an unknown path", async () => { + it("only checks provider enablement when the path needs it", async () => { + await command.runner()("security.auth-only", { project: PROJECT_ID }); + expect(listProvidersStub).to.not.have.been.called; + + await command.runner()("providers.gemini-developer-api", { project: PROJECT_ID }); + expect(listProvidersStub).to.have.been.calledOnce; + }); + + it("throws on an unknown path before making any API calls", async () => { await expect(command.runner()("security.authonly", { project: PROJECT_ID })).to.be.rejectedWith( FirebaseError, /Unknown configuration path/, ); + expect(enabledStub).to.not.have.been.called; + expect(getConfigStub).to.not.have.been.called; + expect(listProvidersStub).to.not.have.been.called; }); it("returns early when AI Logic is not enabled", async () => { - (ailogic.isAILogicApiEnabled as sinon.SinonStub).resolves(false); + enabledStub.resolves(false); expect(await command.runner()(undefined, { project: PROJECT_ID })).to.be.undefined; }); }); diff --git a/src/commands/ailogic-config-get.ts b/src/commands/ailogic-config-get.ts index 292afd92a13..5300478f8b4 100644 --- a/src/commands/ailogic-config-get.ts +++ b/src/commands/ailogic-config-get.ts @@ -3,22 +3,18 @@ import { requirePermissions } from "../requirePermissions"; import { needProjectId } from "../projectUtils"; import * as ailogic from "../gcp/ailogic"; import { logger } from "../logger"; -import { FirebaseError } from "../error"; import { Options } from "../options"; -// Developer-facing config paths that `config:get` can read. Provider sub-paths are -// derived from the canonical provider list so they stay in sync. Used both to -// validate the requested path and to list the valid paths in the error message. +// Everything `config:get` can read: the writable paths plus their group prefixes +// and the read-only provider status derived from API enablement. const READABLE_CONFIG_PATHS = [ "providers", ...ailogic.PROVIDER_TYPES.map((p) => `providers.${p}`), "security", - "security.auth-only", - "security.template-only", + ...ailogic.WRITABLE_CONFIG_PATHS.filter((p) => p.startsWith("security.")), "monitoring", - "monitoring.state", - "monitoring.sample-rate-percentage", + ...ailogic.WRITABLE_CONFIG_PATHS.filter((p) => p.startsWith("monitoring.")), ]; function isRecord(value: unknown): value is Record { @@ -31,31 +27,38 @@ export const command = new Command("ailogic:config:get [path]") .action(async (path: string | undefined, options: Options) => { const projectId = needProjectId(options); + // Validate the path up front so bad input fails fast, before any API calls. + if (path) { + ailogic.assertKnownConfigPath(path, READABLE_CONFIG_PATHS); + } + if (!(await ailogic.isAILogicApiEnabled(projectId))) { logger.info("Firebase AI Logic is not enabled on this project."); return; } const config = await ailogic.getConfig(projectId); - const authOnly = config.trafficFilter?.firebaseAuthRequired ?? false; - const templateOnly = config.trafficFilter?.templateOnly ?? false; const monitoringState = config.telemetryConfig?.mode === "ALL"; - // The API stores the sampling rate as a fraction in (0,1]; the CLI exposes it - // as an integer percentage (1-100). + // An unset samplingRate is displayed as 100% (full sampling). const sampleRatePercent = config.telemetryConfig?.samplingRate !== undefined - ? Math.round(config.telemetryConfig.samplingRate * 100) + ? ailogic.samplingRateToPercent(config.telemetryConfig.samplingRate) : 100; - const enabledProviders = await ailogic.listProviders(projectId); + // Provider status needs extra Service Usage checks, so fetch it only when the + // requested path is under `providers` (or the whole config was requested). + const needsProviders = !path || path === "providers" || path.startsWith("providers."); + const enabledProviders = needsProviders ? await ailogic.listProviders(projectId) : []; const configObj = { - providers: Object.fromEntries( - ailogic.PROVIDER_TYPES.map((p) => [p, enabledProviders.includes(p)]), - ), + ...(needsProviders && { + providers: Object.fromEntries( + ailogic.PROVIDER_TYPES.map((p) => [p, enabledProviders.includes(p)]), + ), + }), security: { - "auth-only": authOnly, - "template-only": templateOnly, + "auth-only": config.trafficFilter?.firebaseAuthRequired ?? false, + "template-only": config.trafficFilter?.templateOnly ?? false, }, monitoring: { state: monitoringState, @@ -68,13 +71,6 @@ export const command = new Command("ailogic:config:get [path]") return configObj; } - if (!READABLE_CONFIG_PATHS.includes(path)) { - throw new FirebaseError( - `Unknown configuration path: ${path}\n\nValid paths:\n\n` + - READABLE_CONFIG_PATHS.map((p) => ` ${p}`).join("\n"), - ); - } - let val: unknown = configObj; for (const part of path.split(".")) { if (!isRecord(val)) { diff --git a/src/commands/ailogic-config-set.spec.ts b/src/commands/ailogic-config-set.spec.ts index 68f8162a6bf..39033512eb5 100644 --- a/src/commands/ailogic-config-set.spec.ts +++ b/src/commands/ailogic-config-set.spec.ts @@ -138,10 +138,19 @@ describe("ailogic:config:set", () => { }); it("rejects an out-of-range or non-integer sample rate", async () => { - for (const bad of ["0", "101", "1.5", "abc"]) { + // "1e2", "0x32", and " 50 " all coerce to valid integers via Number(), so the + // strict decimal check must reject them too. + for (const bad of ["0", "101", "1.5", "abc", "1e2", "0x32", " 50 ", "50%"]) { await expect( command.runner()("monitoring.sample-rate-percentage", bad, { project: PROJECT_ID }), ).to.be.rejectedWith(FirebaseError, /integer in the range 1-100/); } + expect(updateStub).to.not.have.been.called; + }); + + it("returns the normalized value for --json output", async () => { + expect( + await command.runner()("monitoring.state", "TRUE", { project: PROJECT_ID }), + ).to.deep.equal({ path: "monitoring.state", value: "true" }); }); }); diff --git a/src/commands/ailogic-config-set.ts b/src/commands/ailogic-config-set.ts index 4fc8416af52..051e4df023c 100644 --- a/src/commands/ailogic-config-set.ts +++ b/src/commands/ailogic-config-set.ts @@ -9,14 +9,6 @@ import { confirm } from "../prompt"; import { Options } from "../options"; -// Developer-facing config paths that `config:set` can write. -const WRITABLE_CONFIG_PATHS = [ - "security.auth-only", - "security.template-only", - "monitoring.state", - "monitoring.sample-rate-percentage", -]; - /** Parses a "true"/"false" flag value (case-insensitive), throwing a FirebaseError otherwise. */ function parseBool(pathStr: string, value: string): boolean { const normalized = value.toLowerCase(); @@ -26,57 +18,80 @@ function parseBool(pathStr: string, value: string): boolean { return normalized === "true"; } -// The parsed, validated change to apply: the partial Config, its updateMask, and -// whether this is a security tightening (true) that requires confirmation. +// The parsed, validated change to apply. `confirm` is present only when enabling +// the setting is client-breaking and therefore needs approval before writing. interface ConfigUpdate { config: Partial; updateMask: string; - securityTightening: boolean; + normalizedValue: string; + confirm?: { + message: string; + /** Whether the setting is already active, in which case no confirmation is needed. */ + isAlreadyEnabled(current: ailogic.Config): boolean; + }; } -/** Validates the path/value pair and builds the update, throwing on bad input. */ +/** + * Validates the path/value pair and builds the update, throwing on bad input. + * This is the single place that maps a CLI path to its resource field. + */ function buildUpdate(pathStr: string, value: string): ConfigUpdate { - if (pathStr === "security.auth-only" || pathStr === "security.template-only") { - const boolVal = parseBool(pathStr, value); - const isAuthOnly = pathStr === "security.auth-only"; - return { - config: { - trafficFilter: isAuthOnly ? { firebaseAuthRequired: boolVal } : { templateOnly: boolVal }, - }, - updateMask: isAuthOnly ? "trafficFilter.firebaseAuthRequired" : "trafficFilter.templateOnly", - securityTightening: boolVal, - }; - } - if (pathStr === "monitoring.state") { - const boolVal = parseBool(pathStr, value); - return { - config: { telemetryConfig: { mode: boolVal ? "ALL" : "NONE" } }, - updateMask: "telemetryConfig.mode", - securityTightening: false, - }; - } - // monitoring.sample-rate-percentage - const numVal = Number(value); - if (!Number.isInteger(numVal) || numVal < 1 || numVal > 100) { - throw new FirebaseError( - `Value for ${clc.bold(pathStr)} must be an integer in the range 1-100.`, - ); + switch (pathStr) { + case "security.auth-only": { + const boolVal = parseBool(pathStr, value); + return { + config: { trafficFilter: { firebaseAuthRequired: boolVal } }, + updateMask: "trafficFilter.firebaseAuthRequired", + normalizedValue: String(boolVal), + confirm: boolVal + ? { + message: `Enabling ${clc.bold(pathStr)} will reject requests from unauthenticated users. Continue?`, + isAlreadyEnabled: (current) => current.trafficFilter?.firebaseAuthRequired ?? false, + } + : undefined, + }; + } + case "security.template-only": { + const boolVal = parseBool(pathStr, value); + return { + config: { trafficFilter: { templateOnly: boolVal } }, + updateMask: "trafficFilter.templateOnly", + normalizedValue: String(boolVal), + confirm: boolVal + ? { + message: `Enabling ${clc.bold(pathStr)} will reject requests not using templates. Continue?`, + isAlreadyEnabled: (current) => current.trafficFilter?.templateOnly ?? false, + } + : undefined, + }; + } + case "monitoring.state": { + const boolVal = parseBool(pathStr, value); + return { + config: { telemetryConfig: { mode: boolVal ? "ALL" : "NONE" } }, + updateMask: "telemetryConfig.mode", + normalizedValue: String(boolVal), + }; + } + case "monitoring.sample-rate-percentage": { + // Require a plain decimal integer; Number() alone would also accept + // hex ("0x32"), scientific notation ("1e2"), and decimals ("50.0"). + if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 100) { + throw new FirebaseError( + `Value for ${clc.bold(pathStr)} must be an integer in the range 1-100.`, + ); + } + return { + config: { telemetryConfig: { samplingRate: ailogic.percentToSamplingRate(Number(value)) } }, + updateMask: "telemetryConfig.samplingRate", + normalizedValue: value, + }; + } + default: + // Unreachable behind assertKnownConfigPath; kept exhaustive so a newly added + // writable path cannot silently fall into the wrong branch. + throw new FirebaseError(`Unknown configuration path: ${pathStr}`); } - // The API stores the sampling rate as a fraction in (0,1]; the CLI accepts 1-100 percent. - return { - config: { telemetryConfig: { samplingRate: numVal / 100 } }, - updateMask: "telemetryConfig.samplingRate", - securityTightening: false, - }; -} - -/** Whether tightening `pathStr` to `true` changes it from a currently-false value. */ -function isTightening(pathStr: string, current: ailogic.Config): boolean { - const currentVal = - pathStr === "security.auth-only" - ? current.trafficFilter?.firebaseAuthRequired - : current.trafficFilter?.templateOnly; - return !(currentVal ?? false); } export const command = new Command("ailogic:config:set ") @@ -88,25 +103,16 @@ export const command = new Command("ailogic:config:set ") // Validate the path and value up front so bad input fails fast, before the // API-enablement flow. - if (!WRITABLE_CONFIG_PATHS.includes(pathStr)) { - throw new FirebaseError( - `Unknown configuration path: ${pathStr}\n\nValid paths:\n\n` + - WRITABLE_CONFIG_PATHS.map((p) => ` ${p}`).join("\n"), - ); - } + ailogic.assertKnownConfigPath(pathStr, ailogic.WRITABLE_CONFIG_PATHS); const update = buildUpdate(pathStr, value); await ailogic.ensureAILogicApiEnabled(projectId, options); - // Tightening a security setting from false to true is client-breaking, so confirm first. - if (update.securityTightening && isTightening(pathStr, await ailogic.getConfig(projectId))) { - const rejectMsg = - pathStr === "security.auth-only" - ? "reject requests from unauthenticated users" - : "reject requests not using templates"; + // Tightening a security setting from off to on is client-breaking, so confirm first. + if (update.confirm && !update.confirm.isAlreadyEnabled(await ailogic.getConfig(projectId))) { // confirm() aborts in non-interactive mode unless --force is set. const confirmed = await confirm({ - message: `Enabling ${clc.bold(pathStr)} will ${rejectMsg}. Continue?`, + message: update.confirm.message, force: options.force, nonInteractive: options.nonInteractive, }); @@ -116,6 +122,6 @@ export const command = new Command("ailogic:config:set ") } await ailogic.updateConfig(projectId, update.config, [update.updateMask]); - utils.logSuccess(`Updated ${clc.bold(pathStr)} = ${value}`); - return { path: pathStr, value }; + utils.logSuccess(`Updated ${clc.bold(pathStr)} = ${update.normalizedValue}`); + return { path: pathStr, value: update.normalizedValue }; }); diff --git a/src/gcp/ailogic.spec.ts b/src/gcp/ailogic.spec.ts index 54945919c49..38e67fd6d35 100644 --- a/src/gcp/ailogic.spec.ts +++ b/src/gcp/ailogic.spec.ts @@ -3,7 +3,6 @@ import * as sinon from "sinon"; import * as ailogic from "./ailogic"; import * as ensureApiEnabled from "../ensureApiEnabled"; import * as serviceUsage from "./serviceusage"; -import * as rules from "./rules"; import * as cloudbilling from "./cloudbilling"; import { AI_LOGIC_BEFORE_GENERATE_CONTENT, @@ -344,72 +343,4 @@ describe("ailogic", () => { expect(ailogic.isProviderType("nope")).to.be.false; }); }); - - describe("securityRules", () => { - let listReleasesStub: sinon.SinonStub; - let getLatestRulesetNameStub: sinon.SinonStub; - let getRulesetContentStub: sinon.SinonStub; - let createRulesetStub: sinon.SinonStub; - let updateOrCreateReleaseStub: sinon.SinonStub; - - beforeEach(() => { - listReleasesStub = sinon.stub(rules, "listAllReleases"); - getLatestRulesetNameStub = sinon.stub(rules, "getLatestRulesetName"); - getRulesetContentStub = sinon.stub(rules, "getRulesetContent"); - createRulesetStub = sinon.stub(rules, "createRuleset"); - updateOrCreateReleaseStub = sinon.stub(rules, "updateOrCreateRelease"); - }); - - afterEach(() => { - listReleasesStub.restore(); - getLatestRulesetNameStub.restore(); - getRulesetContentStub.restore(); - createRulesetStub.restore(); - updateOrCreateReleaseStub.restore(); - }); - - it("should get rules and parse authOnly and templateOnly", async () => { - listReleasesStub.resolves([]); - getLatestRulesetNameStub.resolves("ruleset-name"); - getRulesetContentStub.resolves([ - { - name: "vertexai.rules", - content: `rules_version = '2'; -service firebase.vertexai { - match /projects/{project}/locations/{location} { - match /templates/{template} { - allow read: if request.auth != null; - } - match /models/{model} { - allow read: if false; - } - } -}`, - }, - ]); - - const config = await ailogic.getSecurityRules("my-project"); - - expect(config).to.deep.equal({ authOnly: true, templateOnly: true }); - }); - - it("should deploy rules with generateRulesContent", async () => { - createRulesetStub.resolves("new-ruleset"); - updateOrCreateReleaseStub.resolves("release-name"); - - await ailogic.updateSecurityRules("my-project", true, false); - - expect(createRulesetStub).to.have.been.calledWith("my-project", [ - { - name: "vertexai.rules", - content: ailogic.generateRulesContent(true, false), - }, - ]); - expect(updateOrCreateReleaseStub).to.have.been.calledWith( - "my-project", - "new-ruleset", - "firebase.vertexai", - ); - }); - }); }); diff --git a/src/gcp/ailogic.ts b/src/gcp/ailogic.ts index 00691c3638d..8f7f48a59e4 100644 --- a/src/gcp/ailogic.ts +++ b/src/gcp/ailogic.ts @@ -5,7 +5,6 @@ import type { AILogicEndpoint } from "../deploy/functions/services/ailogic"; import { FirebaseError, getErrStatus } from "../error"; import * as ensureApiEnabled from "../ensureApiEnabled"; import * as serviceUsage from "./serviceusage"; -import * as rules from "./rules"; import { bold } from "colorette"; import * as cloudbilling from "./cloudbilling"; import * as iam from "./iam"; @@ -243,6 +242,37 @@ export interface Config { telemetryConfig?: TelemetryConfig; } +// Developer-facing config paths that `ailogic:config:set` can write. +export const WRITABLE_CONFIG_PATHS = [ + "security.auth-only", + "security.template-only", + "monitoring.state", + "monitoring.sample-rate-percentage", +]; + +/** Throws a FirebaseError listing the valid paths when `path` is not one of them. */ +export function assertKnownConfigPath(path: string, validPaths: string[]): void { + if (!validPaths.includes(path)) { + throw new FirebaseError( + `Unknown configuration path: ${path}\n\nValid paths:\n\n` + + validPaths.map((p) => ` ${p}`).join("\n"), + ); + } +} + +/** + * Converts the API's telemetry sampling rate, a fraction in (0,1], to the + * integer percentage (1-100) the CLI exposes. + */ +export function samplingRateToPercent(samplingRate: number): number { + return Math.round(samplingRate * 100); +} + +/** Converts a CLI integer percentage (1-100) to the API's (0,1] sampling-rate fraction. */ +export function percentToSamplingRate(percent: number): number { + return percent / 100; +} + export type ProviderType = "gemini-developer-api" | "gemini-agent-platform-api"; export const PROVIDER_TYPES: ProviderType[] = ["gemini-developer-api", "gemini-agent-platform-api"]; @@ -403,69 +433,6 @@ export async function listProviders(projectId: string): Promise return enabled; } -/** - * - */ -export function generateRulesContent(authOnly: boolean, templateOnly: boolean): string { - const condition = authOnly ? "request.auth != null" : "true"; - - return `rules_version = '2'; -service firebase.vertexai { - match /projects/{project}/locations/{location} { - match /templates/{template} { - allow read: if ${condition}; - } - match /models/{model} { - allow read: if ${templateOnly ? "false" : condition}; - } - } -}`; -} - -export interface SecurityRulesConfig { - authOnly: boolean; - templateOnly: boolean; -} - -/** - * Gets security rules settings by fetching and parsing the active release. - */ -export async function getSecurityRules(projectId: string): Promise { - const releases = await rules.listAllReleases(projectId); - const rulesetName = await rules.getLatestRulesetName(projectId, "firebase.vertexai", releases); - if (!rulesetName) { - return { authOnly: false, templateOnly: false }; - } - const files = await rules.getRulesetContent(rulesetName); - const vertexFile = files.find((f) => f.name === "vertexai.rules"); - if (!vertexFile) { - return { authOnly: false, templateOnly: false }; - } - const content = vertexFile.content; - const authOnly = content.includes("request.auth != null"); - const templateOnly = content.includes("allow read: if false"); - return { authOnly, templateOnly }; -} - -/** - * Deploys new security rules. - */ -export async function updateSecurityRules( - projectId: string, - authOnly: boolean, - templateOnly: boolean, -): Promise { - const content = generateRulesContent(authOnly, templateOnly); - const files = [ - { - name: "vertexai.rules", - content, - }, - ]; - const rulesetName = await rules.createRuleset(projectId, files); - await rules.updateOrCreateRelease(projectId, rulesetName, "firebase.vertexai"); -} - /** * Returns whether the Firebase AI Logic API is enabled on the project, without prompting to enable it. * Read-only commands use this to report state instead of forcing enablement. @@ -488,13 +455,7 @@ export async function ensureAILogicApiEnabled( projectId: string, options: { nonInteractive?: boolean; force?: boolean }, ): Promise { - const isEnabled = await ensureApiEnabled.check( - projectId, - "firebasevertexai.googleapis.com", - AILOGIC_LOGGING_PREFIX, - true, - ); - if (isEnabled) { + if (await isAILogicApiEnabled(projectId)) { return; } From dd1d94ef21eb0e04bf8d8032b5d3e77166df0094 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Wed, 22 Jul 2026 23:28:27 -0700 Subject: [PATCH 09/21] fix(ailogic): address bug-hunt findings on config commands - config:set preflights serviceusage.services.get (ensureAILogicApiEnabled reads enablement state via Service Usage; without it a cold cache surfaced a raw 403 mid-command despite passing requirePermissions). - Echo a normalized sample-rate value ('007' -> '7') so the success message and --json output match what was stored. - ensureAILogicApiEnabled honors --force for its enable confirmation and the provider selection offers a cancel choice (the Spark-plan retry loop had no exit besides Ctrl+C). - Strengthen provider specs: pin each provider to its own API via withArgs (a swapped destructure passed before) and assert the disable cross-check consults the other provider's API. --- src/commands/ailogic-config-set.spec.ts | 11 +++++++++++ src/commands/ailogic-config-set.ts | 10 ++++++++-- src/gcp/ailogic.spec.ts | 12 ++++++++++++ src/gcp/ailogic.ts | 8 +++++++- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/commands/ailogic-config-set.spec.ts b/src/commands/ailogic-config-set.spec.ts index 39033512eb5..0b24602aea9 100644 --- a/src/commands/ailogic-config-set.spec.ts +++ b/src/commands/ailogic-config-set.spec.ts @@ -148,6 +148,17 @@ describe("ailogic:config:set", () => { expect(updateStub).to.not.have.been.called; }); + it("normalizes a zero-padded sample rate in the echoed value", async () => { + expect( + await command.runner()("monitoring.sample-rate-percentage", "007", { project: PROJECT_ID }), + ).to.deep.equal({ path: "monitoring.sample-rate-percentage", value: "7" }); + expect(updateStub).to.have.been.calledWith( + PROJECT_ID, + { telemetryConfig: { samplingRate: 0.07 } }, + ["telemetryConfig.samplingRate"], + ); + }); + it("returns the normalized value for --json output", async () => { expect( await command.runner()("monitoring.state", "TRUE", { project: PROJECT_ID }), diff --git a/src/commands/ailogic-config-set.ts b/src/commands/ailogic-config-set.ts index 051e4df023c..c13bb829ccd 100644 --- a/src/commands/ailogic-config-set.ts +++ b/src/commands/ailogic-config-set.ts @@ -84,7 +84,8 @@ function buildUpdate(pathStr: string, value: string): ConfigUpdate { return { config: { telemetryConfig: { samplingRate: ailogic.percentToSamplingRate(Number(value)) } }, updateMask: "telemetryConfig.samplingRate", - normalizedValue: value, + // Number() drops leading zeros so the echo matches what was stored ("007" -> "7"). + normalizedValue: String(Number(value)), }; } default: @@ -97,7 +98,12 @@ function buildUpdate(pathStr: string, value: string): ConfigUpdate { export const command = new Command("ailogic:config:set ") .description("set one configuration value") .option("-f, --force", "bypass confirmation prompt") - .before(requirePermissions, ["firebasevertexai.config.update", "firebasevertexai.config.get"]) + .before(requirePermissions, [ + "firebasevertexai.config.update", + "firebasevertexai.config.get", + // ensureAILogicApiEnabled reads API enablement state via Service Usage. + "serviceusage.services.get", + ]) .action(async (pathStr: string, value: string, options: Options) => { const projectId = needProjectId(options); diff --git a/src/gcp/ailogic.spec.ts b/src/gcp/ailogic.spec.ts index 38e67fd6d35..5d266920ba0 100644 --- a/src/gcp/ailogic.spec.ts +++ b/src/gcp/ailogic.spec.ts @@ -286,6 +286,8 @@ describe("ailogic", () => { await ailogic.disableProvider("my-project", "gemini-developer-api"); + // The cross-check must consult the OTHER provider's API. + expect(checkStub).to.have.been.calledWith("my-project", "aiplatform.googleapis.com"); expect(disableStub).to.have.been.calledTwice; expect(disableStub.firstCall).to.have.been.calledWith( "my-project", @@ -321,6 +323,16 @@ describe("ailogic", () => { expect(enabled).to.deep.equal(["gemini-developer-api", "gemini-agent-platform-api"]); }); + + it("should map each provider to its own API enablement state", async () => { + // Pin per-API results so a swapped destructure/check cannot pass. + checkStub.withArgs("my-project", "generativelanguage.googleapis.com").resolves(true); + checkStub.withArgs("my-project", "aiplatform.googleapis.com").resolves(false); + + const enabled = await ailogic.listProviders("my-project"); + + expect(enabled).to.deep.equal(["gemini-developer-api"]); + }); }); describe("parseProviderType", () => { diff --git a/src/gcp/ailogic.ts b/src/gcp/ailogic.ts index 8f7f48a59e4..a923ff932c6 100644 --- a/src/gcp/ailogic.ts +++ b/src/gcp/ailogic.ts @@ -487,13 +487,15 @@ export async function ensureAILogicApiEnabled( const proceed = await confirm({ message: "Would you like to enable it now?", default: true, + force: options.force, }); if (!proceed) { throw new FirebaseError("Command aborted.", { exit: 1 }); } for (;;) { - const provider = await select({ + // "cancel" gives the Spark-plan retry loop below an exit that is not Ctrl+C. + const provider = await select({ message: "Which Gemini API provider do you want to enable?", choices: [ { name: "gemini-developer-api", value: "gemini-developer-api" }, @@ -501,8 +503,12 @@ export async function ensureAILogicApiEnabled( name: "gemini-agent-platform-api (requires the Blaze plan)", value: "gemini-agent-platform-api", }, + { name: "cancel", value: "cancel" }, ], }); + if (provider === "cancel") { + throw new FirebaseError("Command aborted.", { exit: 1 }); + } if (provider === "gemini-agent-platform-api") { const billingEnabled = await cloudbilling.checkBillingEnabled(projectId); From 3e9f37650c2347e70fbbe3973d90e23215014ffd Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Sun, 26 Jul 2026 21:26:48 -0700 Subject: [PATCH 10/21] fix(ailogic): address review nits from joehan - Add detailed .help() text to ailogic:config:get/set and ailogic:providers:enable/disable documenting allowed values - Drop CHANGELOG entries while the feature is behind the experiment flag - Remove empty JSDoc blocks in gcp/ailogic.ts - Inline single-use sampling-rate conversions instead of exporting helpers --- CHANGELOG.md | 2 -- src/commands/ailogic-config-get.ts | 16 ++++++++++++++-- src/commands/ailogic-config-set.ts | 17 ++++++++++++++++- src/commands/ailogic-providers-disable.ts | 12 ++++++++++++ src/commands/ailogic-providers-enable.ts | 12 ++++++++++++ src/gcp/ailogic.ts | 19 ------------------- 6 files changed, 54 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c326c62b94..ee6645704df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,3 @@ -- Added `firebase ailogic:providers:*` CLI commands to enable, disable, and list Gemini API providers. -- Added `firebase ailogic:config:*` CLI commands to read and modify AI Logic configuration settings. - Add `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` HTTP headers to `OneMcpServer` requests per the MCP 0728 standard release candidate (https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/ and https://modelcontextprotocol.io/seps/2243-http-standardization). - Fixes Storage Emulator to support JSON uploads larger than 100KB without hanging or throwing 413 error (#8355) - Add `extdeprecationwarnings` experiment to display phased deprecation notices and guidance across `ext:*` CLI commands. diff --git a/src/commands/ailogic-config-get.ts b/src/commands/ailogic-config-get.ts index 5300478f8b4..a64be838a5e 100644 --- a/src/commands/ailogic-config-get.ts +++ b/src/commands/ailogic-config-get.ts @@ -23,6 +23,17 @@ function isRecord(value: unknown): value is Record { export const command = new Command("ailogic:config:get [path]") .description("read AI Logic configuration") + .help( + `prints the full AI Logic configuration for the active project as JSON. If [path] is given, prints only that section or value. + +Valid values for [path]: + +${READABLE_CONFIG_PATHS.map((p) => ` ${p}`).join("\n")} + +For example, to check whether requests are restricted to authenticated users: + + firebase ailogic:config:get security.auth-only`, + ) .before(requirePermissions, ["firebasevertexai.config.get", "serviceusage.services.get"]) .action(async (path: string | undefined, options: Options) => { const projectId = needProjectId(options); @@ -39,10 +50,11 @@ export const command = new Command("ailogic:config:get [path]") const config = await ailogic.getConfig(projectId); const monitoringState = config.telemetryConfig?.mode === "ALL"; - // An unset samplingRate is displayed as 100% (full sampling). + // The API stores samplingRate as a fraction in (0,1]; the CLI displays an + // integer percentage. An unset samplingRate is displayed as 100% (full sampling). const sampleRatePercent = config.telemetryConfig?.samplingRate !== undefined - ? ailogic.samplingRateToPercent(config.telemetryConfig.samplingRate) + ? Math.round(config.telemetryConfig.samplingRate * 100) : 100; // Provider status needs extra Service Usage checks, so fetch it only when the diff --git a/src/commands/ailogic-config-set.ts b/src/commands/ailogic-config-set.ts index c13bb829ccd..c0c8a962863 100644 --- a/src/commands/ailogic-config-set.ts +++ b/src/commands/ailogic-config-set.ts @@ -82,7 +82,8 @@ function buildUpdate(pathStr: string, value: string): ConfigUpdate { ); } return { - config: { telemetryConfig: { samplingRate: ailogic.percentToSamplingRate(Number(value)) } }, + // The API stores samplingRate as a fraction in (0,1]. + config: { telemetryConfig: { samplingRate: Number(value) / 100 } }, updateMask: "telemetryConfig.samplingRate", // Number() drops leading zeros so the echo matches what was stored ("007" -> "7"). normalizedValue: String(Number(value)), @@ -97,6 +98,20 @@ function buildUpdate(pathStr: string, value: string): ConfigUpdate { export const command = new Command("ailogic:config:set ") .description("set one configuration value") + .help( + `sets one AI Logic configuration value on the active project. + +Valid values for , and the each accepts: + + security.auth-only true|false - only allow requests from authenticated users + security.template-only true|false - only allow requests that use a server template + monitoring.state true|false - turn AI monitoring on or off + monitoring.sample-rate-percentage 1-100 - percentage of requests sampled for monitoring + +For example, to only allow requests from authenticated users: + + firebase ailogic:config:set security.auth-only true`, + ) .option("-f, --force", "bypass confirmation prompt") .before(requirePermissions, [ "firebasevertexai.config.update", diff --git a/src/commands/ailogic-providers-disable.ts b/src/commands/ailogic-providers-disable.ts index 6a4e81e399f..549c8f45e3a 100644 --- a/src/commands/ailogic-providers-disable.ts +++ b/src/commands/ailogic-providers-disable.ts @@ -11,6 +11,18 @@ import { Options } from "../options"; export const command = new Command("ailogic:providers:disable ") .description("disable a Gemini API provider service") + .help( + `disables the Google Cloud API that backs a Gemini API provider. Running apps that use the provider will no longer be able to invoke it. + +Valid values for : + + gemini-developer-api the Gemini Developer API + gemini-agent-platform-api the Gemini API on the Agent Platform + +For example: + + firebase ailogic:providers:disable gemini-developer-api`, + ) .option("-f, --force", "bypass confirmation prompt") .before(requirePermissions, ["serviceusage.services.disable", "firebasevertexai.config.update"]) .action(async (providerType: string, options: Options) => { diff --git a/src/commands/ailogic-providers-enable.ts b/src/commands/ailogic-providers-enable.ts index 45b14600813..a0a3a6a25e2 100644 --- a/src/commands/ailogic-providers-enable.ts +++ b/src/commands/ailogic-providers-enable.ts @@ -9,6 +9,18 @@ import { Options } from "../options"; export const command = new Command("ailogic:providers:enable ") .description("enable a Gemini API provider service") + .help( + `enables the Google Cloud APIs that back a Gemini API provider, along with the Firebase AI Logic API. + +Valid values for : + + gemini-developer-api the Gemini Developer API + gemini-agent-platform-api the Gemini API on the Agent Platform; requires the Blaze (pay-as-you-go) plan + +For example: + + firebase ailogic:providers:enable gemini-developer-api`, + ) .before(requirePermissions, ["serviceusage.services.enable", "firebasevertexai.config.update"]) .action(async (providerType: string, options: Options) => { const projectId = needProjectId(options); diff --git a/src/gcp/ailogic.ts b/src/gcp/ailogic.ts index a923ff932c6..0bd76a1edc2 100644 --- a/src/gcp/ailogic.ts +++ b/src/gcp/ailogic.ts @@ -183,9 +183,6 @@ export async function listTriggers( return triggers; } -/** - * - */ export async function upsertBlockingFunction(endpoint: AILogicEndpoint): Promise { const eventType = endpoint.blockingTrigger.eventType; const triggerId = AI_LOGIC_EVENTS_TO_TRIGGER[eventType]; @@ -210,9 +207,6 @@ export async function upsertBlockingFunction(endpoint: AILogicEndpoint): Promise } } -/** - * - */ export async function deleteBlockingFunction(endpoint: AILogicEndpoint): Promise { const eventType = endpoint.blockingTrigger.eventType; const triggerId = AI_LOGIC_EVENTS_TO_TRIGGER[eventType]; @@ -260,19 +254,6 @@ export function assertKnownConfigPath(path: string, validPaths: string[]): void } } -/** - * Converts the API's telemetry sampling rate, a fraction in (0,1], to the - * integer percentage (1-100) the CLI exposes. - */ -export function samplingRateToPercent(samplingRate: number): number { - return Math.round(samplingRate * 100); -} - -/** Converts a CLI integer percentage (1-100) to the API's (0,1] sampling-rate fraction. */ -export function percentToSamplingRate(percent: number): number { - return percent / 100; -} - export type ProviderType = "gemini-developer-api" | "gemini-agent-platform-api"; export const PROVIDER_TYPES: ProviderType[] = ["gemini-developer-api", "gemini-agent-platform-api"]; From 5187f7a4cb72621ca70077bb9311774002dd2f45 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Sun, 26 Jul 2026 21:49:37 -0700 Subject: [PATCH 11/21] fix(ailogic): keep provider state consistent with the AI Logic API - listProviders now reports a provider as enabled only when the Firebase AI Logic API (firebasevertexai.googleapis.com) is also enabled, so providers:list can no longer disagree with other ailogic commands about whether AI Logic is enabled on the project - enableProvider enables the AI Logic API before the provider's service API, so a partial failure cannot land in that inconsistent state --- src/gcp/ailogic.spec.ts | 37 ++++++++++++++++----- src/gcp/ailogic.ts | 73 +++++++++++++++++++++++------------------ 2 files changed, 70 insertions(+), 40 deletions(-) diff --git a/src/gcp/ailogic.spec.ts b/src/gcp/ailogic.spec.ts index 0b03921e8d3..258ac3560f1 100644 --- a/src/gcp/ailogic.spec.ts +++ b/src/gcp/ailogic.spec.ts @@ -169,25 +169,27 @@ describe("ailogic", () => { billingStub.restore(); }); - it("should enable gemini-developer-api", async () => { + it("should enable gemini-developer-api, enabling the AI Logic API first", async () => { ensureStub.resolves(); await ailogic.enableProvider("my-project", "gemini-developer-api"); expect(ensureStub).to.have.been.calledTwice; + // The AI Logic API must be enabled before the provider API so a partial + // failure cannot leave a provider on while AI Logic is off. expect(ensureStub.firstCall).to.have.been.calledWith( "my-project", - "generativelanguage.googleapis.com", + "firebasevertexai.googleapis.com", "ailogic", ); expect(ensureStub.secondCall).to.have.been.calledWith( "my-project", - "firebasevertexai.googleapis.com", + "generativelanguage.googleapis.com", "ailogic", ); }); - it("should enable gemini-agent-platform-api if billing is enabled", async () => { + it("should enable gemini-agent-platform-api if billing is enabled, enabling the AI Logic API first", async () => { ensureStub.resolves(); billingStub.resolves(true); @@ -196,12 +198,12 @@ describe("ailogic", () => { expect(ensureStub).to.have.been.calledTwice; expect(ensureStub.firstCall).to.have.been.calledWith( "my-project", - "aiplatform.googleapis.com", + "firebasevertexai.googleapis.com", "ailogic", ); expect(ensureStub.secondCall).to.have.been.calledWith( "my-project", - "firebasevertexai.googleapis.com", + "aiplatform.googleapis.com", "ailogic", ); }); @@ -251,13 +253,32 @@ describe("ailogic", () => { }); it("should list enabled providers", async () => { - checkStub.onFirstCall().resolves(true); // gemini-developer-api is enabled - checkStub.onSecondCall().resolves(true); // gemini-agent-platform-api API is enabled + checkStub + .withArgs("my-project", "firebasevertexai.googleapis.com", "ailogic", true) + .resolves(true); + checkStub + .withArgs("my-project", "generativelanguage.googleapis.com", "ailogic", true) + .resolves(true); + checkStub.withArgs("my-project", "aiplatform.googleapis.com", "ailogic", true).resolves(true); const enabled = await ailogic.listProviders("my-project"); expect(enabled).to.deep.equal(["gemini-developer-api", "gemini-agent-platform-api"]); }); + + it("should list no providers when the AI Logic API is disabled, even if provider APIs are enabled", async () => { + checkStub + .withArgs("my-project", "firebasevertexai.googleapis.com", "ailogic", true) + .resolves(false); + checkStub + .withArgs("my-project", "generativelanguage.googleapis.com", "ailogic", true) + .resolves(true); + checkStub.withArgs("my-project", "aiplatform.googleapis.com", "ailogic", true).resolves(true); + + const enabled = await ailogic.listProviders("my-project"); + + expect(enabled).to.deep.equal([]); + }); }); describe("parseProviderType", () => { diff --git a/src/gcp/ailogic.ts b/src/gcp/ailogic.ts index 5e48cede180..ad3376bfa68 100644 --- a/src/gcp/ailogic.ts +++ b/src/gcp/ailogic.ts @@ -240,18 +240,7 @@ export function parseProviderType(value: string): ProviderType { * Enables a Gemini API provider service. */ export async function enableProvider(projectId: string, providerType: ProviderType): Promise { - if (providerType === "gemini-developer-api") { - await ensureApiEnabled.ensure( - projectId, - "generativelanguage.googleapis.com", - AILOGIC_LOGGING_PREFIX, - ); - await ensureApiEnabled.ensure( - projectId, - "firebasevertexai.googleapis.com", - AILOGIC_LOGGING_PREFIX, - ); - } else if (providerType === "gemini-agent-platform-api") { + if (providerType === "gemini-agent-platform-api") { const billingEnabled = await cloudbilling.checkBillingEnabled(projectId); if (!billingEnabled) { throw new FirebaseError( @@ -260,13 +249,21 @@ export async function enableProvider(projectId: string, providerType: ProviderTy )} must be on the Blaze (pay-as-you-go) plan to enable the Agent Platform. To upgrade, visit the following URL:\n\nhttps://console.firebase.google.com/project/${projectId}/usage/details`, ); } - await ensureApiEnabled.ensure(projectId, "aiplatform.googleapis.com", AILOGIC_LOGGING_PREFIX); - await ensureApiEnabled.ensure( - projectId, - "firebasevertexai.googleapis.com", - AILOGIC_LOGGING_PREFIX, - ); } + + // Enable the AI Logic API first: a partial failure must not leave a provider's + // API enabled while the AI Logic API is off, a state where providers:list would + // report the provider as enabled but AI Logic itself is not usable. + await ensureApiEnabled.ensure( + projectId, + "firebasevertexai.googleapis.com", + AILOGIC_LOGGING_PREFIX, + ); + const providerApi = + providerType === "gemini-developer-api" + ? "generativelanguage.googleapis.com" + : "aiplatform.googleapis.com"; + await ensureApiEnabled.ensure(projectId, providerApi, AILOGIC_LOGGING_PREFIX); } /** @@ -324,24 +321,36 @@ export async function disableProvider( * Lists which Gemini API providers are enabled, derived from underlying API enablement state. */ export async function listProviders(projectId: string): Promise { - const enabled: ProviderType[] = []; + // The three enablement checks are independent, so run them in parallel to keep + // read commands responsive on a cold cache. + const [isAILogicEnabled, isDeveloperEnabled, isVertexEnabled] = await Promise.all([ + ensureApiEnabled.check( + projectId, + "firebasevertexai.googleapis.com", + AILOGIC_LOGGING_PREFIX, + true, + ), + ensureApiEnabled.check( + projectId, + "generativelanguage.googleapis.com", + AILOGIC_LOGGING_PREFIX, + true, + ), + ensureApiEnabled.check(projectId, "aiplatform.googleapis.com", AILOGIC_LOGGING_PREFIX, true), + ]); + + // A provider is only usable through AI Logic when the AI Logic API itself is + // enabled. Without this check, a project with (say) generativelanguage enabled + // outside the CLI would have its provider reported as enabled even though AI + // Logic is off. + if (!isAILogicEnabled) { + return []; + } - const isDeveloperEnabled = await ensureApiEnabled.check( - projectId, - "generativelanguage.googleapis.com", - AILOGIC_LOGGING_PREFIX, - true, - ); + const enabled: ProviderType[] = []; if (isDeveloperEnabled) { enabled.push("gemini-developer-api"); } - - const isVertexEnabled = await ensureApiEnabled.check( - projectId, - "aiplatform.googleapis.com", - AILOGIC_LOGGING_PREFIX, - true, - ); // aiplatform.googleapis.com cannot be enabled without billing (the Blaze plan), // so an enabled Vertex API already implies the agent-platform provider is available. if (isVertexEnabled) { From 79aa70822112dd9dcb05b289b25cd7ee927fee63 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Sun, 26 Jul 2026 22:31:55 -0700 Subject: [PATCH 12/21] refactor(ailogic): drop namespace help listing superseded by progressive help The custom namespace walk in the help command predates #10772, which auto-registers a commander command for every namespace. getCommand now always resolves namespaces to those commands, so the walk was unreachable (and mishandled function-valued intermediate nodes like client.ext, as flagged by review). firebase help ailogic:providers and firebase help ext:dev are both served by progressive help. Also remove two empty JSDoc blocks. --- src/commands/help.spec.ts | 59 --------------------------------------- src/commands/help.ts | 47 +------------------------------ src/gcp/ailogic.ts | 6 ---- 3 files changed, 1 insertion(+), 111 deletions(-) delete mode 100644 src/commands/help.spec.ts diff --git a/src/commands/help.spec.ts b/src/commands/help.spec.ts deleted file mode 100644 index 18f755caac9..00000000000 --- a/src/commands/help.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import * as sinon from "sinon"; -import { expect } from "chai"; -import { command as helpCommand } from "./help"; -import { logger } from "../logger"; - -describe("help command namespace listing", () => { - let loggerStub: sinon.SinonStub; - - beforeEach(() => { - loggerStub = sinon.stub(logger, "info"); - }); - - afterEach(() => { - sinon.restore(); - }); - - it("should show help for namespace subcommands", async () => { - const mockEnableCommand = { - name: () => "ailogic:providers:enable", - description: () => "enable a provider", - outputHelp: sinon.stub(), - }; - const mockDisableCommand = { - name: () => "ailogic:providers:disable", - description: () => "disable a provider", - outputHelp: sinon.stub(), - }; - - const mockClient = { - cli: { - commands: [mockEnableCommand, mockDisableCommand], - outputHelp: sinon.stub(), - }, - getCommand: sinon.stub().returns(undefined), - ailogic: { - providers: {}, - }, - }; - - // Run help command action with mock context - // `actionFn` is a private member of Command; cast through `unknown` to invoke it - // directly with a mocked command context. The target type is fully specified (no `any`). - const actionFn = ( - helpCommand as unknown as { - actionFn: (this: { client: typeof mockClient }, commandName: string) => Promise; - } - ).actionFn; - await actionFn.call({ client: mockClient }, "ailogic:providers"); - - // It should log the subcommands and descriptions - expect(loggerStub).to.have.been.called; - const allArgs = loggerStub.args.map((a) => a.join(" ")).join("\n"); - expect(allArgs).to.include("Commands under ailogic:providers:"); - expect(allArgs).to.include("ailogic:providers:enable"); - expect(allArgs).to.include("enable a provider"); - expect(allArgs).to.include("ailogic:providers:disable"); - expect(allArgs).to.include("disable a provider"); - }); -}); diff --git a/src/commands/help.ts b/src/commands/help.ts index 5191c596d25..7d8067ca410 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import * as clc from "colorette"; -import type { Command as CommanderCommand } from "commander"; import { Command } from "../command"; import { logger } from "../logger"; @@ -15,51 +14,7 @@ export const command = new Command("help [command]") const cmd = commandName ? client.getCommand(commandName) : undefined; if (cmd) { cmd.outputHelp(); - return; - } - - if (commandName) { - // Treat the argument as a command namespace (e.g. "ailogic:providers") and walk - // the nested client command tree segment by segment ("ailogic" -> "providers") to - // check whether it resolves to a group of subcommands rather than a leaf command. - const keys = commandName.split(":"); - let current = client; - let matched = true; - for (const key of keys) { - if (!current || typeof current !== "object") { - matched = false; - break; - } - const nextKey = Object.keys(current).find((k) => k.toLowerCase() === key.toLowerCase()); - if (nextKey) { - current = current[nextKey]; - } else { - matched = false; - break; - } - } - - // If it resolved to a namespace, print every registered command under that prefix - // (e.g. `firebase help ailogic:providers` lists enable/disable/list). - if (matched && current && typeof current === "object") { - const prefix = commandName + ":"; - const subcmds = (client.cli.commands as CommanderCommand[]).filter((c) => - c.name().startsWith(prefix), - ); - if (subcmds.length > 0) { - logger.info(); - logger.info(clc.bold(`Commands under ${clc.green(commandName)}:`)); - logger.info(); - for (const subcmd of subcmds) { - logger.info(` ${clc.bold(subcmd.name().padEnd(45))} ${subcmd.description()}`); - } - logger.info(); - return; - } - } - } - - if (commandName) { + } else if (commandName) { logger.warn(); utils.logWarning( clc.bold(commandName) + " is not a valid command. See below for valid commands", diff --git a/src/gcp/ailogic.ts b/src/gcp/ailogic.ts index ad3376bfa68..32500e63ac8 100644 --- a/src/gcp/ailogic.ts +++ b/src/gcp/ailogic.ts @@ -177,9 +177,6 @@ export async function listTriggers( return triggers; } -/** - * - */ export async function upsertBlockingFunction(endpoint: AILogicEndpoint): Promise { const eventType = endpoint.blockingTrigger.eventType; const triggerId = AI_LOGIC_EVENTS_TO_TRIGGER[eventType]; @@ -204,9 +201,6 @@ export async function upsertBlockingFunction(endpoint: AILogicEndpoint): Promise } } -/** - * - */ export async function deleteBlockingFunction(endpoint: AILogicEndpoint): Promise { const eventType = endpoint.blockingTrigger.eventType; const triggerId = AI_LOGIC_EVENTS_TO_TRIGGER[eventType]; From 9d0abb4c3e9a478a0324738b6c18c6a1ccc8dd86 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Tue, 7 Jul 2026 21:48:18 -0700 Subject: [PATCH 13/21] feat: add firebase ailogic templates CLI commands Exposes the Firebase AI Logic templates command surface under the `firebase ailogic` namespace: - `templates:deploy`, `templates:list`, `templates:get`, `templates:delete`, `templates:lock`, and `templates:unlock` to version control and manage server prompt templates. - Template validations, lock checks, and pruning configurations. - Full unit test coverage for templates deploy command. --- src/commands/ailogic-templates-delete.ts | 56 ++++++ src/commands/ailogic-templates-deploy.spec.ts | 184 ++++++++++++++++++ src/commands/ailogic-templates-deploy.ts | 172 ++++++++++++++++ src/commands/ailogic-templates-get.ts | 21 ++ src/commands/ailogic-templates-list.ts | 44 +++++ src/commands/ailogic-templates-lock.ts | 18 ++ src/commands/ailogic-templates-unlock.ts | 18 ++ src/commands/index.ts | 8 + src/gcp/ailogic.spec.ts | 135 +++++++++++++ src/gcp/ailogic.ts | 114 +++++++++++ 10 files changed, 770 insertions(+) create mode 100644 src/commands/ailogic-templates-delete.ts create mode 100644 src/commands/ailogic-templates-deploy.spec.ts create mode 100644 src/commands/ailogic-templates-deploy.ts create mode 100644 src/commands/ailogic-templates-get.ts create mode 100644 src/commands/ailogic-templates-list.ts create mode 100644 src/commands/ailogic-templates-lock.ts create mode 100644 src/commands/ailogic-templates-unlock.ts diff --git a/src/commands/ailogic-templates-delete.ts b/src/commands/ailogic-templates-delete.ts new file mode 100644 index 00000000000..80d3c17d1be --- /dev/null +++ b/src/commands/ailogic-templates-delete.ts @@ -0,0 +1,56 @@ +import { Command } from "../command"; +import { requirePermissions } from "../requirePermissions"; +import { needProjectId } from "../projectUtils"; +import * as ailogic from "../gcp/ailogic"; +import * as clc from "colorette"; +import { logger } from "../logger"; +import { confirm } from "../prompt"; +import { FirebaseError, getErrStatus } from "../error"; + +import { Options } from "../options"; + +export const command = new Command("ailogic:templates:delete ") + .description("delete a template") + .option("-f, --force", "bypass confirmation prompt") + .before(requirePermissions, ["firebasevertexai.templates.delete"]) + .action(async (templateId: string, options: Options) => { + const projectId = needProjectId(options); + + await ailogic.ensureAILogicApiEnabled(projectId, options); + + let template: ailogic.Template; + try { + template = await ailogic.getTemplate(projectId, "global", templateId); + } catch (err: unknown) { + if (getErrStatus(err) === 404) { + throw new FirebaseError(`Template ${clc.bold(templateId)} does not exist.`); + } + throw err; + } + + if (template.locked) { + throw new FirebaseError( + `The following templates are locked and cannot be deleted:\n\n ${templateId}\n\nUnlock them by running:\n\n firebase ailogic:templates:unlock `, + ); + } + + if (options.nonInteractive && !options.force) { + throw new FirebaseError( + `Deleting template ${clc.bold(templateId)} requires confirmation.\n\n` + + `To proceed in non-interactive mode, rerun with --force:\n\n` + + ` firebase ailogic:templates:delete ${templateId} --force`, + ); + } + + const confirmed = await confirm({ + message: `Are you sure you want to delete template ${clc.bold(templateId)}?`, + force: options.force, + nonInteractive: options.nonInteractive, + }); + if (!confirmed) { + throw new FirebaseError("Command aborted.", { exit: 1 }); + } + + await ailogic.deleteTemplate(projectId, "global", templateId); + logger.info(clc.green(`Successfully deleted template: ${clc.bold(templateId)}`)); + }); diff --git a/src/commands/ailogic-templates-deploy.spec.ts b/src/commands/ailogic-templates-deploy.spec.ts new file mode 100644 index 00000000000..ce999593efe --- /dev/null +++ b/src/commands/ailogic-templates-deploy.spec.ts @@ -0,0 +1,184 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import { command } from "./ailogic-templates-deploy"; +import * as ailogic from "../gcp/ailogic"; +import { logger } from "../logger"; +import { FirebaseError } from "../error"; +import * as fs from "fs"; +import * as prompt from "../prompt"; + +describe("ailogic:templates:deploy", () => { + const sandbox = sinon.createSandbox(); + let listTemplatesStub: sinon.SinonStub; + let updateTemplateStub: sinon.SinonStub; + let deleteTemplateStub: sinon.SinonStub; + let confirmStub: sinon.SinonStub; + let existsSyncStub: sinon.SinonStub; + let statSyncStub: sinon.SinonStub; + let readdirSyncStub: sinon.SinonStub; + let readFileSyncStub: sinon.SinonStub; + + beforeEach(() => { + listTemplatesStub = sandbox.stub(ailogic, "listTemplates"); + updateTemplateStub = sandbox.stub(ailogic, "updateTemplate"); + deleteTemplateStub = sandbox.stub(ailogic, "deleteTemplate"); + sandbox.stub(ailogic, "ensureAILogicApiEnabled").resolves(); + confirmStub = sandbox.stub(prompt, "confirm"); + existsSyncStub = sandbox.stub(fs, "existsSync"); + statSyncStub = sandbox.stub(fs, "statSync"); + readdirSyncStub = sandbox.stub(fs, "readdirSync"); + readFileSyncStub = sandbox.stub(fs, "readFileSync"); + sandbox.stub(logger, "info"); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should fail if validation of a prompt file fails", async () => { + const options = { project: "test-project", dir: "prompts" }; + existsSyncStub.returns(true); + statSyncStub.returns({ isDirectory: () => true }); + readdirSyncStub.returns(["t1.prompt", "t2.prompt"]); + readFileSyncStub.onFirstCall().returns(""); // empty file -> invalid + readFileSyncStub.onSecondCall().returns("---\nmodel: test\n---\nbody"); // valid + + await expect(command.runner()(options)).to.be.rejectedWith( + FirebaseError, + "The following prompt files failed validation:\n t1.prompt: File is empty.", + ); + + expect(updateTemplateStub).to.not.be.called; + }); + + it("should fail if local file targets a locked remote template", async () => { + const options = { project: "test-project", dir: "prompts" }; + existsSyncStub.returns(true); + statSyncStub.returns({ isDirectory: () => true }); + readdirSyncStub.returns(["welcome.prompt"]); + readFileSyncStub.returns("welcome body"); + + // Remote template exists and is locked + listTemplatesStub.resolves([ + { + name: "projects/test-project/locations/global/templates/welcome", + templateString: "old content", + locked: true, + }, + ]); + + await expect(command.runner()(options)).to.be.rejectedWith( + FirebaseError, + "The following templates are locked and cannot be updated or deleted:\n\n welcome\n\nUnlock them by running:\n\n firebase ailogic:templates:unlock \n\nThen deploy again. No templates were deployed.", + ); + + expect(updateTemplateStub).to.not.be.called; + }); + + it("should deploy templates successfully", async () => { + const options = { project: "test-project", dir: "prompts" }; + existsSyncStub.returns(true); + statSyncStub.returns({ isDirectory: () => true }); + readdirSyncStub.returns(["welcome.prompt"]); + readFileSyncStub.returns("welcome body"); + + listTemplatesStub.resolves([]); + updateTemplateStub.resolves({}); + + await command.runner()(options); + + expect(updateTemplateStub).to.have.been.calledOnceWith("test-project", "global", "welcome", { + templateString: "welcome body", + displayName: "welcome", + }); + }); + + it("should prune templates successfully after confirmation", async () => { + const options = { project: "test-project", dir: "prompts", prune: true, interactive: true }; + existsSyncStub.returns(true); + statSyncStub.returns({ isDirectory: () => true }); + readdirSyncStub.returns(["welcome.prompt"]); + readFileSyncStub.returns("welcome body"); + + // remote has welcome and stale-template + listTemplatesStub.resolves([ + { + name: "projects/test-project/locations/global/templates/welcome", + templateString: "old content", + locked: false, + }, + { + name: "projects/test-project/locations/global/templates/stale-template", + templateString: "stale content", + locked: false, + }, + ]); + + updateTemplateStub.resolves({}); + deleteTemplateStub.resolves({}); + confirmStub.resolves(true); // User confirms pruning + + await command.runner()(options); + + expect(updateTemplateStub).to.have.been.calledOnceWith("test-project", "global", "welcome", { + templateString: "welcome body", + displayName: "welcome", + }); + expect(deleteTemplateStub).to.have.been.calledOnceWith( + "test-project", + "global", + "stale-template", + ); + }); + + it("should fail prune if stale remote template is locked", async () => { + const options = { project: "test-project", dir: "prompts", prune: true }; + existsSyncStub.returns(true); + statSyncStub.returns({ isDirectory: () => true }); + readdirSyncStub.returns(["welcome.prompt"]); + readFileSyncStub.returns("welcome body"); + + // stale template is locked + listTemplatesStub.resolves([ + { + name: "projects/test-project/locations/global/templates/welcome", + templateString: "old content", + locked: false, + }, + { + name: "projects/test-project/locations/global/templates/stale-template", + templateString: "stale content", + locked: true, + }, + ]); + + await expect(command.runner()(options)).to.be.rejectedWith( + FirebaseError, + "The following templates are locked and cannot be updated or deleted:\n\n stale-template\n\nUnlock them by running:\n\n firebase ailogic:templates:unlock \n\nThen deploy again. No templates were deployed.", + ); + + expect(updateTemplateStub).to.not.be.called; + expect(deleteTemplateStub).to.not.be.called; + }); + + it("should fail prune in non-interactive mode without force", async () => { + const options = { project: "test-project", dir: "prompts", prune: true, nonInteractive: true }; + existsSyncStub.returns(true); + statSyncStub.returns({ isDirectory: () => true }); + readdirSyncStub.returns(["welcome.prompt"]); + readFileSyncStub.returns("welcome body"); + + listTemplatesStub.resolves([ + { + name: "projects/test-project/locations/global/templates/stale-template", + templateString: "stale content", + locked: false, + }, + ]); + + await expect(command.runner()(options)).to.be.rejectedWith( + FirebaseError, + "Pruning templates requires confirmation.\n\nTo proceed in non-interactive mode, rerun with --force:\n\n firebase ailogic:templates:deploy --dir prompts --prune --force", + ); + }); +}); diff --git a/src/commands/ailogic-templates-deploy.ts b/src/commands/ailogic-templates-deploy.ts new file mode 100644 index 00000000000..81bc39ee434 --- /dev/null +++ b/src/commands/ailogic-templates-deploy.ts @@ -0,0 +1,172 @@ +import { Command } from "../command"; +import { requirePermissions } from "../requirePermissions"; +import { needProjectId } from "../projectUtils"; +import * as ailogic from "../gcp/ailogic"; +import * as clc from "colorette"; +import { logger } from "../logger"; +import { FirebaseError, getError } from "../error"; +import * as fs from "fs"; +import * as path from "path"; +import { confirm } from "../prompt"; +import * as yaml from "js-yaml"; + +import { Options } from "../options"; + +interface DeployOptions extends Options { + dir?: string; + prune?: boolean; +} + +function validatePromptFile(content: string): string | null { + if (!content.trim()) { + return "File is empty."; + } + if (content.startsWith("---")) { + const parts = content.split("---"); + if (parts.length < 3) { + return "Frontmatter block is not closed (missing terminating '---')."; + } + const yamlContent = parts[1]; + try { + yaml.load(yamlContent); + } catch (err: unknown) { + return `Invalid YAML in frontmatter: ${getError(err).message}`; + } + } + return null; +} + +export const command = new Command("ailogic:templates:deploy") + .description("deploy server prompt templates from local files") + .option("--dir ", "directory containing .prompt files", "prompts") + .option("--prune", "delete remote templates with no matching local .prompt file") + .before(requirePermissions, ["firebasevertexai.templates.update"]) + .action(async (options: DeployOptions) => { + const projectId = needProjectId(options); + const dir = options.dir ?? "prompts"; + + await ailogic.ensureAILogicApiEnabled(projectId, options); + + if (!fs.existsSync(dir)) { + if (options.dir) { + throw new FirebaseError(`Directory does not exist: ${dir}`); + } + logger.info(`Default prompts directory '${dir}' does not exist. No templates to deploy.`); + return; + } + const stat = fs.statSync(dir); + if (!stat.isDirectory()) { + throw new FirebaseError(`Path is not a directory: ${dir}`); + } + + const files = fs.readdirSync(dir); + const promptFiles = files.filter((f) => f.endsWith(".prompt")); + + if (promptFiles.length === 0) { + logger.info("No .prompt files found to deploy."); + return; + } + + // 1. Validation pass: validate all local prompt files + const validationErrors: { file: string; error: string }[] = []; + const contentsMap = new Map(); + for (const file of promptFiles) { + const filePath = path.join(dir, file); + const content = fs.readFileSync(filePath, "utf-8"); + const err = validatePromptFile(content); + if (err) { + validationErrors.push({ file, error: err }); + } else { + contentsMap.set(path.basename(file, ".prompt"), content); + } + } + + if (validationErrors.length > 0) { + const msg = ["The following prompt files failed validation:"] + .concat(validationErrors.map((e) => ` ${e.file}: ${e.error}`)) + .join("\n"); + throw new FirebaseError(msg); + } + + // 2. Fetch remote templates and check for locks + const remoteTemplates = await ailogic.listTemplates(projectId, "global"); + const remoteMap = new Map(remoteTemplates.map((t) => [t.name.split("/").pop() ?? "", t])); + + const lockedTemplatesToModify: string[] = []; + for (const file of promptFiles) { + const templateId = path.basename(file, ".prompt"); + const remote = remoteMap.get(templateId); + if (remote && remote.locked) { + lockedTemplatesToModify.push(templateId); + } + } + + const templatesToPrune: string[] = []; + if (options.prune) { + for (const [id, remote] of remoteMap.entries()) { + if (!contentsMap.has(id)) { + if (remote.locked) { + lockedTemplatesToModify.push(id); + } else { + templatesToPrune.push(id); + } + } + } + } + + if (lockedTemplatesToModify.length > 0) { + throw new FirebaseError( + `The following templates are locked and cannot be updated or deleted:\n\n` + + lockedTemplatesToModify.map((id) => ` ${id}`).join("\n") + + `\n\nUnlock them by running:\n\n firebase ailogic:templates:unlock \n\nThen deploy again. No templates were deployed.`, + ); + } + + // 3. Confirm pruning if any + if (options.prune && templatesToPrune.length > 0) { + if (options.nonInteractive && !options.force) { + throw new FirebaseError( + `Pruning templates requires confirmation.\n\n` + + `To proceed in non-interactive mode, rerun with --force:\n\n` + + ` firebase ailogic:templates:deploy ${options.dir ? `--dir ${options.dir} ` : ""}--prune --force`, + ); + } + const confirmed = await confirm({ + message: + `This will delete the following remote templates that do not exist locally:\n\n` + + templatesToPrune.map((id) => ` ${id}`).join("\n") + + `\n\nAre you sure you want to proceed?`, + force: options.force, + nonInteractive: options.nonInteractive, + }); + if (!confirmed) { + throw new FirebaseError("Command aborted.", { exit: 1 }); + } + } + + // 4. Deploy local templates + for (const [templateId, content] of contentsMap.entries()) { + const remote = remoteMap.get(templateId); + + if (remote) { + logger.info(`Updating template ${clc.bold(templateId)}...`); + } else { + logger.info(`Creating template ${clc.bold(templateId)}...`); + } + + await ailogic.updateTemplate(projectId, "global", templateId, { + templateString: content, + displayName: templateId, + }); + } + + // 5. Delete pruned templates + if (options.prune && templatesToPrune.length > 0) { + for (const templateId of templatesToPrune) { + logger.info(`Pruning template ${clc.bold(templateId)}...`); + await ailogic.deleteTemplate(projectId, "global", templateId); + } + } + + logger.info(clc.green("Successfully deployed templates.")); + }); diff --git a/src/commands/ailogic-templates-get.ts b/src/commands/ailogic-templates-get.ts new file mode 100644 index 00000000000..de47dbcd17e --- /dev/null +++ b/src/commands/ailogic-templates-get.ts @@ -0,0 +1,21 @@ +import { Command } from "../command"; +import { requirePermissions } from "../requirePermissions"; +import { needProjectId } from "../projectUtils"; +import * as ailogic from "../gcp/ailogic"; +import { logger } from "../logger"; + +import { Options } from "../options"; + +export const command = new Command("ailogic:templates:get ") + .description("print one template") + .before(requirePermissions, ["firebasevertexai.templates.get"]) + .action(async (templateId: string, options: Options) => { + const projectId = needProjectId(options); + if (!(await ailogic.isAILogicApiEnabled(projectId))) { + logger.info("Firebase AI Logic is not enabled on this project."); + return; + } + const template = await ailogic.getTemplate(projectId, "global", templateId); + logger.info(template.templateString); + return template; + }); diff --git a/src/commands/ailogic-templates-list.ts b/src/commands/ailogic-templates-list.ts new file mode 100644 index 00000000000..49946967728 --- /dev/null +++ b/src/commands/ailogic-templates-list.ts @@ -0,0 +1,44 @@ +import { Command } from "../command"; +import { requirePermissions } from "../requirePermissions"; +import { needProjectId } from "../projectUtils"; +import * as ailogic from "../gcp/ailogic"; +import * as clc from "colorette"; +import { logger } from "../logger"; +import * as Table from "cli-table3"; + +import { Options } from "../options"; + +export const command = new Command("ailogic:templates:list") + .description("list deployed templates") + .before(requirePermissions, ["firebasevertexai.templates.get"]) + .action(async (options: Options) => { + const projectId = needProjectId(options); + if (!(await ailogic.isAILogicApiEnabled(projectId))) { + logger.info(clc.bold("Firebase AI Logic is not enabled on this project.")); + return []; + } + const templates = await ailogic.listTemplates(projectId, "global"); + + if (templates.length === 0) { + logger.info(clc.bold("No deployed templates found.")); + return templates; + } + + const tableHead = ["Template ID", "Display Name", "Locked", "Template Preview"]; + const table = new Table({ head: tableHead, style: { head: ["green"] } }); + + for (const t of templates) { + const templateId = t.name.split("/").pop() || ""; + const preview = + t.templateString.length > 60 ? t.templateString.substring(0, 57) + "..." : t.templateString; + table.push([ + clc.bold(templateId), + t.displayName || "", + t.locked ? "Yes" : "No", + preview.replace(/\n/g, " "), + ]); + } + + logger.info(table.toString()); + return templates; + }); diff --git a/src/commands/ailogic-templates-lock.ts b/src/commands/ailogic-templates-lock.ts new file mode 100644 index 00000000000..3ad97674fbe --- /dev/null +++ b/src/commands/ailogic-templates-lock.ts @@ -0,0 +1,18 @@ +import { Command } from "../command"; +import { requirePermissions } from "../requirePermissions"; +import { needProjectId } from "../projectUtils"; +import * as ailogic from "../gcp/ailogic"; +import * as clc from "colorette"; +import { logger } from "../logger"; + +import { Options } from "../options"; + +export const command = new Command("ailogic:templates:lock ") + .description("lock a template") + .before(requirePermissions, ["firebasevertexai.templates.update"]) + .action(async (templateId: string, options: Options) => { + const projectId = needProjectId(options); + await ailogic.ensureAILogicApiEnabled(projectId, options); + await ailogic.lockTemplate(projectId, "global", templateId); + logger.info(clc.green(`Successfully locked template: ${clc.bold(templateId)}`)); + }); diff --git a/src/commands/ailogic-templates-unlock.ts b/src/commands/ailogic-templates-unlock.ts new file mode 100644 index 00000000000..776e6a155aa --- /dev/null +++ b/src/commands/ailogic-templates-unlock.ts @@ -0,0 +1,18 @@ +import { Command } from "../command"; +import { requirePermissions } from "../requirePermissions"; +import { needProjectId } from "../projectUtils"; +import * as ailogic from "../gcp/ailogic"; +import * as clc from "colorette"; +import { logger } from "../logger"; + +import { Options } from "../options"; + +export const command = new Command("ailogic:templates:unlock ") + .description("unlock a template") + .before(requirePermissions, ["firebasevertexai.templates.update"]) + .action(async (templateId: string, options: Options) => { + const projectId = needProjectId(options); + await ailogic.ensureAILogicApiEnabled(projectId, options); + await ailogic.unlockTemplate(projectId, "global", templateId); + logger.info(clc.green(`Successfully unlocked template: ${clc.bold(templateId)}`)); + }); diff --git a/src/commands/index.ts b/src/commands/index.ts index 41f5fc612a9..7c93974f646 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -237,6 +237,14 @@ export function load(client: CLIClient): CLIClient { client.ailogic.config.set = loadCommand("ailogic-config-set"); } + client.ailogic.templates = {}; + client.ailogic.templates.deploy = loadCommand("ailogic-templates-deploy"); + client.ailogic.templates.list = loadCommand("ailogic-templates-list"); + client.ailogic.templates.get = loadCommand("ailogic-templates-get"); + client.ailogic.templates.delete = loadCommand("ailogic-templates-delete"); + client.ailogic.templates.lock = loadCommand("ailogic-templates-lock"); + client.ailogic.templates.unlock = loadCommand("ailogic-templates-unlock"); + client.login = loadCommand("login"); client.login.add = loadCommand("login-add"); client.login.ci = loadCommand("login-ci"); diff --git a/src/gcp/ailogic.spec.ts b/src/gcp/ailogic.spec.ts index c8737159112..cdcd551e62d 100644 --- a/src/gcp/ailogic.spec.ts +++ b/src/gcp/ailogic.spec.ts @@ -210,6 +210,141 @@ describe("ailogic", () => { }); }); + describe("templates", () => { + let getStub: sinon.SinonStub; + let patchStub: sinon.SinonStub; + let deleteStub: sinon.SinonStub; + let postStub: sinon.SinonStub; + + beforeEach(() => { + getStub = sinon.stub(ailogic.client, "get"); + patchStub = sinon.stub(ailogic.client, "patch"); + deleteStub = sinon.stub(ailogic.client, "delete"); + postStub = sinon.stub(ailogic.client, "post"); + }); + + afterEach(() => { + getStub.restore(); + patchStub.restore(); + deleteStub.restore(); + postStub.restore(); + }); + + it("should get template", async () => { + const mockTemplate: ailogic.Template = { + name: "projects/my-project/locations/global/templates/temp-1", + templateString: "hello", + }; + getStub.resolves({ body: mockTemplate }); + + const template = await ailogic.getTemplate("my-project", "global", "temp-1"); + + expect(getStub).to.have.been.calledWith( + "projects/my-project/locations/global/templates/temp-1", + ); + expect(template).to.deep.equal(mockTemplate); + }); + + it("should update template", async () => { + const mockTemplate: ailogic.Template = { + name: "projects/my-project/locations/global/templates/temp-1", + templateString: "hello", + }; + patchStub.resolves({ body: mockTemplate }); + + const template = await ailogic.updateTemplate("my-project", "global", "temp-1", { + templateString: "hello", + }); + + expect(patchStub).to.have.been.calledWithMatch( + "projects/my-project/locations/global/templates/temp-1", + { templateString: "hello" }, + { + queryParams: { + allowMissing: "true", + }, + }, + ); + expect(template).to.deep.equal(mockTemplate); + }); + + it("should delete template", async () => { + deleteStub.resolves({}); + + await ailogic.deleteTemplate("my-project", "global", "temp-1"); + + expect(deleteStub).to.have.been.calledWith( + "projects/my-project/locations/global/templates/temp-1", + ); + }); + + it("should lock template", async () => { + const mockTemplate: ailogic.Template = { + name: "projects/my-project/locations/global/templates/temp-1", + templateString: "hello", + locked: true, + }; + patchStub.resolves({ body: mockTemplate }); + + const template = await ailogic.lockTemplate("my-project", "global", "temp-1"); + + expect(patchStub).to.have.been.calledWithMatch( + "projects/my-project/locations/global/templates/temp-1", + { locked: true }, + { + queryParams: { + updateMask: "locked", + }, + }, + ); + expect(template).to.deep.equal(mockTemplate); + }); + + it("should unlock template", async () => { + const mockTemplate: ailogic.Template = { + name: "projects/my-project/locations/global/templates/temp-1", + templateString: "hello", + locked: false, + }; + patchStub.resolves({ body: mockTemplate }); + + const template = await ailogic.unlockTemplate("my-project", "global", "temp-1"); + + expect(patchStub).to.have.been.calledWithMatch( + "projects/my-project/locations/global/templates/temp-1", + { locked: false }, + { + queryParams: { + updateMask: "locked", + }, + }, + ); + expect(template).to.deep.equal(mockTemplate); + }); + + it("should list templates slurping all pages", async () => { + getStub.onFirstCall().resolves({ + body: { + templates: [{ name: "t1", templateString: "t1" }], + nextPageToken: "next", + }, + }); + getStub.onSecondCall().resolves({ + body: { + templates: [{ name: "t2", templateString: "t2" }], + }, + }); + + const templates = await ailogic.listTemplates("my-project", "global"); + + expect(getStub).to.have.been.calledTwice; + expect(templates).to.deep.equal([ + { name: "t1", templateString: "t1" }, + { name: "t2", templateString: "t2" }, + ]); + }); + }); + describe("providers", () => { let ensureStub: sinon.SinonStub; let disableStub: sinon.SinonStub; diff --git a/src/gcp/ailogic.ts b/src/gcp/ailogic.ts index 49b955978e3..9c28c185831 100644 --- a/src/gcp/ailogic.ts +++ b/src/gcp/ailogic.ts @@ -236,6 +236,21 @@ export interface Config { telemetryConfig?: TelemetryConfig; } +export interface Template { + name: string; + templateString: string; + displayName?: string; + etag?: string; + locked?: boolean; +} + +export interface ListTemplatesResponse { + templates?: Template[]; + nextPageToken?: string; +} + +export type TemplateOutputOnlyFields = "name" | "etag"; + // Developer-facing config paths that `ailogic:config:set` can write. export const WRITABLE_CONFIG_PATHS = [ "security.auth-only", @@ -275,6 +290,105 @@ export function parseProviderType(value: string): ProviderType { return value; } +/** + * Gets a Template. + */ +export async function getTemplate( + projectId: string, + location: string, + templateId: string, +): Promise