Skip to content

Commit b20995b

Browse files
authored
Merge branch 'main' into firedata-tos-fix
2 parents f9c66dc + eb2fab9 commit b20995b

15 files changed

Lines changed: 1256 additions & 1 deletion

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
- Added `firebase ailogic:providers:*` CLI commands to enable, disable, and list Gemini API providers.
12
- 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).
23
- Fixes Storage Emulator to support JSON uploads larger than 100KB without hanging or throwing 413 error (#8355)
34
- Add `extdeprecationwarnings` experiment to display phased deprecation notices and guidance across `ext:*` CLI commands.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { expect } from "chai";
2+
import * as sinon from "sinon";
3+
4+
import { command } from "./ailogic-config-get";
5+
import * as ailogic from "../gcp/ailogic";
6+
import * as projectUtils from "../projectUtils";
7+
import { FirebaseError } from "../error";
8+
9+
const PROJECT_ID = "test-project";
10+
11+
describe("ailogic:config:get", () => {
12+
let enabledStub: sinon.SinonStub;
13+
let listProvidersStub: sinon.SinonStub;
14+
let getConfigStub: sinon.SinonStub;
15+
16+
beforeEach(() => {
17+
(command as unknown as { befores: unknown[] }).befores = []; // bypass pre-action hooks
18+
sinon.stub(projectUtils, "needProjectId").returns(PROJECT_ID);
19+
enabledStub = sinon.stub(ailogic, "isAILogicApiEnabled").resolves(true);
20+
listProvidersStub = sinon.stub(ailogic, "listProviders").resolves(["gemini-developer-api"]);
21+
getConfigStub = sinon.stub(ailogic, "getConfig").resolves({
22+
name: "config",
23+
trafficFilter: { firebaseAuthRequired: true, templateOnly: false },
24+
telemetryConfig: { mode: "ALL", samplingRate: 0.5 },
25+
});
26+
});
27+
28+
afterEach(() => sinon.restore());
29+
30+
it("returns a structured config with mapped values", async () => {
31+
expect(await command.runner()(undefined, { project: PROJECT_ID })).to.deep.equal({
32+
providers: {
33+
"gemini-developer-api": true,
34+
"gemini-agent-platform-api": false,
35+
},
36+
security: { "auth-only": true, "template-only": false },
37+
monitoring: { state: true, "sample-rate-percentage": 50 },
38+
});
39+
});
40+
41+
it("returns a single value for a valid path", async () => {
42+
expect(await command.runner()("security.auth-only", { project: PROJECT_ID })).to.equal(true);
43+
});
44+
45+
it("returns a nested object for a group path", async () => {
46+
expect(await command.runner()("monitoring", { project: PROJECT_ID })).to.deep.equal({
47+
state: true,
48+
"sample-rate-percentage": 50,
49+
});
50+
});
51+
52+
it("only checks provider enablement when the path needs it", async () => {
53+
await command.runner()("security.auth-only", { project: PROJECT_ID });
54+
expect(listProvidersStub).to.not.have.been.called;
55+
56+
await command.runner()("providers.gemini-developer-api", { project: PROJECT_ID });
57+
expect(listProvidersStub).to.have.been.calledOnce;
58+
});
59+
60+
it("throws on an unknown path before making any API calls", async () => {
61+
await expect(command.runner()("security.authonly", { project: PROJECT_ID })).to.be.rejectedWith(
62+
FirebaseError,
63+
/Unknown configuration path/,
64+
);
65+
expect(enabledStub).to.not.have.been.called;
66+
expect(getConfigStub).to.not.have.been.called;
67+
expect(listProvidersStub).to.not.have.been.called;
68+
});
69+
70+
it("returns early when AI Logic is not enabled", async () => {
71+
enabledStub.resolves(false);
72+
expect(await command.runner()(undefined, { project: PROJECT_ID })).to.be.undefined;
73+
});
74+
});

src/commands/ailogic-config-get.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { Command } from "../command";
2+
import { requirePermissions } from "../requirePermissions";
3+
import { needProjectId } from "../projectUtils";
4+
import * as ailogic from "../gcp/ailogic";
5+
import { logger } from "../logger";
6+
7+
import { Options } from "../options";
8+
9+
// Everything `config:get` can read: the writable paths plus their group prefixes
10+
// and the read-only provider status derived from API enablement.
11+
const READABLE_CONFIG_PATHS = [
12+
"providers",
13+
...ailogic.PROVIDER_TYPES.map((p) => `providers.${p}`),
14+
"security",
15+
...ailogic.WRITABLE_CONFIG_PATHS.filter((p) => p.startsWith("security.")),
16+
"monitoring",
17+
...ailogic.WRITABLE_CONFIG_PATHS.filter((p) => p.startsWith("monitoring.")),
18+
];
19+
20+
function isRecord(value: unknown): value is Record<string, unknown> {
21+
return typeof value === "object" && value !== null;
22+
}
23+
24+
export const command = new Command("ailogic:config:get [path]")
25+
.description("read AI Logic configuration")
26+
.help(
27+
`prints the full AI Logic configuration for the active project as JSON. If [path] is given, prints only that section or value.
28+
29+
Valid values for [path]:
30+
31+
${READABLE_CONFIG_PATHS.map((p) => ` ${p}`).join("\n")}
32+
33+
For example, to check whether requests are restricted to authenticated users:
34+
35+
firebase ailogic:config:get security.auth-only`,
36+
)
37+
.before(requirePermissions, ["firebasevertexai.config.get", "serviceusage.services.get"])
38+
.action(async (path: string | undefined, options: Options) => {
39+
const projectId = needProjectId(options);
40+
41+
// Validate the path up front so bad input fails fast, before any API calls.
42+
if (path) {
43+
ailogic.assertKnownConfigPath(path, READABLE_CONFIG_PATHS);
44+
}
45+
46+
if (!(await ailogic.isAILogicApiEnabled(projectId))) {
47+
logger.info("Firebase AI Logic is not enabled on this project.");
48+
return;
49+
}
50+
const config = await ailogic.getConfig(projectId);
51+
52+
const monitoringState = config.telemetryConfig?.mode === "ALL";
53+
// The API stores samplingRate as a fraction in (0,1]; the CLI displays an
54+
// integer percentage. An unset samplingRate is displayed as 100% (full sampling).
55+
const sampleRatePercent =
56+
config.telemetryConfig?.samplingRate !== undefined
57+
? Math.round(config.telemetryConfig.samplingRate * 100)
58+
: 100;
59+
60+
// Provider status needs extra Service Usage checks, so fetch it only when the
61+
// requested path is under `providers` (or the whole config was requested).
62+
const needsProviders = !path || path === "providers" || path.startsWith("providers.");
63+
const enabledProviders = needsProviders ? await ailogic.listProviders(projectId) : [];
64+
65+
const configObj = {
66+
...(needsProviders && {
67+
providers: Object.fromEntries(
68+
ailogic.PROVIDER_TYPES.map((p) => [p, enabledProviders.includes(p)]),
69+
),
70+
}),
71+
security: {
72+
"auth-only": config.trafficFilter?.firebaseAuthRequired ?? false,
73+
"template-only": config.trafficFilter?.templateOnly ?? false,
74+
},
75+
monitoring: {
76+
state: monitoringState,
77+
"sample-rate-percentage": sampleRatePercent,
78+
},
79+
};
80+
81+
if (!path) {
82+
logger.info(JSON.stringify(configObj, null, 2));
83+
return configObj;
84+
}
85+
86+
let val: unknown = configObj;
87+
for (const part of path.split(".")) {
88+
if (!isRecord(val)) {
89+
val = undefined;
90+
break;
91+
}
92+
val = val[part];
93+
}
94+
logger.info(typeof val === "object" ? JSON.stringify(val, null, 2) : String(val));
95+
return val;
96+
});
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { expect } from "chai";
2+
import * as sinon from "sinon";
3+
4+
import { command } from "./ailogic-config-set";
5+
import * as ailogic from "../gcp/ailogic";
6+
import * as projectUtils from "../projectUtils";
7+
import * as prompt from "../prompt";
8+
import * as utils from "../utils";
9+
import { FirebaseError } from "../error";
10+
11+
const PROJECT_ID = "test-project";
12+
13+
describe("ailogic:config:set", () => {
14+
let updateStub: sinon.SinonStub;
15+
let getConfigStub: sinon.SinonStub;
16+
let confirmStub: sinon.SinonStub;
17+
let ensureStub: sinon.SinonStub;
18+
19+
beforeEach(() => {
20+
(command as unknown as { befores: unknown[] }).befores = []; // bypass pre-action hooks
21+
sinon.stub(projectUtils, "needProjectId").returns(PROJECT_ID);
22+
ensureStub = sinon.stub(ailogic, "ensureAILogicApiEnabled").resolves();
23+
sinon.stub(utils, "logSuccess");
24+
getConfigStub = sinon.stub(ailogic, "getConfig").resolves({ name: "config" });
25+
updateStub = sinon.stub(ailogic, "updateConfig").resolves({ name: "config" });
26+
confirmStub = sinon.stub(prompt, "confirm").resolves(true);
27+
});
28+
29+
afterEach(() => sinon.restore());
30+
31+
it("throws on an unknown path listing the writable paths", async () => {
32+
await expect(
33+
command.runner()("security.authonly", "true", { project: PROJECT_ID }),
34+
).to.be.rejectedWith(FirebaseError, /Unknown configuration path/);
35+
});
36+
37+
it("rejects a non-boolean value for a security path", async () => {
38+
await expect(
39+
command.runner()("security.auth-only", "yes", { project: PROJECT_ID }),
40+
).to.be.rejectedWith(FirebaseError, /must be 'true' or 'false'/);
41+
});
42+
43+
it("validates input before triggering the API-enablement flow (fail-fast)", async () => {
44+
await expect(
45+
command.runner()("monitoring.sample-rate-percentage", "500", { project: PROJECT_ID }),
46+
).to.be.rejectedWith(FirebaseError, /integer in the range 1-100/);
47+
expect(ensureStub).to.not.have.been.called;
48+
});
49+
50+
it("prompts when tightening auth-only from false to true, then updates", async () => {
51+
getConfigStub.resolves({ name: "config", trafficFilter: { firebaseAuthRequired: false } });
52+
await command.runner()("security.auth-only", "true", {
53+
project: PROJECT_ID,
54+
interactive: true,
55+
});
56+
expect(confirmStub).to.have.been.calledOnce;
57+
expect(updateStub).to.have.been.calledWith(
58+
PROJECT_ID,
59+
{ trafficFilter: { firebaseAuthRequired: true } },
60+
["trafficFilter.firebaseAuthRequired"],
61+
);
62+
});
63+
64+
it("does not prompt when auth-only is already true", async () => {
65+
getConfigStub.resolves({ name: "config", trafficFilter: { firebaseAuthRequired: true } });
66+
await command.runner()("security.auth-only", "true", {
67+
project: PROJECT_ID,
68+
interactive: true,
69+
});
70+
expect(confirmStub).to.not.have.been.called;
71+
expect(updateStub).to.have.been.calledOnce;
72+
});
73+
74+
it("does not prompt when relaxing auth-only to false", async () => {
75+
await command.runner()("security.auth-only", "false", { project: PROJECT_ID });
76+
expect(confirmStub).to.not.have.been.called;
77+
expect(updateStub).to.have.been.calledWith(
78+
PROJECT_ID,
79+
{ trafficFilter: { firebaseAuthRequired: false } },
80+
["trafficFilter.firebaseAuthRequired"],
81+
);
82+
});
83+
84+
it("propagates confirm() aborting in non-interactive mode without --force", async () => {
85+
// confirm() throws in non-interactive mode when no --force is given; the command
86+
// must surface that and not proceed to write.
87+
getConfigStub.resolves({ name: "config", trafficFilter: { firebaseAuthRequired: false } });
88+
confirmStub.rejects(new FirebaseError("cannot be answered in non-interactive mode"));
89+
await expect(
90+
command.runner()("security.auth-only", "true", { project: PROJECT_ID, nonInteractive: true }),
91+
).to.be.rejectedWith(FirebaseError, /non-interactive/);
92+
expect(updateStub).to.not.have.been.called;
93+
});
94+
95+
it("prompts when tightening template-only from false to true, then updates", async () => {
96+
getConfigStub.resolves({ name: "config", trafficFilter: { templateOnly: false } });
97+
await command.runner()("security.template-only", "true", {
98+
project: PROJECT_ID,
99+
interactive: true,
100+
});
101+
expect(confirmStub).to.have.been.calledOnce;
102+
expect(updateStub).to.have.been.calledWith(
103+
PROJECT_ID,
104+
{ trafficFilter: { templateOnly: true } },
105+
["trafficFilter.templateOnly"],
106+
);
107+
});
108+
109+
it("accepts a case-insensitive boolean value", async () => {
110+
await command.runner()("monitoring.state", "TRUE", { project: PROJECT_ID });
111+
expect(updateStub).to.have.been.calledWith(PROJECT_ID, { telemetryConfig: { mode: "ALL" } }, [
112+
"telemetryConfig.mode",
113+
]);
114+
});
115+
116+
it("maps monitoring.state true to telemetryConfig.mode ALL", async () => {
117+
await command.runner()("monitoring.state", "true", { project: PROJECT_ID });
118+
expect(updateStub).to.have.been.calledWith(PROJECT_ID, { telemetryConfig: { mode: "ALL" } }, [
119+
"telemetryConfig.mode",
120+
]);
121+
});
122+
123+
it("maps monitoring.state false to telemetryConfig.mode NONE without prompting", async () => {
124+
await command.runner()("monitoring.state", "false", { project: PROJECT_ID });
125+
expect(confirmStub).to.not.have.been.called;
126+
expect(updateStub).to.have.been.calledWith(PROJECT_ID, { telemetryConfig: { mode: "NONE" } }, [
127+
"telemetryConfig.mode",
128+
]);
129+
});
130+
131+
it("maps a sample-rate percentage to a (0,1] sampling fraction", async () => {
132+
await command.runner()("monitoring.sample-rate-percentage", "50", { project: PROJECT_ID });
133+
expect(updateStub).to.have.been.calledWith(
134+
PROJECT_ID,
135+
{ telemetryConfig: { samplingRate: 0.5 } },
136+
["telemetryConfig.samplingRate"],
137+
);
138+
});
139+
140+
it("rejects an out-of-range or non-integer sample rate", async () => {
141+
// "1e2", "0x32", and " 50 " all coerce to valid integers via Number(), so the
142+
// strict decimal check must reject them too.
143+
for (const bad of ["0", "101", "1.5", "abc", "1e2", "0x32", " 50 ", "50%"]) {
144+
await expect(
145+
command.runner()("monitoring.sample-rate-percentage", bad, { project: PROJECT_ID }),
146+
).to.be.rejectedWith(FirebaseError, /integer in the range 1-100/);
147+
}
148+
expect(updateStub).to.not.have.been.called;
149+
});
150+
151+
it("normalizes a zero-padded sample rate in the echoed value", async () => {
152+
expect(
153+
await command.runner()("monitoring.sample-rate-percentage", "007", { project: PROJECT_ID }),
154+
).to.deep.equal({ path: "monitoring.sample-rate-percentage", value: "7" });
155+
expect(updateStub).to.have.been.calledWith(
156+
PROJECT_ID,
157+
{ telemetryConfig: { samplingRate: 0.07 } },
158+
["telemetryConfig.samplingRate"],
159+
);
160+
});
161+
162+
it("returns the normalized value for --json output", async () => {
163+
expect(
164+
await command.runner()("monitoring.state", "TRUE", { project: PROJECT_ID }),
165+
).to.deep.equal({ path: "monitoring.state", value: "true" });
166+
});
167+
});

0 commit comments

Comments
 (0)