Compare commits

...

23 Commits

Author SHA1 Message Date
Sehoon Shon 2ef872e73a update isActiveModel to return false for unknown models 2026-02-19 15:32:30 -05:00
gemini-cli-robot b3014337d5 chore(release): v0.29.4 2026-02-19 19:03:01 +00:00
Sehoon Shon 069641f65a only use customtoolmodel for api key users (#132)
* only use customtoolmodel for api key users

* use displaystring for showing model in about
2026-02-19 13:50:44 -05:00
Sehoon Shon e36dfc9fc9 feat(models): support Gemini 3.1 custom tool model (#131)
* feat(models): add support for Gemini 3.1 and custom tool models

* test(routing): fix classifier and numerical classifier strategy tests

* test(routing): add Gemini 3.1 tests for classifier strategy

* fix(models): correctly filter active Gemini 3.1 models

* fix(routing): ensure useCustomToolModel is only true when Gemini 3.1 is enabled

* fix(test-utils): prevent double newline in lastFrame() on Windows

* fix(test-utils): surgically fix double newline in lastFrame() on Windows

* use custom_tools_model string for api key only

* fix(ui): correct useCustomToolModel logic and update tests

* fix(ui): correct useCustomToolModel logic in StatsDisplay

* fix(routing): ensure test models are active and sync useCustomToolModel logic
2026-02-19 12:25:59 -05:00
Sehoon Shon 2ef6149684 feat(core,ui): support Gemini 3.1 Pro Preview and active model filtering (#125)
* feat(core,ui): support Gemini 3.1 Pro Preview and active model filtering

* fix(core,ui): use optional chaining for config methods to support mocks

* fix(core): clear stale authType in refreshAuth to avoid incorrect model resolution

* do not show gemini 3.1 model when users do not have access to gemini 3.1 in stats
2026-02-19 11:38:15 -05:00
gemini-cli-robot b70cf35df3 chore(release): v0.29.3 2026-02-19 15:36:17 +00:00
gemini-cli-robot f581ebdd19 fix(patch): cherry-pick c43500c to release/v0.29.2-pr-19502 to patch version v0.29.2 and create version 0.29.3 (#19522)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-02-19 10:04:37 -05:00
gemini-cli-robot 54f24574e5 chore(release): v0.29.2 2026-02-19 00:50:15 +00:00
gemini-cli-robot 7f9808ce6b fix(patch): cherry-pick 261788c to release/v0.29.1-pr-19453 to patch version v0.29.1 and create version 0.29.2 (#19491)
Co-authored-by: Shreya Keshive <shreyakeshive@google.com>
2026-02-18 19:24:31 -05:00
gemini-cli-robot 8429f559f0 chore(release): v0.29.1 2026-02-18 23:00:21 +00:00
gemini-cli-robot 9a947f5544 fix(patch): cherry-pick 858918f to release/v0.29.0-pr-19460 to patch version v0.29.0 and create version 0.29.1 (#19480)
Co-authored-by: christine betts <chrstn@uw.edu>
2026-02-18 22:25:34 +00:00
gemini-cli-robot d924ed0128 chore(release): v0.29.0 2026-02-17 23:59:56 +00:00
gemini-cli-robot 9f864ccd25 chore(release): v0.29.0-preview.5 2026-02-17 19:41:31 +00:00
gemini-cli-robot b3ac1299e8 fix(patch): cherry-pick 9590a09 to release/v0.29.0-preview.4-pr-18771 to patch version v0.29.0-preview.4 and create version 0.29.0-preview.5 (#19274)
Co-authored-by: Shreya Keshive <shreyakeshive@google.com>
2026-02-17 19:09:49 +00:00
gemini-cli-robot 95b973e7fc chore(release): v0.29.0-preview.4 2026-02-17 18:47:42 +00:00
gemini-cli-robot 13249bc518 fix(patch): cherry-pick e5ff202 to release/v0.29.0-preview.3-pr-19254 to patch version v0.29.0-preview.3 and create version 0.29.0-preview.4 (#19264)
Co-authored-by: Jerop Kipruto <jerop@google.com>
2026-02-17 18:22:14 +00:00
gemini-cli-robot d1fcb09afa chore(release): v0.29.0-preview.3 2026-02-13 22:10:56 +00:00
gemini-cli-robot f0473f3385 fix(patch): cherry-pick d0c6a56 to release/v0.29.0-preview.2-pr-18976 to patch version v0.29.0-preview.2 and create version 0.29.0-preview.3 (#19023)
Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
2026-02-13 16:36:45 -05:00
gemini-cli-robot 3d8edc5d97 chore(release): v0.29.0-preview.2 2026-02-12 16:12:02 +00:00
gemini-cli-robot 6a2f2d3a91 fix(patch): cherry-pick 08e8eea to release/v0.29.0-preview.1-pr-18855 to patch version v0.29.0-preview.1 and create version 0.29.0-preview.2 (#18905)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-02-12 10:49:16 -05:00
gemini-cli-robot c32969c5a1 chore(release): v0.29.0-preview.1 2026-02-11 21:02:05 +00:00
gemini-cli-robot 31a7a8187b fix(patch): cherry-pick e9a9474 to release/v0.29.0-preview.0-pr-18840 to patch version v0.29.0-preview.0 and create version 0.29.0-preview.1 (#18841)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
2026-02-11 20:36:28 +00:00
gemini-cli-robot 70305907de chore(release): v0.29.0-preview.0 2026-02-10 21:45:03 +00:00
56 changed files with 971 additions and 425 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ Other ways to start in Plan Mode:
You can enter Plan Mode in three ways: You can enter Plan Mode in three ways:
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes 1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes
(`Default` -> `Plan` -> `Auto-Edit`). (`Default` -> `Auto-Edit` -> `Plan`).
2. **Command:** Type `/plan` in the input box. 2. **Command:** Type `/plan` in the input box.
3. **Natural Language:** Ask the agent to "start a plan for...". 3. **Natural Language:** Ask the agent to "start a plan for...".
+1 -12
View File
@@ -739,21 +739,10 @@ The MCP integration tracks several states:
cautiously and only for servers you completely control cautiously and only for servers you completely control
- **Access tokens:** Be security-aware when configuring environment variables - **Access tokens:** Be security-aware when configuring environment variables
containing API keys or tokens containing API keys or tokens
- **Environment variable redaction:** By default, the Gemini CLI redacts
sensitive environment variables (such as `GEMINI_API_KEY`, `GOOGLE_API_KEY`,
and variables matching patterns like `*TOKEN*`, `*SECRET*`, `*PASSWORD*`) when
spawning MCP servers using the `stdio` transport. This prevents unintended
exposure of your credentials to third-party servers.
- **Explicit environment variables:** If you need to pass a specific environment
variable to an MCP server, you should define it explicitly in the `env`
property of the server configuration in `settings.json`.
- **Sandbox compatibility:** When using sandboxing, ensure MCP servers are - **Sandbox compatibility:** When using sandboxing, ensure MCP servers are
available within the sandbox environment. available within the sandbox environment
- **Private data:** Using broadly scoped personal access tokens can lead to - **Private data:** Using broadly scoped personal access tokens can lead to
information leakage between repositories. information leakage between repositories.
- **Untrusted servers:** Be extremely cautious when adding MCP servers from
untrusted or third-party sources. Malicious servers could attempt to
exfiltrate data or perform unauthorized actions through the tools they expose.
### Performance and resource management ### Performance and resource management
+6
View File
@@ -75,6 +75,10 @@ const baseConfig = {
write: true, write: true,
}; };
const commonAliases = {
punycode: 'punycode/',
};
const cliConfig = { const cliConfig = {
...baseConfig, ...baseConfig,
banner: { banner: {
@@ -88,6 +92,7 @@ const cliConfig = {
plugins: createWasmPlugins(), plugins: createWasmPlugins(),
alias: { alias: {
'is-in-ci': path.resolve(__dirname, 'packages/cli/src/patches/is-in-ci.ts'), 'is-in-ci': path.resolve(__dirname, 'packages/cli/src/patches/is-in-ci.ts'),
...commonAliases,
}, },
metafile: true, metafile: true,
}; };
@@ -103,6 +108,7 @@ const a2aServerConfig = {
'process.env.CLI_VERSION': JSON.stringify(pkg.version), 'process.env.CLI_VERSION': JSON.stringify(pkg.version),
}, },
plugins: createWasmPlugins(), plugins: createWasmPlugins(),
alias: commonAliases,
}; };
Promise.allSettled([ Promise.allSettled([
+10 -33
View File
@@ -1,12 +1,12 @@
{ {
"name": "@google/gemini-cli", "name": "@google/gemini-cli",
"version": "0.29.0-nightly.20260203.71f46f116", "version": "0.29.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@google/gemini-cli", "name": "@google/gemini-cli",
"version": "0.29.0-nightly.20260203.71f46f116", "version": "0.29.4",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
@@ -14,6 +14,7 @@
"ink": "npm:@jrichman/ink@6.4.8", "ink": "npm:@jrichman/ink@6.4.8",
"latest-version": "^9.0.0", "latest-version": "^9.0.0",
"proper-lockfile": "^4.1.2", "proper-lockfile": "^4.1.2",
"punycode": "^2.3.1",
"simple-git": "^3.28.0" "simple-git": "^3.28.0"
}, },
"bin": { "bin": {
@@ -2232,7 +2233,6 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@octokit/auth-token": "^6.0.0", "@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2", "@octokit/graphql": "^9.0.2",
@@ -2413,7 +2413,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": ">=8.0.0" "node": ">=8.0.0"
} }
@@ -2447,7 +2446,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0" "@opentelemetry/semantic-conventions": "^1.29.0"
}, },
@@ -2816,7 +2814,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@opentelemetry/core": "2.0.1", "@opentelemetry/core": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0" "@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2850,7 +2847,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@opentelemetry/core": "2.0.1", "@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1" "@opentelemetry/resources": "2.0.1"
@@ -2903,7 +2899,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@opentelemetry/core": "2.0.1", "@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1", "@opentelemetry/resources": "2.0.1",
@@ -4021,7 +4016,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.1.tgz",
"integrity": "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA==", "integrity": "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -4087,7 +4081,6 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -4362,7 +4355,6 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0", "@typescript-eslint/types": "8.35.0",
@@ -5355,7 +5347,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -7895,7 +7886,6 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -8416,7 +8406,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "^2.0.0", "accepts": "^2.0.0",
"body-parser": "^2.2.1", "body-parser": "^2.2.1",
@@ -10023,7 +10012,6 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz", "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz",
"integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==", "integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1", "@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0", "ansi-escapes": "^7.0.0",
@@ -13527,7 +13515,6 @@
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -13693,7 +13680,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -13704,7 +13690,6 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"shell-quote": "^1.6.1", "shell-quote": "^1.6.1",
"ws": "^7" "ws": "^7"
@@ -15866,7 +15851,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -16090,8 +16074,7 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "dev": true,
"license": "0BSD", "license": "0BSD"
"peer": true
}, },
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.20.3", "version": "4.20.3",
@@ -16099,7 +16082,6 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "~0.25.0", "esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5" "get-tsconfig": "^4.7.5"
@@ -16260,7 +16242,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -16468,7 +16449,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -16582,7 +16562,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -16595,7 +16574,6 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4", "@vitest/expect": "3.2.4",
@@ -17228,7 +17206,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@@ -17244,7 +17221,7 @@
}, },
"packages/a2a-server": { "packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server", "name": "@google/gemini-cli-a2a-server",
"version": "0.29.0-nightly.20260203.71f46f116", "version": "0.29.4",
"dependencies": { "dependencies": {
"@a2a-js/sdk": "^0.3.8", "@a2a-js/sdk": "^0.3.8",
"@google-cloud/storage": "^7.16.0", "@google-cloud/storage": "^7.16.0",
@@ -17300,7 +17277,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@google/gemini-cli", "name": "@google/gemini-cli",
"version": "0.29.0-nightly.20260203.71f46f116", "version": "0.29.4",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.12.0", "@agentclientprotocol/sdk": "^0.12.0",
@@ -17313,6 +17290,7 @@
"chalk": "^4.1.2", "chalk": "^4.1.2",
"cli-spinners": "^2.9.2", "cli-spinners": "^2.9.2",
"clipboardy": "^5.0.0", "clipboardy": "^5.0.0",
"color-convert": "^2.0.1",
"command-exists": "^1.2.9", "command-exists": "^1.2.9",
"comment-json": "^4.2.5", "comment-json": "^4.2.5",
"diff": "^8.0.3", "diff": "^8.0.3",
@@ -17397,7 +17375,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@google/gemini-cli-core", "name": "@google/gemini-cli-core",
"version": "0.29.0-nightly.20260203.71f46f116", "version": "0.29.4",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@a2a-js/sdk": "^0.3.8", "@a2a-js/sdk": "^0.3.8",
@@ -17534,7 +17512,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -17557,7 +17534,7 @@
}, },
"packages/test-utils": { "packages/test-utils": {
"name": "@google/gemini-cli-test-utils", "name": "@google/gemini-cli-test-utils",
"version": "0.29.0-nightly.20260203.71f46f116", "version": "0.29.4",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@google/gemini-cli-core": "file:../core", "@google/gemini-cli-core": "file:../core",
@@ -17574,7 +17551,7 @@
}, },
"packages/vscode-ide-companion": { "packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion", "name": "gemini-cli-vscode-ide-companion",
"version": "0.29.0-nightly.20260203.71f46f116", "version": "0.29.4",
"license": "LICENSE", "license": "LICENSE",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0", "@modelcontextprotocol/sdk": "^1.23.0",
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@google/gemini-cli", "name": "@google/gemini-cli",
"version": "0.29.0-nightly.20260203.71f46f116", "version": "0.29.4",
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
}, },
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git" "url": "git+https://github.com/google-gemini/gemini-cli.git"
}, },
"config": { "config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.0-nightly.20260203.71f46f116" "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.4"
}, },
"scripts": { "scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js", "start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -129,6 +129,7 @@
"ink": "npm:@jrichman/ink@6.4.8", "ink": "npm:@jrichman/ink@6.4.8",
"latest-version": "^9.0.0", "latest-version": "^9.0.0",
"proper-lockfile": "^4.1.2", "proper-lockfile": "^4.1.2",
"punycode": "^2.3.1",
"simple-git": "^3.28.0" "simple-git": "^3.28.0"
}, },
"optionalDependencies": { "optionalDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@google/gemini-cli-a2a-server", "name": "@google/gemini-cli-a2a-server",
"version": "0.29.0-nightly.20260203.71f46f116", "version": "0.29.4",
"description": "Gemini CLI A2A Server", "description": "Gemini CLI A2A Server",
"repository": { "repository": {
"type": "git", "type": "git",
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@google/gemini-cli", "name": "@google/gemini-cli",
"version": "0.29.0-nightly.20260203.71f46f116", "version": "0.29.4",
"description": "Gemini CLI", "description": "Gemini CLI",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
@@ -26,7 +26,7 @@
"dist" "dist"
], ],
"config": { "config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.0-nightly.20260203.71f46f116" "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.4"
}, },
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.12.0", "@agentclientprotocol/sdk": "^0.12.0",
@@ -39,6 +39,7 @@
"chalk": "^4.1.2", "chalk": "^4.1.2",
"cli-spinners": "^2.9.2", "cli-spinners": "^2.9.2",
"clipboardy": "^5.0.0", "clipboardy": "^5.0.0",
"color-convert": "^2.0.1",
"command-exists": "^1.2.9", "command-exists": "^1.2.9",
"comment-json": "^4.2.5", "comment-json": "^4.2.5",
"diff": "^8.0.3", "diff": "^8.0.3",
-7
View File
@@ -128,13 +128,6 @@ async function addMcpServer(
settings.setValue(settingsScope, 'mcpServers', mcpServers); settings.setValue(settingsScope, 'mcpServers', mcpServers);
if (transport === 'stdio') {
debugLogger.warn(
'Security Warning: Running MCP servers with stdio transport can expose inherited environment variables. ' +
'While the Gemini CLI redacts common API keys and secrets by default, you should only run servers from trusted sources.',
);
}
if (isExistingServer) { if (isExistingServer) {
debugLogger.log(`MCP server "${name}" updated in ${scope} settings.`); debugLogger.log(`MCP server "${name}" updated in ${scope} settings.`);
} else { } else {
+6 -3
View File
@@ -445,7 +445,11 @@ export async function loadCliConfig(
process.env['VITEST'] === 'true' process.env['VITEST'] === 'true'
? false ? false
: (settings.security?.folderTrust?.enabled ?? false); : (settings.security?.folderTrust?.enabled ?? false);
const trustedFolder = isWorkspaceTrusted(settings, cwd)?.isTrusted ?? false; const trustedFolder =
isWorkspaceTrusted(settings, cwd, undefined, {
prompt: argv.prompt,
query: argv.query,
})?.isTrusted ?? false;
// Set the context filename in the server's memoryTool module BEFORE loading memory // Set the context filename in the server's memoryTool module BEFORE loading memory
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed // TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
@@ -602,8 +606,7 @@ export async function loadCliConfig(
const interactive = const interactive =
!!argv.promptInteractive || !!argv.promptInteractive ||
!!argv.experimentalAcp || !!argv.experimentalAcp ||
(!isHeadlessMode({ prompt: argv.prompt }) && (!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) &&
!argv.query &&
!argv.isCommand); !argv.isCommand);
const allowedTools = argv.allowedTools || settings.tools?.allowed || []; const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
@@ -449,6 +449,14 @@ describe('Trusted Folders', () => {
false, false,
); );
}); });
it('should return true for isPathTrusted when isHeadlessMode is true', async () => {
const geminiCore = await import('@google/gemini-cli-core');
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true);
const folders = loadTrustedFolders();
expect(folders.isPathTrusted('/any-untrusted-path')).toBe(true);
});
}); });
describe('Trusted Folders Caching', () => { describe('Trusted Folders Caching', () => {
+18 -3
View File
@@ -17,6 +17,7 @@ import {
homedir, homedir,
isHeadlessMode, isHeadlessMode,
coreEvents, coreEvents,
type HeadlessModeOptions,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import type { Settings } from './settings.js'; import type { Settings } from './settings.js';
import stripJsonComments from 'strip-json-comments'; import stripJsonComments from 'strip-json-comments';
@@ -128,7 +129,11 @@ export class LoadedTrustedFolders {
isPathTrusted( isPathTrusted(
location: string, location: string,
config?: Record<string, TrustLevel>, config?: Record<string, TrustLevel>,
headlessOptions?: HeadlessModeOptions,
): boolean | undefined { ): boolean | undefined {
if (isHeadlessMode(headlessOptions)) {
return true;
}
const configToUse = config ?? this.user.config; const configToUse = config ?? this.user.config;
// Resolve location to its realpath for canonical comparison // Resolve location to its realpath for canonical comparison
@@ -333,6 +338,7 @@ export function isFolderTrustEnabled(settings: Settings): boolean {
function getWorkspaceTrustFromLocalConfig( function getWorkspaceTrustFromLocalConfig(
workspaceDir: string, workspaceDir: string,
trustConfig?: Record<string, TrustLevel>, trustConfig?: Record<string, TrustLevel>,
headlessOptions?: HeadlessModeOptions,
): TrustResult { ): TrustResult {
const folders = loadTrustedFolders(); const folders = loadTrustedFolders();
const configToUse = trustConfig ?? folders.user.config; const configToUse = trustConfig ?? folders.user.config;
@@ -346,7 +352,11 @@ function getWorkspaceTrustFromLocalConfig(
); );
} }
const isTrusted = folders.isPathTrusted(workspaceDir, configToUse); const isTrusted = folders.isPathTrusted(
workspaceDir,
configToUse,
headlessOptions,
);
return { return {
isTrusted, isTrusted,
source: isTrusted !== undefined ? 'file' : undefined, source: isTrusted !== undefined ? 'file' : undefined,
@@ -357,8 +367,9 @@ export function isWorkspaceTrusted(
settings: Settings, settings: Settings,
workspaceDir: string = process.cwd(), workspaceDir: string = process.cwd(),
trustConfig?: Record<string, TrustLevel>, trustConfig?: Record<string, TrustLevel>,
headlessOptions?: HeadlessModeOptions,
): TrustResult { ): TrustResult {
if (isHeadlessMode()) { if (isHeadlessMode(headlessOptions)) {
return { isTrusted: true, source: undefined }; return { isTrusted: true, source: undefined };
} }
@@ -372,5 +383,9 @@ export function isWorkspaceTrusted(
} }
// Fall back to the local user configuration // Fall back to the local user configuration
return getWorkspaceTrustFromLocalConfig(workspaceDir, trustConfig); return getWorkspaceTrustFromLocalConfig(
workspaceDir,
trustConfig,
headlessOptions,
);
} }
+1
View File
@@ -671,6 +671,7 @@ export const AppContainer = (props: AppContainerProps) => {
settings.setValue(scope, 'security.auth.selectedType', authType); settings.setValue(scope, 'security.auth.selectedType', authType);
try { try {
config.setRemoteAdminSettings(undefined);
await config.refreshAuth(authType); await config.refreshAuth(authType);
setAuthState(AuthState.Authenticated); setAuthState(AuthState.Authenticated);
} catch (e) { } catch (e) {
+4 -1
View File
@@ -9,6 +9,7 @@ import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { useSettings } from '../contexts/SettingsContext.js'; import { useSettings } from '../contexts/SettingsContext.js';
import { getDisplayString } from '@google/gemini-cli-core';
interface AboutBoxProps { interface AboutBoxProps {
cliVersion: string; cliVersion: string;
@@ -79,7 +80,9 @@ export const AboutBox: React.FC<AboutBoxProps> = ({
</Text> </Text>
</Box> </Box>
<Box> <Box>
<Text color={theme.text.primary}>{modelVersion}</Text> <Text color={theme.text.primary}>
{getDisplayString(modelVersion)}
</Text>
</Box> </Box>
</Box> </Box>
<Box flexDirection="row"> <Box flexDirection="row">
@@ -14,9 +14,7 @@ describe('ApprovalModeIndicator', () => {
const { lastFrame } = render( const { lastFrame } = render(
<ApprovalModeIndicator approvalMode={ApprovalMode.AUTO_EDIT} />, <ApprovalModeIndicator approvalMode={ApprovalMode.AUTO_EDIT} />,
); );
const output = lastFrame(); expect(lastFrame()).toMatchSnapshot();
expect(output).toContain('auto-accept edits');
expect(output).toContain('shift+tab to manual');
}); });
it('renders correctly for AUTO_EDIT mode with plan enabled', () => { it('renders correctly for AUTO_EDIT mode with plan enabled', () => {
@@ -26,35 +24,28 @@ describe('ApprovalModeIndicator', () => {
isPlanEnabled={true} isPlanEnabled={true}
/>, />,
); );
const output = lastFrame(); expect(lastFrame()).toMatchSnapshot();
expect(output).toContain('auto-accept edits');
expect(output).toContain('shift+tab to manual');
}); });
it('renders correctly for PLAN mode', () => { it('renders correctly for PLAN mode', () => {
const { lastFrame } = render( const { lastFrame } = render(
<ApprovalModeIndicator approvalMode={ApprovalMode.PLAN} />, <ApprovalModeIndicator approvalMode={ApprovalMode.PLAN} />,
); );
const output = lastFrame(); expect(lastFrame()).toMatchSnapshot();
expect(output).toContain('plan');
expect(output).toContain('shift+tab to accept edits');
}); });
it('renders correctly for YOLO mode', () => { it('renders correctly for YOLO mode', () => {
const { lastFrame } = render( const { lastFrame } = render(
<ApprovalModeIndicator approvalMode={ApprovalMode.YOLO} />, <ApprovalModeIndicator approvalMode={ApprovalMode.YOLO} />,
); );
const output = lastFrame(); expect(lastFrame()).toMatchSnapshot();
expect(output).toContain('YOLO');
expect(output).toContain('ctrl+y');
}); });
it('renders correctly for DEFAULT mode', () => { it('renders correctly for DEFAULT mode', () => {
const { lastFrame } = render( const { lastFrame } = render(
<ApprovalModeIndicator approvalMode={ApprovalMode.DEFAULT} />, <ApprovalModeIndicator approvalMode={ApprovalMode.DEFAULT} />,
); );
const output = lastFrame(); expect(lastFrame()).toMatchSnapshot();
expect(output).toContain('shift+tab to accept edits');
}); });
it('renders correctly for DEFAULT mode with plan enabled', () => { it('renders correctly for DEFAULT mode with plan enabled', () => {
@@ -64,7 +55,6 @@ describe('ApprovalModeIndicator', () => {
isPlanEnabled={true} isPlanEnabled={true}
/>, />,
); );
const output = lastFrame(); expect(lastFrame()).toMatchSnapshot();
expect(output).toContain('shift+tab to plan');
}); });
}); });
@@ -14,6 +14,16 @@ interface ApprovalModeIndicatorProps {
isPlanEnabled?: boolean; isPlanEnabled?: boolean;
} }
export const APPROVAL_MODE_TEXT = {
AUTO_EDIT: 'auto-accept edits',
PLAN: 'plan',
YOLO: 'YOLO',
HINT_SWITCH_TO_PLAN_MODE: 'shift+tab to plan',
HINT_SWITCH_TO_MANUAL_MODE: 'shift+tab to manual',
HINT_SWITCH_TO_AUTO_EDIT_MODE: 'shift+tab to accept edits',
HINT_SWITCH_TO_YOLO_MODE: 'ctrl+y',
};
export const ApprovalModeIndicator: React.FC<ApprovalModeIndicatorProps> = ({ export const ApprovalModeIndicator: React.FC<ApprovalModeIndicatorProps> = ({
approvalMode, approvalMode,
isPlanEnabled, isPlanEnabled,
@@ -25,26 +35,26 @@ export const ApprovalModeIndicator: React.FC<ApprovalModeIndicatorProps> = ({
switch (approvalMode) { switch (approvalMode) {
case ApprovalMode.AUTO_EDIT: case ApprovalMode.AUTO_EDIT:
textColor = theme.status.warning; textColor = theme.status.warning;
textContent = 'auto-accept edits'; textContent = APPROVAL_MODE_TEXT.AUTO_EDIT;
subText = 'shift+tab to manual'; subText = isPlanEnabled
? APPROVAL_MODE_TEXT.HINT_SWITCH_TO_PLAN_MODE
: APPROVAL_MODE_TEXT.HINT_SWITCH_TO_MANUAL_MODE;
break; break;
case ApprovalMode.PLAN: case ApprovalMode.PLAN:
textColor = theme.status.success; textColor = theme.status.success;
textContent = 'plan'; textContent = APPROVAL_MODE_TEXT.PLAN;
subText = 'shift+tab to accept edits'; subText = APPROVAL_MODE_TEXT.HINT_SWITCH_TO_MANUAL_MODE;
break; break;
case ApprovalMode.YOLO: case ApprovalMode.YOLO:
textColor = theme.status.error; textColor = theme.status.error;
textContent = 'YOLO'; textContent = APPROVAL_MODE_TEXT.YOLO;
subText = 'ctrl+y'; subText = APPROVAL_MODE_TEXT.HINT_SWITCH_TO_YOLO_MODE;
break; break;
case ApprovalMode.DEFAULT: case ApprovalMode.DEFAULT:
default: default:
textColor = theme.text.accent; textColor = theme.text.accent;
textContent = ''; textContent = '';
subText = isPlanEnabled subText = APPROVAL_MODE_TEXT.HINT_SWITCH_TO_AUTO_EDIT_MODE;
? 'shift+tab to plan'
: 'shift+tab to accept edits';
break; break;
} }
@@ -14,8 +14,14 @@ import {
DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL, DEFAULT_GEMINI_FLASH_LITE_MODEL,
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
AuthType,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core'; import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core';
import { createMockSettings } from '../../test-utils/settings.js';
// Mock dependencies // Mock dependencies
const mockGetDisplayString = vi.fn(); const mockGetDisplayString = vi.fn();
@@ -42,12 +48,14 @@ describe('<ModelDialog />', () => {
const mockGetModel = vi.fn(); const mockGetModel = vi.fn();
const mockOnClose = vi.fn(); const mockOnClose = vi.fn();
const mockGetHasAccessToPreviewModel = vi.fn(); const mockGetHasAccessToPreviewModel = vi.fn();
const mockGetGemini31LaunchedSync = vi.fn();
interface MockConfig extends Partial<Config> { interface MockConfig extends Partial<Config> {
setModel: (model: string, isTemporary?: boolean) => void; setModel: (model: string, isTemporary?: boolean) => void;
getModel: () => string; getModel: () => string;
getHasAccessToPreviewModel: () => boolean; getHasAccessToPreviewModel: () => boolean;
getIdeMode: () => boolean; getIdeMode: () => boolean;
getGemini31LaunchedSync: () => boolean;
} }
const mockConfig: MockConfig = { const mockConfig: MockConfig = {
@@ -55,12 +63,14 @@ describe('<ModelDialog />', () => {
getModel: mockGetModel, getModel: mockGetModel,
getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel, getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel,
getIdeMode: () => false, getIdeMode: () => false,
getGemini31LaunchedSync: mockGetGemini31LaunchedSync,
}; };
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO); mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
mockGetHasAccessToPreviewModel.mockReturnValue(false); mockGetHasAccessToPreviewModel.mockReturnValue(false);
mockGetGemini31LaunchedSync.mockReturnValue(false);
// Default implementation for getDisplayString // Default implementation for getDisplayString
mockGetDisplayString.mockImplementation((val: string) => { mockGetDisplayString.mockImplementation((val: string) => {
@@ -70,11 +80,24 @@ describe('<ModelDialog />', () => {
}); });
}); });
const renderComponent = (configValue = mockConfig as Config) => const renderComponent = (
renderWithProviders(<ModelDialog onClose={mockOnClose} />, { configValue = mockConfig as Config,
config: configValue, authType = AuthType.LOGIN_WITH_GOOGLE,
) => {
const settings = createMockSettings({
security: {
auth: {
selectedType: authType,
},
},
}); });
return renderWithProviders(<ModelDialog onClose={mockOnClose} />, {
config: configValue,
settings,
});
};
it('renders the initial "main" view correctly', () => { it('renders the initial "main" view correctly', () => {
const { lastFrame } = renderComponent(); const { lastFrame } = renderComponent();
expect(lastFrame()).toContain('Select Model'); expect(lastFrame()).toContain('Select Model');
@@ -210,4 +233,96 @@ describe('<ModelDialog />', () => {
expect(lastFrame()).toContain('Manual'); expect(lastFrame()).toContain('Manual');
}); });
}); });
it('shows the preferred manual model in the main view option', () => {
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL);
const { lastFrame, unmount } = renderComponent();
expect(lastFrame()).toContain(`Manual (${DEFAULT_GEMINI_MODEL})`);
unmount();
});
describe('Preview Models', () => {
beforeEach(() => {
mockGetHasAccessToPreviewModel.mockReturnValue(true);
});
it('shows Auto (Preview) in main view when access is granted', () => {
const { lastFrame, unmount } = renderComponent();
expect(lastFrame()).toContain('Auto (Preview)');
unmount();
});
it('shows Gemini 3 models in manual view when Gemini 3.1 is NOT launched', async () => {
mockGetGemini31LaunchedSync.mockReturnValue(false);
const { lastFrame, stdin, unmount } = renderComponent();
// Go to manual view
await act(async () => {
stdin.write('\u001B[B'); // Manual
});
await act(async () => {
stdin.write('\r');
});
await waitFor(() => {
const output = lastFrame();
expect(output).toContain(PREVIEW_GEMINI_MODEL);
expect(output).toContain(PREVIEW_GEMINI_FLASH_MODEL);
});
unmount();
});
it('shows Gemini 3.1 models in manual view when Gemini 3.1 IS launched', async () => {
mockGetGemini31LaunchedSync.mockReturnValue(true);
const { lastFrame, stdin, unmount } = renderComponent(
mockConfig as Config,
AuthType.USE_VERTEX_AI,
);
// Go to manual view
await act(async () => {
stdin.write('\u001B[B'); // Manual
});
await act(async () => {
stdin.write('\r');
});
await waitFor(() => {
const output = lastFrame();
expect(output).toContain(PREVIEW_GEMINI_3_1_MODEL);
expect(output).toContain(PREVIEW_GEMINI_FLASH_MODEL);
});
unmount();
});
it('uses custom tools model when Gemini 3.1 IS launched and auth is Gemini API Key', async () => {
mockGetGemini31LaunchedSync.mockReturnValue(true);
const { stdin, unmount } = renderComponent(
mockConfig as Config,
AuthType.USE_GEMINI,
);
// Go to manual view
await act(async () => {
stdin.write('\u001B[B'); // Manual
});
await act(async () => {
stdin.write('\r');
});
// Select Gemini 3.1 (first item in preview section)
await act(async () => {
stdin.write('\r');
});
await waitFor(() => {
expect(mockSetModel).toHaveBeenCalledWith(
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
true,
);
});
unmount();
});
});
}); });
+27 -7
View File
@@ -9,6 +9,7 @@ import { useCallback, useContext, useMemo, useState } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { import {
PREVIEW_GEMINI_MODEL, PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_FLASH_MODEL, PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_MODEL_AUTO, PREVIEW_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL,
@@ -18,11 +19,14 @@ import {
ModelSlashCommandEvent, ModelSlashCommandEvent,
logModelSlashCommand, logModelSlashCommand,
getDisplayString, getDisplayString,
AuthType,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
import { ConfigContext } from '../contexts/ConfigContext.js'; import { ConfigContext } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
interface ModelDialogProps { interface ModelDialogProps {
onClose: () => void; onClose: () => void;
@@ -30,6 +34,7 @@ interface ModelDialogProps {
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const config = useContext(ConfigContext); const config = useContext(ConfigContext);
const settings = useSettings();
const [view, setView] = useState<'main' | 'manual'>('main'); const [view, setView] = useState<'main' | 'manual'>('main');
const [persistMode, setPersistMode] = useState(false); const [persistMode, setPersistMode] = useState(false);
@@ -37,6 +42,10 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const preferredModel = config?.getModel() || DEFAULT_GEMINI_MODEL_AUTO; const preferredModel = config?.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
const shouldShowPreviewModels = config?.getHasAccessToPreviewModel(); const shouldShowPreviewModels = config?.getHasAccessToPreviewModel();
const useGemini31 = config?.getGemini31LaunchedSync?.() ?? false;
const selectedAuthType = settings.merged.security.auth.selectedType;
const useCustomToolModel =
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
const manualModelSelected = useMemo(() => { const manualModelSelected = useMemo(() => {
const manualModels = [ const manualModels = [
@@ -44,6 +53,8 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL, DEFAULT_GEMINI_FLASH_LITE_MODEL,
PREVIEW_GEMINI_MODEL, PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL, PREVIEW_GEMINI_FLASH_MODEL,
]; ];
if (manualModels.includes(preferredModel)) { if (manualModels.includes(preferredModel)) {
@@ -94,13 +105,14 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
list.unshift({ list.unshift({
value: PREVIEW_GEMINI_MODEL_AUTO, value: PREVIEW_GEMINI_MODEL_AUTO,
title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO), title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO),
description: description: useGemini31
'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash', ? 'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash'
: 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
key: PREVIEW_GEMINI_MODEL_AUTO, key: PREVIEW_GEMINI_MODEL_AUTO,
}); });
} }
return list; return list;
}, [shouldShowPreviewModels, manualModelSelected]); }, [shouldShowPreviewModels, manualModelSelected, useGemini31]);
const manualOptions = useMemo(() => { const manualOptions = useMemo(() => {
const list = [ const list = [
@@ -122,11 +134,19 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
]; ];
if (shouldShowPreviewModels) { if (shouldShowPreviewModels) {
const previewProModel = useGemini31
? PREVIEW_GEMINI_3_1_MODEL
: PREVIEW_GEMINI_MODEL;
const previewProValue = useCustomToolModel
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
: previewProModel;
list.unshift( list.unshift(
{ {
value: PREVIEW_GEMINI_MODEL, value: previewProValue,
title: PREVIEW_GEMINI_MODEL, title: previewProModel,
key: PREVIEW_GEMINI_MODEL, key: previewProModel,
}, },
{ {
value: PREVIEW_GEMINI_FLASH_MODEL, value: PREVIEW_GEMINI_FLASH_MODEL,
@@ -136,7 +156,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
); );
} }
return list; return list;
}, [shouldShowPreviewModels]); }, [shouldShowPreviewModels, useGemini31, useCustomToolModel]);
const options = view === 'main' ? mainOptions : manualOptions; const options = view === 'main' ? mainOptions : manualOptions;
@@ -23,11 +23,13 @@ import {
import { computeSessionStats } from '../utils/computeStats.js'; import { computeSessionStats } from '../utils/computeStats.js';
import { import {
type RetrieveUserQuotaResponse, type RetrieveUserQuotaResponse,
VALID_GEMINI_MODELS, isActiveModel,
getDisplayString, getDisplayString,
isAutoModel, isAutoModel,
AuthType,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { useSettings } from '../contexts/SettingsContext.js'; import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import type { QuotaStats } from '../types.js'; import type { QuotaStats } from '../types.js';
import { QuotaStatsInfo } from './QuotaStatsInfo.js'; import { QuotaStatsInfo } from './QuotaStatsInfo.js';
@@ -82,9 +84,13 @@ const Section: React.FC<SectionProps> = ({ title, children }) => (
const buildModelRows = ( const buildModelRows = (
models: Record<string, ModelMetrics>, models: Record<string, ModelMetrics>,
quotas?: RetrieveUserQuotaResponse, quotas?: RetrieveUserQuotaResponse,
useGemini3_1 = false,
useCustomToolModel = false,
) => { ) => {
const getBaseModelName = (name: string) => name.replace('-001', ''); const getBaseModelName = (name: string) => name.replace('-001', '');
const usedModelNames = new Set(Object.keys(models).map(getBaseModelName)); const usedModelNames = new Set(
Object.keys(models).map(getBaseModelName).map(getDisplayString),
);
// 1. Models with active usage // 1. Models with active usage
const activeRows = Object.entries(models).map(([name, metrics]) => { const activeRows = Object.entries(models).map(([name, metrics]) => {
@@ -93,7 +99,7 @@ const buildModelRows = (
const inputTokens = metrics.tokens.input; const inputTokens = metrics.tokens.input;
return { return {
key: name, key: name,
modelName, modelName: getDisplayString(modelName),
requests: metrics.api.totalRequests, requests: metrics.api.totalRequests,
cachedTokens: cachedTokens.toLocaleString(), cachedTokens: cachedTokens.toLocaleString(),
inputTokens: inputTokens.toLocaleString(), inputTokens: inputTokens.toLocaleString(),
@@ -109,12 +115,12 @@ const buildModelRows = (
?.filter( ?.filter(
(b) => (b) =>
b.modelId && b.modelId &&
VALID_GEMINI_MODELS.has(b.modelId) && isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) &&
!usedModelNames.has(b.modelId), !usedModelNames.has(getDisplayString(b.modelId)),
) )
.map((bucket) => ({ .map((bucket) => ({
key: bucket.modelId!, key: bucket.modelId!,
modelName: bucket.modelId!, modelName: getDisplayString(bucket.modelId!),
requests: '-', requests: '-',
cachedTokens: '-', cachedTokens: '-',
inputTokens: '-', inputTokens: '-',
@@ -135,6 +141,8 @@ const ModelUsageTable: React.FC<{
pooledRemaining?: number; pooledRemaining?: number;
pooledLimit?: number; pooledLimit?: number;
pooledResetTime?: string; pooledResetTime?: string;
useGemini3_1?: boolean;
useCustomToolModel?: boolean;
}> = ({ }> = ({
models, models,
quotas, quotas,
@@ -144,8 +152,10 @@ const ModelUsageTable: React.FC<{
pooledRemaining, pooledRemaining,
pooledLimit, pooledLimit,
pooledResetTime, pooledResetTime,
useGemini3_1,
useCustomToolModel,
}) => { }) => {
const rows = buildModelRows(models, quotas); const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel);
if (rows.length === 0) { if (rows.length === 0) {
return null; return null;
@@ -401,7 +411,11 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
const { models, tools, files } = metrics; const { models, tools, files } = metrics;
const computed = computeSessionStats(metrics); const computed = computeSessionStats(metrics);
const settings = useSettings(); const settings = useSettings();
const config = useConfig();
const useGemini3_1 = config.getGemini31LaunchedSync?.() ?? false;
const useCustomToolModel =
useGemini3_1 &&
config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI;
const pooledRemaining = quotaStats?.remaining; const pooledRemaining = quotaStats?.remaining;
const pooledLimit = quotaStats?.limit; const pooledLimit = quotaStats?.limit;
const pooledResetTime = quotaStats?.resetTime; const pooledResetTime = quotaStats?.resetTime;
@@ -535,6 +549,8 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
pooledRemaining={pooledRemaining} pooledRemaining={pooledRemaining}
pooledLimit={pooledLimit} pooledLimit={pooledLimit}
pooledResetTime={pooledResetTime} pooledResetTime={pooledResetTime}
useGemini3_1={useGemini3_1}
useCustomToolModel={useCustomToolModel}
/> />
</Box> </Box>
); );
@@ -0,0 +1,13 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ApprovalModeIndicator > renders correctly for AUTO_EDIT mode 1`] = `"auto-accept edits shift+tab to manual"`;
exports[`ApprovalModeIndicator > renders correctly for AUTO_EDIT mode with plan enabled 1`] = `"auto-accept edits shift+tab to plan"`;
exports[`ApprovalModeIndicator > renders correctly for DEFAULT mode 1`] = `"shift+tab to accept edits"`;
exports[`ApprovalModeIndicator > renders correctly for DEFAULT mode with plan enabled 1`] = `"shift+tab to accept edits"`;
exports[`ApprovalModeIndicator > renders correctly for PLAN mode 1`] = `"plan shift+tab to manual"`;
exports[`ApprovalModeIndicator > renders correctly for YOLO mode 1`] = `"YOLO ctrl+y"`;
@@ -7,6 +7,7 @@
import type React from 'react'; import type React from 'react';
import { Text, Box } from 'ink'; import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../semantic-colors.js';
import { getDisplayString } from '@google/gemini-cli-core';
interface ModelMessageProps { interface ModelMessageProps {
model: string; model: string;
@@ -15,7 +16,7 @@ interface ModelMessageProps {
export const ModelMessage: React.FC<ModelMessageProps> = ({ model }) => ( export const ModelMessage: React.FC<ModelMessageProps> = ({ model }) => (
<Box marginLeft={2}> <Box marginLeft={2}>
<Text color={theme.ui.comment} italic> <Text color={theme.ui.comment} italic>
Responding with {model} Responding with {getDisplayString(model)}
</Text> </Text>
</Box> </Box>
); );
@@ -202,7 +202,7 @@ describe('useApprovalModeIndicator', () => {
); );
expect(result.current).toBe(ApprovalMode.YOLO); expect(result.current).toBe(ApprovalMode.YOLO);
// Shift+Tab cycles back to DEFAULT (since PLAN is disabled by default in mock) // Shift+Tab cycles back to AUTO_EDIT (from YOLO)
act(() => { act(() => {
capturedUseKeypressHandler({ capturedUseKeypressHandler({
name: 'tab', name: 'tab',
@@ -236,7 +236,7 @@ describe('useApprovalModeIndicator', () => {
expect(result.current).toBe(ApprovalMode.AUTO_EDIT); expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
}); });
it('should cycle through DEFAULT -> PLAN -> AUTO_EDIT -> DEFAULT when plan is enabled', () => { it('should cycle through DEFAULT -> AUTO_EDIT -> PLAN -> DEFAULT when plan is enabled', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
mockConfigInstance.isPlanEnabled.mockReturnValue(true); mockConfigInstance.isPlanEnabled.mockReturnValue(true);
renderHook(() => renderHook(() =>
@@ -246,15 +246,7 @@ describe('useApprovalModeIndicator', () => {
}), }),
); );
// DEFAULT -> PLAN // DEFAULT -> AUTO_EDIT
act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.PLAN,
);
// PLAN -> AUTO_EDIT
act(() => { act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key); capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
}); });
@@ -262,7 +254,15 @@ describe('useApprovalModeIndicator', () => {
ApprovalMode.AUTO_EDIT, ApprovalMode.AUTO_EDIT,
); );
// AUTO_EDIT -> DEFAULT // AUTO_EDIT -> PLAN
act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.PLAN,
);
// PLAN -> DEFAULT
act(() => { act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key); capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
}); });
@@ -72,14 +72,14 @@ export function useApprovalModeIndicator({
const currentMode = config.getApprovalMode(); const currentMode = config.getApprovalMode();
switch (currentMode) { switch (currentMode) {
case ApprovalMode.DEFAULT: case ApprovalMode.DEFAULT:
nextApprovalMode = config.isPlanEnabled()
? ApprovalMode.PLAN
: ApprovalMode.AUTO_EDIT;
break;
case ApprovalMode.PLAN:
nextApprovalMode = ApprovalMode.AUTO_EDIT; nextApprovalMode = ApprovalMode.AUTO_EDIT;
break; break;
case ApprovalMode.AUTO_EDIT: case ApprovalMode.AUTO_EDIT:
nextApprovalMode = config.isPlanEnabled()
? ApprovalMode.PLAN
: ApprovalMode.DEFAULT;
break;
case ApprovalMode.PLAN:
nextApprovalMode = ApprovalMode.DEFAULT; nextApprovalMode = ApprovalMode.DEFAULT;
break; break;
case ApprovalMode.YOLO: case ApprovalMode.YOLO:
@@ -155,9 +155,10 @@ describe('useQuotaAndFallback', () => {
expect(request?.isTerminalQuotaError).toBe(true); expect(request?.isTerminalQuotaError).toBe(true);
const message = request!.message; const message = request!.message;
expect(message).toContain('Usage limit reached for gemini-pro.'); expect(message).toContain('Usage limit reached for all Pro models.');
expect(message).toContain('Access resets at'); // From getResetTimeMessage expect(message).toContain('Access resets at'); // From getResetTimeMessage
expect(message).toContain('/stats model for usage details'); expect(message).toContain('/stats model for usage details');
expect(message).toContain('/model to switch models.');
expect(message).toContain('/auth to switch to API key.'); expect(message).toContain('/auth to switch to API key.');
expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
@@ -176,6 +177,77 @@ describe('useQuotaAndFallback', () => {
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1); expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
}); });
it('should show the model name for a terminal quota error on a non-pro model', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
}),
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
let promise: Promise<FallbackIntent | null>;
const error = new TerminalQuotaError(
'flash quota',
mockGoogleApiError,
1000 * 60 * 5,
);
act(() => {
promise = handler('gemini-flash', 'gemini-pro', error);
});
const request = result.current.proQuotaRequest;
expect(request).not.toBeNull();
expect(request?.failedModel).toBe('gemini-flash');
const message = request!.message;
expect(message).toContain('Usage limit reached for gemini-flash.');
expect(message).not.toContain('all Pro models');
act(() => {
result.current.handleProQuotaChoice('retry_later');
});
await promise!;
});
it('should handle terminal quota error without retry delay', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
}),
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
let promise: Promise<FallbackIntent | null>;
const error = new TerminalQuotaError('no delay', mockGoogleApiError);
act(() => {
promise = handler('gemini-pro', 'gemini-flash', error);
});
const request = result.current.proQuotaRequest;
const message = request!.message;
expect(message).not.toContain('Access resets at');
expect(message).toContain('Usage limit reached for all Pro models.');
act(() => {
result.current.handleProQuotaChoice('retry_later');
});
await promise!;
});
it('should handle race conditions by stopping subsequent requests', async () => { it('should handle race conditions by stopping subsequent requests', async () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
useQuotaAndFallback({ useQuotaAndFallback({
@@ -14,9 +14,9 @@ import {
TerminalQuotaError, TerminalQuotaError,
ModelNotFoundError, ModelNotFoundError,
type UserTierId, type UserTierId,
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL,
VALID_GEMINI_MODELS, VALID_GEMINI_MODELS,
isProModel,
getDisplayString,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { type UseHistoryManagerReturn } from './useHistoryManager.js'; import { type UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -67,11 +67,9 @@ export function useQuotaAndFallback({
let message: string; let message: string;
let isTerminalQuotaError = false; let isTerminalQuotaError = false;
let isModelNotFoundError = false; let isModelNotFoundError = false;
const usageLimitReachedModel = const usageLimitReachedModel = isProModel(failedModel)
failedModel === DEFAULT_GEMINI_MODEL || ? 'all Pro models'
failedModel === PREVIEW_GEMINI_MODEL : failedModel;
? 'all Pro models'
: failedModel;
if (error instanceof TerminalQuotaError) { if (error instanceof TerminalQuotaError) {
isTerminalQuotaError = true; isTerminalQuotaError = true;
// Common part of the message for both tiers // Common part of the message for both tiers
@@ -89,7 +87,7 @@ export function useQuotaAndFallback({
) { ) {
isModelNotFoundError = true; isModelNotFoundError = true;
const messageLines = [ const messageLines = [
`It seems like you don't have access to ${failedModel}.`, `It seems like you don't have access to ${getDisplayString(failedModel)}.`,
`Your admin might have disabled the access. Contact them to enable the Preview Release Channel.`, `Your admin might have disabled the access. Contact them to enable the Preview Release Channel.`,
]; ];
message = messageLines.join('\n'); message = messageLines.join('\n');
@@ -483,7 +483,10 @@ export class Session {
const functionCalls: FunctionCall[] = []; const functionCalls: FunctionCall[] = [];
try { try {
const model = resolveModel(this.config.getModel()); const model = resolveModel(
this.config.getModel(),
(await this.config.getGemini31Launched?.()) ?? false,
);
const responseStream = await chat.sendMessageStream( const responseStream = await chat.sendMessageStream(
{ model }, { model },
nextMessage?.parts ?? [], nextMessage?.parts ?? [],
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@google/gemini-cli-core", "name": "@google/gemini-cli-core",
"version": "0.29.0-nightly.20260203.71f46f116", "version": "0.29.4",
"description": "Gemini CLI Core", "description": "Gemini CLI Core",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
@@ -43,7 +43,10 @@ export function resolvePolicyChain(
const configuredModel = config.getModel(); const configuredModel = config.getModel();
let chain; let chain;
const resolvedModel = resolveModel(modelFromConfig); const resolvedModel = resolveModel(
modelFromConfig,
config.getGemini31LaunchedSync?.() ?? false,
);
const isAutoPreferred = preferredModel ? isAutoModel(preferredModel) : false; const isAutoPreferred = preferredModel ? isAutoModel(preferredModel) : false;
const isAutoConfigured = isAutoModel(configuredModel); const isAutoConfigured = isAutoModel(configuredModel);
@@ -345,6 +345,7 @@ describe('Admin Controls', () => {
// Should still start polling // Should still start polling
(mockServer.fetchAdminControls as Mock).mockResolvedValue({ (mockServer.fetchAdminControls as Mock).mockResolvedValue({
strictModeDisabled: true, strictModeDisabled: true,
adminControlsApplicable: true,
}); });
await vi.advanceTimersByTimeAsync(5 * 60 * 1000); await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
@@ -363,7 +364,10 @@ describe('Admin Controls', () => {
}); });
it('should fetch from server if no cachedSettings provided', async () => { it('should fetch from server if no cachedSettings provided', async () => {
const serverResponse = { strictModeDisabled: false }; const serverResponse = {
strictModeDisabled: false,
adminControlsApplicable: true,
};
(mockServer.fetchAdminControls as Mock).mockResolvedValue(serverResponse); (mockServer.fetchAdminControls as Mock).mockResolvedValue(serverResponse);
const result = await fetchAdminControls( const result = await fetchAdminControls(
@@ -386,31 +390,24 @@ describe('Admin Controls', () => {
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1); expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1);
}); });
it('should return empty object on fetch error and still start polling', async () => { it('should throw error on fetch error and NOT start polling', async () => {
(mockServer.fetchAdminControls as Mock).mockRejectedValue( const error = new Error('Network error');
new Error('Network error'), (mockServer.fetchAdminControls as Mock).mockRejectedValue(error);
);
const result = await fetchAdminControls(
mockServer,
undefined,
true,
mockOnSettingsChanged,
);
expect(result).toEqual({}); await expect(
fetchAdminControls(mockServer, undefined, true, mockOnSettingsChanged),
).rejects.toThrow(error);
// Polling should have been started and should retry // Polling should NOT have been started
(mockServer.fetchAdminControls as Mock).mockResolvedValue({ // Advance timers just to be absolutely sure
strictModeDisabled: false,
});
await vi.advanceTimersByTimeAsync(5 * 60 * 1000); await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(2); // Initial + poll expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1); // Only initial fetch
}); });
it('should return empty object on 403 fetch error and STOP polling', async () => { it('should return empty object on adminControlsApplicable false and STOP polling', async () => {
const error403 = new Error('Forbidden'); (mockServer.fetchAdminControls as Mock).mockResolvedValue({
Object.assign(error403, { status: 403 }); adminControlsApplicable: false,
(mockServer.fetchAdminControls as Mock).mockRejectedValue(error403); });
const result = await fetchAdminControls( const result = await fetchAdminControls(
mockServer, mockServer,
@@ -421,7 +418,7 @@ describe('Admin Controls', () => {
expect(result).toEqual({}); expect(result).toEqual({});
// Advance time - should NOT poll because of 403 // Advance time - should NOT poll because of adminControlsApplicable: false
await vi.advanceTimersByTimeAsync(5 * 60 * 1000); await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1); // Only the initial call expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1); // Only the initial call
}); });
@@ -430,6 +427,7 @@ describe('Admin Controls', () => {
(mockServer.fetchAdminControls as Mock).mockResolvedValue({ (mockServer.fetchAdminControls as Mock).mockResolvedValue({
strictModeDisabled: false, strictModeDisabled: false,
unknownField: 'bad', unknownField: 'bad',
adminControlsApplicable: true,
}); });
const result = await fetchAdminControls( const result = await fetchAdminControls(
@@ -455,7 +453,9 @@ describe('Admin Controls', () => {
}); });
it('should reset polling interval if called again', async () => { it('should reset polling interval if called again', async () => {
(mockServer.fetchAdminControls as Mock).mockResolvedValue({}); (mockServer.fetchAdminControls as Mock).mockResolvedValue({
adminControlsApplicable: true,
});
// First call // First call
await fetchAdminControls( await fetchAdminControls(
@@ -514,6 +514,7 @@ describe('Admin Controls', () => {
const serverResponse = { const serverResponse = {
strictModeDisabled: true, strictModeDisabled: true,
unknownField: 'should be removed', unknownField: 'should be removed',
adminControlsApplicable: true,
}; };
(mockServer.fetchAdminControls as Mock).mockResolvedValue(serverResponse); (mockServer.fetchAdminControls as Mock).mockResolvedValue(serverResponse);
@@ -532,22 +533,22 @@ describe('Admin Controls', () => {
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1); expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1);
}); });
it('should return empty object on 403 fetch error', async () => { it('should return empty object on adminControlsApplicable false', async () => {
const error403 = new Error('Forbidden'); (mockServer.fetchAdminControls as Mock).mockResolvedValue({
Object.assign(error403, { status: 403 }); adminControlsApplicable: false,
(mockServer.fetchAdminControls as Mock).mockRejectedValue(error403); });
const result = await fetchAdminControlsOnce(mockServer, true); const result = await fetchAdminControlsOnce(mockServer, true);
expect(result).toEqual({}); expect(result).toEqual({});
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1); expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1);
}); });
it('should return empty object on any other fetch error', async () => { it('should throw error on any other fetch error', async () => {
(mockServer.fetchAdminControls as Mock).mockRejectedValue( const error = new Error('Network error');
new Error('Network error'), (mockServer.fetchAdminControls as Mock).mockRejectedValue(error);
await expect(fetchAdminControlsOnce(mockServer, true)).rejects.toThrow(
error,
); );
const result = await fetchAdminControlsOnce(mockServer, true);
expect(result).toEqual({});
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1); expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1);
}); });
@@ -555,7 +556,9 @@ describe('Admin Controls', () => {
const setIntervalSpy = vi.spyOn(global, 'setInterval'); const setIntervalSpy = vi.spyOn(global, 'setInterval');
const clearIntervalSpy = vi.spyOn(global, 'clearInterval'); const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
(mockServer.fetchAdminControls as Mock).mockResolvedValue({}); (mockServer.fetchAdminControls as Mock).mockResolvedValue({
adminControlsApplicable: true,
});
await fetchAdminControlsOnce(mockServer, true); await fetchAdminControlsOnce(mockServer, true);
expect(setIntervalSpy).not.toHaveBeenCalled(); expect(setIntervalSpy).not.toHaveBeenCalled();
@@ -568,6 +571,7 @@ describe('Admin Controls', () => {
// Initial fetch // Initial fetch
(mockServer.fetchAdminControls as Mock).mockResolvedValue({ (mockServer.fetchAdminControls as Mock).mockResolvedValue({
strictModeDisabled: true, strictModeDisabled: true,
adminControlsApplicable: true,
}); });
await fetchAdminControls( await fetchAdminControls(
mockServer, mockServer,
@@ -579,6 +583,7 @@ describe('Admin Controls', () => {
// Update for next poll // Update for next poll
(mockServer.fetchAdminControls as Mock).mockResolvedValue({ (mockServer.fetchAdminControls as Mock).mockResolvedValue({
strictModeDisabled: false, strictModeDisabled: false,
adminControlsApplicable: true,
}); });
// Fast forward // Fast forward
@@ -598,7 +603,10 @@ describe('Admin Controls', () => {
}); });
it('should NOT emit if settings are deeply equal but not the same instance', async () => { it('should NOT emit if settings are deeply equal but not the same instance', async () => {
const settings = { strictModeDisabled: false }; const settings = {
strictModeDisabled: false,
adminControlsApplicable: true,
};
(mockServer.fetchAdminControls as Mock).mockResolvedValue(settings); (mockServer.fetchAdminControls as Mock).mockResolvedValue(settings);
await fetchAdminControls( await fetchAdminControls(
@@ -613,6 +621,7 @@ describe('Admin Controls', () => {
// Next poll returns a different object with the same values // Next poll returns a different object with the same values
(mockServer.fetchAdminControls as Mock).mockResolvedValue({ (mockServer.fetchAdminControls as Mock).mockResolvedValue({
strictModeDisabled: false, strictModeDisabled: false,
adminControlsApplicable: true,
}); });
await vi.advanceTimersByTimeAsync(5 * 60 * 1000); await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
@@ -623,6 +632,7 @@ describe('Admin Controls', () => {
// Initial fetch is successful // Initial fetch is successful
(mockServer.fetchAdminControls as Mock).mockResolvedValue({ (mockServer.fetchAdminControls as Mock).mockResolvedValue({
strictModeDisabled: true, strictModeDisabled: true,
adminControlsApplicable: true,
}); });
await fetchAdminControls( await fetchAdminControls(
mockServer, mockServer,
@@ -643,6 +653,7 @@ describe('Admin Controls', () => {
// Subsequent poll succeeds with new data // Subsequent poll succeeds with new data
(mockServer.fetchAdminControls as Mock).mockResolvedValue({ (mockServer.fetchAdminControls as Mock).mockResolvedValue({
strictModeDisabled: false, strictModeDisabled: false,
adminControlsApplicable: true,
}); });
await vi.advanceTimersByTimeAsync(5 * 60 * 1000); await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(3); expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(3);
@@ -659,10 +670,11 @@ describe('Admin Controls', () => {
}); });
}); });
it('should STOP polling if server returns 403', async () => { it('should STOP polling if server returns adminControlsApplicable false', async () => {
// Initial fetch is successful // Initial fetch is successful
(mockServer.fetchAdminControls as Mock).mockResolvedValue({ (mockServer.fetchAdminControls as Mock).mockResolvedValue({
strictModeDisabled: true, strictModeDisabled: true,
adminControlsApplicable: true,
}); });
await fetchAdminControls( await fetchAdminControls(
mockServer, mockServer,
@@ -672,10 +684,10 @@ describe('Admin Controls', () => {
); );
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1); expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1);
// Next poll returns 403 // Next poll returns adminControlsApplicable: false
const error403 = new Error('Forbidden'); (mockServer.fetchAdminControls as Mock).mockResolvedValue({
Object.assign(error403, { status: 403 }); adminControlsApplicable: false,
(mockServer.fetchAdminControls as Mock).mockRejectedValue(error403); });
await vi.advanceTimersByTimeAsync(5 * 60 * 1000); await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(2); expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(2);
@@ -688,7 +700,9 @@ describe('Admin Controls', () => {
describe('stopAdminControlsPolling', () => { describe('stopAdminControlsPolling', () => {
it('should stop polling after it has started', async () => { it('should stop polling after it has started', async () => {
(mockServer.fetchAdminControls as Mock).mockResolvedValue({}); (mockServer.fetchAdminControls as Mock).mockResolvedValue({
adminControlsApplicable: true,
});
// Start polling // Start polling
await fetchAdminControls( await fetchAdminControls(
@@ -80,15 +80,6 @@ export function sanitizeAdminSettings(
}; };
} }
function isGaxiosError(error: unknown): error is { status: number } {
return (
typeof error === 'object' &&
error !== null &&
'status' in error &&
typeof (error as { status: unknown }).status === 'number'
);
}
/** /**
* Fetches the admin controls from the server if enabled by experiment flag. * Fetches the admin controls from the server if enabled by experiment flag.
* Safely handles polling start/stop based on the flag and server availability. * Safely handles polling start/stop based on the flag and server availability.
@@ -113,7 +104,7 @@ export async function fetchAdminControls(
// If we already have settings (e.g. from IPC during relaunch), use them // If we already have settings (e.g. from IPC during relaunch), use them
// to avoid blocking startup with another fetch. We'll still start polling. // to avoid blocking startup with another fetch. We'll still start polling.
if (cachedSettings) { if (cachedSettings && Object.keys(cachedSettings).length !== 0) {
currentSettings = cachedSettings; currentSettings = cachedSettings;
startAdminControlsPolling(server, server.projectId, onSettingsChanged); startAdminControlsPolling(server, server.projectId, onSettingsChanged);
return cachedSettings; return cachedSettings;
@@ -123,22 +114,20 @@ export async function fetchAdminControls(
const rawSettings = await server.fetchAdminControls({ const rawSettings = await server.fetchAdminControls({
project: server.projectId, project: server.projectId,
}); });
if (rawSettings.adminControlsApplicable !== true) {
stopAdminControlsPolling();
currentSettings = undefined;
return {};
}
const sanitizedSettings = sanitizeAdminSettings(rawSettings); const sanitizedSettings = sanitizeAdminSettings(rawSettings);
currentSettings = sanitizedSettings; currentSettings = sanitizedSettings;
startAdminControlsPolling(server, server.projectId, onSettingsChanged); startAdminControlsPolling(server, server.projectId, onSettingsChanged);
return sanitizedSettings; return sanitizedSettings;
} catch (e) { } catch (e) {
// Non-enterprise users don't have access to fetch settings.
if (isGaxiosError(e) && e.status === 403) {
stopAdminControlsPolling();
currentSettings = undefined;
return {};
}
debugLogger.error('Failed to fetch admin controls: ', e); debugLogger.error('Failed to fetch admin controls: ', e);
// If initial fetch fails, start polling to retry. throw e;
currentSettings = {};
startAdminControlsPolling(server, server.projectId, onSettingsChanged);
return {};
} }
} }
@@ -162,17 +151,18 @@ export async function fetchAdminControlsOnce(
const rawSettings = await server.fetchAdminControls({ const rawSettings = await server.fetchAdminControls({
project: server.projectId, project: server.projectId,
}); });
return sanitizeAdminSettings(rawSettings);
} catch (e) { if (rawSettings.adminControlsApplicable !== true) {
// Non-enterprise users don't have access to fetch settings.
if (isGaxiosError(e) && e.status === 403) {
return {}; return {};
} }
return sanitizeAdminSettings(rawSettings);
} catch (e) {
debugLogger.error( debugLogger.error(
'Failed to fetch admin controls: ', 'Failed to fetch admin controls: ',
e instanceof Error ? e.message : e, e instanceof Error ? e.message : e,
); );
return {}; throw e;
} }
} }
@@ -192,6 +182,13 @@ function startAdminControlsPolling(
const rawSettings = await server.fetchAdminControls({ const rawSettings = await server.fetchAdminControls({
project, project,
}); });
if (rawSettings.adminControlsApplicable !== true) {
stopAdminControlsPolling();
currentSettings = undefined;
return;
}
const newSettings = sanitizeAdminSettings(rawSettings); const newSettings = sanitizeAdminSettings(rawSettings);
if (!isDeepStrictEqual(newSettings, currentSettings)) { if (!isDeepStrictEqual(newSettings, currentSettings)) {
@@ -199,12 +196,6 @@ function startAdminControlsPolling(
onSettingsChanged(newSettings); onSettingsChanged(newSettings);
} }
} catch (e) { } catch (e) {
// Non-enterprise users don't have access to fetch settings.
if (isGaxiosError(e) && e.status === 403) {
stopAdminControlsPolling();
currentSettings = undefined;
return;
}
debugLogger.error('Failed to poll admin controls: ', e); debugLogger.error('Failed to poll admin controls: ', e);
} }
}, },
@@ -16,6 +16,7 @@ export const ExperimentFlags = {
MASKING_PROTECTION_THRESHOLD: 45758817, MASKING_PROTECTION_THRESHOLD: 45758817,
MASKING_PRUNABLE_THRESHOLD: 45758818, MASKING_PRUNABLE_THRESHOLD: 45758818,
MASKING_PROTECT_LATEST_TURN: 45758819, MASKING_PROTECT_LATEST_TURN: 45758819,
GEMINI_3_1_PRO_LAUNCHED: 45760185,
} as const; } as const;
export type ExperimentFlagName = export type ExperimentFlagName =
+1
View File
@@ -355,4 +355,5 @@ export const FetchAdminControlsResponseSchema = z.object({
strictModeDisabled: z.boolean().optional(), strictModeDisabled: z.boolean().optional(),
mcpSetting: McpSettingSchema.optional(), mcpSetting: McpSettingSchema.optional(),
cliFeatureSetting: CliFeatureSettingSchema.optional(), cliFeatureSetting: CliFeatureSettingSchema.optional(),
adminControlsApplicable: z.boolean().optional(),
}); });
+49 -4
View File
@@ -1023,6 +1023,12 @@ export class Config {
// Reset availability status when switching auth (e.g. from limited key to OAuth) // Reset availability status when switching auth (e.g. from limited key to OAuth)
this.modelAvailabilityService.reset(); this.modelAvailabilityService.reset();
// Clear stale authType to ensure getGemini31LaunchedSync doesn't return stale results
// during the transition.
if (this.contentGeneratorConfig) {
this.contentGeneratorConfig.authType = undefined;
}
const newContentGeneratorConfig = await createContentGeneratorConfig( const newContentGeneratorConfig = await createContentGeneratorConfig(
this, this,
authMethod, authMethod,
@@ -1145,7 +1151,7 @@ export class Config {
return this.remoteAdminSettings; return this.remoteAdminSettings;
} }
setRemoteAdminSettings(settings: AdminControlsSettings): void { setRemoteAdminSettings(settings: AdminControlsSettings | undefined): void {
this.remoteAdminSettings = settings; this.remoteAdminSettings = settings;
} }
@@ -1298,7 +1304,10 @@ export class Config {
if (pooled.remaining !== undefined) { if (pooled.remaining !== undefined) {
return pooled.remaining; return pooled.remaining;
} }
const primaryModel = resolveModel(this.getModel()); const primaryModel = resolveModel(
this.getModel(),
this.getGemini31LaunchedSync(),
);
return this.modelQuotas.get(primaryModel)?.remaining; return this.modelQuotas.get(primaryModel)?.remaining;
} }
@@ -1307,7 +1316,10 @@ export class Config {
if (pooled.limit !== undefined) { if (pooled.limit !== undefined) {
return pooled.limit; return pooled.limit;
} }
const primaryModel = resolveModel(this.getModel()); const primaryModel = resolveModel(
this.getModel(),
this.getGemini31LaunchedSync(),
);
return this.modelQuotas.get(primaryModel)?.limit; return this.modelQuotas.get(primaryModel)?.limit;
} }
@@ -1316,7 +1328,10 @@ export class Config {
if (pooled.resetTime !== undefined) { if (pooled.resetTime !== undefined) {
return pooled.resetTime; return pooled.resetTime;
} }
const primaryModel = resolveModel(this.getModel()); const primaryModel = resolveModel(
this.getModel(),
this.getGemini31LaunchedSync(),
);
return this.modelQuotas.get(primaryModel)?.resetTime; return this.modelQuotas.get(primaryModel)?.resetTime;
} }
@@ -2145,6 +2160,36 @@ export class Config {
); );
} }
/**
* Returns whether Gemini 3.1 has been launched.
* This method is async and ensures that experiments are loaded before returning the result.
*/
async getGemini31Launched(): Promise<boolean> {
await this.ensureExperimentsLoaded();
return this.getGemini31LaunchedSync();
}
/**
* Returns whether Gemini 3.1 has been launched.
*
* Note: This method should only be called after startup, once experiments have been loaded.
* If you need to call this during startup or from an async context, use
* getGemini31Launched instead.
*/
getGemini31LaunchedSync(): boolean {
const authType = this.contentGeneratorConfig?.authType;
if (
authType === AuthType.USE_GEMINI ||
authType === AuthType.USE_VERTEX_AI
) {
return true;
}
return (
this.experiments?.flags[ExperimentFlags.GEMINI_3_1_PRO_LAUNCHED]
?.boolValue ?? false
);
}
private async ensureExperimentsLoaded(): Promise<void> { private async ensureExperimentsLoaded(): Promise<void> {
if (!this.experimentsPromise) { if (!this.experimentsPromise) {
return; return;
+116
View File
@@ -23,8 +23,41 @@ import {
PREVIEW_GEMINI_FLASH_MODEL, PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_MODEL_AUTO, PREVIEW_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL_AUTO,
isActiveModel,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
isPreviewModel,
isProModel,
} from './models.js'; } from './models.js';
describe('isPreviewModel', () => {
it('should return true for preview models', () => {
expect(isPreviewModel(PREVIEW_GEMINI_MODEL)).toBe(true);
expect(isPreviewModel(PREVIEW_GEMINI_3_1_MODEL)).toBe(true);
expect(isPreviewModel(PREVIEW_GEMINI_FLASH_MODEL)).toBe(true);
expect(isPreviewModel(PREVIEW_GEMINI_MODEL_AUTO)).toBe(true);
});
it('should return false for non-preview models', () => {
expect(isPreviewModel(DEFAULT_GEMINI_MODEL)).toBe(false);
expect(isPreviewModel('gemini-1.5-pro')).toBe(false);
});
});
describe('isProModel', () => {
it('should return true for models containing "pro"', () => {
expect(isProModel('gemini-3-pro-preview')).toBe(true);
expect(isProModel('gemini-2.5-pro')).toBe(true);
expect(isProModel('pro')).toBe(true);
});
it('should return false for models without "pro"', () => {
expect(isProModel('gemini-3-flash-preview')).toBe(false);
expect(isProModel('gemini-2.5-flash')).toBe(false);
expect(isProModel('auto')).toBe(false);
});
});
describe('isGemini3Model', () => { describe('isGemini3Model', () => {
it('should return true for gemini-3 models', () => { it('should return true for gemini-3 models', () => {
expect(isGemini3Model('gemini-3-pro-preview')).toBe(true); expect(isGemini3Model('gemini-3-pro-preview')).toBe(true);
@@ -69,6 +102,12 @@ describe('getDisplayString', () => {
); );
}); });
it('should return PREVIEW_GEMINI_3_1_MODEL for PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL', () => {
expect(getDisplayString(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL)).toBe(
PREVIEW_GEMINI_3_1_MODEL,
);
});
it('should return the model name as is for other models', () => { it('should return the model name as is for other models', () => {
expect(getDisplayString('custom-model')).toBe('custom-model'); expect(getDisplayString('custom-model')).toBe('custom-model');
expect(getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL)).toBe( expect(getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL)).toBe(
@@ -100,6 +139,16 @@ describe('resolveModel', () => {
expect(model).toBe(PREVIEW_GEMINI_MODEL); expect(model).toBe(PREVIEW_GEMINI_MODEL);
}); });
it('should return Gemini 3.1 Pro when auto-gemini-3 is requested and useGemini3_1 is true', () => {
const model = resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true);
expect(model).toBe(PREVIEW_GEMINI_3_1_MODEL);
});
it('should return Gemini 3.1 Pro Custom Tools when auto-gemini-3 is requested, useGemini3_1 is true, and useCustomToolModel is true', () => {
const model = resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true, true);
expect(model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
});
it('should return the Default Pro model when auto-gemini-2.5 is requested', () => { it('should return the Default Pro model when auto-gemini-2.5 is requested', () => {
const model = resolveModel(DEFAULT_GEMINI_MODEL_AUTO); const model = resolveModel(DEFAULT_GEMINI_MODEL_AUTO);
expect(model).toBe(DEFAULT_GEMINI_MODEL); expect(model).toBe(DEFAULT_GEMINI_MODEL);
@@ -193,4 +242,71 @@ describe('resolveClassifierModel', () => {
resolveClassifierModel(PREVIEW_GEMINI_MODEL_AUTO, GEMINI_MODEL_ALIAS_PRO), resolveClassifierModel(PREVIEW_GEMINI_MODEL_AUTO, GEMINI_MODEL_ALIAS_PRO),
).toBe(PREVIEW_GEMINI_MODEL); ).toBe(PREVIEW_GEMINI_MODEL);
}); });
it('should return Gemini 3.1 Pro when alias is pro and useGemini3_1 is true', () => {
expect(
resolveClassifierModel(
PREVIEW_GEMINI_MODEL_AUTO,
GEMINI_MODEL_ALIAS_PRO,
true,
),
).toBe(PREVIEW_GEMINI_3_1_MODEL);
});
it('should return Gemini 3.1 Pro Custom Tools when alias is pro, useGemini3_1 is true, and useCustomToolModel is true', () => {
expect(
resolveClassifierModel(
PREVIEW_GEMINI_MODEL_AUTO,
GEMINI_MODEL_ALIAS_PRO,
true,
true,
),
).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
});
});
describe('isActiveModel', () => {
it('should return true for valid models when useGemini3_1 is false', () => {
expect(isActiveModel(DEFAULT_GEMINI_MODEL)).toBe(true);
expect(isActiveModel(PREVIEW_GEMINI_MODEL)).toBe(true);
expect(isActiveModel(DEFAULT_GEMINI_FLASH_MODEL)).toBe(true);
});
it('should return true for unknown models and aliases', () => {
expect(isActiveModel('invalid-model')).toBe(false);
expect(isActiveModel(GEMINI_MODEL_ALIAS_AUTO)).toBe(false);
});
it('should return false for PREVIEW_GEMINI_MODEL when useGemini3_1 is true', () => {
expect(isActiveModel(PREVIEW_GEMINI_MODEL, true)).toBe(false);
});
it('should return true for other valid models when useGemini3_1 is true', () => {
expect(isActiveModel(DEFAULT_GEMINI_MODEL, true)).toBe(true);
});
it('should correctly filter Gemini 3.1 models based on useCustomToolModel when useGemini3_1 is true', () => {
// When custom tools are preferred, standard 3.1 should be inactive
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, true)).toBe(false);
expect(
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, true),
).toBe(true);
// When custom tools are NOT preferred, custom tools 3.1 should be inactive
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, false)).toBe(true);
expect(
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, false),
).toBe(false);
});
it('should return false for both Gemini 3.1 models when useGemini3_1 is false', () => {
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, false, true)).toBe(false);
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, false, false)).toBe(false);
expect(
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, false, true),
).toBe(false);
expect(
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, false, false),
).toBe(false);
});
}); });
+68 -7
View File
@@ -5,6 +5,9 @@
*/ */
export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview'; export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview';
export const PREVIEW_GEMINI_3_1_MODEL = 'gemini-3.1-pro-preview';
export const PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL =
'gemini-3.1-pro-preview-customtools';
export const PREVIEW_GEMINI_FLASH_MODEL = 'gemini-3-flash-preview'; export const PREVIEW_GEMINI_FLASH_MODEL = 'gemini-3-flash-preview';
export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro'; export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro';
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash'; export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
@@ -12,6 +15,8 @@ export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';
export const VALID_GEMINI_MODELS = new Set([ export const VALID_GEMINI_MODELS = new Set([
PREVIEW_GEMINI_MODEL, PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL, PREVIEW_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_FLASH_MODEL,
@@ -37,20 +42,29 @@ export const DEFAULT_THINKING_MODE = 8192;
* to a concrete model name. * to a concrete model name.
* *
* @param requestedModel The model alias or concrete model name requested by the user. * @param requestedModel The model alias or concrete model name requested by the user.
* @param useGemini3_1 Whether to use Gemini 3.1 Pro Preview for auto/pro aliases.
* @returns The resolved concrete model name. * @returns The resolved concrete model name.
*/ */
export function resolveModel(requestedModel: string): string { export function resolveModel(
requestedModel: string,
useGemini3_1: boolean = false,
useCustomToolModel: boolean = false,
): string {
switch (requestedModel) { switch (requestedModel) {
case PREVIEW_GEMINI_MODEL_AUTO: { case PREVIEW_GEMINI_MODEL:
case PREVIEW_GEMINI_MODEL_AUTO:
case GEMINI_MODEL_ALIAS_AUTO:
case GEMINI_MODEL_ALIAS_PRO: {
if (useGemini3_1) {
return useCustomToolModel
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
: PREVIEW_GEMINI_3_1_MODEL;
}
return PREVIEW_GEMINI_MODEL; return PREVIEW_GEMINI_MODEL;
} }
case DEFAULT_GEMINI_MODEL_AUTO: { case DEFAULT_GEMINI_MODEL_AUTO: {
return DEFAULT_GEMINI_MODEL; return DEFAULT_GEMINI_MODEL;
} }
case GEMINI_MODEL_ALIAS_AUTO:
case GEMINI_MODEL_ALIAS_PRO: {
return PREVIEW_GEMINI_MODEL;
}
case GEMINI_MODEL_ALIAS_FLASH: { case GEMINI_MODEL_ALIAS_FLASH: {
return PREVIEW_GEMINI_FLASH_MODEL; return PREVIEW_GEMINI_FLASH_MODEL;
} }
@@ -73,6 +87,8 @@ export function resolveModel(requestedModel: string): string {
export function resolveClassifierModel( export function resolveClassifierModel(
requestedModel: string, requestedModel: string,
modelAlias: string, modelAlias: string,
useGemini3_1: boolean = false,
useCustomToolModel: boolean = false,
): string { ): string {
if (modelAlias === GEMINI_MODEL_ALIAS_FLASH) { if (modelAlias === GEMINI_MODEL_ALIAS_FLASH) {
if ( if (
@@ -89,7 +105,7 @@ export function resolveClassifierModel(
} }
return resolveModel(GEMINI_MODEL_ALIAS_FLASH); return resolveModel(GEMINI_MODEL_ALIAS_FLASH);
} }
return resolveModel(requestedModel); return resolveModel(requestedModel, useGemini3_1, useCustomToolModel);
} }
export function getDisplayString(model: string) { export function getDisplayString(model: string) {
switch (model) { switch (model) {
@@ -101,6 +117,8 @@ export function getDisplayString(model: string) {
return PREVIEW_GEMINI_MODEL; return PREVIEW_GEMINI_MODEL;
case GEMINI_MODEL_ALIAS_FLASH: case GEMINI_MODEL_ALIAS_FLASH:
return PREVIEW_GEMINI_FLASH_MODEL; return PREVIEW_GEMINI_FLASH_MODEL;
case PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL:
return PREVIEW_GEMINI_3_1_MODEL;
default: default:
return model; return model;
} }
@@ -115,11 +133,22 @@ export function getDisplayString(model: string) {
export function isPreviewModel(model: string): boolean { export function isPreviewModel(model: string): boolean {
return ( return (
model === PREVIEW_GEMINI_MODEL || model === PREVIEW_GEMINI_MODEL ||
model === PREVIEW_GEMINI_3_1_MODEL ||
model === PREVIEW_GEMINI_FLASH_MODEL || model === PREVIEW_GEMINI_FLASH_MODEL ||
model === PREVIEW_GEMINI_MODEL_AUTO model === PREVIEW_GEMINI_MODEL_AUTO
); );
} }
/**
* Checks if the model is a Pro model.
*
* @param model The model name to check.
* @returns True if the model is a Pro model.
*/
export function isProModel(model: string): boolean {
return model.toLowerCase().includes('pro');
}
/** /**
* Checks if the model is a Gemini 3 model. * Checks if the model is a Gemini 3 model.
* *
@@ -165,3 +194,35 @@ export function isAutoModel(model: string): boolean {
export function supportsMultimodalFunctionResponse(model: string): boolean { export function supportsMultimodalFunctionResponse(model: string): boolean {
return model.startsWith('gemini-3-'); return model.startsWith('gemini-3-');
} }
/**
* Checks if the given model is considered active based on the current configuration.
*
* @param model The model name to check.
* @param useGemini3_1 Whether Gemini 3.1 Pro Preview is enabled.
* @returns True if the model is active.
*/
export function isActiveModel(
model: string,
useGemini3_1: boolean = false,
useCustomToolModel: boolean = false,
): boolean {
if (!VALID_GEMINI_MODELS.has(model)) {
return false;
}
if (useGemini3_1) {
if (model === PREVIEW_GEMINI_MODEL) {
return false;
}
if (useCustomToolModel) {
return model !== PREVIEW_GEMINI_3_1_MODEL;
} else {
return model !== PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL;
}
} else {
return (
model !== PREVIEW_GEMINI_3_1_MODEL &&
model !== PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
);
}
}
+4 -1
View File
@@ -541,7 +541,10 @@ export class GeminiClient {
// Availability logic: The configured model is the source of truth, // Availability logic: The configured model is the source of truth,
// including any permanent fallbacks (config.setModel) or manual overrides. // including any permanent fallbacks (config.setModel) or manual overrides.
return resolveModel(this.config.getActiveModel()); return resolveModel(
this.config.getActiveModel(),
this.config.getGemini31LaunchedSync?.() ?? false,
);
} }
private async *processTurn( private async *processTurn(
+6 -1
View File
@@ -122,7 +122,12 @@ export async function createContentGenerator(
return new LoggingContentGenerator(fakeGenerator, gcConfig); return new LoggingContentGenerator(fakeGenerator, gcConfig);
} }
const version = await getVersion(); const version = await getVersion();
const model = resolveModel(gcConfig.getModel()); const model = resolveModel(
gcConfig.getModel(),
config.authType === AuthType.USE_GEMINI ||
config.authType === AuthType.USE_VERTEX_AI ||
((await gcConfig.getGemini31Launched?.()) ?? false),
);
const customHeadersEnv = const customHeadersEnv =
process.env['GEMINI_CLI_CUSTOM_HEADERS'] || undefined; process.env['GEMINI_CLI_CUSTOM_HEADERS'] || undefined;
const userAgent = `GeminiCLI/${version}/${model} (${process.platform}; ${process.arch})`; const userAgent = `GeminiCLI/${version}/${model} (${process.platform}; ${process.arch})`;
+3 -2
View File
@@ -492,13 +492,14 @@ export class GeminiChat {
const initialActiveModel = this.config.getActiveModel(); const initialActiveModel = this.config.getActiveModel();
const apiCall = async () => { const apiCall = async () => {
const useGemini3_1 = (await this.config.getGemini31Launched?.()) ?? false;
// Default to the last used model (which respects arguments/availability selection) // Default to the last used model (which respects arguments/availability selection)
let modelToUse = resolveModel(lastModelToUse); let modelToUse = resolveModel(lastModelToUse, useGemini3_1);
// If the active model has changed (e.g. due to a fallback updating the config), // If the active model has changed (e.g. due to a fallback updating the config),
// we switch to the new active model. // we switch to the new active model.
if (this.config.getActiveModel() !== initialActiveModel) { if (this.config.getActiveModel() !== initialActiveModel) {
modelToUse = resolveModel(this.config.getActiveModel()); modelToUse = resolveModel(this.config.getActiveModel(), useGemini3_1);
} }
if (modelToUse !== lastModelToUse) { if (modelToUse !== lastModelToUse) {
+8 -2
View File
@@ -58,7 +58,10 @@ export class PromptProvider {
const enabledToolNames = new Set(toolNames); const enabledToolNames = new Set(toolNames);
const approvedPlanPath = config.getApprovedPlanPath(); const approvedPlanPath = config.getApprovedPlanPath();
const desiredModel = resolveModel(config.getActiveModel()); const desiredModel = resolveModel(
config.getActiveModel(),
config.getGemini31LaunchedSync?.() ?? false,
);
const isGemini3 = isPreviewModel(desiredModel); const isGemini3 = isPreviewModel(desiredModel);
const activeSnippets = isGemini3 ? snippets : legacySnippets; const activeSnippets = isGemini3 ? snippets : legacySnippets;
const contextFilenames = getAllGeminiMdFilenames(); const contextFilenames = getAllGeminiMdFilenames();
@@ -233,7 +236,10 @@ export class PromptProvider {
} }
getCompressionPrompt(config: Config): string { getCompressionPrompt(config: Config): string {
const desiredModel = resolveModel(config.getActiveModel()); const desiredModel = resolveModel(
config.getActiveModel(),
config.getGemini31LaunchedSync?.() ?? false,
);
const isGemini3 = isPreviewModel(desiredModel); const isGemini3 = isPreviewModel(desiredModel);
const activeSnippets = isGemini3 ? snippets : legacySnippets; const activeSnippets = isGemini3 ? snippets : legacySnippets;
return activeSnippets.getCompressionPrompt(); return activeSnippets.getCompressionPrompt();
@@ -18,11 +18,14 @@ import {
DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_MODEL_AUTO, PREVIEW_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
} from '../../config/models.js'; } from '../../config/models.js';
import { promptIdContext } from '../../utils/promptIdContext.js'; import { promptIdContext } from '../../utils/promptIdContext.js';
import type { Content } from '@google/genai'; import type { Content } from '@google/genai';
import type { ResolvedModelConfig } from '../../services/modelConfigService.js'; import type { ResolvedModelConfig } from '../../services/modelConfigService.js';
import { debugLogger } from '../../utils/debugLogger.js'; import { debugLogger } from '../../utils/debugLogger.js';
import { AuthType } from '../../core/contentGenerator.js';
vi.mock('../../core/baseLlmClient.js'); vi.mock('../../core/baseLlmClient.js');
@@ -53,6 +56,10 @@ describe('ClassifierStrategy', () => {
}, },
getModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO), getModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO),
getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false), getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false),
getGemini31Launched: vi.fn().mockResolvedValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
}),
} as unknown as Config; } as unknown as Config;
mockBaseLlmClient = { mockBaseLlmClient = {
generateJson: vi.fn(), generateJson: vi.fn(),
@@ -339,4 +346,49 @@ describe('ClassifierStrategy', () => {
// Since requestedModel is Pro, and choice is flash, it should resolve to Flash // Since requestedModel is Pro, and choice is flash, it should resolve to Flash
expect(decision?.model).toBe(DEFAULT_GEMINI_FLASH_MODEL); expect(decision?.model).toBe(DEFAULT_GEMINI_FLASH_MODEL);
}); });
describe('Gemini 3.1 and Custom Tools Routing', () => {
it('should route to PREVIEW_GEMINI_3_1_MODEL when Gemini 3.1 is launched', async () => {
vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true);
vi.mocked(mockConfig.getModel).mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO);
const mockApiResponse = {
reasoning: 'Complex task',
model_choice: 'pro',
};
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
mockApiResponse,
);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
);
expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_MODEL);
});
it('should route to PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL when Gemini 3.1 is launched and auth is USE_GEMINI', async () => {
vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true);
vi.mocked(mockConfig.getModel).mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO);
vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
authType: AuthType.USE_GEMINI,
});
const mockApiResponse = {
reasoning: 'Complex task',
model_choice: 'pro',
};
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
mockApiResponse,
);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
);
expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
});
});
}); });
@@ -20,6 +20,7 @@ import {
isFunctionResponse, isFunctionResponse,
} from '../../utils/messageInspectors.js'; } from '../../utils/messageInspectors.js';
import { debugLogger } from '../../utils/debugLogger.js'; import { debugLogger } from '../../utils/debugLogger.js';
import { AuthType } from '../../core/contentGenerator.js';
// The number of recent history turns to provide to the router for context. // The number of recent history turns to provide to the router for context.
const HISTORY_TURNS_FOR_CONTEXT = 4; const HISTORY_TURNS_FOR_CONTEXT = 4;
@@ -167,9 +168,15 @@ export class ClassifierStrategy implements RoutingStrategy {
const reasoning = routerResponse.reasoning; const reasoning = routerResponse.reasoning;
const latencyMs = Date.now() - startTime; const latencyMs = Date.now() - startTime;
const useGemini3_1 = (await config.getGemini31Launched?.()) ?? false;
const useCustomToolModel =
useGemini3_1 &&
config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI;
const selectedModel = resolveClassifierModel( const selectedModel = resolveClassifierModel(
model, model,
routerResponse.model_choice, routerResponse.model_choice,
useGemini3_1,
useCustomToolModel,
); );
return { return {
@@ -21,7 +21,10 @@ export class DefaultStrategy implements TerminalStrategy {
config: Config, config: Config,
_baseLlmClient: BaseLlmClient, _baseLlmClient: BaseLlmClient,
): Promise<RoutingDecision> { ): Promise<RoutingDecision> {
const defaultModel = resolveModel(config.getModel()); const defaultModel = resolveModel(
config.getModel(),
config.getGemini31LaunchedSync?.() ?? false,
);
return { return {
model: defaultModel, model: defaultModel,
metadata: { metadata: {
@@ -23,7 +23,10 @@ export class FallbackStrategy implements RoutingStrategy {
_baseLlmClient: BaseLlmClient, _baseLlmClient: BaseLlmClient,
): Promise<RoutingDecision | null> { ): Promise<RoutingDecision | null> {
const requestedModel = context.requestedModel ?? config.getModel(); const requestedModel = context.requestedModel ?? config.getModel();
const resolvedModel = resolveModel(requestedModel); const resolvedModel = resolveModel(
requestedModel,
config.getGemini31LaunchedSync?.() ?? false,
);
const service = config.getModelAvailabilityService(); const service = config.getModelAvailabilityService();
const snapshot = service.snapshot(resolvedModel); const snapshot = service.snapshot(resolvedModel);
@@ -12,6 +12,8 @@ import type { BaseLlmClient } from '../../core/baseLlmClient.js';
import { import {
PREVIEW_GEMINI_FLASH_MODEL, PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_MODEL, PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_MODEL_AUTO, PREVIEW_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL,
@@ -20,6 +22,7 @@ import { promptIdContext } from '../../utils/promptIdContext.js';
import type { Content } from '@google/genai'; import type { Content } from '@google/genai';
import type { ResolvedModelConfig } from '../../services/modelConfigService.js'; import type { ResolvedModelConfig } from '../../services/modelConfigService.js';
import { debugLogger } from '../../utils/debugLogger.js'; import { debugLogger } from '../../utils/debugLogger.js';
import { AuthType } from '../../core/contentGenerator.js';
vi.mock('../../core/baseLlmClient.js'); vi.mock('../../core/baseLlmClient.js');
@@ -52,6 +55,10 @@ describe('NumericalClassifierStrategy', () => {
getSessionId: vi.fn().mockReturnValue('control-group-id'), // Default to Control Group (Hash 71 >= 50) getSessionId: vi.fn().mockReturnValue('control-group-id'), // Default to Control Group (Hash 71 >= 50)
getNumericalRoutingEnabled: vi.fn().mockResolvedValue(true), getNumericalRoutingEnabled: vi.fn().mockResolvedValue(true),
getClassifierThreshold: vi.fn().mockResolvedValue(undefined), getClassifierThreshold: vi.fn().mockResolvedValue(undefined),
getGemini31Launched: vi.fn().mockResolvedValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
}),
} as unknown as Config; } as unknown as Config;
mockBaseLlmClient = { mockBaseLlmClient = {
generateJson: vi.fn(), generateJson: vi.fn(),
@@ -535,4 +542,68 @@ describe('NumericalClassifierStrategy', () => {
), ),
); );
}); });
describe('Gemini 3.1 and Custom Tools Routing', () => {
it('should route to PREVIEW_GEMINI_3_1_MODEL when Gemini 3.1 is launched', async () => {
vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true);
const mockApiResponse = {
complexity_reasoning: 'Complex task',
complexity_score: 80,
};
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
mockApiResponse,
);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
);
expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_MODEL);
});
it('should route to PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL when Gemini 3.1 is launched and auth is USE_GEMINI', async () => {
vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true);
vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
authType: AuthType.USE_GEMINI,
});
const mockApiResponse = {
complexity_reasoning: 'Complex task',
complexity_score: 80,
};
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
mockApiResponse,
);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
);
expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
});
it('should NOT route to custom tools model when auth is USE_VERTEX_AI', async () => {
vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true);
vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
authType: AuthType.USE_VERTEX_AI,
});
const mockApiResponse = {
complexity_reasoning: 'Complex task',
complexity_score: 80,
};
vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue(
mockApiResponse,
);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
);
expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_MODEL);
});
});
}); });
@@ -16,6 +16,7 @@ import { resolveClassifierModel, isGemini3Model } from '../../config/models.js';
import { createUserContent, Type } from '@google/genai'; import { createUserContent, Type } from '@google/genai';
import type { Config } from '../../config/config.js'; import type { Config } from '../../config/config.js';
import { debugLogger } from '../../utils/debugLogger.js'; import { debugLogger } from '../../utils/debugLogger.js';
import { AuthType } from '../../core/contentGenerator.js';
// The number of recent history turns to provide to the router for context. // The number of recent history turns to provide to the router for context.
const HISTORY_TURNS_FOR_CONTEXT = 8; const HISTORY_TURNS_FOR_CONTEXT = 8;
@@ -180,8 +181,16 @@ export class NumericalClassifierStrategy implements RoutingStrategy {
config, config,
config.getSessionId() || 'unknown-session', config.getSessionId() || 'unknown-session',
); );
const useGemini3_1 = (await config.getGemini31Launched?.()) ?? false;
const selectedModel = resolveClassifierModel(model, modelAlias); const useCustomToolModel =
useGemini3_1 &&
config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI;
const selectedModel = resolveClassifierModel(
model,
modelAlias,
useGemini3_1,
useCustomToolModel,
);
const latencyMs = Date.now() - startTime; const latencyMs = Date.now() - startTime;
@@ -33,7 +33,10 @@ export class OverrideStrategy implements RoutingStrategy {
// Return the overridden model name. // Return the overridden model name.
return { return {
model: resolveModel(overrideModel), model: resolveModel(
overrideModel,
config.getGemini31LaunchedSync?.() ?? false,
),
metadata: { metadata: {
source: this.name, source: this.name,
latencyMs: 0, latencyMs: 0,
@@ -29,6 +29,7 @@ import {
DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL, PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_FLASH_MODEL, PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
} from '../config/models.js'; } from '../config/models.js';
import { PreCompressTrigger } from '../hooks/types.js'; import { PreCompressTrigger } from '../hooks/types.js';
@@ -100,6 +101,7 @@ export function findCompressSplitPoint(
export function modelStringToModelConfigAlias(model: string): string { export function modelStringToModelConfigAlias(model: string): string {
switch (model) { switch (model) {
case PREVIEW_GEMINI_MODEL: case PREVIEW_GEMINI_MODEL:
case PREVIEW_GEMINI_3_1_MODEL:
return 'chat-compression-3-pro'; return 'chat-compression-3-pro';
case PREVIEW_GEMINI_FLASH_MODEL: case PREVIEW_GEMINI_FLASH_MODEL:
return 'chat-compression-3-flash'; return 'chat-compression-3-flash';
@@ -46,9 +46,6 @@ describe('sanitizeEnvironment', () => {
CLIENT_ID: 'sensitive-id', CLIENT_ID: 'sensitive-id',
DB_URI: 'sensitive-uri', DB_URI: 'sensitive-uri',
DATABASE_URL: 'sensitive-url', DATABASE_URL: 'sensitive-url',
GEMINI_API_KEY: 'sensitive-gemini-key',
GOOGLE_API_KEY: 'sensitive-google-key',
GOOGLE_APPLICATION_CREDENTIALS: '/path/to/creds.json',
SAFE_VAR: 'is-safe', SAFE_VAR: 'is-safe',
}; };
const sanitized = sanitizeEnvironment(env, EMPTY_OPTIONS); const sanitized = sanitizeEnvironment(env, EMPTY_OPTIONS);
@@ -103,9 +103,6 @@ export const NEVER_ALLOWED_ENVIRONMENT_VARIABLES: ReadonlySet<string> = new Set(
'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_PROJECT',
'GOOGLE_CLOUD_ACCOUNT', 'GOOGLE_CLOUD_ACCOUNT',
'FIREBASE_PROJECT_ID', 'FIREBASE_PROJECT_ID',
'GEMINI_API_KEY',
'GOOGLE_API_KEY',
'GOOGLE_APPLICATION_CREDENTIALS',
], ],
); );
+10 -79
View File
@@ -901,9 +901,9 @@ describe('mcp-client', () => {
vi.mocked(ClientLib.Client).mockReturnValue( vi.mocked(ClientLib.Client).mockReturnValue(
mockedClient as unknown as ClientLib.Client, mockedClient as unknown as ClientLib.Client,
); );
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue({ vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
close: vi.fn(), {} as SdkClientStdioLib.StdioClientTransport,
} as unknown as SdkClientStdioLib.StdioClientTransport); );
const mockedToolRegistry = { const mockedToolRegistry = {
registerTool: vi.fn(), registerTool: vi.fn(),
unregisterTool: vi.fn(), unregisterTool: vi.fn(),
@@ -1623,7 +1623,7 @@ describe('mcp-client', () => {
{ {
command: 'test-command', command: 'test-command',
args: ['--foo', 'bar'], args: ['--foo', 'bar'],
env: { GEMINI_CLI_FOO: 'bar' }, env: { FOO: 'bar' },
cwd: 'test/cwd', cwd: 'test/cwd',
}, },
false, false,
@@ -1634,80 +1634,11 @@ describe('mcp-client', () => {
command: 'test-command', command: 'test-command',
args: ['--foo', 'bar'], args: ['--foo', 'bar'],
cwd: 'test/cwd', cwd: 'test/cwd',
env: expect.objectContaining({ GEMINI_CLI_FOO: 'bar' }), env: expect.objectContaining({ FOO: 'bar' }),
stderr: 'pipe', stderr: 'pipe',
}); });
}); });
it('should redact sensitive environment variables for command transport', async () => {
const mockedTransport = vi
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
.mockReturnValue({} as SdkClientStdioLib.StdioClientTransport);
const originalEnv = process.env;
process.env = {
...originalEnv,
GEMINI_API_KEY: 'sensitive-key',
GEMINI_CLI_SAFE_VAR: 'safe-value',
};
// Ensure strict sanitization is not triggered for this test
delete process.env['GITHUB_SHA'];
delete process.env['SURFACE'];
try {
await createTransport(
'test-server',
{
command: 'test-command',
},
false,
EMPTY_CONFIG,
);
const callArgs = mockedTransport.mock.calls[0][0];
expect(callArgs.env).toBeDefined();
expect(callArgs.env!['GEMINI_CLI_SAFE_VAR']).toBe('safe-value');
expect(callArgs.env!['GEMINI_API_KEY']).toBeUndefined();
} finally {
process.env = originalEnv;
}
});
it('should include extension settings in environment', async () => {
const mockedTransport = vi
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
.mockReturnValue({} as SdkClientStdioLib.StdioClientTransport);
await createTransport(
'test-server',
{
command: 'test-command',
extension: {
name: 'test-ext',
resolvedSettings: [
{
envVar: 'GEMINI_CLI_EXT_VAR',
value: 'ext-value',
sensitive: false,
name: 'ext-setting',
},
],
version: '',
isActive: false,
path: '',
contextFiles: [],
id: '',
},
},
false,
EMPTY_CONFIG,
);
const callArgs = mockedTransport.mock.calls[0][0];
expect(callArgs.env).toBeDefined();
expect(callArgs.env!['GEMINI_CLI_EXT_VAR']).toBe('ext-value');
});
it('should exclude extension settings with undefined values from environment', async () => { it('should exclude extension settings with undefined values from environment', async () => {
const mockedTransport = vi const mockedTransport = vi
.spyOn(SdkClientStdioLib, 'StdioClientTransport') .spyOn(SdkClientStdioLib, 'StdioClientTransport')
@@ -2040,7 +1971,7 @@ describe('connectToMcpServer with OAuth', () => {
EMPTY_CONFIG, EMPTY_CONFIG,
); );
expect(client.client).toBe(mockedClient); expect(client).toBe(mockedClient);
expect(mockedClient.connect).toHaveBeenCalledTimes(2); expect(mockedClient.connect).toHaveBeenCalledTimes(2);
expect(mockAuthProvider.authenticate).toHaveBeenCalledOnce(); expect(mockAuthProvider.authenticate).toHaveBeenCalledOnce();
@@ -2086,7 +2017,7 @@ describe('connectToMcpServer with OAuth', () => {
EMPTY_CONFIG, EMPTY_CONFIG,
); );
expect(client.client).toBe(mockedClient); expect(client).toBe(mockedClient);
expect(mockedClient.connect).toHaveBeenCalledTimes(2); expect(mockedClient.connect).toHaveBeenCalledTimes(2);
expect(mockAuthProvider.authenticate).toHaveBeenCalledOnce(); expect(mockAuthProvider.authenticate).toHaveBeenCalledOnce();
expect(OAuthUtils.discoverOAuthConfig).toHaveBeenCalledWith(serverUrl); expect(OAuthUtils.discoverOAuthConfig).toHaveBeenCalledWith(serverUrl);
@@ -2181,7 +2112,7 @@ describe('connectToMcpServer - HTTP→SSE fallback', () => {
EMPTY_CONFIG, EMPTY_CONFIG,
); );
expect(client.client).toBe(mockedClient); expect(client).toBe(mockedClient);
// First HTTP attempt fails, second SSE attempt succeeds // First HTTP attempt fails, second SSE attempt succeeds
expect(mockedClient.connect).toHaveBeenCalledTimes(2); expect(mockedClient.connect).toHaveBeenCalledTimes(2);
}); });
@@ -2222,7 +2153,7 @@ describe('connectToMcpServer - HTTP→SSE fallback', () => {
EMPTY_CONFIG, EMPTY_CONFIG,
); );
expect(client.client).toBe(mockedClient); expect(client).toBe(mockedClient);
expect(mockedClient.connect).toHaveBeenCalledTimes(2); expect(mockedClient.connect).toHaveBeenCalledTimes(2);
}); });
}); });
@@ -2307,7 +2238,7 @@ describe('connectToMcpServer - OAuth with transport fallback', () => {
EMPTY_CONFIG, EMPTY_CONFIG,
); );
expect(client.client).toBe(mockedClient); expect(client).toBe(mockedClient);
expect(mockedClient.connect).toHaveBeenCalledTimes(3); expect(mockedClient.connect).toHaveBeenCalledTimes(3);
expect(mockAuthProvider.authenticate).toHaveBeenCalledOnce(); expect(mockAuthProvider.authenticate).toHaveBeenCalledOnce();
}); });
+22 -87
View File
@@ -34,11 +34,7 @@ import {
} from '@modelcontextprotocol/sdk/types.js'; } from '@modelcontextprotocol/sdk/types.js';
import { ApprovalMode, PolicyDecision } from '../policy/types.js'; import { ApprovalMode, PolicyDecision } from '../policy/types.js';
import { parse } from 'shell-quote'; import { parse } from 'shell-quote';
import type { import type { Config, MCPServerConfig } from '../config/config.js';
Config,
GeminiCLIExtension,
MCPServerConfig,
} from '../config/config.js';
import { AuthProviderType } from '../config/config.js'; import { AuthProviderType } from '../config/config.js';
import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js'; import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js';
import { ServiceAccountImpersonationProvider } from '../mcp/sa-impersonation-provider.js'; import { ServiceAccountImpersonationProvider } from '../mcp/sa-impersonation-provider.js';
@@ -146,7 +142,7 @@ export class McpClient {
} }
this.updateStatus(MCPServerStatus.CONNECTING); this.updateStatus(MCPServerStatus.CONNECTING);
try { try {
const { client, transport } = await connectToMcpServer( this.client = await connectToMcpServer(
this.clientVersion, this.clientVersion,
this.serverName, this.serverName,
this.serverConfig, this.serverConfig,
@@ -154,13 +150,11 @@ export class McpClient {
this.workspaceContext, this.workspaceContext,
this.cliConfig.sanitizationConfig, this.cliConfig.sanitizationConfig,
); );
this.client = client;
this.transport = transport;
this.registerNotificationHandlers(); this.registerNotificationHandlers();
const originalOnError = this.client.onerror; const originalOnError = this.client.onerror;
this.client.onerror = async (error) => { this.client.onerror = (error) => {
if (this.status !== MCPServerStatus.CONNECTED) { if (this.status !== MCPServerStatus.CONNECTED) {
return; return;
} }
@@ -171,14 +165,6 @@ export class McpClient {
error, error,
); );
this.updateStatus(MCPServerStatus.DISCONNECTED); this.updateStatus(MCPServerStatus.DISCONNECTED);
// Close transport to prevent memory leaks
if (this.transport) {
try {
await this.transport.close();
} catch {
// Ignore errors when closing transport on error
}
}
}; };
this.updateStatus(MCPServerStatus.CONNECTED); this.updateStatus(MCPServerStatus.CONNECTED);
} catch (error) { } catch (error) {
@@ -927,9 +913,8 @@ export async function connectAndDiscover(
updateMCPServerStatus(mcpServerName, MCPServerStatus.CONNECTING); updateMCPServerStatus(mcpServerName, MCPServerStatus.CONNECTING);
let mcpClient: Client | undefined; let mcpClient: Client | undefined;
let transport: Transport | undefined;
try { try {
const result = await connectToMcpServer( mcpClient = await connectToMcpServer(
clientVersion, clientVersion,
mcpServerName, mcpServerName,
mcpServerConfig, mcpServerConfig,
@@ -937,20 +922,10 @@ export async function connectAndDiscover(
workspaceContext, workspaceContext,
cliConfig.sanitizationConfig, cliConfig.sanitizationConfig,
); );
mcpClient = result.client;
transport = result.transport;
mcpClient.onerror = async (error) => { mcpClient.onerror = (error) => {
coreEvents.emitFeedback('error', `MCP ERROR (${mcpServerName}):`, error); coreEvents.emitFeedback('error', `MCP ERROR (${mcpServerName}):`, error);
updateMCPServerStatus(mcpServerName, MCPServerStatus.DISCONNECTED); updateMCPServerStatus(mcpServerName, MCPServerStatus.DISCONNECTED);
// Close transport to prevent memory leaks
if (transport) {
try {
await transport.close();
} catch {
// Ignore errors when closing transport on error
}
}
}; };
// Attempt to discover both prompts and tools // Attempt to discover both prompts and tools
@@ -1348,18 +1323,16 @@ function createSSETransportWithAuth(
* @param client The MCP client to connect * @param client The MCP client to connect
* @param config The MCP server configuration * @param config The MCP server configuration
* @param accessToken Optional OAuth access token for authentication * @param accessToken Optional OAuth access token for authentication
* @returns The transport used for connection
*/ */
async function connectWithSSETransport( async function connectWithSSETransport(
client: Client, client: Client,
config: MCPServerConfig, config: MCPServerConfig,
accessToken?: string | null, accessToken?: string | null,
): Promise<Transport> { ): Promise<void> {
const transport = createSSETransportWithAuth(config, accessToken); const transport = createSSETransportWithAuth(config, accessToken);
await client.connect(transport, { await client.connect(transport, {
timeout: config.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC, timeout: config.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC,
}); });
return transport;
} }
/** /**
@@ -1389,7 +1362,6 @@ async function showAuthRequiredMessage(serverName: string): Promise<never> {
* @param config The MCP server configuration * @param config The MCP server configuration
* @param accessToken The OAuth access token to use * @param accessToken The OAuth access token to use
* @param httpReturned404 Whether the HTTP transport returned 404 (indicating SSE-only server) * @param httpReturned404 Whether the HTTP transport returned 404 (indicating SSE-only server)
* @returns The transport used for connection
*/ */
async function retryWithOAuth( async function retryWithOAuth(
client: Client, client: Client,
@@ -1397,21 +1369,17 @@ async function retryWithOAuth(
config: MCPServerConfig, config: MCPServerConfig,
accessToken: string, accessToken: string,
httpReturned404: boolean, httpReturned404: boolean,
): Promise<Transport> { ): Promise<void> {
if (httpReturned404) { if (httpReturned404) {
// HTTP returned 404, only try SSE // HTTP returned 404, only try SSE
debugLogger.log( debugLogger.log(
`Retrying SSE connection to '${serverName}' with OAuth token...`, `Retrying SSE connection to '${serverName}' with OAuth token...`,
); );
const transport = await connectWithSSETransport( await connectWithSSETransport(client, config, accessToken);
client,
config,
accessToken,
);
debugLogger.log( debugLogger.log(
`Successfully connected to '${serverName}' using SSE with OAuth.`, `Successfully connected to '${serverName}' using SSE with OAuth.`,
); );
return transport; return;
} }
// HTTP returned 401, try HTTP with OAuth first // HTTP returned 401, try HTTP with OAuth first
@@ -1435,7 +1403,6 @@ async function retryWithOAuth(
debugLogger.log( debugLogger.log(
`Successfully connected to '${serverName}' using HTTP with OAuth.`, `Successfully connected to '${serverName}' using HTTP with OAuth.`,
); );
return httpTransport;
} catch (httpError) { } catch (httpError) {
await httpTransport.close(); await httpTransport.close();
@@ -1447,15 +1414,10 @@ async function retryWithOAuth(
!config.httpUrl !config.httpUrl
) { ) {
debugLogger.log(`HTTP with OAuth returned 404, trying SSE with OAuth...`); debugLogger.log(`HTTP with OAuth returned 404, trying SSE with OAuth...`);
const sseTransport = await connectWithSSETransport( await connectWithSSETransport(client, config, accessToken);
client,
config,
accessToken,
);
debugLogger.log( debugLogger.log(
`Successfully connected to '${serverName}' using SSE with OAuth.`, `Successfully connected to '${serverName}' using SSE with OAuth.`,
); );
return sseTransport;
} else { } else {
throw httpError; throw httpError;
} }
@@ -1469,7 +1431,7 @@ async function retryWithOAuth(
* *
* @param mcpServerName The name of the MCP server, used for logging and identification. * @param mcpServerName The name of the MCP server, used for logging and identification.
* @param mcpServerConfig The configuration specifying how to connect to the server. * @param mcpServerConfig The configuration specifying how to connect to the server.
* @returns A promise that resolves to a connected MCP `Client` instance and its transport. * @returns A promise that resolves to a connected MCP `Client` instance.
* @throws An error if the connection fails or the configuration is invalid. * @throws An error if the connection fails or the configuration is invalid.
*/ */
export async function connectToMcpServer( export async function connectToMcpServer(
@@ -1479,7 +1441,7 @@ export async function connectToMcpServer(
debugMode: boolean, debugMode: boolean,
workspaceContext: WorkspaceContext, workspaceContext: WorkspaceContext,
sanitizationConfig: EnvironmentSanitizationConfig, sanitizationConfig: EnvironmentSanitizationConfig,
): Promise<{ client: Client; transport: Transport }> { ): Promise<Client> {
const mcpClient = new Client( const mcpClient = new Client(
{ {
name: 'gemini-cli-mcp-client', name: 'gemini-cli-mcp-client',
@@ -1551,7 +1513,7 @@ export async function connectToMcpServer(
await mcpClient.connect(transport, { await mcpClient.connect(transport, {
timeout: mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC, timeout: mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC,
}); });
return { client: mcpClient, transport }; return mcpClient;
} catch (error) { } catch (error) {
await transport.close(); await transport.close();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
@@ -1583,7 +1545,7 @@ export async function connectToMcpServer(
try { try {
// Try SSE with stored OAuth token if available // Try SSE with stored OAuth token if available
// This ensures that SSE fallback works for authenticated servers // This ensures that SSE fallback works for authenticated servers
const sseTransport = await connectWithSSETransport( await connectWithSSETransport(
mcpClient, mcpClient,
mcpServerConfig, mcpServerConfig,
await getStoredOAuthToken(mcpServerName), await getStoredOAuthToken(mcpServerName),
@@ -1592,7 +1554,7 @@ export async function connectToMcpServer(
debugLogger.log( debugLogger.log(
`MCP server '${mcpServerName}': Successfully connected using SSE transport.`, `MCP server '${mcpServerName}': Successfully connected using SSE transport.`,
); );
return { client: mcpClient, transport: sseTransport }; return mcpClient;
} catch (sseFallbackError) { } catch (sseFallbackError) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
sseError = sseFallbackError as Error; sseError = sseFallbackError as Error;
@@ -1700,14 +1662,14 @@ export async function connectToMcpServer(
); );
} }
const oauthTransport = await retryWithOAuth( await retryWithOAuth(
mcpClient, mcpClient,
mcpServerName, mcpServerName,
mcpServerConfig, mcpServerConfig,
accessToken, accessToken,
httpReturned404, httpReturned404,
); );
return { client: mcpClient, transport: oauthTransport }; return mcpClient;
} else { } else {
throw new Error( throw new Error(
`Failed to handle automatic OAuth for server '${mcpServerName}'`, `Failed to handle automatic OAuth for server '${mcpServerName}'`,
@@ -1788,7 +1750,7 @@ export async function connectToMcpServer(
timeout: mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC, timeout: mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC,
}); });
// Connection successful with OAuth // Connection successful with OAuth
return { client: mcpClient, transport: oauthTransport }; return mcpClient;
} else { } else {
throw new Error( throw new Error(
`OAuth configuration failed for '${mcpServerName}'. Please authenticate manually with /mcp auth ${mcpServerName}`, `OAuth configuration failed for '${mcpServerName}'. Please authenticate manually with /mcp auth ${mcpServerName}`,
@@ -1936,23 +1898,10 @@ export async function createTransport(
command: mcpServerConfig.command, command: mcpServerConfig.command,
args: mcpServerConfig.args || [], args: mcpServerConfig.args || [],
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
env: sanitizeEnvironment( env: {
{ ...sanitizeEnvironment(process.env, sanitizationConfig),
...process.env, ...(mcpServerConfig.env || {}),
...getExtensionEnvironment(mcpServerConfig.extension), } as Record<string, string>,
...(mcpServerConfig.env || {}),
},
{
...sanitizationConfig,
allowedEnvironmentVariables: [
...(sanitizationConfig.allowedEnvironmentVariables ?? []),
...(mcpServerConfig.extension?.resolvedSettings?.map(
(s) => s.envVar,
) ?? []),
],
enableEnvironmentVariableRedaction: true,
},
) as Record<string, string>,
cwd: mcpServerConfig.cwd, cwd: mcpServerConfig.cwd,
stderr: 'pipe', stderr: 'pipe',
}); });
@@ -2027,17 +1976,3 @@ export function isEnabled(
) )
); );
} }
function getExtensionEnvironment(
extension?: GeminiCLIExtension,
): Record<string, string> {
const env: Record<string, string> = {};
if (extension?.resolvedSettings) {
for (const setting of extension.resolvedSettings) {
if (setting.value) {
env[setting.envVar] = setting.value;
}
}
}
return env;
}
+44 -8
View File
@@ -99,16 +99,50 @@ describe('isHeadlessMode', () => {
expect(isHeadlessMode({ prompt: true })).toBe(true); expect(isHeadlessMode({ prompt: true })).toBe(true);
}); });
it('should return false if query is provided but it is still a TTY', () => { it('should return true if query is provided', () => {
// Note: per current logic, query alone doesn't force headless if TTY expect(isHeadlessMode({ query: 'test query' })).toBe(true);
// This matches the existing behavior in packages/cli/src/config/config.ts });
expect(isHeadlessMode({ query: 'test query' })).toBe(false);
it('should return true if -p or --prompt is in process.argv as a fallback', () => {
const originalArgv = process.argv;
process.argv = ['node', 'index.js', '-p', 'hello'];
try {
expect(isHeadlessMode()).toBe(true);
} finally {
process.argv = originalArgv;
}
process.argv = ['node', 'index.js', '--prompt', 'hello'];
try {
expect(isHeadlessMode()).toBe(true);
} finally {
process.argv = originalArgv;
}
});
it('should return false if -y or --yolo is in process.argv as a fallback', () => {
const originalArgv = process.argv;
process.argv = ['node', 'index.js', '-y'];
try {
expect(isHeadlessMode()).toBe(false);
} finally {
process.argv = originalArgv;
}
process.argv = ['node', 'index.js', '--yolo'];
try {
expect(isHeadlessMode()).toBe(false);
} finally {
process.argv = originalArgv;
}
}); });
it('should handle undefined process.stdout gracefully', () => { it('should handle undefined process.stdout gracefully', () => {
const originalStdout = process.stdout; const originalStdout = process.stdout;
// @ts-expect-error - testing edge case Object.defineProperty(process, 'stdout', {
delete process.stdout; value: undefined,
configurable: true,
});
try { try {
expect(isHeadlessMode()).toBe(false); expect(isHeadlessMode()).toBe(false);
@@ -122,8 +156,10 @@ describe('isHeadlessMode', () => {
it('should handle undefined process.stdin gracefully', () => { it('should handle undefined process.stdin gracefully', () => {
const originalStdin = process.stdin; const originalStdin = process.stdin;
// @ts-expect-error - testing edge case Object.defineProperty(process, 'stdin', {
delete process.stdin; value: undefined,
configurable: true,
});
try { try {
expect(isHeadlessMode()).toBe(false); expect(isHeadlessMode()).toBe(false);
+16 -12
View File
@@ -28,18 +28,22 @@ export interface HeadlessModeOptions {
* @returns true if the environment is considered headless. * @returns true if the environment is considered headless.
*/ */
export function isHeadlessMode(options?: HeadlessModeOptions): boolean { export function isHeadlessMode(options?: HeadlessModeOptions): boolean {
if (process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true') { if (process.env['GEMINI_CLI_INTEGRATION_TEST'] !== 'true') {
return ( const isCI =
!!options?.prompt || process.env['CI'] === 'true' || process.env['GITHUB_ACTIONS'] === 'true';
(!!process.stdin && !process.stdin.isTTY) || if (isCI) {
(!!process.stdout && !process.stdout.isTTY) return true;
); }
} }
return (
process.env['CI'] === 'true' || const isNotTTY =
process.env['GITHUB_ACTIONS'] === 'true' ||
!!options?.prompt ||
(!!process.stdin && !process.stdin.isTTY) || (!!process.stdin && !process.stdin.isTTY) ||
(!!process.stdout && !process.stdout.isTTY) (!!process.stdout && !process.stdout.isTTY);
);
if (isNotTTY || !!options?.prompt || !!options?.query) {
return true;
}
// Fallback: check process.argv for flags that imply headless mode.
return process.argv.some((arg) => arg === '-p' || arg === '--prompt');
} }
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@google/gemini-cli-test-utils", "name": "@google/gemini-cli-test-utils",
"version": "0.29.0-nightly.20260203.71f46f116", "version": "0.29.4",
"private": true, "private": true,
"main": "src/index.ts", "main": "src/index.ts",
"license": "Apache-2.0", "license": "Apache-2.0",
+3
View File
@@ -49,6 +49,9 @@ async function main() {
define: { define: {
'import.meta.url': 'import_meta.url', 'import.meta.url': 'import_meta.url',
}, },
alias: {
punycode: 'punycode/',
},
plugins: [ plugins: [
/* add to the end of plugins array */ /* add to the end of plugins array */
esbuildProblemMatcherPlugin, esbuildProblemMatcherPlugin,
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion", "name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion", "displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.", "description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.29.0-nightly.20260203.71f46f116", "version": "0.29.4",
"publisher": "google", "publisher": "google",
"icon": "assets/icon.png", "icon": "assets/icon.png",
"repository": { "repository": {