Fix OpenRouter model routing and add configurable model setting

This commit is contained in:
John Neerdael 2026-02-20 23:15:41 +01:00
parent a05a16f67b
commit a7fcf6779c
3 changed files with 128 additions and 9 deletions

View file

@ -696,8 +696,14 @@ const AIChatScreen: React.FC = () => {
if (error instanceof Error) {
if (error.message.includes('not configured')) {
errorMessage = 'Please configure your OpenRouter API key in Settings > AI Assistant.';
} else if (/401|unauthorized|invalid api key|authentication/i.test(error.message)) {
errorMessage = 'OpenRouter rejected your API key. Please verify the key in Settings > AI Assistant.';
} else if (/insufficient|credit|quota|429/i.test(error.message)) {
errorMessage = 'OpenRouter quota/credits were rejected for this request. Please check your OpenRouter usage and limits.';
} else if (/model|provider|endpoint|unsupported|unavailable|not found/i.test(error.message)) {
errorMessage = 'The selected OpenRouter model is unavailable. Retry with `openrouter/free` or choose another custom model in Settings > AI Assistant.';
} else if (error.message.includes('API request failed')) {
errorMessage = 'Failed to connect to AI service. Please check your internet connection and API key.';
errorMessage = 'Failed to connect to AI service. Please check your internet connection, API key, and OpenRouter model availability.';
}
}

View file

@ -25,6 +25,7 @@ import { useTranslation } from 'react-i18next';
const { width } = Dimensions.get('window');
const isTablet = width >= 768;
const DEFAULT_OPENROUTER_MODEL = 'openrouter/free';
const AISettingsScreen: React.FC = () => {
const { t } = useTranslation();
@ -75,6 +76,8 @@ const AISettingsScreen: React.FC = () => {
const [apiKey, setApiKey] = useState('');
const [loading, setLoading] = useState(false);
const [isKeySet, setIsKeySet] = useState(false);
const [useDefaultModel, setUseDefaultModel] = useState(true);
const [customModel, setCustomModel] = useState('');
useEffect(() => {
loadApiKey();
@ -82,11 +85,21 @@ const AISettingsScreen: React.FC = () => {
const loadApiKey = async () => {
try {
const savedKey = await mmkvStorage.getItem('openrouter_api_key');
const [savedKey, savedModel] = await Promise.all([
mmkvStorage.getItem('openrouter_api_key'),
mmkvStorage.getItem('openrouter_model'),
]);
if (savedKey) {
setApiKey(savedKey);
setIsKeySet(true);
}
if (savedModel && savedModel.trim()) {
setUseDefaultModel(false);
setCustomModel(savedModel.trim());
} else {
setUseDefaultModel(true);
setCustomModel('');
}
} catch (error) {
if (__DEV__) console.error('Error loading OpenRouter API key:', error);
}
@ -106,6 +119,11 @@ const AISettingsScreen: React.FC = () => {
setLoading(true);
try {
await mmkvStorage.setItem('openrouter_api_key', apiKey.trim());
if (useDefaultModel || !customModel.trim()) {
await mmkvStorage.removeItem('openrouter_model');
} else {
await mmkvStorage.setItem('openrouter_model', customModel.trim());
}
setIsKeySet(true);
openAlert(t('common.success'), t('ai_settings.success_saved'));
} catch (error) {
@ -253,6 +271,44 @@ const AISettingsScreen: React.FC = () => {
autoCorrect={false}
/>
<View style={styles.modelSection}>
<View style={styles.modelHeader}>
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}>
Model
</Text>
<Switch
value={useDefaultModel}
onValueChange={setUseDefaultModel}
trackColor={{ false: currentTheme.colors.elevation2, true: currentTheme.colors.primary }}
thumbColor={useDefaultModel ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis}
ios_backgroundColor={currentTheme.colors.elevation2}
/>
</View>
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}>
{useDefaultModel
? `Using ${DEFAULT_OPENROUTER_MODEL} (free automatic routing).`
: 'Use a custom OpenRouter model ID (useful for paid plans).'}
</Text>
{!useDefaultModel && (
<TextInput
style={[
styles.input,
{
backgroundColor: currentTheme.colors.elevation2,
color: currentTheme.colors.highEmphasis,
borderColor: currentTheme.colors.elevation2
}
]}
value={customModel}
onChangeText={setCustomModel}
placeholder="e.g. openai/gpt-4o-mini"
placeholderTextColor={currentTheme.colors.mediumEmphasis}
autoCapitalize="none"
autoCorrect={false}
/>
)}
</View>
<View style={styles.buttonContainer}>
{!isKeySet ? (
<TouchableOpacity
@ -468,6 +524,15 @@ const styles = StyleSheet.create({
apiKeySection: {
gap: 12,
},
modelSection: {
gap: 8,
marginTop: 4,
},
modelHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
label: {
fontSize: 16,
fontWeight: '600',

View file

@ -126,10 +126,18 @@ interface OpenRouterResponse {
};
}
interface OpenRouterErrorResponse {
error?: {
message?: string;
code?: string;
};
}
class AIService {
private static instance: AIService;
private apiKey: string | null = null;
private baseUrl = 'https://openrouter.ai/api/v1';
private defaultModel = 'openrouter/free';
private constructor() { }
@ -151,12 +159,41 @@ class AIService {
}
async isConfigured(): Promise<boolean> {
if (!this.apiKey) {
await this.initialize();
}
// Always refresh from storage so key changes in settings are picked up immediately.
await this.initialize();
return !!this.apiKey;
}
private async getPreferredModels(): Promise<string[]> {
const configuredModel = (await mmkvStorage.getItem('openrouter_model'))?.trim();
if (!configuredModel) {
return [this.defaultModel];
}
return [configuredModel];
}
private async parseErrorResponse(response: Response): Promise<{
statusLine: string;
message: string;
raw: string;
}> {
const raw = await response.text();
let message = '';
try {
const parsed = JSON.parse(raw) as OpenRouterErrorResponse;
message = parsed.error?.message || '';
} catch {
message = raw;
}
return {
statusLine: `${response.status} ${response.statusText}`,
message: (message || '').trim(),
raw,
};
}
private createSystemPrompt(context: ContentContext): string {
const isSeries = 'episodesBySeason' in (context as any);
const isEpisode = !isSeries && 'showTitle' in (context as any);
@ -349,6 +386,9 @@ Answer questions about this movie using only the verified database information a
});
}
const model = (await this.getPreferredModels())[0];
if (__DEV__) console.log('[AIService] Using model:', model);
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
@ -358,7 +398,7 @@ Answer questions about this movie using only the verified database information a
'X-Title': 'Nuvio - AI Chat',
},
body: JSON.stringify({
model: 'xiaomi/mimo-v2-flash:free',
model,
messages,
max_tokens: 1000,
temperature: 0.7,
@ -369,9 +409,17 @@ Answer questions about this movie using only the verified database information a
});
if (!response.ok) {
const errorText = await response.text();
if (__DEV__) console.error('[AIService] API Error:', response.status, errorText);
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
const parsedError = await this.parseErrorResponse(response);
if (__DEV__) {
console.error('[AIService] API Error:', {
model,
status: parsedError.statusLine,
message: parsedError.message || parsedError.raw,
});
}
throw new Error(`API request failed: ${parsedError.statusLine} - ${parsedError.message || parsedError.raw || 'Request failed'}`);
}
const data: OpenRouterResponse = await response.json();