mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
🐛 fix: surface provider model fetch errors
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user