personal memory agent
0
fork

Configure Feed

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

feat: Vertex AI service account credentials support

Add service account auth for Vertex AI Standard. When google_backend
is "vertex", users can upload or paste a GCP service account JSON in
settings. Credentials are saved to {journal}/config/vertex-credentials.json
and used for all Gemini operations via google.oauth2.service_account.
Credentials. GOOGLE_APPLICATION_CREDENTIALS is passed to cogitate
subprocesses. The AI Studio API key flow is unchanged.

- Settings UI: credentials upload/paste/remove, "Vertex AI" label,
required project/location, API key hidden when vertex
- Backend: POST/DELETE /api/vertex-credentials endpoints
- Client init: service account, ADC fallback, and API key branches
- Cogitate env: GOOGLE_APPLICATION_CREDENTIALS for service account
- Validation: vertex credentials validated via validate-keys
- Tests: client init, cogitate env, validation, settings API

+622 -22
+141
apps/settings/routes.py
··· 425 425 # Get cached key validation results 426 426 key_validation = providers_config.get("key_validation", {}) 427 427 428 + # Check vertex credentials status 429 + vertex_credentials_email = None 430 + vertex_creds_path = providers_config.get("vertex_credentials") 431 + if vertex_creds_path: 432 + try: 433 + creds_data = json.loads(Path(vertex_creds_path).read_text()) 434 + vertex_credentials_email = creds_data.get("client_email") 435 + except Exception: 436 + pass 437 + 428 438 return jsonify( 429 439 { 430 440 "providers": providers_list, ··· 438 448 "google_backend": providers_config.get("google_backend", "auto"), 439 449 "vertex_project": providers_config.get("vertex_project", ""), 440 450 "vertex_location": providers_config.get("vertex_location", ""), 451 + "vertex_credentials_email": vertex_credentials_email, 441 452 } 442 453 ) 443 454 except Exception: ··· 477 488 result["timestamp"] = datetime.now(timezone.utc).isoformat() 478 489 key_validation[provider] = result 479 490 491 + # Validate vertex credentials if configured 492 + providers_config = config.get("providers", {}) 493 + if ( 494 + providers_config.get("google_backend") == "vertex" 495 + and providers_config.get("vertex_credentials") 496 + ): 497 + from think.providers.google import validate_vertex_credentials 498 + 499 + result = validate_vertex_credentials( 500 + providers_config["vertex_credentials"], 501 + project=providers_config.get("vertex_project"), 502 + location=providers_config.get("vertex_location"), 503 + ) 504 + result["timestamp"] = datetime.now(timezone.utc).isoformat() 505 + key_validation["google"] = result 506 + 480 507 config["providers"]["key_validation"] = key_validation 481 508 482 509 config_dir = Path(state.journal_root) / "config" ··· 490 517 return jsonify({"success": True, "key_validation": key_validation}) 491 518 except Exception: 492 519 logger.exception("error validating keys") 520 + return jsonify({"error": "something went wrong — try again, and if it persists, check the health dashboard"}), 500 521 + 522 + 523 + @settings_bp.route("/api/vertex-credentials", methods=["POST"]) 524 + def upload_vertex_credentials() -> Any: 525 + """Upload or paste Vertex AI service account credentials JSON. 526 + 527 + Accepts JSON body with a "credentials" key containing the service account JSON. 528 + Validates required fields, saves to {journal}/config/vertex-credentials.json, 529 + and stores the path in providers.vertex_credentials. 530 + """ 531 + try: 532 + request_data = request.get_json() 533 + if not request_data or "credentials" not in request_data: 534 + return jsonify({"error": "No credentials provided"}), 400 535 + 536 + creds = request_data["credentials"] 537 + if isinstance(creds, str): 538 + try: 539 + creds = json.loads(creds) 540 + except json.JSONDecodeError: 541 + return jsonify({"error": "Invalid JSON"}), 400 542 + 543 + if not isinstance(creds, dict): 544 + return jsonify({"error": "Credentials must be a JSON object"}), 400 545 + 546 + # Validate required fields 547 + required = ("type", "project_id", "client_email", "private_key") 548 + missing = [f for f in required if not creds.get(f)] 549 + if missing: 550 + return ( 551 + jsonify( 552 + {"error": f"Missing required fields: {', '.join(missing)}"} 553 + ), 554 + 400, 555 + ) 556 + 557 + if creds.get("type") != "service_account": 558 + return jsonify({"error": "Credentials type must be 'service_account'"}), 400 559 + 560 + # Save credentials file 561 + config_dir = Path(state.journal_root) / "config" 562 + config_dir.mkdir(parents=True, exist_ok=True) 563 + creds_path = config_dir / "vertex-credentials.json" 564 + with open(creds_path, "w", encoding="utf-8") as f: 565 + json.dump(creds, f, indent=2, ensure_ascii=False) 566 + f.write("\n") 567 + os.chmod(creds_path, 0o600) 568 + 569 + # Store path in config 570 + config = get_journal_config() 571 + if "providers" not in config: 572 + config["providers"] = {} 573 + config["providers"]["vertex_credentials"] = str(creds_path) 574 + 575 + config_path = config_dir / "journal.json" 576 + with open(config_path, "w", encoding="utf-8") as f: 577 + json.dump(config, f, indent=2, ensure_ascii=False) 578 + f.write("\n") 579 + os.chmod(config_path, 0o600) 580 + 581 + log_app_action( 582 + app="settings", 583 + facet=None, 584 + action="vertex_credentials_upload", 585 + params={"client_email": creds.get("client_email")}, 586 + ) 587 + 588 + return jsonify( 589 + { 590 + "success": True, 591 + "client_email": creds.get("client_email"), 592 + } 593 + ) 594 + except Exception: 595 + logger.exception("error saving vertex credentials") 596 + return jsonify({"error": "something went wrong — try again, and if it persists, check the health dashboard"}), 500 597 + 598 + 599 + @settings_bp.route("/api/vertex-credentials", methods=["DELETE"]) 600 + def delete_vertex_credentials() -> Any: 601 + """Remove Vertex AI service account credentials.""" 602 + try: 603 + # Remove credentials file 604 + config_dir = Path(state.journal_root) / "config" 605 + creds_path = config_dir / "vertex-credentials.json" 606 + if creds_path.exists(): 607 + creds_path.unlink() 608 + 609 + # Remove from config 610 + config = get_journal_config() 611 + if "providers" in config: 612 + config["providers"].pop("vertex_credentials", None) 613 + # Clear stale validation result 614 + if "key_validation" in config["providers"]: 615 + config["providers"]["key_validation"].pop("google", None) 616 + 617 + config_path = config_dir / "journal.json" 618 + config_dir.mkdir(parents=True, exist_ok=True) 619 + with open(config_path, "w", encoding="utf-8") as f: 620 + json.dump(config, f, indent=2, ensure_ascii=False) 621 + f.write("\n") 622 + os.chmod(config_path, 0o600) 623 + 624 + log_app_action( 625 + app="settings", 626 + facet=None, 627 + action="vertex_credentials_delete", 628 + params={}, 629 + ) 630 + 631 + return jsonify({"success": True}) 632 + except Exception: 633 + logger.exception("error deleting vertex credentials") 493 634 return jsonify({"error": "something went wrong — try again, and if it persists, check the health dashboard"}), 500 494 635 495 636
+118 -4
apps/settings/workspace.html
··· 1919 1919 <select id="field-google-backend"> 1920 1920 <option value="auto">Auto-detect</option> 1921 1921 <option value="aistudio">AI Studio</option> 1922 - <option value="vertex">Vertex AI Express</option> 1922 + <option value="vertex">Vertex AI</option> 1923 1923 </select> 1924 1924 <small>Auto-detect probes your key on first use</small> 1925 1925 </div> ··· 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="Optional for Express Mode"> 1931 + <input type="text" id="field-vertex-project" placeholder="Required — e.g., my-project-id"> 1932 1932 <small>Google Cloud project ID</small> 1933 1933 </div> 1934 1934 <div class="settings-field"> 1935 1935 <label for="field-vertex-location">location</label> 1936 - <input type="text" id="field-vertex-location" placeholder="Optional - e.g., us-central1"> 1936 + <input type="text" id="field-vertex-location" placeholder="Required — e.g., us-central1"> 1937 1937 <small>Google Cloud region</small> 1938 1938 </div> 1939 1939 </div> 1940 1940 </div> 1941 + <div id="vertexCredentials" style="display:none"> 1942 + <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> 1949 + </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> 1963 + </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> 1966 + </div> 1967 + </div> 1968 + </div> 1941 1969 <div id="cogitateProviderKeyWarning" class="provider-key-warning" style="display:none"> 1942 1970 <span>&#9888;</span> 1943 1971 <span>API key not configured for cogitate provider.</span> ··· 1969 1997 <span id="revalidateStatus" style="margin-left: 0.5em; font-size: 0.85em;"></span> 1970 1998 </div> 1971 1999 1972 - <div class="settings-field"> 2000 + <div id="googleApiKeyField" class="settings-field"> 1973 2001 <label for="field-env-google">Google AI (Gemini)</label> 1974 2002 <div class="password-wrap"> 1975 2003 <input type="password" id="field-env-google" data-section="env" data-key="GOOGLE_API_KEY" placeholder="Enter API key"> ··· 3759 3787 if (vertexFields) { 3760 3788 vertexFields.style.display = (data.google_backend === 'vertex') ? '' : 'none'; 3761 3789 } 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 + // 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 + } 3762 3810 3763 3811 // Update status text 3764 3812 document.getElementById('providerStatus').textContent = ''; ··· 4089 4137 showFieldStatus(this, 'saved'); 4090 4138 const vf = document.getElementById('vertexFields'); 4091 4139 if (vf) vf.style.display = value === 'vertex' ? '' : 'none'; 4140 + const vc = document.getElementById('vertexCredentials'); 4141 + if (vc) vc.style.display = value === 'vertex' ? '' : 'none'; 4142 + const gk = document.getElementById('googleApiKeyField'); 4143 + if (gk) gk.style.display = value === 'vertex' ? 'none' : ''; 4092 4144 } catch (err) { 4093 4145 console.error('Error saving google backend:', err); 4094 4146 showFieldStatus(this, 'error', err.message); ··· 4116 4168 showFieldStatus(this, 'error', err.message); 4117 4169 } 4118 4170 }); 4171 + } 4172 + 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 = ''; 4179 + } 4180 + 4181 + async function submitVertexCredPaste() { 4182 + const text = document.getElementById('vertexCredPasteInput')?.value; 4183 + if (!text) return; 4184 + await saveVertexCredentials(text); 4185 + } 4186 + 4187 + async function saveVertexCredentials(jsonText) { 4188 + const statusEl = document.getElementById('vertexCredStatus2'); 4189 + 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'; } 4195 + return; 4196 + } 4197 + const response = await fetch('api/vertex-credentials', { 4198 + method: 'POST', 4199 + headers: { 'Content-Type': 'application/json' }, 4200 + body: JSON.stringify({ credentials: creds }) 4201 + }); 4202 + const result = await response.json(); 4203 + 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); 4215 + } catch (err) { 4216 + console.error('Error saving vertex credentials:', err); 4217 + if (statusEl) { statusEl.textContent = err.message; statusEl.style.color = '#c44'; } 4218 + } 4219 + } 4220 + 4221 + async function removeVertexCredentials() { 4222 + try { 4223 + const response = await fetch('api/vertex-credentials', { method: 'DELETE' }); 4224 + const result = await response.json(); 4225 + 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 = ''; 4230 + } catch (err) { 4231 + console.error('Error removing vertex credentials:', err); 4232 + } 4119 4233 } 4120 4234 4121 4235 // ========== TRANSCRIPTION ==========
+1
tests/baselines/api/settings/providers.json
··· 453 453 "name": "openai" 454 454 } 455 455 ], 456 + "vertex_credentials_email": null, 456 457 "vertex_location": "", 457 458 "vertex_project": "" 458 459 }
+56
tests/test_cli_provider.py
··· 722 722 env = build_cogitate_env("ANTHROPIC_API_KEY") 723 723 assert "GOOGLE_GENAI_USE_VERTEXAI" not in env 724 724 assert env["ANTHROPIC_API_KEY"] == "sk-ant" 725 + 726 + def test_vertex_service_account_sets_gac(self): 727 + """Vertex + vertex_credentials sets GOOGLE_APPLICATION_CREDENTIALS.""" 728 + config = { 729 + "providers": { 730 + "google_backend": "vertex", 731 + "vertex_credentials": "/path/to/creds.json", 732 + "vertex_project": "my-project", 733 + "vertex_location": "us-central1", 734 + "auth": {"google": "platform"}, 735 + } 736 + } 737 + with ( 738 + patch.dict(os.environ, {"GOOGLE_API_KEY": "gk-test"}, clear=True), 739 + patch("think.utils.get_config", return_value=config), 740 + ): 741 + env = build_cogitate_env("GOOGLE_API_KEY") 742 + assert env["GOOGLE_GENAI_USE_VERTEXAI"] == "true" 743 + assert env["GOOGLE_APPLICATION_CREDENTIALS"] == "/path/to/creds.json" 744 + assert env["GOOGLE_CLOUD_PROJECT"] == "my-project" 745 + assert env["GOOGLE_CLOUD_LOCATION"] == "us-central1" 746 + assert "GOOGLE_API_KEY" not in env 747 + 748 + def test_vertex_service_account_no_api_key(self): 749 + """Vertex + service account works even without GOOGLE_API_KEY in env.""" 750 + config = { 751 + "providers": { 752 + "google_backend": "vertex", 753 + "vertex_credentials": "/path/to/creds.json", 754 + } 755 + } 756 + with ( 757 + patch.dict(os.environ, {}, clear=True), 758 + patch("think.utils.get_config", return_value=config), 759 + ): 760 + env = build_cogitate_env("GOOGLE_API_KEY") 761 + assert env["GOOGLE_GENAI_USE_VERTEXAI"] == "true" 762 + assert env["GOOGLE_APPLICATION_CREDENTIALS"] == "/path/to/creds.json" 763 + assert "GOOGLE_API_KEY" not in env 764 + 765 + def test_vertex_no_credentials_preserves_api_key(self): 766 + """Vertex without vertex_credentials preserves API key behavior.""" 767 + config = { 768 + "providers": { 769 + "google_backend": "vertex", 770 + "auth": {"google": "api_key"}, 771 + } 772 + } 773 + with ( 774 + patch.dict(os.environ, {"GOOGLE_API_KEY": "gk-test"}, clear=True), 775 + patch("think.utils.get_config", return_value=config), 776 + ): 777 + env = build_cogitate_env("GOOGLE_API_KEY") 778 + assert env["GOOGLE_GENAI_USE_VERTEXAI"] == "true" 779 + assert env["GOOGLE_API_KEY"] == "gk-test" 780 + assert "GOOGLE_APPLICATION_CREDENTIALS" not in env
+221
tests/test_validate_key.py
··· 4 4 from __future__ import annotations 5 5 6 6 import json 7 + from pathlib import Path 7 8 from unittest.mock import Mock, patch 8 9 9 10 import pytest ··· 134 135 assert mock_cls.call_args.kwargs["api_key"] == "test-key" 135 136 136 137 138 + def test_validate_vertex_credentials_success(): 139 + """validate_vertex_credentials returns valid with backend vertex.""" 140 + client = Mock() 141 + client.models.list.return_value = [Mock()] 142 + 143 + with ( 144 + 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 + ): 149 + mock_creds.return_value = Mock() 150 + result = think.providers.google.validate_vertex_credentials( 151 + "/path/to/creds.json", project="my-project", location="us-central1" 152 + ) 153 + 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 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" 162 + 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" 204 + 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 + 242 + 137 243 def test_probe_backend_aistudio(): 138 244 """HTTP 200 from AI Studio endpoint -> aistudio.""" 139 245 mock_resp = Mock() ··· 354 460 ) 355 461 assert response.status_code == 400 356 462 assert "Invalid google_backend" in response.get_json()["error"] 463 + 464 + 465 + def test_vertex_credentials_upload_and_delete(settings_client): 466 + """POST/DELETE vertex-credentials saves and removes credentials file.""" 467 + client, journal = settings_client 468 + 469 + creds = { 470 + "type": "service_account", 471 + "project_id": "test-project", 472 + "client_email": "test@test-project.iam.gserviceaccount.com", 473 + "private_key": "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----\n", 474 + } 475 + 476 + # Upload 477 + response = client.post( 478 + "/app/settings/api/vertex-credentials", 479 + json={"credentials": creds}, 480 + ) 481 + assert response.status_code == 200 482 + payload = response.get_json() 483 + assert payload["success"] is True 484 + assert payload["client_email"] == "test@test-project.iam.gserviceaccount.com" 485 + 486 + # Verify file saved 487 + creds_path = journal / "config" / "vertex-credentials.json" 488 + assert creds_path.exists() 489 + saved = json.loads(creds_path.read_text()) 490 + assert saved["client_email"] == "test@test-project.iam.gserviceaccount.com" 491 + assert oct(creds_path.stat().st_mode)[-3:] == "600" 492 + 493 + # Verify config updated 494 + config = json.loads((journal / "config" / "journal.json").read_text()) 495 + assert Path(config["providers"]["vertex_credentials"]) == creds_path 496 + 497 + # GET providers returns email 498 + response = client.get("/app/settings/api/providers") 499 + payload = response.get_json() 500 + assert payload["vertex_credentials_email"] == "test@test-project.iam.gserviceaccount.com" 501 + 502 + # Delete 503 + response = client.delete("/app/settings/api/vertex-credentials") 504 + assert response.status_code == 200 505 + assert response.get_json()["success"] is True 506 + assert not creds_path.exists() 507 + 508 + config = json.loads((journal / "config" / "journal.json").read_text()) 509 + assert "vertex_credentials" not in config.get("providers", {}) 510 + 511 + # GET providers returns null email 512 + response = client.get("/app/settings/api/providers") 513 + payload = response.get_json() 514 + assert payload["vertex_credentials_email"] is None 515 + 516 + 517 + def test_vertex_credentials_upload_validation(settings_client): 518 + """POST vertex-credentials rejects invalid credentials JSON.""" 519 + client, _journal = settings_client 520 + 521 + # Missing required fields 522 + response = client.post( 523 + "/app/settings/api/vertex-credentials", 524 + json={"credentials": {"type": "service_account"}}, 525 + ) 526 + assert response.status_code == 400 527 + assert "Missing required fields" in response.get_json()["error"] 528 + 529 + # Wrong type 530 + response = client.post( 531 + "/app/settings/api/vertex-credentials", 532 + json={ 533 + "credentials": { 534 + "type": "authorized_user", 535 + "project_id": "p", 536 + "client_email": "e", 537 + "private_key": "k", 538 + } 539 + }, 540 + ) 541 + assert response.status_code == 400 542 + assert "service_account" in response.get_json()["error"] 543 + 544 + # Non-dict JSON (e.g., array) 545 + response = client.post( 546 + "/app/settings/api/vertex-credentials", 547 + json={"credentials": ["not", "a", "dict"]}, 548 + ) 549 + assert response.status_code == 400 550 + assert "JSON object" in response.get_json()["error"] 551 + 552 + 553 + def test_validate_all_keys_with_vertex_credentials(settings_client): 554 + """validate-all-keys validates vertex credentials when configured.""" 555 + client, journal = settings_client 556 + config_path = journal / "config" / "journal.json" 557 + 558 + # Set up vertex backend + credentials 559 + config = json.loads(config_path.read_text()) 560 + config.setdefault("providers", {})["google_backend"] = "vertex" 561 + config["providers"]["vertex_credentials"] = str( 562 + journal / "config" / "vertex-credentials.json" 563 + ) 564 + config["providers"]["vertex_project"] = "my-project" 565 + config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") 566 + 567 + with patch( 568 + "think.providers.google.validate_vertex_credentials", 569 + return_value={"valid": True, "backend": "vertex"}, 570 + ) as mock_validate: 571 + response = client.post("/app/settings/api/validate-keys") 572 + 573 + assert response.status_code == 200 574 + payload = response.get_json() 575 + assert payload["key_validation"]["google"]["valid"] is True 576 + assert payload["key_validation"]["google"]["backend"] == "vertex" 577 + mock_validate.assert_called_once()
+13 -5
think/providers/cli.py
··· 445 445 if auth_mode == "platform": 446 446 env.pop(env_key, None) 447 447 448 - # Vertex AI Express: set backend env vars for Google provider 448 + # Vertex AI: 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 - # Preserve API key for Vertex Express regardless of auth mode 468 - api_key = os.getenv("GOOGLE_API_KEY") 469 - if api_key: 470 - env["GOOGLE_API_KEY"] = api_key 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 471 478 # Set project/location from config if present 472 479 vertex_project = providers_config.get("vertex_project") 473 480 if vertex_project: ··· 479 486 # AI Studio: clear any inherited Vertex env vars so the CLI 480 487 # doesn't accidentally run in Vertex mode. 481 488 for vkey in ( 489 + "GOOGLE_APPLICATION_CREDENTIALS", 482 490 "GOOGLE_GENAI_USE_VERTEXAI", 483 491 "GOOGLE_CLOUD_PROJECT", 484 492 "GOOGLE_CLOUD_LOCATION",
+72 -13
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 41 42 42 43 from think.models import GEMINI_FLASH 43 44 from think.utils import now_ms ··· 60 61 61 62 logger = logging.getLogger(__name__) 62 63 63 - # Vertex AI Express Mode backend detection cache 64 + # Vertex AI backend detection cache 64 65 _detected_backend: str | None = None 65 66 66 67 ··· 120 121 ---------- 121 122 client : genai.Client, optional 122 123 Existing client to reuse. If not provided, creates a new one 123 - using GOOGLE_API_KEY from environment. 124 + using service account credentials, ADC, or GOOGLE_API_KEY 125 + depending on the configured backend. 124 126 125 127 Returns 126 128 ------- ··· 128 130 The provided client or a newly created one. 129 131 """ 130 132 if client is None: 131 - api_key = os.getenv("GOOGLE_API_KEY") 132 - if not api_key: 133 - raise ValueError("GOOGLE_API_KEY not found in environment") 134 - client_kwargs = { 135 - "api_key": api_key, 136 - "http_options": types.HttpOptions( 137 - retry_options=types.HttpRetryOptions(attempts=8) 138 - ), 139 - } 140 - client_kwargs["vertexai"] = _get_effective_backend(api_key) == "vertex" 141 - client = genai.Client(**client_kwargs) 133 + from think.utils import get_config 134 + 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 + ) 141 + 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( 145 + creds_path, 146 + scopes=["https://www.googleapis.com/auth/cloud-platform"], 147 + ) 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, 154 + ) 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) 142 172 return client 143 173 144 174 ··· 753 783 return {"valid": False, "error": str(e)} 754 784 755 785 786 + def validate_vertex_credentials( 787 + credentials_path: str, 788 + project: str | None = None, 789 + location: str | None = None, 790 + ) -> dict: 791 + """Validate Vertex AI service account credentials by listing models. 792 + 793 + Returns {"valid": True, "backend": "vertex"} or 794 + {"valid": False, "error": "..."}. 795 + """ 796 + try: 797 + creds = service_account.Credentials.from_service_account_file( 798 + credentials_path, 799 + scopes=["https://www.googleapis.com/auth/cloud-platform"], 800 + ) 801 + client = genai.Client( 802 + vertexai=True, 803 + credentials=creds, 804 + project=project, 805 + location=location, 806 + http_options=types.HttpOptions(timeout=10000), 807 + ) 808 + list(client.models.list(config={"page_size": 1})) 809 + return {"valid": True, "backend": "vertex"} 810 + except Exception as e: 811 + return {"valid": False, "error": str(e)} 812 + 813 + 756 814 __all__ = [ 757 815 "run_cogitate", 758 816 "run_generate", 759 817 "run_agenerate", 760 818 "get_or_create_client", 819 + "validate_vertex_credentials", 761 820 "_detect_backend", 762 821 "_get_effective_backend", 763 822 "list_models",