Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion frontend/src/components/article/AISearchBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { PhMagnifyingGlass, PhX, PhSparkle, PhSpinner } from '@phosphor-icons/vue';
import type { Article } from '@/types/models';
import { getAIErrorMessage } from '@/utils/aiError';

const { t } = useI18n();

Expand Down Expand Up @@ -37,7 +38,7 @@ async function performAISearch() {
const data = await response.json();

if (!data.success) {
errorMessage.value = data.error || t('aiSearch.searchFailed');
errorMessage.value = getAIErrorMessage(data, response.status);
window.showToast(errorMessage.value, 'error');
return;
}
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/components/modals/settings/ai/AIProfileList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { AIProfile, AIProfileTestResult, AIProfileFormData } from '@/types/
import { useAIProfiles } from '@/composables/ai/useAIProfiles';
import { getProviderIconUrl } from '@/composables/ai/useAIProvider';
import AIProfileModal from './AIProfileModal.vue';
import { getAIErrorMessage } from '@/utils/aiError';

const { t } = useI18n();
const {
Expand Down Expand Up @@ -130,6 +131,11 @@ function getTestStatus(profileId: number): 'success' | 'error' | 'unknown' {
if (!result) return 'unknown';
return result.config_valid && result.connection_success ? 'success' : 'error';
}

function getTestError(profileId: number): string {
const result = testResults.value.get(profileId);
return result ? getAIErrorMessage(result) : '';
}
</script>

<template>
Expand Down Expand Up @@ -216,7 +222,7 @@ function getTestStatus(profileId: number): 'success' | 'error' | 'unknown' {
<div
v-else-if="getTestStatus(profile.id) === 'error'"
class="status-indicator status-error"
:title="testResults.get(profile.id)?.error_message"
:title="getTestError(profile.id)"
>
<PhX :size="14" class="text-red-500" />
</div>
Expand Down Expand Up @@ -254,7 +260,7 @@ function getTestStatus(profileId: number): 'success' | 'error' | 'unknown' {
v-if="testResults.get(profile.id)?.error_message && getTestStatus(profile.id) === 'error'"
class="mt-2 text-xs text-red-500 bg-red-500/5 rounded p-2 break-words"
>
{{ testResults.get(profile.id)?.error_message }}
{{ getTestError(profile.id) }}
</div>
</div>
</div>
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/modals/settings/ai/AIProfileModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { AIProfileFormData, AIProfileTestResult } from '@/types/aiProfile';
import type { Status } from '@/components/settings/base/StatusBox.vue';
import { defaultAIProfileFormData } from '@/types/aiProfile';
import { useAIProfiles } from '@/composables/ai/useAIProfiles';
import { getAIErrorMessage } from '@/utils/aiError';
import { openInBrowser } from '@/utils/browser';

const { t, locale } = useI18n();
Expand Down Expand Up @@ -105,7 +106,7 @@ async function testConfiguration() {
if (result) {
testResult.value = result;
if (!result.config_valid || !result.connection_success) {
testError.value = result.error_message || t('setting.ai.aiTestFailed');
testError.value = getAIErrorMessage(result);
}
} else {
testError.value = t('setting.ai.aiTestFailed');
Expand Down Expand Up @@ -155,7 +156,7 @@ async function saveProfile() {
}
} catch (e) {
console.error('Save failed:', e);
saveError.value = e instanceof Error ? e.message : t('setting.ai.saveFailed');
saveError.value = getAIErrorMessage(e);
} finally {
isSaving.value = false;
}
Expand Down
7 changes: 3 additions & 4 deletions frontend/src/composables/ai/useAIProfiles.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ref, computed } from 'vue';
import type { AIProfile, AIProfileTestResult, AIProfileFormData } from '@/types/aiProfile';
import { defaultAIProfileFormData } from '@/types/aiProfile';
import { readAIError } from '@/utils/aiError';

// Shared state for AI profiles
const profiles = ref<AIProfile[]>([]);
Expand Down Expand Up @@ -59,8 +60,7 @@ export function useAIProfiles() {
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || `Failed to create profile: ${response.status}`);
throw new Error((await readAIError(response)).message);
}

const newProfile = await response.json();
Expand All @@ -82,8 +82,7 @@ export function useAIProfiles() {
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || `Failed to update profile: ${response.status}`);
throw new Error((await readAIError(response)).message);
}

const updatedProfile = await response.json();
Expand Down
16 changes: 3 additions & 13 deletions frontend/src/composables/article/useArticleSummary.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ref, type Ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Article } from '@/types/models';
import { getAIErrorMessage, readAIError } from '@/utils/aiError';

interface SummarySettings {
enabled: boolean;
Expand Down Expand Up @@ -116,18 +117,7 @@ export function useArticleSummary() {

return data;
} else {
// Handle API errors properly
let errorMessage = `${t('setting.content.summaryGenerationFailed')}: ${res.status} ${res.statusText}`;

try {
const errorData = await res.json();
if (errorData.error) {
errorMessage = errorData.error;
}
} catch (jsonError) {
// If we can't parse JSON, use the status text
console.error('Error parsing error response:', jsonError);
}
const { message: errorMessage } = await readAIError(res);

console.error('Summary generation failed:', errorMessage);

Expand All @@ -148,7 +138,7 @@ export function useArticleSummary() {
return null;
}

const errorMessage = `${t('setting.content.summaryGenerationFailed')}: ${e instanceof Error ? e.message : t('common.errors.unknownError')}`;
const errorMessage = getAIErrorMessage(e);
console.error('Error generating summary:', e);

// Cache the error to show in UI
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,26 @@ const en: TranslationMessages = {
searchFailed: 'AI search failed. Please check your AI settings.',
showingResults: 'Showing AI search results',
},
aiErrors: {
configuration_invalid:
'The AI configuration is incomplete or invalid. Check the endpoint and model.',
usage_limit_reached:
'The AI usage limit configured in MrRSS has been reached. Adjust it and try again.',
rate_limited: 'The AI service is receiving too many requests. Please try again later.',
authentication_failed: 'AI authentication failed. Check the API key and access permissions.',
payment_required:
'The AI service has insufficient quota or balance. Check the provider account.',
model_or_endpoint_not_found:
'The AI model or endpoint is unavailable. Check the configuration.',
request_too_large: 'The content sent to AI is too large. Shorten it or choose another model.',
timeout: 'The AI service response timed out. Please try again.',
network_error: 'Could not reach the AI service. Check the network, proxy, and endpoint.',
provider_unavailable: 'The AI service is temporarily unavailable. Please try again later.',
invalid_response: 'The AI service returned an invalid response. Retry or choose another model.',
provider_rejected_request:
'The AI service rejected the request. Check the model and endpoint settings.',
request_failed: 'The AI request failed. Check the AI configuration and try again.',
},
common: {
cancel: 'Cancel',
confirm: 'Confirm',
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,21 @@ const zh: TranslationMessages = {
searchFailed: 'AI 搜索失败,请检查 AI 设置。',
showingResults: '正在显示 AI 搜索结果',
},
aiErrors: {
configuration_invalid: 'AI 配置不完整或无效,请检查接口地址和模型。',
usage_limit_reached: '已达到 MrRSS 设置的 AI 使用上限,请调整上限后重试。',
rate_limited: 'AI 服务请求过于频繁,请稍后再试。',
authentication_failed: 'AI 服务鉴权失败,请检查 API Key 和访问权限。',
payment_required: 'AI 服务额度不足,请检查服务商账户余额。',
model_or_endpoint_not_found: 'AI 模型或接口地址不可用,请检查配置。',
request_too_large: '发送给 AI 的内容过长,请缩短内容或更换模型。',
timeout: 'AI 服务响应超时,请稍后重试。',
network_error: '无法连接 AI 服务,请检查网络、代理和接口地址。',
provider_unavailable: 'AI 服务暂时不可用,请稍后重试。',
invalid_response: 'AI 服务返回内容格式异常,请重试或更换模型。',
provider_rejected_request: 'AI 服务拒绝了请求,请检查模型和接口配置。',
request_failed: 'AI 请求失败,请检查 AI 配置后重试。',
},
common: {
cancel: '取消',
confirm: '确认',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/aiProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface AIProfileTestResult {
model_available: boolean;
response_time_ms: number;
error_message?: string;
error_code?: string;
}

export interface AIProfileFormData {
Expand Down
118 changes: 118 additions & 0 deletions frontend/src/utils/aiError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import i18n from '@/i18n';

export interface ParsedAIError {
code: string;
message: string;
payload?: unknown;
}

const knownCodes = new Set([
'configuration_invalid',
'usage_limit_reached',
'rate_limited',
'authentication_failed',
'payment_required',
'model_or_endpoint_not_found',
'request_too_large',
'timeout',
'network_error',
'provider_unavailable',
'invalid_response',
'provider_rejected_request',
'request_failed',
]);

const legacyCodeAliases: Record<string, string> = {
AI_CONFIG_FAILED: 'configuration_invalid',
AI_QUOTA_EXCEEDED: 'rate_limited',
AI_INVALID_REQUEST: 'provider_rejected_request',
AI_REQUEST_FAILED: 'request_failed',
};

function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : null;
}

function payloadCode(payload: unknown): string {
const record = asRecord(payload);
if (!record) return '';
const error = asRecord(record.error);
const candidate = record.error_code ?? error?.code ?? record.code;
if (typeof candidate !== 'string') return '';
return legacyCodeAliases[candidate] || candidate.toLowerCase();
}

function payloadText(payload: unknown): string {
if (typeof payload === 'string') return payload;
if (payload instanceof Error) return payload.message;
const record = asRecord(payload);
if (!record) return '';
const error = asRecord(record.error);
const values = [record.error, error?.message, record.message];
return values.find((value): value is string => typeof value === 'string') || '';
}

function inferCode(payload: unknown, status?: number): string {
const explicit = payloadCode(payload);
if (knownCodes.has(explicit)) return explicit;

switch (status) {
case 401:
case 403:
return 'authentication_failed';
case 402:
return 'payment_required';
case 404:
return 'model_or_endpoint_not_found';
case 408:
case 504:
return 'timeout';
case 413:
return 'request_too_large';
case 429:
return 'rate_limited';
default:
if (status && status >= 500) return 'provider_unavailable';
}

const text = payloadText(payload).toLowerCase();
if (/\b429\b|rate.?limit|too many requests/.test(text)) return 'rate_limited';
if (/\b401\b|\b403\b|unauthori[sz]ed|forbidden|invalid api.?key|authentication/.test(text)) {
return 'authentication_failed';
}
if (/\b402\b|insufficient (quota|balance)|payment required|credit/.test(text)) {
return 'payment_required';
}
if (/\b404\b|model.*not found|endpoint.*not found/.test(text)) {
return 'model_or_endpoint_not_found';
}
if (/\b413\b|request.*too large|context length/.test(text)) return 'request_too_large';
if (/timeout|timed out|deadline exceeded/.test(text)) return 'timeout';
if (/connection refused|network|no such host|failed to fetch/.test(text)) return 'network_error';
if (/invalid json|invalid response|empty response|no choices/.test(text))
return 'invalid_response';
return 'request_failed';
}

export function getAIErrorMessage(payload: unknown, status?: number): string {
const code = inferCode(payload, status);
return String(i18n.global.t(`aiErrors.${code}`));
}

export async function readAIError(response: Response): Promise<ParsedAIError> {
let raw = '';
try {
raw = await response.text();
} catch {
// A response body is optional; the HTTP status still provides a stable
// classification without exposing transport details.
}
let payload: unknown;
try {
payload = JSON.parse(raw);
} catch {
payload = raw;
}
const code = inferCode(payload, response.status);
return { code, message: getAIErrorMessage(payload, response.status), payload };
}
43 changes: 42 additions & 1 deletion internal/ai/deepseek_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package ai

import "testing"
import (
"context"
"errors"
"net"
"strings"
"testing"
)

func TestDeepSeekFormatEndpointNormalizesBaseURLs(t *testing.T) {
handler := &DeepSeekHandler{}
Expand Down Expand Up @@ -45,3 +51,38 @@ func TestDeepSeekFormatEndpointNormalizesBaseURLs(t *testing.T) {
})
}
}

func TestClassifyUserFacingErrorDoesNotExposeProviderResponse(t *testing.T) {
const secret = `{"error":{"message":"provider detail sk-secret-value"}}`
tests := []struct {
name string
err error
code string
}{
{name: "authentication", err: errors.New("OpenAI API returned status 401: " + secret), code: ErrorCodeAuthenticationFailed},
{name: "payment", err: errors.New("OpenAI API returned status 402: " + secret), code: ErrorCodePaymentRequired},
{name: "not found", err: errors.New("OpenAI API returned status 404: " + secret), code: ErrorCodeModelOrEndpointNotFound},
{name: "too large", err: errors.New("OpenAI API returned status 413: " + secret), code: ErrorCodeRequestTooLarge},
{name: "rate limited", err: errors.New("OpenAI API returned status 429: " + secret), code: ErrorCodeRateLimited},
{name: "provider unavailable", err: errors.New("OpenAI API returned status 503: " + secret), code: ErrorCodeProviderUnavailable},
{name: "timeout", err: context.DeadlineExceeded, code: ErrorCodeTimeout},
{name: "network", err: &net.DNSError{Err: "no such host", Name: "private.example"}, code: ErrorCodeNetwork},
{name: "invalid response", err: errors.New("invalid JSON response"), code: ErrorCodeInvalidResponse},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ClassifyUserFacingError(tt.err)
if got.Code != tt.code {
t.Fatalf("code = %q, want %q", got.Code, tt.code)
}
if got.Message == "" || strings.Contains(got.Message, "sk-secret-value") || strings.Contains(got.Message, "provider detail") {
t.Fatalf("unsafe user-facing message: %q", got.Message)
}
})
}

if got := RedactEndpoint("https://user:secret@api.example.com/v1/chat?token=secret#fragment"); got != "https://api.example.com/v1/chat" {
t.Fatalf("RedactEndpoint leaked or changed the endpoint: %q", got)
}
}
Loading