🐛 fix: surface provider model fetch errors

This commit is contained in:
yutengjing
2026-06-12 23:14:13 +08:00
parent 0ce9752ffa
commit a9993136d1
15 changed files with 292 additions and 146 deletions
@@ -190,27 +190,31 @@ describe('LobeAiHubMixAI', () => {
expect(passedModels.find((m) => m.id === 'gpt-4o')).toBeDefined();
});
it('should return empty array on non-ok HTTP response', async () => {
it('should throw with cause on non-ok HTTP response', async () => {
mockFetch.mockResolvedValueOnce(new Response('Unauthorized', { status: 401 }));
const list = await instance.models();
expect(list).toEqual([]);
try {
await instance.models();
expect.fail('Expected models() to reject');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('AiHubMix models API request failed');
expect((error as Error).cause).toEqual({ body: 'Unauthorized', status: 401 });
}
});
it('should return empty array on network error', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network Error'));
it('should preserve network errors', async () => {
const error = new Error('Network Error');
mockFetch.mockRejectedValueOnce(error);
const list = await instance.models();
expect(list).toEqual([]);
await expect(instance.models()).rejects.toBe(error);
});
it('should return empty array on timeout (AbortError)', async () => {
mockFetch.mockRejectedValueOnce(
Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }),
);
it('should preserve timeout errors', async () => {
const error = Object.assign(new Error('The operation was aborted'), { name: 'AbortError' });
mockFetch.mockRejectedValueOnce(error);
const list = await instance.models();
expect(list).toEqual([]);
await expect(instance.models()).rejects.toBe(error);
});
});
});
@@ -171,9 +171,11 @@ export const params: CreateRouterRuntimeOptions = {
signal: controller.signal,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`HTTP ${response.status}: ${text}`);
if (response.ok === false) {
const body = await response.text().catch(() => '');
throw new Error('AiHubMix models API request failed', {
cause: { body, status: response.status },
});
}
const json = (await response.json()) as { data?: any[] };
@@ -181,12 +183,6 @@ export const params: CreateRouterRuntimeOptions = {
.filter((m: any) => !UNSUPPORTED_AIHUBMIX_TYPES.has(m.types ?? ''))
.map((m: any) => mapAiHubMixModel(m));
return await processMultiProviderModelList(modelList, 'aihubmix');
} catch (error) {
console.warn(
'Failed to fetch AiHubMix models. Please ensure your AiHubMix API key is valid:',
error,
);
return [];
} finally {
clearTimeout(timeoutId);
}
@@ -467,6 +467,26 @@ describe('LobeGithubAI - custom features', () => {
await expect(params.models!()).rejects.toThrow('Invalid JSON');
});
it('should throw with cause when model list request fails', async () => {
global.fetch = vi.fn().mockResolvedValue({
json: async () => ({ error: { message: 'Access denied' } }),
ok: false,
status: 403,
});
try {
await params.models!();
expect.fail('Expected models() to reject');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('GitHub models API request failed');
expect((error as Error).cause).toEqual({
body: { error: { message: 'Access denied' } },
status: 403,
});
}
});
it('should handle models with missing optional fields', async () => {
const minimalModel: GithubModelCard = {
capabilities: [],
@@ -25,7 +25,6 @@ export interface GithubModelCard {
version: string;
}
export const params = {
baseURL: 'https://models.github.ai/inference',
chatCompletion: {
@@ -52,7 +51,17 @@ export const params = {
},
models: async () => {
const response = await fetch('https://models.github.ai/catalog/models');
const modelList: GithubModelCard[] = await response.json();
const modelList: unknown = await response.json();
if (response.ok === false) {
throw new Error('GitHub models API request failed', {
cause: { body: modelList, status: response.status },
});
}
if (!Array.isArray(modelList)) {
throw new Error('GitHub models API returned an invalid response', { cause: modelList });
}
const formattedModels = modelList.map((model) => ({
contextWindowTokens: model.limits?.max_input_tokens + model.limits?.max_output_tokens,
@@ -216,6 +216,32 @@ describe('LobeGithubCopilotAI', () => {
expect(models).toEqual([]);
});
it('should throw Error with cause when models response fails', async () => {
mockFetch.mockResolvedValueOnce({
json: () => Promise.resolve({ error: { message: 'Copilot access denied' } }),
ok: false,
status: 403,
});
const instance = new LobeGithubCopilotAI({
bearerToken: 'cached-bearer-token',
bearerTokenExpiresAt: Date.now() + 60 * 60 * 1000,
});
try {
await instance.models();
expect.fail('Expected models() to reject');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('GitHub Copilot models API request failed');
expect((error as Error).cause).toEqual({
body: { error: { message: 'Copilot access denied' } },
status: 403,
});
expect((error as Error & { status?: number }).status).toBe(403);
}
});
});
describe('error handling in constructor', () => {
@@ -398,34 +398,45 @@ export class LobeGithubCopilotAI implements LobeRuntimeAI {
}
async models(): Promise<ChatModelCard[]> {
return this.executeWithRetry(async () => {
const bearerToken = this.cachedBearerToken || (await tokenManager.getToken(this.githubToken));
return this.executeWithRetry(
async () => {
const bearerToken =
this.cachedBearerToken || (await tokenManager.getToken(this.githubToken));
const response = await fetch(`${COPILOT_BASE_URL}/models`, {
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${bearerToken}`,
'Copilot-Integration-Id': 'vscode-chat',
'Editor-Plugin-Version': 'LobeChat/1.0',
'Editor-Version': 'LobeChat/1.0',
},
method: 'GET',
});
const response = await fetch(`${COPILOT_BASE_URL}/models`, {
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${bearerToken}`,
'Copilot-Integration-Id': 'vscode-chat',
'Editor-Plugin-Version': 'LobeChat/1.0',
'Editor-Version': 'LobeChat/1.0',
},
method: 'GET',
});
if (!response.ok) {
throw { status: response.status };
}
if (!response.ok) {
const body = await response.json().catch(() => undefined);
throw Object.assign(
new Error('GitHub Copilot models API request failed', {
cause: { body, status: response.status },
}),
{ status: response.status },
);
}
const data = await response.json();
const data = await response.json();
const modelList = data.models || data.data || [];
// Transform Copilot models to ChatModelCard format
return (data.models || data.data || []).map((model: any) => ({
displayName: model.name || model.id,
enabled: true,
id: model.id || model.name,
type: 'chat',
}));
});
// Transform Copilot models to ChatModelCard format
return modelList.map((model: any) => ({
displayName: model.name || model.id,
enabled: true,
id: model.id || model.name,
type: 'chat',
}));
},
{ mapError: false },
);
}
private handlePayload(payload: ChatStreamPayload) {
@@ -443,7 +454,10 @@ export class LobeGithubCopilotAI implements LobeRuntimeAI {
return { type: tool.type, ...tool.function } as any;
};
private async executeWithRetry<T>(fn: () => Promise<T>): Promise<T> {
private async executeWithRetry<T>(
fn: () => Promise<T>,
options: { mapError?: boolean } = {},
): Promise<T> {
let totalAttempts = 0;
let hasRefreshedAuth = false;
let rateLimitAttempts = 0;
@@ -471,6 +485,7 @@ export class LobeGithubCopilotAI implements LobeRuntimeAI {
// If retry-after exceeds the quota exhaustion threshold, surface immediately
if (retryAfter > QUOTA_EXHAUSTION_THRESHOLD_MS) {
if (options.mapError === false) throw error;
throw this.mapError(error);
}
@@ -481,10 +496,17 @@ export class LobeGithubCopilotAI implements LobeRuntimeAI {
}
// Map and throw
if (options.mapError === false) throw error;
throw this.mapError(error);
}
}
if (options.mapError === false) {
throw new Error('Max retry attempts exceeded', {
cause: { endpoint: this.baseURL },
});
}
throw AgentRuntimeError.chat({
endpoint: this.baseURL,
error: { message: 'Max retry attempts exceeded' },
@@ -996,4 +996,27 @@ describe('models', () => {
'x-goog-api-key': apiKey,
});
});
it('should throw Error with cause when model list request fails', async () => {
const mockFetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve({ error: { message: 'API key expired' } }),
ok: false,
status: 403,
});
global.fetch = mockFetch;
const localInstance = new LobeGoogleAI({ apiKey: 'test-google-key' });
try {
await localInstance.models();
expect.fail('Expected models() to reject');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('Google models API request failed');
expect((error as Error).cause).toEqual({
body: { error: { message: 'API key expired' } },
status: 403,
});
}
});
});
@@ -465,13 +465,20 @@ export class LobeGoogleAI implements LobeRuntimeAI {
signal: options?.signal,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
if (response.ok === false) {
const body = await response.json().catch(() => undefined);
throw new Error('Google models API request failed', {
cause: { body, status: response.status },
});
}
const json = await response.json();
const modelList: GoogleModelCard[] = json.models;
const modelList: GoogleModelCard[] | undefined = json.models;
if (!Array.isArray(modelList)) {
throw new Error('Google models API returned an invalid response', { cause: json });
}
const processedModels = modelList.map((model) => {
const id = model.name.replace(/^models\//, '');
@@ -673,23 +673,23 @@ describe('LobeHuggingFaceAI', () => {
expect(models).toEqual([]);
});
it('should handle API errors gracefully', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('API Error'));
it('should preserve API errors', async () => {
const error = new Error('API Error');
global.fetch = vi.fn().mockRejectedValue(error);
const result = await params.models!();
expect(result).toEqual([]);
await expect(params.models!()).rejects.toBe(error);
});
it('should handle invalid JSON response', async () => {
it('should preserve invalid JSON errors', async () => {
const error = new Error('Invalid JSON');
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => {
throw new Error('Invalid JSON');
throw error;
},
} as unknown as Response);
const result = await params.models!();
expect(result).toEqual([]);
await expect(params.models!()).rejects.toBe(error);
});
it('should preserve contextWindowTokens from provider info', async () => {
@@ -8,7 +8,7 @@ import { createOpenAICompatibleRuntime } from '../../core/openaiCompatibleFactor
import { convertIterableToStream } from '../../core/streams';
import { AgentRuntimeErrorType } from '../../types/error';
import { processMultiProviderModelList } from '../../utils/modelParse';
import type { HuggingFaceRouterModelCard, HuggingFaceRouterResponse } from './type';
import type { HuggingFaceRouterResponse } from './type';
export const params = {
chatCompletion: {
@@ -54,17 +54,19 @@ export const params = {
chatCompletion: () => process.env.DEBUG_HUGGINGFACE_CHAT_COMPLETION === '1',
},
models: async () => {
let modelList: HuggingFaceRouterModelCard[] = [];
const response = await fetch('https://router.huggingface.co/v1/models');
const data: HuggingFaceRouterResponse = await response.json();
try {
const response = await fetch('https://router.huggingface.co/v1/models');
if (response.ok) {
const data: HuggingFaceRouterResponse = await response.json();
modelList = data.data;
}
} catch (error) {
console.error('Failed to fetch HuggingFace router models:', error);
return [];
if (response.ok === false) {
throw new Error('HuggingFace models API request failed', {
cause: { body: data, status: response.status },
});
}
const modelList = data.data;
if (!Array.isArray(modelList)) {
throw new Error('HuggingFace models API returned an invalid response', { cause: data });
}
const formattedModels = modelList
@@ -1347,29 +1347,34 @@ describe('LobeOpenRouterAI - custom features', () => {
expect(models).toEqual([]);
});
it('should return empty array when fetch fails', async () => {
it('should throw with cause when fetch fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
json: async () => ({ error: { message: 'Unauthorized' } }),
ok: false,
status: 401,
}),
);
const models = await params.models();
expect(models).toEqual([]);
try {
await params.models();
expect.fail('Expected models() to reject');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('OpenRouter models API request failed');
expect((error as Error).cause).toEqual({
body: { error: { message: 'Unauthorized' } },
status: 401,
});
}
});
it('should return empty array when fetch throws error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
it('should preserve network errors', async () => {
const error = new Error('Network error');
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(error));
const models = await params.models();
expect(models).toEqual([]);
expect(console.error).toHaveBeenCalledWith(
'Failed to fetch OpenRouter frontend models:',
expect.any(Error),
);
await expect(params.models()).rejects.toBe(error);
});
it('should handle models with missing optional fields', async () => {
@@ -3,7 +3,7 @@ import { ModelProvider } from 'model-bank';
import type { OpenAICompatibleFactoryOptions } from '../../core/openaiCompatibleFactory';
import { createOpenAICompatibleRuntime } from '../../core/openaiCompatibleFactory';
import { processMultiProviderModelList } from '../../utils/modelParse';
import type { OpenRouterModelCard, OpenRouterReasoning } from './type';
import type { OpenRouterReasoning } from './type';
const formatPrice = (price?: string) => {
if (price === undefined || price === '-1') return undefined;
@@ -93,17 +93,19 @@ export const params = {
chatCompletion: () => process.env.DEBUG_OPENROUTER_CHAT_COMPLETION === '1',
},
models: async () => {
let modelList: OpenRouterModelCard[] = [];
const response = await fetch('https://openrouter.ai/api/v1/models');
const data = await response.json();
try {
const response = await fetch('https://openrouter.ai/api/v1/models');
if (response.ok) {
const data = await response.json();
modelList = data['data'];
}
} catch (error) {
console.error('Failed to fetch OpenRouter frontend models:', error);
return [];
if (response.ok === false) {
throw new Error('OpenRouter models API request failed', {
cause: { body: data, status: response.status },
});
}
const modelList = data['data'];
if (!Array.isArray(modelList)) {
throw new Error('OpenRouter models API returned an invalid response', { cause: data });
}
// Process the model info fetched from the frontend and convert to standard format
@@ -2,7 +2,7 @@ import { ModelProvider } from 'model-bank';
import { createOpenAICompatibleRuntime } from '../../core/openaiCompatibleFactory';
import { processMultiProviderModelList } from '../../utils/modelParse';
import type { StraicoChatModel, StraicoModelsResponse } from './type';
import type { StraicoModelsResponse } from './type';
const formatPrice = (pricing?: { coins?: number; words?: number }) => {
if (!pricing || typeof pricing.coins !== 'number' || typeof pricing.words !== 'number') {
@@ -27,7 +27,6 @@ export const LobeStraicoAI = createOpenAICompatibleRuntime({
baseURL: 'https://api.straico.com/v0',
chatCompletion: {
handlePayload: (payload) => {
const { model, ...rest } = payload;
return {
@@ -41,56 +40,55 @@ export const LobeStraicoAI = createOpenAICompatibleRuntime({
chatCompletion: () => process.env.DEBUG_STRAICO_CHAT_COMPLETION === '1',
},
models: async ({ client }) => {
try {
const url = 'https://api.straico.com/v1/models';
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${client.apiKey}`,
},
method: 'GET',
const url = 'https://api.straico.com/v1/models';
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${client.apiKey}`,
},
method: 'GET',
});
const json: StraicoModelsResponse = await response.json();
if (response.ok === false) {
throw new Error('Straico models API request failed', {
cause: { body: json, status: response.status },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json: StraicoModelsResponse = await response.json();
const chatModels: StraicoChatModel[] = json?.data?.chat || []; // There are also audio and image models to be adapted
// Transform Straico models to standardized format
const formattedModels = chatModels.map((model) => {
const inputPrice = formatPrice(model.pricing);
const outputPrice = inputPrice; // Straico uses same price for input/output
return {
contextWindowTokens: model.word_limit
? Math.floor(model.word_limit * 1.33) // Convert words to tokens
: undefined,
description: model.metadata?.pros?.join('; ') || '',
displayName: cleanModelName(model.name),
enabled: model.enabled ?? false,
functionCall: false,
id: model.model,
maxOutput: model.max_output,
pricing: inputPrice
? {
input: inputPrice,
output: outputPrice,
}
: undefined,
reasoning: model.metadata?.applications?.includes('Reasoning') ?? false,
vision: model.metadata?.features?.includes('Image input') ?? false,
};
});
return await processMultiProviderModelList(formattedModels, 'straico');
} catch (error) {
console.warn(
'Failed to fetch Straico models. Please ensure your Straico API key is valid:',
error,
);
return [];
}
const chatModels = json?.data?.chat; // There are also audio and image models to be adapted
if (!Array.isArray(chatModels)) {
throw new Error('Straico models API returned an invalid response', { cause: json });
}
// Transform Straico models to standardized format
const formattedModels = chatModels.map((model) => {
const inputPrice = formatPrice(model.pricing);
const outputPrice = inputPrice; // Straico uses same price for input/output
return {
contextWindowTokens: model.word_limit
? Math.floor(model.word_limit * 1.33) // Convert words to tokens
: undefined,
description: model.metadata?.pros?.join('; ') || '',
displayName: cleanModelName(model.name),
enabled: model.enabled ?? false,
functionCall: false,
id: model.model,
maxOutput: model.max_output,
pricing: inputPrice
? {
input: inputPrice,
output: outputPrice,
}
: undefined,
reasoning: model.metadata?.applications?.includes('Reasoning') ?? false,
vision: model.metadata?.features?.includes('Image input') ?? false,
};
});
return await processMultiProviderModelList(formattedModels, 'straico');
},
provider: ModelProvider.Straico,
});
@@ -1091,6 +1091,28 @@ describe('LobeZhipuAI - custom features', () => {
await expect(params.models({ client: mockClient as any })).rejects.toThrow('API Error');
});
it('should throw with cause when model list request fails', async () => {
mockFetch.mockResolvedValue({
json: async () => ({ error: { message: 'Access denied' } }),
ok: false,
status: 403,
});
const mockClient = { apiKey: 'test_api_key' };
try {
await params.models({ client: mockClient as any });
expect.fail('Expected models() to reject');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('Zhipu models API request failed');
expect((error as Error).cause).toEqual({
body: { error: { message: 'Access denied' } },
status: 403,
});
}
});
it('should use correct API endpoint', async () => {
mockFetch.mockResolvedValue({
json: async () => ({ rows: [] }),
@@ -199,7 +199,17 @@ export const params = {
});
const json = await response.json();
const modelList: ZhipuModelCard[] = json.rows;
if (response.ok === false) {
throw new Error('Zhipu models API request failed', {
cause: { body: json, status: response.status },
});
}
const modelList: ZhipuModelCard[] | undefined = json.rows;
if (!Array.isArray(modelList)) {
throw new Error('Zhipu models API returned an invalid response', { cause: json });
}
const standardModelList = modelList.map((model) => ({
description: model.description,