Skip to content

Commit 1d68e0d

Browse files
committed
feat: add support for free models in environment-based API key management
1 parent f2f8f9f commit 1d68e0d

File tree

4 files changed

+136
-20
lines changed

4 files changed

+136
-20
lines changed

crossbar_llm/backend/config.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@
5151
"openrouter": "OpenRouter",
5252
}
5353

54+
PRODUCTION_FREE_ENV_MODELS = {
55+
"gpt-5-mini",
56+
"gemini-3-flash-preview",
57+
}
58+
5459

5560
def get_setting(key, default=None):
5661
"""Get a setting from the configuration."""
@@ -73,13 +78,13 @@ def get_provider_for_model(model_name: str) -> str | None:
7378
"""
7479
if not model_name:
7580
return None
76-
81+
7782
from models_config import get_provider_for_model_name
78-
83+
7984
display_name = get_provider_for_model_name(model_name)
8085
if not display_name:
8186
return None
82-
87+
8388
display_to_provider = {
8489
"OpenAI": "openai",
8590
"Anthropic": "anthropic",
@@ -89,7 +94,7 @@ def get_provider_for_model(model_name: str) -> str | None:
8994
"OpenRouter": "openrouter",
9095
"Ollama": "ollama",
9196
}
92-
97+
9398
return display_to_provider.get(display_name)
9499

95100

@@ -102,3 +107,12 @@ def get_api_keys_status() -> dict:
102107
value != "" and value != "default"
103108
)
104109
return status
110+
111+
112+
def is_env_model_allowed(model_name: str) -> bool:
113+
"""Return whether a model is allowed with api_key='env' in this environment."""
114+
if not model_name:
115+
return False
116+
if IS_DEVELOPMENT:
117+
return True
118+
return model_name in PRODUCTION_FREE_ENV_MODELS

crossbar_llm/backend/main.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
get_provider_env_var,
2525
get_provider_for_model,
2626
get_setting,
27+
is_env_model_allowed,
2728
)
2829
from config import get_api_keys_status as get_api_keys_status_from_config
2930
from dotenv import load_dotenv
@@ -54,6 +55,28 @@
5455
from tools.conversation_store import get_conversation_store, ConversationTurn
5556
from models_config import get_all_models
5657

58+
59+
def get_free_models_for_environment() -> List[str]:
60+
"""
61+
Return model names that can be used with server-managed (`api_key='env'`) keys.
62+
Development: all models.
63+
Production: only explicitly allowed free models with configured provider keys.
64+
"""
65+
all_models = get_all_models()
66+
if IS_DEVELOPMENT:
67+
return [model for provider_models in all_models.values() for model in provider_models]
68+
69+
free_models: List[str] = []
70+
for model_list in all_models.values():
71+
for model_name in model_list:
72+
if not is_env_model_allowed(model_name):
73+
continue
74+
provider_id = get_provider_for_model(model_name)
75+
env_var = get_provider_env_var(provider_id or "")
76+
if env_var and os.getenv(env_var):
77+
free_models.append(model_name)
78+
return free_models
79+
5780
# Load environment variables
5881
load_dotenv()
5982

@@ -747,6 +770,23 @@ async def generate_query(
747770
# Handle "env" API key by using the API key from .env
748771
api_key = generate_query_request.api_key
749772
if api_key == "env":
773+
if not is_env_model_allowed(generate_query_request.llm_type):
774+
Logger.warning(
775+
"[API] /generate_query/ - Model not allowed for env API key",
776+
extra={
777+
"request_id": query_log.request_id,
778+
"model": generate_query_request.llm_type,
779+
"environment": "production" if IS_PRODUCTION else "development",
780+
},
781+
)
782+
finalize_query_log(status="failed")
783+
raise HTTPException(
784+
status_code=403,
785+
detail=(
786+
f"Model '{generate_query_request.llm_type}' is not available "
787+
"with server-managed API keys in this environment."
788+
),
789+
)
750790
if not provider:
751791
Logger.error(
752792
"[API] /generate_query/ - Provider required for env API key",
@@ -1066,6 +1106,23 @@ async def run_query(
10661106
# Handle "env" API key by using the API key from .env
10671107
api_key = run_query_request.api_key
10681108
if api_key == "env":
1109+
if not is_env_model_allowed(run_query_request.llm_type):
1110+
Logger.warning(
1111+
"[API] /run_query/ - Model not allowed for env API key",
1112+
extra={
1113+
"request_id": query_log.request_id,
1114+
"model": run_query_request.llm_type,
1115+
"environment": "production" if IS_PRODUCTION else "development",
1116+
},
1117+
)
1118+
finalize_query_log(status="failed")
1119+
raise HTTPException(
1120+
status_code=403,
1121+
detail=(
1122+
f"Model '{run_query_request.llm_type}' is not available "
1123+
"with server-managed API keys in this environment."
1124+
),
1125+
)
10691126
if not provider:
10701127
Logger.error(
10711128
"[API] /run_query/ - Provider required for env API key",
@@ -1421,6 +1478,19 @@ async def event_generator():
14211478
# Handle "env" API key
14221479
api_key = run_query_request.api_key
14231480
if api_key == "env":
1481+
if not is_env_model_allowed(run_query_request.llm_type):
1482+
yield {
1483+
"event": "failed",
1484+
"data": json.dumps({
1485+
"error": (
1486+
f"Model '{run_query_request.llm_type}' is not available "
1487+
"with server-managed API keys in this environment."
1488+
),
1489+
"error_type": "ModelNotAllowed",
1490+
"attempts": attempts
1491+
})
1492+
}
1493+
return
14241494
if not provider:
14251495
yield {
14261496
"event": "failed",
@@ -2078,6 +2148,17 @@ def get_available_models():
20782148
return models
20792149

20802150

2151+
@app.get("/free_models/")
2152+
def get_free_models():
2153+
"""
2154+
Get models that are available without user API key (server-managed env keys).
2155+
"""
2156+
Logger.info("Free models requested")
2157+
free_models = get_free_models_for_environment()
2158+
Logger.debug(f"Returning {len(free_models)} free models")
2159+
return {"models": free_models}
2160+
2161+
20812162
# Initialize logging on startup
20822163
@app.on_event("startup")
20832164
async def startup_event():

crossbar_llm/frontend/src/components/ChatLayout.js

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ import CloseIcon from '@mui/icons-material/Close';
5959
import SyntaxHighlighter from 'react-syntax-highlighter';
6060
import { docco, dracula } from 'react-syntax-highlighter/dist/esm/styles/hljs';
6161
import NodeVisualization from './NodeVisualization';
62-
import api, { getAvailableModels } from '../services/api';
62+
import api, { getAvailableModels, getFreeModels } from '../services/api';
6363
import axios from 'axios';
6464
import Fuse from 'fuse.js';
6565
import { loadSuggestions } from '../utils/loadSuggestions';
@@ -149,6 +149,7 @@ function ChatLayout({
149149
const [apiKeysStatus, setApiKeysStatus] = useState({});
150150
const [apiKeysLoaded, setApiKeysLoaded] = useState(false);
151151
const [modelChoices, setModelChoices] = useState({});
152+
const [freeModels, setFreeModels] = useState([]);
152153
const [modelsLoaded, setModelsLoaded] = useState(false);
153154

154155
// Expanded sections in right panel
@@ -247,10 +248,13 @@ function ChatLayout({
247248
try {
248249
const models = await getAvailableModels();
249250
setModelChoices(models);
251+
const freeModelNames = await getFreeModels();
252+
setFreeModels(freeModelNames);
250253
setModelsLoaded(true);
251254
} catch (error) {
252-
console.error('Error fetching available models:', error);
255+
console.error('Error fetching available/free models:', error);
253256
setModelChoices({});
257+
setFreeModels([]);
254258
setModelsLoaded(true);
255259
}
256260
};
@@ -265,9 +269,6 @@ function ChatLayout({
265269
if (response.data) {
266270
setApiKeysStatus(response.data);
267271
setApiKeysLoaded(true);
268-
if (provider && response.data[provider]) {
269-
setApiKey('env');
270-
}
271272
}
272273
} catch (error) {
273274
console.error('Error fetching API keys status:', error);
@@ -276,16 +277,17 @@ function ChatLayout({
276277
fetchApiKeysStatus();
277278
}, [provider, setApiKey]);
278279

279-
// When provider changes, update API key
280+
// When provider/model changes, default to server key only for free models
280281
useEffect(() => {
281282
if (apiKeysLoaded && provider) {
282-
if (apiKeysStatus[provider]) {
283+
const selectedModelIsFree = !!llmType && freeModels.includes(llmType);
284+
if (apiKeysStatus[provider] && selectedModelIsFree) {
283285
setApiKey('env');
284-
} else {
286+
} else if (apiKey === 'env') {
285287
setApiKey('');
286288
}
287289
}
288-
}, [provider, apiKeysStatus, apiKeysLoaded, setApiKey]);
290+
}, [provider, llmType, freeModels, apiKeysStatus, apiKeysLoaded, apiKey, setApiKey]);
289291

290292
// Scroll to bottom when messages update
291293
useEffect(() => {
@@ -494,9 +496,11 @@ function ChatLayout({
494496
const isSettingsValid = useCallback(() => {
495497
if (!provider) return false;
496498
if (!llmType) return false;
497-
if (!apiKeysStatus[provider] && !apiKey) return false;
499+
const selectedModelIsFree = freeModels.includes(llmType);
500+
const canUseServerKeyForSelection = apiKeysStatus[provider] && selectedModelIsFree;
501+
if (!canUseServerKeyForSelection && !apiKey) return false;
498502
return true;
499-
}, [provider, llmType, apiKeysStatus, apiKey]);
503+
}, [provider, llmType, apiKeysStatus, freeModels, apiKey]);
500504

501505
// Check if semantic search settings are valid (only when enabled)
502506
const isSemanticSearchValid = useCallback(() => {
@@ -550,7 +554,9 @@ function ChatLayout({
550554
abortControllerRef.current = new AbortController();
551555
const signal = abortControllerRef.current.signal;
552556

553-
const effectiveApiKey = (apiKeysStatus[provider] && apiKey === 'env') ? 'env' : apiKey;
557+
const selectedModelIsFree = freeModels.includes(llmType);
558+
const canUseServerKeyForSelection = apiKeysStatus[provider] && selectedModelIsFree;
559+
const effectiveApiKey = (canUseServerKeyForSelection && (apiKey === 'env' || !apiKey)) ? 'env' : apiKey;
554560

555561
try {
556562
// Build request data
@@ -636,7 +642,9 @@ function ChatLayout({
636642
abortControllerRef.current = new AbortController();
637643
const signal = abortControllerRef.current.signal;
638644

639-
const effectiveApiKey = (apiKeysStatus[provider] && apiKey === 'env') ? 'env' : apiKey;
645+
const selectedModelIsFree = freeModels.includes(llmType);
646+
const canUseServerKeyForSelection = apiKeysStatus[provider] && selectedModelIsFree;
647+
const effectiveApiKey = (canUseServerKeyForSelection && (apiKey === 'env' || !apiKey)) ? 'env' : apiKey;
640648

641649
try {
642650
const runResponse = await api.post('/run_query/', {
@@ -883,7 +891,9 @@ function ChatLayout({
883891
abortControllerRef.current = new AbortController();
884892
const signal = abortControllerRef.current.signal;
885893

886-
const effectiveApiKey = (apiKeysStatus[provider] && apiKey === 'env') ? 'env' : apiKey;
894+
const selectedModelIsFree = freeModels.includes(llmType);
895+
const canUseServerKeyForSelection = apiKeysStatus[provider] && selectedModelIsFree;
896+
const effectiveApiKey = (canUseServerKeyForSelection && (apiKey === 'env' || !apiKey)) ? 'env' : apiKey;
887897

888898
try {
889899
// Build request data
@@ -2230,6 +2240,7 @@ function ChatLayout({
22302240
return <MenuItem key={`label-${idx}`} disabled sx={{ opacity: 0.7, fontWeight: 'bold', fontSize: '0.85rem' }}>{m.label}</MenuItem>;
22312241
}
22322242
const isSupported = supportedModels.includes(m);
2243+
const isFreeModel = freeModels.includes(m);
22332244
return (
22342245
<MenuItem
22352246
key={m}
@@ -2256,15 +2267,15 @@ function ChatLayout({
22562267
fontWeight: 700,
22572268
}}></Box>
22582269
)}
2259-
{m}
2270+
{m}{isFreeModel ? ' (Free)' : ''}
22602271
</MenuItem>
22612272
);
22622273
})}
22632274
</Select>
22642275
</FormControl>
22652276

22662277
{/* API Key Section */}
2267-
{apiKeysLoaded && apiKeysStatus[provider] ? (
2278+
{apiKeysLoaded && apiKeysStatus[provider] && freeModels.includes(llmType) ? (
22682279
<Paper
22692280
variant="outlined"
22702281
sx={{

crossbar_llm/frontend/src/services/api.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,16 @@ export const getAvailableModels = async () => {
134134
}
135135
};
136136

137+
export const getFreeModels = async () => {
138+
try {
139+
const response = await instance.get('/free_models/');
140+
return response.data?.models || [];
141+
} catch (error) {
142+
console.error('Error fetching free models:', error);
143+
throw error;
144+
}
145+
};
146+
137147
// Export the refreshCsrfToken function for explicit usage
138148
export { refreshCsrfToken };
139149

0 commit comments

Comments
 (0)