-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat(ailogic): add ailogic:config CLI commands #10841
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
+1,360
−2
Closed
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
79fbcdc
feat: add firebase ailogic:providers CLI commands
marb2000 f314b73
Merge branch 'main' into feat/ailogic-providers
marb2000 6621514
Merge branch 'main' into feat/ailogic-providers
marb2000 f93655f
Merge branch 'main' into feat/ailogic-providers
marb2000 dc501fe
fix(ailogic): address PR review feedback
marb2000 089c274
Merge remote-tracking branch 'origin/main' into feat/ailogic-providers
marb2000 4273fe0
style: format CHANGELOG.md with prettier
marb2000 f06ed08
feat: add firebase ailogic:config CLI commands
marb2000 847f078
fix(ailogic): address review feedback on config commands
marb2000 dd7c215
fix(ailogic): polish config commands per pre-PR review
marb2000 6eb7b69
fix(ailogic): address code review on config PR
marb2000 86e99a0
refactor(ailogic): reuse shared config helpers and remove unused code
marb2000 dd1d94e
fix(ailogic): address bug-hunt findings on config commands
marb2000 3e9f376
fix(ailogic): address review nits from joehan
marb2000 5187f7a
fix(ailogic): keep provider state consistent with the AI Logic API
marb2000 5e69ca9
Merge feat/ailogic-providers: keep provider state consistent with the…
marb2000 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| 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", () => { | ||
| 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); | ||
| 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 }, | ||
| }); | ||
| }); | ||
|
|
||
| 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("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("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 () => { | ||
| enabledStub.resolves(false); | ||
| expect(await command.runner()(undefined, { project: PROJECT_ID })).to.be.undefined; | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| 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"; | ||
|
|
||
| // 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", | ||
| ...ailogic.WRITABLE_CONFIG_PATHS.filter((p) => p.startsWith("security.")), | ||
| "monitoring", | ||
| ...ailogic.WRITABLE_CONFIG_PATHS.filter((p) => p.startsWith("monitoring.")), | ||
| ]; | ||
|
|
||
| function isRecord(value: unknown): value is Record<string, unknown> { | ||
| return typeof value === "object" && value !== null; | ||
| } | ||
|
|
||
| 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); | ||
|
|
||
| // 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 monitoringState = config.telemetryConfig?.mode === "ALL"; | ||
| // 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 | ||
| ? Math.round(config.telemetryConfig.samplingRate * 100) | ||
| : 100; | ||
|
|
||
| // 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 = { | ||
| ...(needsProviders && { | ||
| providers: Object.fromEntries( | ||
| ailogic.PROVIDER_TYPES.map((p) => [p, enabledProviders.includes(p)]), | ||
| ), | ||
| }), | ||
| security: { | ||
| "auth-only": config.trafficFilter?.firebaseAuthRequired ?? false, | ||
| "template-only": config.trafficFilter?.templateOnly ?? false, | ||
| }, | ||
| monitoring: { | ||
| state: monitoringState, | ||
| "sample-rate-percentage": sampleRatePercent, | ||
| }, | ||
| }; | ||
|
|
||
| if (!path) { | ||
| logger.info(JSON.stringify(configObj, null, 2)); | ||
| return configObj; | ||
| } | ||
|
|
||
| let val: unknown = configObj; | ||
| for (const part of path.split(".")) { | ||
| if (!isRecord(val)) { | ||
| val = undefined; | ||
| break; | ||
| } | ||
| val = val[part]; | ||
| } | ||
| logger.info(typeof val === "object" ? JSON.stringify(val, null, 2) : String(val)); | ||
| return val; | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| 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; | ||
| let ensureStub: sinon.SinonStub; | ||
|
|
||
| beforeEach(() => { | ||
| (command as unknown as { befores: unknown[] }).befores = []; // bypass pre-action hooks | ||
| sinon.stub(projectUtils, "needProjectId").returns(PROJECT_ID); | ||
| 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" }); | ||
| 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("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", { | ||
| 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("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("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" } }, [ | ||
| "telemetryConfig.mode", | ||
| ]); | ||
| }); | ||
|
|
||
| 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( | ||
| PROJECT_ID, | ||
| { telemetryConfig: { samplingRate: 0.5 } }, | ||
| ["telemetryConfig.samplingRate"], | ||
| ); | ||
| }); | ||
|
|
||
| it("rejects an out-of-range or non-integer sample rate", async () => { | ||
| // "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("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 }), | ||
| ).to.deep.equal({ path: "monitoring.state", value: "true" }); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please add detailed help text explaining how to use this command using the
.help()method. Would be particularly nice to explain what values are allowed for [path]There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done. Added help text listing all the readable paths (built from the path list so it stays in sync) plus an example.