tangled
alpha
login
or
join now
solpbc.org
/
solstone
0
fork
atom
personal memory agent
0
fork
atom
overview
issues
pulls
pipelines
Merge branch 'hopper-w6zmhkql-api-key-validation'
Jer Miller
1 week ago
e5561c30
4c3dd745
+524
-7
8 changed files
expand all
collapse all
unified
split
apps
settings
routes.py
workspace.html
tests
baselines
api
settings
providers.json
test_validate_key.py
think
providers
__init__.py
anthropic.py
google.py
openai.py
+71
-1
apps/settings/routes.py
reviewed
···
8
8
import os
9
9
import re
10
10
import subprocess
11
11
+
from datetime import datetime, timezone
11
12
from pathlib import Path
12
13
from typing import Any
13
14
···
198
199
mode = "api_key" if new_val else "platform"
199
200
config["providers"]["auth"][provider] = mode
200
201
202
202
+
# Validate changed provider API keys
203
203
+
if "key_validation" not in config["providers"]:
204
204
+
config["providers"]["key_validation"] = {}
205
205
+
for env_var in changed_fields:
206
206
+
provider = env_to_provider.get(env_var)
207
207
+
if provider:
208
208
+
new_val = data.get(env_var, "")
209
209
+
if new_val:
210
210
+
from think.providers import validate_key as _validate_key
211
211
+
212
212
+
result = _validate_key(provider, new_val)
213
213
+
result["timestamp"] = datetime.now(timezone.utc).isoformat()
214
214
+
config["providers"]["key_validation"][provider] = result
215
215
+
else:
216
216
+
config["providers"]["key_validation"].pop(provider, None)
217
217
+
201
218
# Write back to file
202
219
with open(config_path, "w", encoding="utf-8") as f:
203
220
json.dump(config, f, indent=2, ensure_ascii=False)
···
234
251
if "env" in config:
235
252
config["env"] = {k: bool(v) for k, v in config["env"].items()}
236
253
237
237
-
return jsonify({"success": True, "config": config})
254
254
+
key_validation = config.get("providers", {}).get("key_validation", {})
255
255
+
return jsonify(
256
256
+
{"success": True, "config": config, "key_validation": key_validation}
257
257
+
)
238
258
except Exception as e:
239
259
return jsonify({"error": str(e)}), 500
240
260
···
377
397
p["name"]: auth_config.get(p["name"], "platform") for p in providers_list
378
398
}
379
399
400
400
+
# Get cached key validation results
401
401
+
key_validation = providers_config.get("key_validation", {})
402
402
+
380
403
return jsonify(
381
404
{
382
405
"providers": providers_list,
···
386
409
"context_defaults": context_defaults,
387
410
"api_keys": api_keys,
388
411
"auth": auth,
412
412
+
"key_validation": key_validation,
389
413
}
390
414
)
415
415
+
except Exception as e:
416
416
+
return jsonify({"error": str(e)}), 500
417
417
+
418
418
+
419
419
+
@settings_bp.route("/api/validate-keys", methods=["POST"])
420
420
+
def validate_all_keys() -> Any:
421
421
+
"""Re-validate all configured provider API keys.
422
422
+
423
423
+
Reads keys from journal.json config (not environment), validates each
424
424
+
against the provider API, and stores results in providers.key_validation.
425
425
+
"""
426
426
+
try:
427
427
+
from think.providers import PROVIDER_METADATA, validate_key as _validate_key
428
428
+
429
429
+
config = get_journal_config()
430
430
+
env_config = config.get("env", {})
431
431
+
432
432
+
# Build reverse map: env_key -> provider name
433
433
+
env_to_provider = {
434
434
+
meta["env_key"]: name
435
435
+
for name, meta in PROVIDER_METADATA.items()
436
436
+
if "env_key" in meta
437
437
+
}
438
438
+
439
439
+
if "providers" not in config:
440
440
+
config["providers"] = {}
441
441
+
key_validation = {}
442
442
+
443
443
+
for env_var, provider in env_to_provider.items():
444
444
+
api_key = env_config.get(env_var, "")
445
445
+
if api_key:
446
446
+
result = _validate_key(provider, api_key)
447
447
+
result["timestamp"] = datetime.now(timezone.utc).isoformat()
448
448
+
key_validation[provider] = result
449
449
+
450
450
+
config["providers"]["key_validation"] = key_validation
451
451
+
452
452
+
config_dir = Path(state.journal_root) / "config"
453
453
+
config_dir.mkdir(parents=True, exist_ok=True)
454
454
+
config_path = config_dir / "journal.json"
455
455
+
with open(config_path, "w", encoding="utf-8") as f:
456
456
+
json.dump(config, f, indent=2, ensure_ascii=False)
457
457
+
f.write("\n")
458
458
+
os.chmod(config_path, 0o600)
459
459
+
460
460
+
return jsonify({"success": True, "key_validation": key_validation})
391
461
except Exception as e:
392
462
return jsonify({"error": str(e)}), 500
393
463
+160
-6
apps/settings/workspace.html
reviewed
···
194
194
font-size: 0.85em;
195
195
}
196
196
197
197
+
.key-status-valid {
198
198
+
color: #28a745;
199
199
+
font-size: 0.85em;
200
200
+
margin-left: 0.5em;
201
201
+
}
202
202
+
203
203
+
.key-status-invalid {
204
204
+
color: #dc3545;
205
205
+
font-size: 0.85em;
206
206
+
margin-left: 0.5em;
207
207
+
}
208
208
+
209
209
+
.key-status-validating {
210
210
+
color: #6c757d;
211
211
+
font-size: 0.85em;
212
212
+
margin-left: 0.5em;
213
213
+
font-style: italic;
214
214
+
}
215
215
+
197
216
/* Password field with toggle */
198
217
.password-wrap {
199
218
position: relative;
···
1836
1855
<section class="settings-section" id="section-apikeys">
1837
1856
<h2>API Keys</h2>
1838
1857
<p class="settings-section-desc">Configure API keys for AI providers. Keys are stored in your journal config.</p>
1858
1858
+
<div style="margin-bottom: 1em;">
1859
1859
+
<button type="button" id="revalidateAllKeys" class="btn-secondary" onclick="revalidateAllKeys()">Re-validate All Keys</button>
1860
1860
+
<span id="revalidateStatus" style="margin-left: 0.5em; font-size: 0.85em;"></span>
1861
1861
+
</div>
1839
1862
1840
1863
<div class="settings-field">
1841
1864
<label for="field-env-google">Google AI (Gemini)</label>
···
2516
2539
let saveTimeout = null;
2517
2540
let logNextCursor = null;
2518
2541
let insightsData = null;
2542
2542
+
let keyValidationData = {};
2519
2543
2520
2544
// ========== NAVIGATION ==========
2521
2545
const VALID_SECTIONS = ['profile', 'agent', 'providers', 'apikeys', 'transcription', 'observer', 'vision', 'insights', 'security', 'sync', 'storage', 'support', 'facet-appearance', 'facet-activities', 'facet-activity'];
···
2717
2741
// env = journal config status, runtime_env = loaded into process
2718
2742
const env = config.env || {};
2719
2743
const sysEnv = config.runtime_env || {};
2744
2744
+
keyValidationData = config.providers?.key_validation || {};
2720
2745
updateEnvStatus('field-env-google', env.GOOGLE_API_KEY, sysEnv.GOOGLE_API_KEY);
2721
2746
updateEnvStatus('field-env-openai', env.OPENAI_API_KEY, sysEnv.OPENAI_API_KEY);
2722
2747
updateEnvStatus('field-env-anthropic', env.ANTHROPIC_API_KEY, sysEnv.ANTHROPIC_API_KEY);
···
2755
2780
status.textContent = ' (system)';
2756
2781
label.appendChild(status);
2757
2782
}
2783
2783
+
2784
2784
+
// Add validation status for provider keys
2785
2785
+
const providerName = fieldId.replace('field-env-', '');
2786
2786
+
if (['google', 'openai', 'anthropic'].includes(providerName)) {
2787
2787
+
const validation = keyValidationData[providerName];
2788
2788
+
const oldValidation = label.querySelector('.key-validation-status');
2789
2789
+
if (oldValidation) oldValidation.remove();
2790
2790
+
2791
2791
+
if (validation) {
2792
2792
+
const validationSpan = document.createElement('span');
2793
2793
+
validationSpan.className = 'key-validation-status';
2794
2794
+
if (validation.valid) {
2795
2795
+
validationSpan.className += ' key-status-valid';
2796
2796
+
validationSpan.textContent = ' ✓ Valid';
2797
2797
+
} else {
2798
2798
+
validationSpan.className += ' key-status-invalid';
2799
2799
+
const errMsg = validation.error || 'Invalid';
2800
2800
+
const shortErr = errMsg.length > 60 ? errMsg.slice(0, 60) + '…' : errMsg;
2801
2801
+
validationSpan.textContent = ' ✗ ' + shortErr;
2802
2802
+
validationSpan.title = errMsg;
2803
2803
+
}
2804
2804
+
label.appendChild(validationSpan);
2805
2805
+
}
2806
2806
+
}
2758
2807
}
2759
2808
}
2760
2809
···
2806
2855
clearTimeout(saveTimeout);
2807
2856
saveTimeout = setTimeout(async () => {
2808
2857
try {
2858
2858
+
const runtimeEnv = configData?.runtime_env || {};
2809
2859
const response = await fetch('api/config', {
2810
2860
method: 'PUT',
2811
2861
headers: { 'Content-Type': 'application/json' },
···
2814
2864
const result = await response.json();
2815
2865
if (result.success) {
2816
2866
configData = result.config;
2867
2867
+
configData.runtime_env = runtimeEnv;
2817
2868
if (el) showFieldStatus(el, 'saved');
2818
2869
2819
2870
// For env fields, clear the input and update status indicator
2820
2871
if (el && section === 'env') {
2821
2872
el.value = '';
2822
2822
-
updateEnvStatus(el.id, true, true);
2873
2873
+
if (result.key_validation) {
2874
2874
+
keyValidationData = result.key_validation;
2875
2875
+
configData.providers = configData.providers || {};
2876
2876
+
configData.providers.key_validation = keyValidationData;
2877
2877
+
}
2878
2878
+
updateEnvStatus(
2879
2879
+
el.id,
2880
2880
+
configData?.env?.[key],
2881
2881
+
configData?.runtime_env?.[key]
2882
2882
+
);
2883
2883
+
if (providersData) {
2884
2884
+
providersData.key_validation = keyValidationData;
2885
2885
+
for (const type of ['generate', 'cogitate']) {
2886
2886
+
const providerSelect = document.getElementById(`field-${type}-provider`);
2887
2887
+
if (providerSelect) {
2888
2888
+
updateTypeProviderKeyWarning(
2889
2889
+
type,
2890
2890
+
providerSelect.value,
2891
2891
+
providersData.api_keys || {},
2892
2892
+
providersData.auth || {}
2893
2893
+
);
2894
2894
+
}
2895
2895
+
}
2896
2896
+
}
2823
2897
}
2824
2898
} else {
2825
2899
throw new Error(result.error);
···
3254
3328
}
3255
3329
3256
3330
function populateProviders(data) {
3331
3331
+
// Cache key validation data
3332
3332
+
if (data.key_validation) {
3333
3333
+
keyValidationData = data.key_validation;
3334
3334
+
}
3335
3335
+
3257
3336
// Populate all provider/backup dropdowns for both types
3258
3337
for (const type of ['generate', 'cogitate']) {
3259
3338
for (const suffix of ['provider', 'backup']) {
···
3289
3368
// Update status text
3290
3369
document.getElementById('providerStatus').textContent = '';
3291
3370
3371
3371
+
// Refresh env status displays with latest validation data
3372
3372
+
const env = configData?.env || {};
3373
3373
+
const sysEnv = configData?.runtime_env || {};
3374
3374
+
updateEnvStatus('field-env-google', env.GOOGLE_API_KEY, sysEnv.GOOGLE_API_KEY);
3375
3375
+
updateEnvStatus('field-env-openai', env.OPENAI_API_KEY, sysEnv.OPENAI_API_KEY);
3376
3376
+
updateEnvStatus('field-env-anthropic', env.ANTHROPIC_API_KEY, sysEnv.ANTHROPIC_API_KEY);
3377
3377
+
3292
3378
// Populate context overrides
3293
3379
renderContextGroups(data);
3294
3380
}
···
3297
3383
const warning = document.getElementById(`${type}ProviderKeyWarning`);
3298
3384
const link = document.getElementById(`${type}ProviderKeyLink`);
3299
3385
if (!warning || !link) return;
3386
3386
+
const message = warning.querySelectorAll('span')[1];
3300
3387
3301
3388
// For cogitate, hide key warning when using platform auth
3302
3389
const authMode = (auth && type === 'cogitate') ? (auth[provider] || 'platform') : 'api_key';
3303
3303
-
if (authMode === 'platform' || !provider || apiKeys[provider]) {
3390
3390
+
const validation = provider ? keyValidationData[provider] : null;
3391
3391
+
if (authMode === 'platform' || !provider) {
3392
3392
+
warning.style.display = 'none';
3393
3393
+
warning.title = '';
3394
3394
+
return;
3395
3395
+
}
3396
3396
+
3397
3397
+
if (validation) {
3398
3398
+
if (validation.valid) {
3399
3399
+
warning.style.display = 'none';
3400
3400
+
warning.title = '';
3401
3401
+
return;
3402
3402
+
}
3403
3403
+
warning.style.display = 'flex';
3404
3404
+
if (message) {
3405
3405
+
message.textContent = `API key validation failed for ${type} provider.`;
3406
3406
+
}
3407
3407
+
warning.title = validation.error || 'Invalid API key';
3408
3408
+
} else if (apiKeys[provider]) {
3304
3409
warning.style.display = 'none';
3410
3410
+
warning.title = '';
3411
3411
+
return;
3305
3412
} else {
3306
3413
warning.style.display = 'flex';
3307
3307
-
link.onclick = (e) => {
3308
3308
-
e.preventDefault();
3309
3309
-
switchSection('apikeys');
3310
3310
-
};
3414
3414
+
if (message) {
3415
3415
+
message.textContent = `API key not configured for ${type} provider.`;
3416
3416
+
}
3417
3417
+
warning.title = '';
3418
3418
+
}
3419
3419
+
3420
3420
+
link.onclick = (e) => {
3421
3421
+
e.preventDefault();
3422
3422
+
switchSection('apikeys');
3423
3423
+
};
3424
3424
+
}
3425
3425
+
3426
3426
+
async function revalidateAllKeys() {
3427
3427
+
const btn = document.getElementById('revalidateAllKeys');
3428
3428
+
const status = document.getElementById('revalidateStatus');
3429
3429
+
btn.disabled = true;
3430
3430
+
status.textContent = 'Validating...';
3431
3431
+
status.className = 'key-status-validating';
3432
3432
+
try {
3433
3433
+
const response = await fetch('api/validate-keys', { method: 'POST' });
3434
3434
+
const result = await response.json();
3435
3435
+
if (result.error) throw new Error(result.error);
3436
3436
+
keyValidationData = result.key_validation || {};
3437
3437
+
if (configData) {
3438
3438
+
configData.providers = configData.providers || {};
3439
3439
+
configData.providers.key_validation = keyValidationData;
3440
3440
+
}
3441
3441
+
// Refresh env status displays
3442
3442
+
const env = configData?.env || {};
3443
3443
+
const sysEnv = configData?.runtime_env || {};
3444
3444
+
updateEnvStatus('field-env-google', env.GOOGLE_API_KEY, sysEnv.GOOGLE_API_KEY);
3445
3445
+
updateEnvStatus('field-env-openai', env.OPENAI_API_KEY, sysEnv.OPENAI_API_KEY);
3446
3446
+
updateEnvStatus('field-env-anthropic', env.ANTHROPIC_API_KEY, sysEnv.ANTHROPIC_API_KEY);
3447
3447
+
// Update provider warnings too
3448
3448
+
if (providersData) {
3449
3449
+
providersData.key_validation = keyValidationData;
3450
3450
+
for (const type of ['generate', 'cogitate']) {
3451
3451
+
const providerSelect = document.getElementById(`field-${type}-provider`);
3452
3452
+
if (providerSelect) {
3453
3453
+
updateTypeProviderKeyWarning(type, providerSelect.value, providersData.api_keys, providersData.auth);
3454
3454
+
}
3455
3455
+
}
3456
3456
+
}
3457
3457
+
status.textContent = 'Done';
3458
3458
+
status.className = 'key-status-valid';
3459
3459
+
setTimeout(() => { status.textContent = ''; }, 2000);
3460
3460
+
} catch (err) {
3461
3461
+
status.textContent = 'Error: ' + err.message;
3462
3462
+
status.className = 'key-status-invalid';
3463
3463
+
} finally {
3464
3464
+
btn.disabled = false;
3311
3465
}
3312
3466
}
3313
3467
+1
tests/baselines/api/settings/providers.json
reviewed
···
451
451
"provider": "google",
452
452
"tier": 2
453
453
},
454
454
+
"key_validation": {},
454
455
"providers": [
455
456
{
456
457
"env_key": "ANTHROPIC_API_KEY",
+209
tests/test_validate_key.py
reviewed
···
1
1
+
# SPDX-License-Identifier: AGPL-3.0-only
2
2
+
# Copyright (c) 2026 sol pbc
3
3
+
4
4
+
from __future__ import annotations
5
5
+
6
6
+
import json
7
7
+
import shutil
8
8
+
from pathlib import Path
9
9
+
from unittest.mock import Mock, patch
10
10
+
11
11
+
import pytest
12
12
+
13
13
+
import think.providers
14
14
+
import think.providers.anthropic
15
15
+
import think.providers.google
16
16
+
import think.providers.openai
17
17
+
from convey import create_app
18
18
+
from think.providers import validate_key
19
19
+
20
20
+
21
21
+
@pytest.fixture
22
22
+
def settings_client(tmp_path, monkeypatch):
23
23
+
src = Path(__file__).resolve().parent / "fixtures" / "journal"
24
24
+
journal = tmp_path / "journal"
25
25
+
shutil.copytree(src, journal)
26
26
+
monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal))
27
27
+
28
28
+
app = create_app(str(journal))
29
29
+
app.config["TESTING"] = True
30
30
+
return app.test_client(), journal
31
31
+
32
32
+
33
33
+
def test_validate_key_anthropic_success():
34
34
+
client = Mock()
35
35
+
client.models.list.return_value = [Mock()]
36
36
+
37
37
+
with patch("anthropic.Anthropic", return_value=client) as mock_cls:
38
38
+
result = think.providers.anthropic.validate_key("test-key")
39
39
+
40
40
+
assert result == {"valid": True}
41
41
+
mock_cls.assert_called_once_with(api_key="test-key", timeout=10)
42
42
+
43
43
+
44
44
+
def test_validate_key_anthropic_auth_error():
45
45
+
client = Mock()
46
46
+
client.models.list.side_effect = Exception("invalid x-api-key")
47
47
+
48
48
+
with patch("anthropic.Anthropic", return_value=client):
49
49
+
result = think.providers.anthropic.validate_key("bad-key")
50
50
+
51
51
+
assert result["valid"] is False
52
52
+
assert "invalid x-api-key" in result["error"]
53
53
+
54
54
+
55
55
+
def test_validate_key_openai_success():
56
56
+
client = Mock()
57
57
+
client.models.list.return_value = [Mock()]
58
58
+
59
59
+
with patch("openai.OpenAI", return_value=client) as mock_cls:
60
60
+
result = think.providers.openai.validate_key("test-key")
61
61
+
62
62
+
assert result == {"valid": True}
63
63
+
mock_cls.assert_called_once_with(api_key="test-key", timeout=10)
64
64
+
65
65
+
66
66
+
def test_validate_key_openai_auth_error():
67
67
+
client = Mock()
68
68
+
client.models.list.side_effect = Exception("Incorrect API key")
69
69
+
70
70
+
with patch("openai.OpenAI", return_value=client):
71
71
+
result = think.providers.openai.validate_key("bad-key")
72
72
+
73
73
+
assert result["valid"] is False
74
74
+
assert "Incorrect API key" in result["error"]
75
75
+
76
76
+
77
77
+
def test_validate_key_google_success():
78
78
+
client = Mock()
79
79
+
client.models.list.return_value = [Mock()]
80
80
+
81
81
+
with patch("think.providers.google.genai.Client", return_value=client) as mock_cls:
82
82
+
result = think.providers.google.validate_key("test-key")
83
83
+
84
84
+
assert result == {"valid": True}
85
85
+
mock_cls.assert_called_once()
86
86
+
assert mock_cls.call_args.kwargs["api_key"] == "test-key"
87
87
+
88
88
+
89
89
+
def test_validate_key_google_auth_error():
90
90
+
client = Mock()
91
91
+
client.models.list.side_effect = Exception("API key not valid")
92
92
+
93
93
+
with patch("think.providers.google.genai.Client", return_value=client):
94
94
+
result = think.providers.google.validate_key("bad-key")
95
95
+
96
96
+
assert result["valid"] is False
97
97
+
assert "API key not valid" in result["error"]
98
98
+
99
99
+
100
100
+
def test_validate_key_dispatcher_success():
101
101
+
with patch("think.providers.google.validate_key", return_value={"valid": True}):
102
102
+
result = validate_key("google", "test-key")
103
103
+
104
104
+
assert result == {"valid": True}
105
105
+
106
106
+
107
107
+
def test_validate_key_dispatcher_unknown_provider():
108
108
+
with pytest.raises(ValueError, match="Unknown provider"):
109
109
+
validate_key("bogus", "test-key")
110
110
+
111
111
+
112
112
+
def test_validate_key_timeout():
113
113
+
"""Validate that timeout exceptions are caught and reported."""
114
114
+
client = Mock()
115
115
+
client.models.list.side_effect = TimeoutError("Connection timed out")
116
116
+
117
117
+
with patch("openai.OpenAI", return_value=client):
118
118
+
result = think.providers.openai.validate_key("test-key")
119
119
+
120
120
+
assert result["valid"] is False
121
121
+
assert "timed out" in result["error"]
122
122
+
123
123
+
124
124
+
def test_update_config_saves_key_validation(settings_client):
125
125
+
client, journal = settings_client
126
126
+
127
127
+
with patch("think.providers.validate_key", return_value={"valid": False, "error": "bad key"}):
128
128
+
response = client.put(
129
129
+
"/app/settings/api/config",
130
130
+
json={"section": "env", "data": {"GOOGLE_API_KEY": "bad-key"}},
131
131
+
)
132
132
+
133
133
+
assert response.status_code == 200
134
134
+
payload = response.get_json()
135
135
+
assert payload["success"] is True
136
136
+
assert payload["key_validation"]["google"]["valid"] is False
137
137
+
assert payload["key_validation"]["google"]["error"] == "bad key"
138
138
+
assert "timestamp" in payload["key_validation"]["google"]
139
139
+
140
140
+
config = json.loads((journal / "config" / "journal.json").read_text())
141
141
+
assert config["providers"]["auth"]["google"] == "api_key"
142
142
+
assert config["providers"]["key_validation"]["google"]["valid"] is False
143
143
+
144
144
+
145
145
+
def test_update_config_clears_key_validation(settings_client):
146
146
+
client, journal = settings_client
147
147
+
config_path = journal / "config" / "journal.json"
148
148
+
config = json.loads(config_path.read_text())
149
149
+
config.setdefault("env", {})["GOOGLE_API_KEY"] = "existing-key"
150
150
+
config.setdefault("providers", {}).setdefault("auth", {})["google"] = "api_key"
151
151
+
config["providers"]["key_validation"] = {
152
152
+
"google": {"valid": True, "timestamp": "2026-01-01T00:00:00+00:00"}
153
153
+
}
154
154
+
config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
155
155
+
156
156
+
response = client.put(
157
157
+
"/app/settings/api/config",
158
158
+
json={"section": "env", "data": {"GOOGLE_API_KEY": ""}},
159
159
+
)
160
160
+
161
161
+
assert response.status_code == 200
162
162
+
payload = response.get_json()
163
163
+
assert payload["success"] is True
164
164
+
assert "google" not in payload["key_validation"]
165
165
+
166
166
+
saved = json.loads(config_path.read_text())
167
167
+
assert saved["providers"]["auth"]["google"] == "platform"
168
168
+
assert "google" not in saved["providers"]["key_validation"]
169
169
+
170
170
+
171
171
+
def test_get_providers_includes_key_validation(settings_client):
172
172
+
client, journal = settings_client
173
173
+
config_path = journal / "config" / "journal.json"
174
174
+
config = json.loads(config_path.read_text())
175
175
+
config.setdefault("providers", {})["key_validation"] = {
176
176
+
"openai": {"valid": True, "timestamp": "2026-01-01T00:00:00+00:00"}
177
177
+
}
178
178
+
config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
179
179
+
180
180
+
response = client.get("/app/settings/api/providers")
181
181
+
182
182
+
assert response.status_code == 200
183
183
+
payload = response.get_json()
184
184
+
assert payload["key_validation"]["openai"]["valid"] is True
185
185
+
186
186
+
187
187
+
def test_validate_all_keys_endpoint(settings_client):
188
188
+
client, journal = settings_client
189
189
+
config_path = journal / "config" / "journal.json"
190
190
+
config = json.loads(config_path.read_text())
191
191
+
config.setdefault("env", {})["GOOGLE_API_KEY"] = "google-key"
192
192
+
config["env"]["OPENAI_API_KEY"] = "openai-key"
193
193
+
config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
194
194
+
195
195
+
def fake_validate(provider: str, api_key: str) -> dict:
196
196
+
return {"valid": provider == "google", "error": "" if provider == "google" else "bad key"}
197
197
+
198
198
+
with patch("think.providers.validate_key", side_effect=fake_validate):
199
199
+
response = client.post("/app/settings/api/validate-keys")
200
200
+
201
201
+
assert response.status_code == 200
202
202
+
payload = response.get_json()
203
203
+
assert payload["success"] is True
204
204
+
assert payload["key_validation"]["google"]["valid"] is True
205
205
+
assert payload["key_validation"]["openai"]["valid"] is False
206
206
+
assert "timestamp" in payload["key_validation"]["google"]
207
207
+
208
208
+
saved = json.loads(config_path.read_text())
209
209
+
assert set(saved["providers"]["key_validation"]) == {"google", "openai"}
+25
think/providers/__init__.py
reviewed
···
117
117
return module.list_models()
118
118
119
119
120
120
+
def validate_key(provider: str, api_key: str) -> dict:
121
121
+
"""Validate an API key for a provider.
122
122
+
123
123
+
Parameters
124
124
+
----------
125
125
+
provider
126
126
+
Provider name (e.g., "google", "openai", "anthropic").
127
127
+
api_key
128
128
+
The API key string to validate.
129
129
+
130
130
+
Returns
131
131
+
-------
132
132
+
dict
133
133
+
{"valid": True} or {"valid": False, "error": "..."}.
134
134
+
135
135
+
Raises
136
136
+
------
137
137
+
ValueError
138
138
+
If the provider is not registered.
139
139
+
"""
140
140
+
module = get_provider_module(provider)
141
141
+
return module.validate_key(api_key)
142
142
+
143
143
+
120
144
__all__ = [
121
145
"PROVIDER_REGISTRY",
122
146
"PROVIDER_METADATA",
123
147
"get_provider_module",
124
148
"get_provider_list",
125
149
"get_provider_models",
150
150
+
"validate_key",
126
151
]
+19
think/providers/anthropic.py
reviewed
···
566
566
return [m.model_dump() for m in client.models.list()]
567
567
568
568
569
569
+
def validate_key(api_key: str) -> dict:
570
570
+
"""Validate an Anthropic API key by listing models.
571
571
+
572
572
+
Creates a temporary client with the provided key. Never uses
573
573
+
the cached client or environment variables.
574
574
+
575
575
+
Returns {"valid": True} or {"valid": False, "error": "..."}.
576
576
+
"""
577
577
+
try:
578
578
+
from anthropic import Anthropic
579
579
+
580
580
+
client = Anthropic(api_key=api_key, timeout=10)
581
581
+
list(client.models.list(limit=1))
582
582
+
return {"valid": True}
583
583
+
except Exception as e:
584
584
+
return {"valid": False, "error": str(e)}
585
585
+
586
586
+
569
587
__all__ = [
570
588
"run_cogitate",
571
589
"run_generate",
572
590
"run_agenerate",
573
591
"list_models",
592
592
+
"validate_key",
574
593
]
+20
think/providers/google.py
reviewed
···
674
674
return [m.model_dump() for m in client.models.list()]
675
675
676
676
677
677
+
def validate_key(api_key: str) -> dict:
678
678
+
"""Validate a Google API key by listing models.
679
679
+
680
680
+
Creates a temporary client with the provided key. Never uses
681
681
+
the cached client or environment variables.
682
682
+
683
683
+
Returns {"valid": True} or {"valid": False, "error": "..."}.
684
684
+
"""
685
685
+
try:
686
686
+
client = genai.Client(
687
687
+
api_key=api_key,
688
688
+
http_options=types.HttpOptions(timeout=10000),
689
689
+
)
690
690
+
list(client.models.list(config={"page_size": 1}))
691
691
+
return {"valid": True}
692
692
+
except Exception as e:
693
693
+
return {"valid": False, "error": str(e)}
694
694
+
695
695
+
677
696
__all__ = [
678
697
"run_cogitate",
679
698
"run_generate",
680
699
"run_agenerate",
681
700
"get_or_create_client",
682
701
"list_models",
702
702
+
"validate_key",
683
703
]
+19
think/providers/openai.py
reviewed
···
465
465
return [m.model_dump() for m in client.models.list()]
466
466
467
467
468
468
+
def validate_key(api_key: str) -> dict:
469
469
+
"""Validate an OpenAI API key by listing models.
470
470
+
471
471
+
Creates a temporary client with the provided key. Never uses
472
472
+
the cached client or environment variables.
473
473
+
474
474
+
Returns {"valid": True} or {"valid": False, "error": "..."}.
475
475
+
"""
476
476
+
try:
477
477
+
import openai
478
478
+
479
479
+
client = openai.OpenAI(api_key=api_key, timeout=10)
480
480
+
list(client.models.list())
481
481
+
return {"valid": True}
482
482
+
except Exception as e:
483
483
+
return {"valid": False, "error": str(e)}
484
484
+
485
485
+
468
486
__all__ = [
469
487
"run_cogitate",
470
488
"run_generate",
471
489
"run_agenerate",
472
490
"list_models",
491
491
+
"validate_key",
473
492
]