personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'hopper-zgw4ggix-vertex-sa-creds'

# Conflicts:
# apps/settings/routes.py
# apps/settings/workspace.html
# tests/test_validate_key.py
# think/providers/cli.py
# think/providers/google.py

+425 -255
+104 -5
apps/settings/routes.py
··· 17 17 18 18 from apps.utils import log_app_action 19 19 from convey import state 20 + from think.providers.google import validate_vertex_credentials 20 21 from think.retention import ( 21 22 _human_bytes, 22 23 compute_storage_summary, ··· 425 426 # Get cached key validation results 426 427 key_validation = providers_config.get("key_validation", {}) 427 428 428 - # Check vertex credentials status 429 - vertex_credentials_email = None 429 + # Vertex SA credentials status (never expose secrets) 430 430 vertex_creds_path = providers_config.get("vertex_credentials") 431 - if vertex_creds_path: 431 + vertex_creds_configured = False 432 + vertex_creds_email = "" 433 + if vertex_creds_path and Path(vertex_creds_path).exists(): 434 + vertex_creds_configured = True 432 435 try: 433 436 creds_data = json.loads(Path(vertex_creds_path).read_text()) 434 - vertex_credentials_email = creds_data.get("client_email") 437 + vertex_creds_email = creds_data.get("client_email", "") 435 438 except Exception: 436 439 pass 437 440 ··· 448 451 "google_backend": providers_config.get("google_backend", "auto"), 449 452 "vertex_project": providers_config.get("vertex_project", ""), 450 453 "vertex_location": providers_config.get("vertex_location", ""), 451 - "vertex_credentials_email": vertex_credentials_email, 454 + "vertex_credentials_configured": vertex_creds_configured, 455 + "vertex_credentials_email": vertex_creds_email, 452 456 } 453 457 ) 454 458 except Exception: ··· 871 875 config["providers"][vfield] = value 872 876 else: 873 877 config["providers"].pop(vfield, None) 878 + 879 + # Handle vertex credentials 880 + if "vertex_credentials" in request_data: 881 + vertex_creds_value = request_data["vertex_credentials"] 882 + 883 + if vertex_creds_value: 884 + # Parse and validate JSON structure 885 + try: 886 + creds_data = ( 887 + json.loads(vertex_creds_value) 888 + if isinstance(vertex_creds_value, str) 889 + else vertex_creds_value 890 + ) 891 + except json.JSONDecodeError: 892 + return jsonify({"error": "Invalid JSON in vertex_credentials"}), 400 893 + 894 + required_fields = ( 895 + "type", 896 + "project_id", 897 + "client_email", 898 + "private_key", 899 + ) 900 + missing = [f for f in required_fields if f not in creds_data] 901 + if missing: 902 + return ( 903 + jsonify( 904 + { 905 + "error": f"Missing required fields: {', '.join(missing)}" 906 + } 907 + ), 908 + 400, 909 + ) 910 + 911 + # Save credentials file 912 + creds_dir = Path(state.journal_root) / ".config" 913 + creds_dir.mkdir(parents=True, exist_ok=True) 914 + creds_file = creds_dir / "vertex-credentials.json" 915 + with open(creds_file, "w", encoding="utf-8") as f: 916 + json.dump(creds_data, f, indent=2, ensure_ascii=False) 917 + f.write("\n") 918 + os.chmod(creds_file, 0o600) 919 + 920 + # Store path in config 921 + old_val = old_providers.get("vertex_credentials", "") 922 + creds_path_str = str(creds_file) 923 + if old_val != creds_path_str: 924 + changed_fields["vertex_credentials"] = { 925 + "old": old_val, 926 + "new": creds_path_str, 927 + } 928 + config["providers"]["vertex_credentials"] = creds_path_str 929 + 930 + # Validate credentials by attempting to list models 931 + project = config["providers"].get("vertex_project") 932 + location = config["providers"].get("vertex_location") 933 + validation = validate_vertex_credentials( 934 + creds_path_str, 935 + project=project, 936 + location=location, 937 + ) 938 + 939 + if not validation.get("valid"): 940 + # Still save the file (user can fix project/location), but report the error 941 + # Don't block save - credentials may be valid once project is configured 942 + pass 943 + 944 + # Store validation result 945 + if "key_validation" not in config["providers"]: 946 + config["providers"]["key_validation"] = {} 947 + config["providers"]["key_validation"]["google_vertex"] = { 948 + **validation, 949 + "timestamp": datetime.now(timezone.utc).isoformat(), 950 + } 951 + 952 + else: 953 + # Remove credentials — only delete the canonical path 954 + old_path = config["providers"].get("vertex_credentials") 955 + if old_path: 956 + changed_fields["vertex_credentials"] = { 957 + "old": old_path, 958 + "new": None, 959 + } 960 + # Only delete the file we created, not arbitrary paths 961 + canonical = ( 962 + Path(state.journal_root) / ".config" / "vertex-credentials.json" 963 + ) 964 + if Path(old_path).resolve() == canonical.resolve(): 965 + try: 966 + canonical.unlink(missing_ok=True) 967 + except OSError: 968 + pass 969 + config["providers"].pop("vertex_credentials", None) 970 + # Clear validation 971 + kv = config["providers"].get("key_validation", {}) 972 + kv.pop("google_vertex", None) 874 973 875 974 # Write back to file 876 975 with open(config_path, "w", encoding="utf-8") as f:
+95 -81
apps/settings/workspace.html
··· 1928 1928 <div class="provider-row"> 1929 1929 <div class="settings-field"> 1930 1930 <label for="field-vertex-project">project</label> 1931 - <input type="text" id="field-vertex-project" placeholder="Required — e.g., my-project-id"> 1931 + <input type="text" id="field-vertex-project" placeholder="my-gcp-project"> 1932 1932 <small>Google Cloud project ID</small> 1933 1933 </div> 1934 1934 <div class="settings-field"> ··· 1937 1937 <small>Google Cloud region</small> 1938 1938 </div> 1939 1939 </div> 1940 - </div> 1941 - <div id="vertexCredentials" style="display:none"> 1942 1940 <div class="provider-row"> 1943 - <div class="settings-field" style="flex:1"> 1944 - <label>service account credentials</label> 1945 - <div id="vertexCredStatus" style="display:none;margin-bottom:0.5em;"> 1946 - <span style="color:#4a9;">&#10003;</span> 1947 - <span id="vertexCredEmail" style="font-family:monospace;font-size:0.9em;"></span> 1948 - <button type="button" class="btn-secondary" style="margin-left:0.5em;font-size:0.8em;" onclick="removeVertexCredentials()">Remove</button> 1941 + <div class="settings-field" style="flex:1" id="vertex-creds-field"> 1942 + <label>service account</label> 1943 + <div id="vertex-creds-status" style="margin-bottom:0.4em"> 1944 + <span id="vertex-creds-email" style="color:#666;font-size:0.9em">not configured</span> 1949 1945 </div> 1950 - <div id="vertexCredUpload"> 1951 - <div style="display:flex;gap:0.5em;align-items:start;"> 1952 - <label class="btn-secondary" style="cursor:pointer;display:inline-block;text-align:center;"> 1953 - Upload JSON 1954 - <input type="file" id="vertexCredFile" accept=".json" style="display:none" onchange="handleVertexCredFile(this)"> 1955 - </label> 1956 - <span style="color:#888;padding-top:0.3em;">or</span> 1957 - <button type="button" class="btn-secondary" onclick="document.getElementById('vertexCredPasteArea').style.display=''">Paste JSON</button> 1958 - </div> 1959 - <div id="vertexCredPasteArea" style="display:none;margin-top:0.5em;"> 1960 - <textarea id="vertexCredPasteInput" rows="4" style="width:100%;font-family:monospace;font-size:0.85em;" placeholder="Paste service account JSON here..."></textarea> 1961 - <button type="button" class="btn-secondary" style="margin-top:0.3em;" onclick="submitVertexCredPaste()">Save Credentials</button> 1962 - </div> 1946 + <div id="vertex-creds-actions"> 1947 + <button type="button" id="btn-vertex-creds-file" onclick="document.getElementById('vertex-creds-file-input').click()" style="margin-right:0.4em">Choose file</button> 1948 + <button type="button" id="btn-vertex-creds-paste" onclick="toggleVertexCredsPaste()">Paste JSON</button> 1949 + <button type="button" id="btn-vertex-creds-remove" onclick="removeVertexCreds()" style="display:none;color:#c33">Remove</button> 1963 1950 </div> 1964 - <small>Service account JSON for Vertex AI authentication</small> 1965 - <span id="vertexCredStatus2" style="font-size:0.85em;margin-left:0.5em;"></span> 1951 + <input type="file" id="vertex-creds-file-input" accept=".json" style="display:none" onchange="uploadVertexCredsFile(this)"> 1952 + <textarea id="vertex-creds-paste-input" rows="6" style="display:none;width:100%;margin-top:0.5em;font-family:monospace;font-size:0.85em" placeholder="Paste service account JSON here"></textarea> 1953 + <div id="vertex-creds-paste-actions" style="display:none;margin-top:0.4em"> 1954 + <button type="button" onclick="submitVertexCredsPaste()">Save</button> 1955 + <button type="button" onclick="cancelVertexCredsPaste()" style="margin-left:0.4em">Cancel</button> 1956 + </div> 1957 + <small>JSON credentials for Vertex AI authentication</small> 1966 1958 </div> 1967 1959 </div> 1968 1960 </div> ··· 3787 3779 if (vertexFields) { 3788 3780 vertexFields.style.display = (data.google_backend === 'vertex') ? '' : 'none'; 3789 3781 } 3790 - const vertexCreds = document.getElementById('vertexCredentials'); 3791 - if (vertexCreds) { 3792 - vertexCreds.style.display = (data.google_backend === 'vertex') ? '' : 'none'; 3793 - } 3794 - const googleKeyField = document.getElementById('googleApiKeyField'); 3795 - if (googleKeyField) { 3796 - googleKeyField.style.display = (data.google_backend === 'vertex') ? 'none' : ''; 3797 - } 3798 3782 // Populate vertex credentials status 3799 - const credStatus = document.getElementById('vertexCredStatus'); 3800 - const credUpload = document.getElementById('vertexCredUpload'); 3801 - const credEmail = document.getElementById('vertexCredEmail'); 3802 - if (data.vertex_credentials_email) { 3803 - if (credStatus) credStatus.style.display = ''; 3804 - if (credUpload) credUpload.style.display = 'none'; 3805 - if (credEmail) credEmail.textContent = data.vertex_credentials_email; 3806 - } else { 3807 - if (credStatus) credStatus.style.display = 'none'; 3808 - if (credUpload) credUpload.style.display = ''; 3809 - } 3783 + updateVertexCredsUI(data.vertex_credentials_configured, data.vertex_credentials_email); 3810 3784 3811 3785 // Update status text 3812 3786 document.getElementById('providerStatus').textContent = ''; ··· 4170 4144 }); 4171 4145 } 4172 4146 4173 - // Vertex credentials handlers 4174 - async function handleVertexCredFile(input) { 4175 - if (!input.files || !input.files[0]) return; 4176 - const text = await input.files[0].text(); 4177 - await saveVertexCredentials(text); 4178 - input.value = ''; 4147 + // Vertex SA credentials handlers 4148 + function updateVertexCredsUI(configured, email) { 4149 + const emailEl = document.getElementById('vertex-creds-email'); 4150 + const removeBtn = document.getElementById('btn-vertex-creds-remove'); 4151 + const fileBtn = document.getElementById('btn-vertex-creds-file'); 4152 + const pasteBtn = document.getElementById('btn-vertex-creds-paste'); 4153 + if (configured && email) { 4154 + emailEl.textContent = email; 4155 + emailEl.style.color = '#2a7'; 4156 + removeBtn.style.display = ''; 4157 + fileBtn.style.display = 'none'; 4158 + pasteBtn.style.display = 'none'; 4159 + } else { 4160 + emailEl.textContent = 'not configured'; 4161 + emailEl.style.color = '#666'; 4162 + removeBtn.style.display = 'none'; 4163 + fileBtn.style.display = ''; 4164 + pasteBtn.style.display = ''; 4165 + } 4166 + // Hide paste area 4167 + document.getElementById('vertex-creds-paste-input').style.display = 'none'; 4168 + document.getElementById('vertex-creds-paste-actions').style.display = 'none'; 4179 4169 } 4180 4170 4181 - async function submitVertexCredPaste() { 4182 - const text = document.getElementById('vertexCredPasteInput')?.value; 4183 - if (!text) return; 4184 - await saveVertexCredentials(text); 4171 + function toggleVertexCredsPaste() { 4172 + const ta = document.getElementById('vertex-creds-paste-input'); 4173 + const actions = document.getElementById('vertex-creds-paste-actions'); 4174 + const show = ta.style.display === 'none'; 4175 + ta.style.display = show ? '' : 'none'; 4176 + actions.style.display = show ? '' : 'none'; 4177 + if (show) { ta.value = ''; ta.focus(); } 4178 + } 4179 + 4180 + function cancelVertexCredsPaste() { 4181 + document.getElementById('vertex-creds-paste-input').style.display = 'none'; 4182 + document.getElementById('vertex-creds-paste-actions').style.display = 'none'; 4185 4183 } 4186 4184 4187 - async function saveVertexCredentials(jsonText) { 4188 - const statusEl = document.getElementById('vertexCredStatus2'); 4185 + async function saveVertexCreds(jsonString) { 4186 + const field = document.getElementById('vertex-creds-field'); 4187 + const small = field?.querySelector('small'); 4189 4188 try { 4190 - let creds; 4191 - try { 4192 - creds = JSON.parse(jsonText); 4193 - } catch (e) { 4194 - if (statusEl) { statusEl.textContent = 'Invalid JSON'; statusEl.style.color = '#c44'; } 4189 + // Basic client-side JSON validation 4190 + const parsed = JSON.parse(jsonString); 4191 + const required = ['type', 'project_id', 'client_email', 'private_key']; 4192 + const missing = required.filter(f => !parsed[f]); 4193 + if (missing.length) { 4194 + showFieldStatus(document.getElementById('btn-vertex-creds-file'), 'error', `Missing: ${missing.join(', ')}`); 4195 4195 return; 4196 4196 } 4197 - const response = await fetch('api/vertex-credentials', { 4198 - method: 'POST', 4197 + 4198 + const response = await fetch('api/providers', { 4199 + method: 'PUT', 4199 4200 headers: { 'Content-Type': 'application/json' }, 4200 - body: JSON.stringify({ credentials: creds }) 4201 + body: JSON.stringify({ vertex_credentials: jsonString }) 4201 4202 }); 4202 4203 const result = await response.json(); 4203 4204 if (result.error) throw new Error(result.error); 4204 - // Update UI 4205 - const credStatus = document.getElementById('vertexCredStatus'); 4206 - const credUpload = document.getElementById('vertexCredUpload'); 4207 - const credEmail = document.getElementById('vertexCredEmail'); 4208 - if (credStatus) credStatus.style.display = ''; 4209 - if (credUpload) credUpload.style.display = 'none'; 4210 - if (credEmail) credEmail.textContent = result.client_email; 4211 - document.getElementById('vertexCredPasteArea').style.display = 'none'; 4212 - document.getElementById('vertexCredPasteInput').value = ''; 4213 - if (statusEl) { statusEl.textContent = 'Saved'; statusEl.style.color = '#4a9'; } 4214 - setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 2000); 4205 + providersData = result; 4206 + updateVertexCredsUI(result.vertex_credentials_configured, result.vertex_credentials_email); 4207 + showFieldStatus(document.getElementById('btn-vertex-creds-remove'), 'saved', result.vertex_credentials_email || 'saved'); 4215 4208 } catch (err) { 4216 4209 console.error('Error saving vertex credentials:', err); 4217 - if (statusEl) { statusEl.textContent = err.message; statusEl.style.color = '#c44'; } 4210 + showFieldStatus(document.getElementById('btn-vertex-creds-file') || small?.parentElement?.querySelector('button'), 'error', err.message); 4218 4211 } 4219 4212 } 4220 4213 4221 - async function removeVertexCredentials() { 4214 + function uploadVertexCredsFile(input) { 4215 + const file = input.files[0]; 4216 + if (!file) return; 4217 + const reader = new FileReader(); 4218 + reader.onload = function(e) { 4219 + saveVertexCreds(e.target.result); 4220 + }; 4221 + reader.readAsText(file); 4222 + input.value = ''; // reset so same file can be re-selected 4223 + } 4224 + 4225 + function submitVertexCredsPaste() { 4226 + const ta = document.getElementById('vertex-creds-paste-input'); 4227 + if (!ta.value.trim()) return; 4228 + saveVertexCreds(ta.value.trim()); 4229 + } 4230 + 4231 + async function removeVertexCreds() { 4222 4232 try { 4223 - const response = await fetch('api/vertex-credentials', { method: 'DELETE' }); 4233 + const response = await fetch('api/providers', { 4234 + method: 'PUT', 4235 + headers: { 'Content-Type': 'application/json' }, 4236 + body: JSON.stringify({ vertex_credentials: '' }) 4237 + }); 4224 4238 const result = await response.json(); 4225 4239 if (result.error) throw new Error(result.error); 4226 - const credStatus = document.getElementById('vertexCredStatus'); 4227 - const credUpload = document.getElementById('vertexCredUpload'); 4228 - if (credStatus) credStatus.style.display = 'none'; 4229 - if (credUpload) credUpload.style.display = ''; 4240 + providersData = result; 4241 + updateVertexCredsUI(false, ''); 4242 + showFieldStatus(document.getElementById('btn-vertex-creds-file'), 'saved', 'credentials removed'); 4230 4243 } catch (err) { 4231 4244 console.error('Error removing vertex credentials:', err); 4245 + showFieldStatus(document.getElementById('btn-vertex-creds-remove'), 'error', err.message); 4232 4246 } 4233 4247 } 4234 4248
+5 -2
tests/baselines/api/settings/providers.json
··· 444 444 "vertex_env_keys": [ 445 445 "GOOGLE_GENAI_USE_VERTEXAI", 446 446 "GOOGLE_CLOUD_PROJECT", 447 - "GOOGLE_CLOUD_LOCATION" 447 + "GOOGLE_CLOUD_LOCATION", 448 + "GOOGLE_APPLICATION_CREDENTIALS" 448 449 ] 449 450 }, 450 451 { ··· 455 456 ], 456 457 "vertex_credentials_email": null, 457 458 "vertex_location": "", 458 - "vertex_project": "" 459 + "vertex_project": "", 460 + "vertex_credentials_configured": false, 461 + "vertex_credentials_email": "" 459 462 }
+22 -4
tests/test_cli_provider.py
··· 644 644 assert "OPENAI_API_KEY" not in oai_env 645 645 646 646 def test_vertex_backend_sets_env_vars(self): 647 - """Vertex backend sets GOOGLE_GENAI_USE_VERTEXAI and preserves key.""" 647 + """Vertex backend sets GOOGLE_GENAI_USE_VERTEXAI (no API key for vertex).""" 648 648 config = { 649 649 "providers": { 650 650 "google_backend": "vertex", ··· 657 657 ): 658 658 env = build_cogitate_env("GOOGLE_API_KEY") 659 659 assert env["GOOGLE_GENAI_USE_VERTEXAI"] == "true" 660 - assert env["GOOGLE_API_KEY"] == "gk-test" 660 + assert "GOOGLE_API_KEY" not in env 661 + 662 + def test_vertex_backend_with_sa_creds(self): 663 + """Vertex with SA credentials sets GOOGLE_APPLICATION_CREDENTIALS, removes API key.""" 664 + config = { 665 + "providers": { 666 + "google_backend": "vertex", 667 + "vertex_credentials": "/tmp/fake-sa.json", 668 + "auth": {"google": "platform"}, 669 + } 670 + } 671 + with ( 672 + patch.dict(os.environ, {"GOOGLE_API_KEY": "gk-test"}, clear=True), 673 + patch("think.utils.get_config", return_value=config), 674 + patch("os.path.exists", return_value=True), 675 + ): 676 + env = build_cogitate_env("GOOGLE_API_KEY") 677 + assert env["GOOGLE_GENAI_USE_VERTEXAI"] == "true" 678 + assert env["GOOGLE_APPLICATION_CREDENTIALS"] == "/tmp/fake-sa.json" 679 + assert "GOOGLE_API_KEY" not in env 661 680 662 681 def test_vertex_backend_with_project_location(self): 663 682 """Vertex config sets project/location env vars.""" ··· 677 696 assert env["GOOGLE_GENAI_USE_VERTEXAI"] == "true" 678 697 assert env["GOOGLE_CLOUD_PROJECT"] == "my-project" 679 698 assert env["GOOGLE_CLOUD_LOCATION"] == "us-central1" 680 - assert env["GOOGLE_API_KEY"] == "gk-test" 681 699 682 700 def test_aistudio_backend_no_vertex_env_vars(self): 683 701 """AI Studio backend does not set Vertex env vars.""" ··· 705 723 ): 706 724 env = build_cogitate_env("GOOGLE_API_KEY") 707 725 assert env["GOOGLE_GENAI_USE_VERTEXAI"] == "true" 708 - assert env["GOOGLE_API_KEY"] == "gk-test" 726 + assert "GOOGLE_API_KEY" not in env 709 727 710 728 def test_non_google_key_unaffected_by_vertex(self): 711 729 """Vertex logic only applies to GOOGLE_API_KEY."""
+106 -91
tests/test_validate_key.py
··· 135 135 assert mock_cls.call_args.kwargs["api_key"] == "test-key" 136 136 137 137 138 - def test_validate_vertex_credentials_success(): 139 - """validate_vertex_credentials returns valid with backend vertex.""" 138 + def test_validate_vertex_credentials(): 139 + """validate_vertex_credentials creates SA-authenticated client.""" 140 140 client = Mock() 141 141 client.models.list.return_value = [Mock()] 142 + 143 + mock_creds = Mock() 144 + mock_creds.service_account_email = "test@project.iam.gserviceaccount.com" 142 145 143 146 with ( 144 147 patch( 145 - "think.providers.google.service_account.Credentials.from_service_account_file" 146 - ) as mock_creds, 147 - patch("think.providers.google.genai.Client", return_value=client) as mock_cls, 148 + "think.providers.google.genai.Client", return_value=client 149 + ) as mock_cls, 150 + patch( 151 + "google.oauth2.service_account.Credentials.from_service_account_file", 152 + return_value=mock_creds, 153 + ), 148 154 ): 149 - mock_creds.return_value = Mock() 150 155 result = think.providers.google.validate_vertex_credentials( 151 - "/path/to/creds.json", project="my-project", location="us-central1" 156 + "/tmp/sa.json", project="my-proj", location="us-central1" 152 157 ) 153 158 154 - assert result == {"valid": True, "backend": "vertex"} 155 - mock_creds.assert_called_once_with( 156 - "/path/to/creds.json", 157 - scopes=["https://www.googleapis.com/auth/cloud-platform"], 158 - ) 159 + assert result == { 160 + "valid": True, 161 + "email": "test@project.iam.gserviceaccount.com", 162 + } 159 163 assert mock_cls.call_args.kwargs["vertexai"] is True 160 - assert mock_cls.call_args.kwargs["credentials"] is mock_creds.return_value 161 - assert mock_cls.call_args.kwargs["project"] == "my-project" 164 + assert mock_cls.call_args.kwargs["credentials"] is mock_creds 162 165 assert "api_key" not in mock_cls.call_args.kwargs 163 - 164 - 165 - def test_validate_vertex_credentials_error(): 166 - """validate_vertex_credentials returns error on failure.""" 167 - with patch( 168 - "think.providers.google.service_account.Credentials.from_service_account_file", 169 - side_effect=Exception("Invalid credentials"), 170 - ): 171 - result = think.providers.google.validate_vertex_credentials("/bad/path.json") 172 - 173 - assert result["valid"] is False 174 - assert "Invalid credentials" in result["error"] 175 - 176 - 177 - def test_get_or_create_client_service_account(): 178 - """Vertex + vertex_credentials uses service_account.Credentials.""" 179 - config = { 180 - "providers": { 181 - "google_backend": "vertex", 182 - "vertex_credentials": "/path/to/creds.json", 183 - "vertex_project": "my-project", 184 - "vertex_location": "us-central1", 185 - } 186 - } 187 - with ( 188 - patch("think.utils.get_config", return_value=config), 189 - patch( 190 - "think.providers.google.service_account.Credentials.from_service_account_file" 191 - ) as mock_creds, 192 - patch("think.providers.google.genai.Client") as mock_cls, 193 - ): 194 - mock_creds.return_value = Mock() 195 - think.providers.google.get_or_create_client() 196 - 197 - mock_creds.assert_called_once_with( 198 - "/path/to/creds.json", 199 - scopes=["https://www.googleapis.com/auth/cloud-platform"], 200 - ) 201 - assert mock_cls.call_args.kwargs["vertexai"] is True 202 - assert mock_cls.call_args.kwargs["credentials"] is mock_creds.return_value 203 - assert mock_cls.call_args.kwargs["project"] == "my-project" 166 + assert mock_cls.call_args.kwargs["project"] == "my-proj" 204 167 assert mock_cls.call_args.kwargs["location"] == "us-central1" 205 - assert "api_key" not in mock_cls.call_args.kwargs 206 - 207 - 208 - def test_get_or_create_client_vertex_adc(): 209 - """Vertex without credentials uses ADC (no api_key, no credentials).""" 210 - config = { 211 - "providers": { 212 - "google_backend": "vertex", 213 - "vertex_project": "my-project", 214 - } 215 - } 216 - with ( 217 - patch("think.utils.get_config", return_value=config), 218 - patch("think.providers.google.genai.Client") as mock_cls, 219 - ): 220 - think.providers.google.get_or_create_client() 221 - 222 - assert mock_cls.call_args.kwargs["vertexai"] is True 223 - assert mock_cls.call_args.kwargs["project"] == "my-project" 224 - assert "api_key" not in mock_cls.call_args.kwargs 225 - assert "credentials" not in mock_cls.call_args.kwargs 226 - 227 - 228 - def test_get_or_create_client_api_key(): 229 - """Default path uses GOOGLE_API_KEY from environment.""" 230 - config = {"providers": {"google_backend": "auto"}} 231 - with ( 232 - patch("think.utils.get_config", return_value=config), 233 - patch.dict("os.environ", {"GOOGLE_API_KEY": "test-key"}), 234 - patch("think.providers.google._get_effective_backend", return_value="aistudio"), 235 - patch("think.providers.google.genai.Client") as mock_cls, 236 - ): 237 - think.providers.google.get_or_create_client() 238 - 239 - assert mock_cls.call_args.kwargs["api_key"] == "test-key" 240 - assert "credentials" not in mock_cls.call_args.kwargs 241 168 242 169 243 170 def test_probe_backend_aistudio(): ··· 448 375 assert payload["google_backend"] == "vertex" 449 376 assert payload["vertex_project"] == "my-project" 450 377 assert payload["vertex_location"] == "us-central1" 378 + 379 + 380 + def test_providers_vertex_credentials_roundtrip(settings_client): 381 + """PUT/GET vertex_credentials saves file and returns email.""" 382 + client, journal = settings_client 383 + 384 + sa_json = json.dumps({ 385 + "type": "service_account", 386 + "project_id": "test-project", 387 + "client_email": "test@test-project.iam.gserviceaccount.com", 388 + "private_key": "-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----\n", 389 + "client_id": "123", 390 + "token_uri": "https://oauth2.googleapis.com/token", 391 + }) 392 + 393 + # Mock validation (don't actually call Google API) 394 + with patch( 395 + "apps.settings.routes.validate_vertex_credentials", 396 + return_value={ 397 + "valid": True, 398 + "email": "test@test-project.iam.gserviceaccount.com", 399 + }, 400 + ): 401 + response = client.put( 402 + "/app/settings/api/providers", 403 + json={"vertex_credentials": sa_json}, 404 + ) 405 + assert response.status_code == 200 406 + payload = response.get_json() 407 + assert payload["vertex_credentials_configured"] is True 408 + assert ( 409 + payload["vertex_credentials_email"] 410 + == "test@test-project.iam.gserviceaccount.com" 411 + ) 412 + 413 + # Verify file saved with correct permissions 414 + creds_file = journal / ".config" / "vertex-credentials.json" 415 + assert creds_file.exists() 416 + assert oct(creds_file.stat().st_mode & 0o777) == "0o600" 417 + 418 + # Verify config stores path 419 + config = json.loads((journal / "config" / "journal.json").read_text()) 420 + assert config["providers"]["vertex_credentials"] == str(creds_file) 421 + 422 + # GET returns status without secrets 423 + response = client.get("/app/settings/api/providers") 424 + payload = response.get_json() 425 + assert payload["vertex_credentials_configured"] is True 426 + assert ( 427 + payload["vertex_credentials_email"] 428 + == "test@test-project.iam.gserviceaccount.com" 429 + ) 430 + assert "private_key" not in json.dumps(payload) 431 + 432 + # Remove credentials 433 + response = client.put( 434 + "/app/settings/api/providers", 435 + json={"vertex_credentials": ""}, 436 + ) 437 + assert response.status_code == 200 438 + payload = response.get_json() 439 + assert payload["vertex_credentials_configured"] is False 440 + assert payload["vertex_credentials_email"] == "" 441 + assert not creds_file.exists() 442 + 443 + 444 + def test_providers_vertex_credentials_invalid_json(settings_client): 445 + """Invalid JSON in vertex_credentials is rejected.""" 446 + client, _journal = settings_client 447 + 448 + response = client.put( 449 + "/app/settings/api/providers", 450 + json={"vertex_credentials": "not json"}, 451 + ) 452 + assert response.status_code == 400 453 + assert "Invalid JSON" in response.get_json()["error"] 454 + 455 + 456 + def test_providers_vertex_credentials_missing_fields(settings_client): 457 + """SA JSON missing required fields is rejected.""" 458 + client, _journal = settings_client 459 + 460 + response = client.put( 461 + "/app/settings/api/providers", 462 + json={"vertex_credentials": json.dumps({"type": "service_account"})}, 463 + ) 464 + assert response.status_code == 400 465 + assert "Missing required fields" in response.get_json()["error"] 451 466 452 467 453 468 def test_providers_google_backend_invalid(settings_client):
+1
think/providers/__init__.py
··· 54 54 "GOOGLE_GENAI_USE_VERTEXAI", 55 55 "GOOGLE_CLOUD_PROJECT", 56 56 "GOOGLE_CLOUD_LOCATION", 57 + "GOOGLE_APPLICATION_CREDENTIALS", 57 58 ], 58 59 }, 59 60 "openai": {"label": "OpenAI (GPT)", "env_key": "OPENAI_API_KEY"},
+8 -12
think/providers/cli.py
··· 445 445 if auth_mode == "platform": 446 446 env.pop(env_key, None) 447 447 448 - # Vertex AI: set backend env vars for Google provider 448 + # Vertex AI / AI Studio: set backend env vars for Google provider 449 449 if env_key == "GOOGLE_API_KEY": 450 450 providers_config = config.get("providers", {}) 451 451 google_backend = providers_config.get("google_backend", "auto") ··· 464 464 465 465 if effective_backend == "vertex": 466 466 env["GOOGLE_GENAI_USE_VERTEXAI"] = "true" 467 - # Service account credentials path 468 - vertex_credentials = providers_config.get("vertex_credentials") 469 - if vertex_credentials: 470 - env["GOOGLE_APPLICATION_CREDENTIALS"] = vertex_credentials 471 - # Don't set GOOGLE_API_KEY for service account auth 472 - env.pop("GOOGLE_API_KEY", None) 473 - else: 474 - # Vertex Express: preserve API key 475 - api_key = os.getenv("GOOGLE_API_KEY") 476 - if api_key: 477 - env["GOOGLE_API_KEY"] = api_key 467 + # Vertex uses SA credentials, not API key — always strip 468 + env.pop("GOOGLE_API_KEY", None) 469 + # SA credentials: set GOOGLE_APPLICATION_CREDENTIALS 470 + creds_path = providers_config.get("vertex_credentials") 471 + if creds_path and os.path.exists(creds_path): 472 + env["GOOGLE_APPLICATION_CREDENTIALS"] = creds_path 473 + # else: GOOGLE_APPLICATION_CREDENTIALS may be inherited from env 478 474 # Set project/location from config if present 479 475 vertex_project = providers_config.get("vertex_project") 480 476 if vertex_project:
+84 -60
think/providers/google.py
··· 38 38 39 39 from google import genai 40 40 from google.genai import types 41 - from google.oauth2 import service_account 42 41 43 42 from think.models import GEMINI_FLASH 44 43 from think.utils import now_ms ··· 61 60 62 61 logger = logging.getLogger(__name__) 63 62 64 - # Vertex AI backend detection cache 63 + # Backend detection cache 65 64 _detected_backend: str | None = None 66 65 67 66 ··· 117 116 def get_or_create_client(client: genai.Client | None = None) -> genai.Client: 118 117 """Get existing client or create new one. 119 118 120 - Parameters 121 - ---------- 122 - client : genai.Client, optional 123 - Existing client to reuse. If not provided, creates a new one 124 - using service account credentials, ADC, or GOOGLE_API_KEY 125 - depending on the configured backend. 119 + For Vertex AI backend, uses service account credentials from config 120 + or falls back to GOOGLE_APPLICATION_CREDENTIALS env var. 121 + For AI Studio / auto-detect, uses GOOGLE_API_KEY. 122 + """ 123 + if client is not None: 124 + return client 125 + 126 + from think.utils import get_config 127 + 128 + config = get_config() 129 + providers_config = config.get("providers", {}) 130 + 131 + http_options = types.HttpOptions( 132 + retry_options=types.HttpRetryOptions(attempts=8) 133 + ) 126 134 127 - Returns 128 - ------- 129 - genai.Client 130 - The provided client or a newly created one. 131 - """ 132 - if client is None: 133 - from think.utils import get_config 135 + api_key = os.getenv("GOOGLE_API_KEY") 134 136 135 - config = get_config() 136 - providers_config = config.get("providers", {}) 137 - backend = providers_config.get("google_backend", "auto") 138 - http_options = types.HttpOptions( 139 - retry_options=types.HttpRetryOptions(attempts=8) 140 - ) 137 + # Determine backend 138 + configured_backend = providers_config.get("google_backend", "auto") 139 + if configured_backend == "vertex": 140 + backend = "vertex" 141 + elif configured_backend == "aistudio": 142 + backend = "aistudio" 143 + elif api_key: 144 + backend = _get_effective_backend(api_key) 145 + else: 146 + raise ValueError("GOOGLE_API_KEY not found in environment") 141 147 142 - if backend == "vertex" and providers_config.get("vertex_credentials"): 143 - creds_path = providers_config["vertex_credentials"] 144 - creds = service_account.Credentials.from_service_account_file( 148 + if backend == "vertex": 149 + creds_path = providers_config.get("vertex_credentials") 150 + project = providers_config.get("vertex_project") 151 + location = providers_config.get("vertex_location") 152 + 153 + client_kwargs: dict[str, Any] = { 154 + "vertexai": True, 155 + "http_options": http_options, 156 + } 157 + if project: 158 + client_kwargs["project"] = project 159 + if location: 160 + client_kwargs["location"] = location 161 + 162 + if creds_path and os.path.exists(creds_path): 163 + from google.oauth2.service_account import Credentials 164 + 165 + creds = Credentials.from_service_account_file( 145 166 creds_path, 146 167 scopes=["https://www.googleapis.com/auth/cloud-platform"], 147 168 ) 148 - client = genai.Client( 149 - vertexai=True, 150 - credentials=creds, 151 - project=providers_config.get("vertex_project"), 152 - location=providers_config.get("vertex_location"), 153 - http_options=http_options, 169 + client_kwargs["credentials"] = creds 170 + elif not os.getenv("GOOGLE_APPLICATION_CREDENTIALS"): 171 + raise ValueError( 172 + "Vertex AI backend requires service account credentials. " 173 + "Configure in Settings or set GOOGLE_APPLICATION_CREDENTIALS." 154 174 ) 155 - elif backend == "vertex": 156 - client = genai.Client( 157 - vertexai=True, 158 - project=providers_config.get("vertex_project"), 159 - location=providers_config.get("vertex_location"), 160 - http_options=http_options, 161 - ) 162 - else: 163 - api_key = os.getenv("GOOGLE_API_KEY") 164 - if not api_key: 165 - raise ValueError("GOOGLE_API_KEY not found in environment") 166 - client_kwargs = { 167 - "api_key": api_key, 168 - "http_options": http_options, 169 - } 170 - client_kwargs["vertexai"] = _get_effective_backend(api_key) == "vertex" 171 - client = genai.Client(**client_kwargs) 175 + # else: GOOGLE_APPLICATION_CREDENTIALS is set, SDK auto-discovers 176 + 177 + client = genai.Client(**client_kwargs) 178 + else: 179 + # AI Studio path 180 + if not api_key: 181 + raise ValueError("GOOGLE_API_KEY not found in environment") 182 + client = genai.Client( 183 + api_key=api_key, 184 + vertexai=False, 185 + http_options=http_options, 186 + ) 187 + 172 188 return client 173 189 174 190 ··· 784 800 785 801 786 802 def validate_vertex_credentials( 787 - credentials_path: str, 803 + creds_path: str, 788 804 project: str | None = None, 789 805 location: str | None = None, 790 806 ) -> dict: 791 807 """Validate Vertex AI service account credentials by listing models. 792 808 793 - Returns {"valid": True, "backend": "vertex"} or 794 - {"valid": False, "error": "..."}. 809 + Creates a temporary client with the provided SA credentials. 810 + 811 + Returns {"valid": True, "email": "..."} or {"valid": False, "error": "..."}. 795 812 """ 796 813 try: 797 - creds = service_account.Credentials.from_service_account_file( 798 - credentials_path, 814 + from google.oauth2.service_account import Credentials 815 + 816 + creds = Credentials.from_service_account_file( 817 + creds_path, 799 818 scopes=["https://www.googleapis.com/auth/cloud-platform"], 800 819 ) 801 - client = genai.Client( 802 - vertexai=True, 803 - credentials=creds, 804 - project=project, 805 - location=location, 806 - http_options=types.HttpOptions(timeout=10000), 807 - ) 820 + client_kwargs: dict[str, Any] = { 821 + "vertexai": True, 822 + "credentials": creds, 823 + "http_options": types.HttpOptions(timeout=10000), 824 + } 825 + if project: 826 + client_kwargs["project"] = project 827 + if location: 828 + client_kwargs["location"] = location 829 + 830 + client = genai.Client(**client_kwargs) 808 831 list(client.models.list(config={"page_size": 1})) 809 - return {"valid": True, "backend": "vertex"} 832 + return {"valid": True, "email": creds.service_account_email} 810 833 except Exception as e: 811 834 return {"valid": False, "error": str(e)} 812 835 ··· 821 844 "_get_effective_backend", 822 845 "list_models", 823 846 "validate_key", 847 + "validate_vertex_credentials", 824 848 ]