diff --git a/README.md b/README.md index 5133adec..096ef06c 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Alice is more than a chatbot; she’s built to feel present, responsive, emotion ### 💻 Local and Cloud use -Alice is designed to work with Cloud(OpenAI / Codex subscription, OpenRouter, Z.ai, Minimax, Deepseek) and Local LLMs (Ollama/LM Studio). +Alice is designed to work with Cloud(OpenAI / Codex subscription, OpenRouter, Requesty, Z.ai, Minimax, Deepseek) and Local LLMs (Ollama/LM Studio). Has built-in speech-to-text, text-to-speech, and embedding services. While the OpenAI cloud API is preferred and provides the best user experience, Alice can also operate **fully locally** (experimental). @@ -83,7 +83,7 @@ With the local STT model, you can set a **wake-up word** (like "Hey, Siri"). Fully customizable settings interface: -* LLM provider selection between OpenAI, OpenRouter, API Route, DeepSeek, Z.ai(coding plan), Minimax(token plan), Ollama, LM Studio +* LLM provider selection between OpenAI, OpenRouter, API Route, Requesty, DeepSeek, Z.ai(coding plan), Minimax(token plan), Ollama, LM Studio * Cloud or local TTS, STT, Embeddings * Model choice & parameters (temperature, top\_p, history, etc) * Prompt and summarization tuning @@ -130,7 +130,7 @@ Follow the [Setup Instructions](https://github.com/pmbstyle/Alice/blob/main/docs * **Frontend:** [Vue.js](https://vuejs.org/), [TailwindCSS](https://tailwindcss.com/) * **Desktop Shell:** [Electron](https://www.electronjs.org/) * **State Management:** [Pinia](https://pinia.vuejs.org/) -* **AI APIs:** [OpenAI](https://platform.openai.com/), [OpenRouter](https://openrouter.ai/), [API Route](https://www.api-route.com/), [DeepSeek](https://platform.deepseek.com/), [Groq](https://console.groq.com/) +* **AI APIs:** [OpenAI](https://platform.openai.com/), [OpenRouter](https://openrouter.ai/), [API Route](https://www.api-route.com/), [Requesty](https://www.requesty.ai/), [DeepSeek](https://platform.deepseek.com/), [Groq](https://console.groq.com/) * **Backend:** [Go](https://go.dev/) * **Vector search engine**: [hnswlib-node](https://github.com/nmslib/hnswlib) * **Local storage**: [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) diff --git a/docs/setupInstructions.md b/docs/setupInstructions.md index d31a4925..07d499e7 100644 --- a/docs/setupInstructions.md +++ b/docs/setupInstructions.md @@ -14,7 +14,7 @@ Run in your terminal `xattr -cr "/Applications/Alice AI App.app"` # AI Provider Setup -Alice supports OpenAI, OpenRouter, API Route, DeepSeek, MiniMax, Z.ai, and local LLM inference. +Alice supports OpenAI, OpenRouter, API Route, Requesty, DeepSeek, MiniMax, Z.ai, and local LLM inference. ## OpenAI (Default) @@ -46,6 +46,15 @@ Alice supports OpenAI, OpenRouter, API Route, DeepSeek, MiniMax, Z.ai, and local - API Route powers chat inference only; cloud TTS/STT/embeddings still require OpenAI or local voice and memory mode - No image generation support (use OpenAI provider for image-gen) +## Requesty (Alternative) + +- Go to [Requesty](https://app.requesty.ai/api-keys) to get your Requesty API key +- Select "AI Provider" in Core Settings as Requesty +- Alice loads Requesty managed models (for example `claude-sonnet-4-5`) and the full model catalog (for example `openai/gpt-4o-mini`); the default model is `openai/gpt-4o-mini` +- Requesty powers chat inference only; cloud TTS/STT/embeddings still require OpenAI or local voice and memory mode +- No image generation support (use OpenAI provider for image-gen) +- See the [Requesty docs](https://docs.requesty.ai) for more details + ## Local Ollama / LM studio - Run Ollama or LM studio diff --git a/electron/main/securityBoundaries.ts b/electron/main/securityBoundaries.ts index 53b82952..b4562395 100644 --- a/electron/main/securityBoundaries.ts +++ b/electron/main/securityBoundaries.ts @@ -7,6 +7,7 @@ const BUILT_IN_HTTP_BASE_URLS = [ 'https://api.minimax.io', 'https://api.deepseek.com', 'https://global.api-route.com', + 'https://router.requesty.ai', 'http://localhost:11434', 'http://localhost:1234', ] diff --git a/electron/main/settingsManager.ts b/electron/main/settingsManager.ts index fd472bdb..e36b4812 100644 --- a/electron/main/settingsManager.ts +++ b/electron/main/settingsManager.ts @@ -26,6 +26,7 @@ export interface AppSettings { VITE_MINIMAX_API_KEY?: string VITE_DEEPSEEK_API_KEY?: string VITE_API_ROUTE_API_KEY?: string + VITE_REQUESTY_API_KEY?: string VITE_GROQ_API_KEY?: string sttProvider?: 'openai' | 'groq' | 'transformers' aiProvider?: @@ -37,6 +38,7 @@ export interface AppSettings { | 'minimax' | 'deepseek' | 'api-route' + | 'requesty' | 'codex' // Transformers STT settings diff --git a/electron/main/settingsSecurity.ts b/electron/main/settingsSecurity.ts index 722f064c..1c8623ad 100644 --- a/electron/main/settingsSecurity.ts +++ b/electron/main/settingsSecurity.ts @@ -5,6 +5,7 @@ export const SECRET_SETTING_KEYS = [ 'VITE_MINIMAX_API_KEY', 'VITE_DEEPSEEK_API_KEY', 'VITE_API_ROUTE_API_KEY', + 'VITE_REQUESTY_API_KEY', 'VITE_GROQ_API_KEY', 'VITE_GOOGLE_API_KEY', 'VITE_JACKETT_API_KEY', diff --git a/src/__tests__/securityBoundaries.test.ts b/src/__tests__/securityBoundaries.test.ts index a6d47da0..ae2ad54e 100644 --- a/src/__tests__/securityBoundaries.test.ts +++ b/src/__tests__/securityBoundaries.test.ts @@ -56,6 +56,7 @@ describe('security boundaries', () => { 'https://openrouter.ai/api/v1/models', 'https://api.minimax.io/v1/models', 'https://global.api-route.com/v1/models', + 'https://router.requesty.ai/v1/models', ]) { expect(validateHttpBridgeUrl(url, origins)).toBe(url) } diff --git a/src/components/settings/AssistantSettingsTab.vue b/src/components/settings/AssistantSettingsTab.vue index 9ce7dfc0..f412db29 100644 --- a/src/components/settings/AssistantSettingsTab.vue +++ b/src/components/settings/AssistantSettingsTab.vue @@ -73,6 +73,8 @@ currentSettings.deepseekBaseUrl) || (currentSettings.aiProvider === 'api-route' && currentSettings.VITE_API_ROUTE_API_KEY) || + (currentSettings.aiProvider === 'requesty' && + currentSettings.VITE_REQUESTY_API_KEY) || (currentSettings.aiProvider === 'ollama' && currentSettings.ollamaBaseUrl) || (currentSettings.aiProvider === 'lm-studio' && @@ -316,6 +318,8 @@ currentSettings.deepseekBaseUrl) || (currentSettings.aiProvider === 'api-route' && currentSettings.VITE_API_ROUTE_API_KEY) || + (currentSettings.aiProvider === 'requesty' && + currentSettings.VITE_REQUESTY_API_KEY) || (currentSettings.aiProvider === 'ollama' && currentSettings.ollamaBaseUrl) || (currentSettings.aiProvider === 'lm-studio' && @@ -531,6 +535,7 @@ const getProviderDisplayName = (provider: string): string => { minimax: 'MiniMax', deepseek: 'DeepSeek', 'api-route': 'API Route', + requesty: 'Requesty', } return providerNames[provider] || provider } diff --git a/src/components/settings/CoreSettingsTab.vue b/src/components/settings/CoreSettingsTab.vue index e8960a36..fed2d48e 100644 --- a/src/components/settings/CoreSettingsTab.vue +++ b/src/components/settings/CoreSettingsTab.vue @@ -23,6 +23,7 @@ + @@ -238,6 +239,28 @@ >.

+
+ + +

+ Required for chat models when using Requesty. + Get an API key. +

+
{ formData.aiProvider === 'minimax' || formData.aiProvider === 'deepseek' || formData.aiProvider === 'api-route' || + formData.aiProvider === 'requesty' || formData.aiProvider === 'codex') && !formData.VITE_OPENAI_API_KEY.trim() ) { @@ -546,6 +552,33 @@ const testAPIRouteKey = async () => { } } +const testRequestyKey = async () => { + if (!formData.VITE_REQUESTY_API_KEY.trim()) { + testResult.requesty.error = 'API Key cannot be empty.' + testResult.requesty.success = false + return + } + + isTesting.requesty = true + testResult.requesty.error = '' + testResult.requesty.success = false + + try { + await listRequestyModelsForConfig(formData.VITE_REQUESTY_API_KEY) + testResult.requesty.success = true + } catch (e: any) { + testResult.requesty.error = 'API Key is invalid or has no permissions.' + if (e.message?.includes('401')) { + testResult.requesty.error = 'Invalid API key - please check your key.' + } else if (e.message?.includes('429')) { + testResult.requesty.error = + 'Rate limit exceeded - please try again later.' + } + } finally { + isTesting.requesty = false + } +} + const syncCodexStatus = async () => { const status = await window.aliceIPC.invoke('codex-auth:status') const connected = Boolean(status?.connected) @@ -706,6 +739,8 @@ const resetTestResults = () => { testResult.deepseek.error = '' testResult.apiRoute.success = false testResult.apiRoute.error = '' + testResult.requesty.success = false + testResult.requesty.error = '' testResult.codex.success = false testResult.codex.error = '' testResult.ollama.success = false @@ -719,6 +754,8 @@ const isCurrentProviderTested = () => { return testResult.openai.success } else if (formData.aiProvider === 'openrouter') { return testResult.openrouter.success + } else if (formData.aiProvider === 'requesty') { + return testResult.requesty.success } else if (formData.aiProvider === 'zai') { return ( testResult.zai.success && diff --git a/src/components/wizard/steps/AIProviderStep.vue b/src/components/wizard/steps/AIProviderStep.vue index f89f248b..a606529d 100644 --- a/src/components/wizard/steps/AIProviderStep.vue +++ b/src/components/wizard/steps/AIProviderStep.vue @@ -25,6 +25,7 @@ + @@ -163,6 +164,69 @@
+ +
+
+ + + + Requesty routes to hundreds of models through one API key. You can + choose local models for voice features in the next step. +
+ + +
+ +
+ Get your key from the + Requesty dashboard +
+ +
+ + + + +
+
@@ -918,6 +982,7 @@ defineEmits<{ 'test-minimax': [] 'test-deepseek': [] 'test-api-route': [] + 'test-requesty': [] 'test-codex': [] 'test-ollama': [] 'test-lmstudio': [] diff --git a/src/components/wizard/steps/FinalSetupStep.vue b/src/components/wizard/steps/FinalSetupStep.vue index fc46dfd3..2c117f8e 100644 --- a/src/components/wizard/steps/FinalSetupStep.vue +++ b/src/components/wizard/steps/FinalSetupStep.vue @@ -160,6 +160,7 @@ const providerLabel = (provider: string) => { minimax: 'MiniMax', deepseek: 'DeepSeek', 'api-route': 'API Route', + requesty: 'Requesty', ollama: 'Ollama', openai: 'OpenAI', openrouter: 'OpenRouter', diff --git a/src/components/wizard/steps/VoiceModelsStep.vue b/src/components/wizard/steps/VoiceModelsStep.vue index a2950a7f..d6f1414b 100644 --- a/src/components/wizard/steps/VoiceModelsStep.vue +++ b/src/components/wizard/steps/VoiceModelsStep.vue @@ -157,7 +157,8 @@ formData.aiProvider === 'zai' || formData.aiProvider === 'minimax' || formData.aiProvider === 'deepseek' || - formData.aiProvider === 'api-route') && + formData.aiProvider === 'api-route' || + formData.aiProvider === 'requesty') && !formData.VITE_OPENAI_API_KEY?.trim() " class="alert alert-warning text-sm" @@ -210,7 +211,8 @@ formData.aiProvider === 'zai' || formData.aiProvider === 'minimax' || formData.aiProvider === 'deepseek' || - formData.aiProvider === 'api-route' + formData.aiProvider === 'api-route' || + formData.aiProvider === 'requesty' " class="form-control" > diff --git a/src/modules/conversation/backendService.ts b/src/modules/conversation/backendService.ts index 5bd2d511..b51d919c 100644 --- a/src/modules/conversation/backendService.ts +++ b/src/modules/conversation/backendService.ts @@ -11,6 +11,7 @@ export interface BackendServiceDependencies { VITE_MINIMAX_API_KEY?: string VITE_DEEPSEEK_API_KEY?: string VITE_API_ROUTE_API_KEY?: string + VITE_REQUESTY_API_KEY?: string codexAuthConnected?: boolean ollamaBaseUrl?: string lmStudioBaseUrl?: string @@ -108,6 +109,10 @@ export function createBackendService( deps.logInfo('Cannot fetch models: API Route API Key is missing.') return } + if (provider === 'requesty' && !config.VITE_REQUESTY_API_KEY) { + deps.logInfo('Cannot fetch models: Requesty API Key is missing.') + return + } if (provider === 'codex' && !config.codexAuthConnected) { deps.logInfo('Cannot fetch models: ChatGPT Codex is not connected.') return @@ -134,6 +139,7 @@ export function createBackendService( minimax: 'MiniMax', deepseek: 'DeepSeek', 'api-route': 'API Route', + requesty: 'Requesty', codex: 'ChatGPT Codex', } const providerName = providerNameMap[provider] || provider diff --git a/src/services/apiClients.ts b/src/services/apiClients.ts index 986f9381..82b63faf 100644 --- a/src/services/apiClients.ts +++ b/src/services/apiClients.ts @@ -6,6 +6,7 @@ import { API_ROUTE_OPENAI_BASE_URL, DEEPSEEK_OPENAI_BASE_URL, MINIMAX_OPENAI_BASE_URL, + REQUESTY_OPENAI_BASE_URL, ZAI_CODING_BASE_URL, } from './llmProviders/providerCatalog' @@ -18,6 +19,7 @@ let zaiClient: OpenAI | null = null let minimaxClient: OpenAI | null = null let deepseekClient: OpenAI | null = null let apiRouteClient: OpenAI | null = null +let requestyClient: OpenAI | null = null export function getOpenAIClient(): OpenAI { if (!openaiClient) { @@ -109,6 +111,16 @@ export function getAPIRouteClient(): OpenAI { return apiRouteClient } +export function getRequestyClient(): OpenAI { + if (!requestyClient) { + initializeRequestyClient() + } + if (!requestyClient) { + throw new Error('Requesty client could not be initialized') + } + return requestyClient +} + function initializeOpenAIClient(): void { const settings = useSettingsStore().config if (!settings.VITE_OPENAI_API_KEY) { @@ -253,6 +265,21 @@ function initializeAPIRouteClient(): void { }) } +function initializeRequestyClient(): void { + const settings = useSettingsStore().config + if (!settings.VITE_REQUESTY_API_KEY) { + throw new Error('Requesty API Key is not configured.') + } + + requestyClient = new OpenAI({ + apiKey: settings.VITE_REQUESTY_API_KEY, + baseURL: REQUESTY_OPENAI_BASE_URL, + dangerouslyAllowBrowser: true, + timeout: 20 * 1000, + maxRetries: 1, + }) +} + export function reinitializeClients(): void { console.log('Reinitializing API clients with updated settings...') @@ -327,6 +354,14 @@ export function reinitializeClients(): void { console.error('Failed to reinitialize API Route client:', error) apiRouteClient = null } + + try { + initializeRequestyClient() + console.log('Requesty client reinitialized successfully') + } catch (error) { + console.error('Failed to reinitialize Requesty client:', error) + requestyClient = null + } } export async function initializeClients(): Promise { diff --git a/src/services/apiService.ts b/src/services/apiService.ts index 4b1743e4..a5451b69 100644 --- a/src/services/apiService.ts +++ b/src/services/apiService.ts @@ -11,6 +11,7 @@ import { getMiniMaxClient, getDeepSeekClient, getAPIRouteClient, + getRequestyClient, } from './apiClients' import { createOpenAIResponse as createOpenAIResponseWithOpenAI, @@ -38,6 +39,10 @@ import { createAPIRouteResponse, listAPIRouteModels, } from './llmProviders/apiRoute' +import { + createRequestyResponse, + listRequestyModels, +} from './llmProviders/requesty' import { createCodexResponse, createCodexTextResponse, @@ -128,6 +133,8 @@ function getAIClient(): OpenAI { return getDeepSeekClient() case 'api-route': return getAPIRouteClient() + case 'requesty': + return getRequestyClient() default: return getOpenAIClient() } @@ -156,6 +163,9 @@ export const fetchOpenAIModels = async (): Promise => { if (settings.aiProvider === 'api-route') { return listAPIRouteModels() } + if (settings.aiProvider === 'requesty') { + return listRequestyModels() + } if (settings.aiProvider === 'codex') { return listCodexModels() } @@ -234,6 +244,15 @@ export const createOpenAIResponse = async ( signal ) } + if (settings.aiProvider === 'requesty') { + return createRequestyResponse( + input, + previousResponseId, + stream, + customInstructions, + signal + ) + } if (settings.aiProvider === 'codex') { return createCodexResponse( input, diff --git a/src/services/llmProviders/__tests__/providerCatalog.test.ts b/src/services/llmProviders/__tests__/providerCatalog.test.ts index 77a32ef0..a18baae2 100644 --- a/src/services/llmProviders/__tests__/providerCatalog.test.ts +++ b/src/services/llmProviders/__tests__/providerCatalog.test.ts @@ -84,4 +84,12 @@ describe('providerCatalog', () => { 'claude-sonnet-4-5' ) }) + + it('accepts dynamically discovered Requesty models', () => { + expect(getStaticModelsForProvider('requesty')).toEqual([]) + expect(getSafeProviderModel('requesty', '')).toBe('openai/gpt-4o-mini') + expect(getSafeProviderModel('requesty', 'claude-sonnet-4-5')).toBe( + 'claude-sonnet-4-5' + ) + }) }) diff --git a/src/services/llmProviders/__tests__/requesty.test.ts b/src/services/llmProviders/__tests__/requesty.test.ts new file mode 100644 index 00000000..c4d4cba4 --- /dev/null +++ b/src/services/llmProviders/__tests__/requesty.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useSettingsStore } from '../../../stores/settingsStore' +import { getRequestyClient } from '../../apiClients' +import { + createRequestyResponse, + listRequestyModelsForConfig, +} from '../requesty' + +vi.mock('../../apiClients', () => ({ + getRequestyClient: vi.fn(), +})) + +describe('Requesty model listing', () => { + afterEach(() => { + vi.restoreAllMocks() + delete (globalThis as any).window + }) + + it('lists managed models first, then the model catalog', async () => { + const request = vi.fn().mockImplementation(({ url }: { url: string }) => + Promise.resolve({ + success: true, + status: 200, + data: { + data: url.endsWith('/models/managed') + ? [ + { id: 'gpt-5.4-mini', api: 'chat' }, + { id: 'claude-sonnet-4-5', api: 'chat' }, + ] + : [ + { id: 'openai/gpt-4o-mini', api: 'chat' }, + { id: 'openai/text-embedding-3-small', api: 'embedding' }, + { id: 'anthropic/claude-sonnet-4-5', api: 'chat' }, + ], + }, + }) + ) + ;(globalThis as any).window = { httpAPI: { request } } + + const models = await listRequestyModelsForConfig('rqsty-test') + + expect(models.map(model => model.id)).toEqual([ + 'claude-sonnet-4-5', + 'gpt-5.4-mini', + 'anthropic/claude-sonnet-4-5', + 'openai/gpt-4o-mini', + ]) + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://router.requesty.ai/v1/models', + method: 'GET', + headers: { Authorization: 'Bearer rqsty-test' }, + }) + ) + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://router.requesty.ai/v1/models/managed', + }) + ) + }) + + it('keeps the catalog when the managed list is unavailable', async () => { + const request = vi.fn().mockImplementation(({ url }: { url: string }) => + Promise.resolve( + url.endsWith('/models/managed') + ? { success: false, error: 'network error' } + : { + success: true, + status: 200, + data: { data: [{ id: 'openai/gpt-4o-mini', api: 'chat' }] }, + } + ) + ) + ;(globalThis as any).window = { httpAPI: { request } } + + const models = await listRequestyModelsForConfig('rqsty-test') + + expect(models.map(model => model.id)).toEqual(['openai/gpt-4o-mini']) + }) + + it('rejects an invalid key', async () => { + const request = vi.fn().mockResolvedValue({ + success: true, + status: 403, + data: { error: { message: 'Invalid authorization token' } }, + }) + ;(globalThis as any).window = { httpAPI: { request } } + + await expect(listRequestyModelsForConfig('bad-key')).rejects.toThrow( + 'Invalid authorization token' + ) + }) +}) + +describe('createRequestyResponse', () => { + beforeEach(() => { + setActivePinia(createPinia()) + ;(globalThis as any).window = { + customToolsAPI: { + list: vi.fn().mockResolvedValue({ + success: true, + data: { + tools: [], + diagnostics: [], + filePath: '', + lastModified: Date.now(), + }, + }), + }, + } + }) + + afterEach(() => { + vi.restoreAllMocks() + delete (globalThis as any).window + }) + + it('sends chat completions with the configured Requesty model', async () => { + const settingsStore = useSettingsStore() + settingsStore.updateSetting('aiProvider', 'requesty') + settingsStore.updateSetting('assistantModel', 'anthropic/claude-sonnet-4-5') + + const create = vi.fn().mockResolvedValue({ choices: [] }) + vi.mocked(getRequestyClient).mockReturnValue({ + chat: { completions: { create } }, + } as any) + + await createRequestyResponse( + [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello there' }], + } as any, + ], + null, + false + ) + + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'anthropic/claude-sonnet-4-5', + stream: false, + }), + expect.any(Object) + ) + }) +}) diff --git a/src/services/llmProviders/openAICompatible.ts b/src/services/llmProviders/openAICompatible.ts index 334fd59d..93f6c519 100644 --- a/src/services/llmProviders/openAICompatible.ts +++ b/src/services/llmProviders/openAICompatible.ts @@ -10,7 +10,8 @@ import { } from './providerCatalog' type OpenAIClientGetter = () => OpenAI -type OpenAICompatibleProviderKey = 'zai' | 'minimax' | 'deepseek' | 'api-route' +type OpenAICompatibleProviderKey = + 'zai' | 'minimax' | 'deepseek' | 'api-route' | 'requesty' function convertResponsesInputToChatMessages( input: OpenAI.Responses.Request.InputItemLike[] diff --git a/src/services/llmProviders/providerCatalog.ts b/src/services/llmProviders/providerCatalog.ts index 9c1bdfad..0189f150 100644 --- a/src/services/llmProviders/providerCatalog.ts +++ b/src/services/llmProviders/providerCatalog.ts @@ -7,6 +7,7 @@ export type AIProviderKey = | 'minimax' | 'deepseek' | 'api-route' + | 'requesty' | 'codex' export type ChatCompletionsProviderKey = Exclude< @@ -98,6 +99,11 @@ export const PROVIDER_CONFIGS: Record = { defaultModel: 'gpt-5.4-mini', nativeWebSearch: false, }, + requesty: { + displayName: 'Requesty', + defaultModel: 'openai/gpt-4o-mini', + nativeWebSearch: false, + }, codex: { displayName: 'ChatGPT Codex', defaultModel: 'gpt-5.4', @@ -109,6 +115,7 @@ export const ZAI_CODING_BASE_URL = 'https://api.z.ai/api/coding/paas/v4' export const MINIMAX_OPENAI_BASE_URL = 'https://api.minimax.io/v1' export const DEEPSEEK_OPENAI_BASE_URL = 'https://api.deepseek.com' export const API_ROUTE_OPENAI_BASE_URL = 'https://global.api-route.com/v1' +export const REQUESTY_OPENAI_BASE_URL = 'https://router.requesty.ai/v1' export const CHAT_COMPLETIONS_PROVIDERS: ChatCompletionsProviderKey[] = [ 'openrouter', @@ -118,6 +125,7 @@ export const CHAT_COMPLETIONS_PROVIDERS: ChatCompletionsProviderKey[] = [ 'minimax', 'deepseek', 'api-route', + 'requesty', ] export function getProviderDisplayName(provider: string): string { diff --git a/src/services/llmProviders/requesty.ts b/src/services/llmProviders/requesty.ts new file mode 100644 index 00000000..7ede3a3f --- /dev/null +++ b/src/services/llmProviders/requesty.ts @@ -0,0 +1,81 @@ +import type OpenAI from 'openai' +import { getRequestyClient } from '../apiClients' +import { useSettingsStore } from '../../stores/settingsStore' +import { listModelsViaMainProcess } from './modelDiscovery' +import { createOpenAICompatibleResponse } from './openAICompatible' +import { REQUESTY_OPENAI_BASE_URL } from './providerCatalog' + +function isChatModel(model: any): boolean { + return !model.api || model.api === 'chat' +} + +async function listRequestyManagedModels( + apiKey: string +): Promise { + try { + const response = await window.httpAPI.request({ + url: `${REQUESTY_OPENAI_BASE_URL}/models/managed`, + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + timeout: 10 * 1000, + }) + if (!response.success || (response.status && response.status >= 400)) { + return [] + } + const models = Array.isArray(response.data?.data) ? response.data.data : [] + return models + .filter((model: any) => typeof model?.id === 'string') + .map((model: any) => ({ ...model, object: model.object || 'model' })) + .sort((a: OpenAI.Models.Model, b: OpenAI.Models.Model) => + a.id.localeCompare(b.id) + ) + } catch { + return [] + } +} + +export async function listRequestyModelsForConfig( + apiKey: string +): Promise { + // The authenticated catalog request also validates the key. + const catalogModels = await listModelsViaMainProcess({ + apiKey, + baseURL: REQUESTY_OPENAI_BASE_URL, + providerName: 'Requesty', + }) + const managedModels = await listRequestyManagedModels(apiKey) + + // Managed policies come first, followed by the full model catalog. + const seen = new Set() + return [...managedModels, ...catalogModels].filter(model => { + if (!isChatModel(model) || seen.has(model.id)) { + return false + } + seen.add(model.id) + return true + }) +} + +export function listRequestyModels(): Promise { + const settings = useSettingsStore().config + return listRequestyModelsForConfig(settings.VITE_REQUESTY_API_KEY || '') +} + +export function createRequestyResponse( + input: OpenAI.Responses.Request.InputItemLike[], + _previousResponseId: string | null, + stream: boolean = false, + customInstructions?: string, + signal?: AbortSignal +): Promise { + return createOpenAICompatibleResponse( + 'requesty', + getRequestyClient, + input, + stream, + customInstructions, + signal + ) +} diff --git a/src/services/llmProviders/streamAdapters.ts b/src/services/llmProviders/streamAdapters.ts index bac13042..b9b441ef 100644 --- a/src/services/llmProviders/streamAdapters.ts +++ b/src/services/llmProviders/streamAdapters.ts @@ -3,7 +3,13 @@ import { isExpectedAbortError } from '../../utils/isAbortError' export async function* convertLocalLLMStreamToResponsesFormat( stream: any, provider: - 'ollama' | 'lm-studio' | 'zai' | 'minimax' | 'deepseek' | 'api-route' + | 'ollama' + | 'lm-studio' + | 'zai' + | 'minimax' + | 'deepseek' + | 'api-route' + | 'requesty' ) { let responseId = `${provider}-${Date.now()}` let messageItemId = `message-${Date.now()}` @@ -207,7 +213,7 @@ export async function* convertLocalLLMStreamToResponsesFormat( export async function* convertChatCompletionToResponsesFormat( completion: any, - provider: 'zai' | 'minimax' | 'deepseek' | 'api-route' + provider: 'zai' | 'minimax' | 'deepseek' | 'api-route' | 'requesty' ) { const responseId = completion?.id || `${provider}-${Date.now()}` const messageItemId = `message-${Date.now()}` diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 2eb8c4c1..135a4717 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -35,6 +35,7 @@ export interface AliceSettings { VITE_MINIMAX_API_KEY: string VITE_DEEPSEEK_API_KEY: string VITE_API_ROUTE_API_KEY: string + VITE_REQUESTY_API_KEY: string VITE_GROQ_API_KEY: string VITE_GOOGLE_API_KEY: string sttProvider: 'openai' | 'groq' | 'google' | 'local' @@ -130,6 +131,9 @@ function hasMinimumConfigForOnboarding(config: AliceSettings): boolean { if (config.VITE_API_ROUTE_API_KEY?.trim()) { return true } + if (config.VITE_REQUESTY_API_KEY?.trim()) { + return true + } if (config.codexAuthConnected) { return true } @@ -151,6 +155,7 @@ const defaultSettings: AliceSettings = { VITE_MINIMAX_API_KEY: '', VITE_DEEPSEEK_API_KEY: '', VITE_API_ROUTE_API_KEY: '', + VITE_REQUESTY_API_KEY: '', VITE_GROQ_API_KEY: '', VITE_GOOGLE_API_KEY: '', sttProvider: 'openai', @@ -226,6 +231,7 @@ const settingKeyToLabelMap: Record = { VITE_MINIMAX_API_KEY: 'MiniMax API Key', VITE_DEEPSEEK_API_KEY: 'DeepSeek API Key', VITE_API_ROUTE_API_KEY: 'API Route API Key', + VITE_REQUESTY_API_KEY: 'Requesty API Key', VITE_GROQ_API_KEY: 'Groq API Key (STT)', VITE_GOOGLE_API_KEY: 'Google API Key', sttProvider: 'Speech-to-Text Provider', @@ -294,6 +300,7 @@ const ESSENTIAL_CORE_API_KEYS: (keyof AliceSettings)[] = [ 'VITE_MINIMAX_API_KEY', 'VITE_DEEPSEEK_API_KEY', 'VITE_API_ROUTE_API_KEY', + 'VITE_REQUESTY_API_KEY', ] function requiresOpenAIKey(config: AliceSettings): boolean { @@ -369,6 +376,7 @@ export const useSettingsStore = defineStore('settings', () => { 'minimax', 'deepseek', 'api-route', + 'requesty', 'codex', ] as const if (!validAIProviders.includes(validated.aiProvider as any)) { @@ -458,6 +466,8 @@ export const useSettingsStore = defineStore('settings', () => { essentialKeys.push('VITE_DEEPSEEK_API_KEY', 'deepseekBaseUrl') } else if (settings.value.aiProvider === 'api-route') { essentialKeys.push('VITE_API_ROUTE_API_KEY') + } else if (settings.value.aiProvider === 'requesty') { + essentialKeys.push('VITE_REQUESTY_API_KEY') } else if (settings.value.aiProvider === 'codex') { essentialKeys.push('codexAuthConnected') } else if (settings.value.aiProvider === 'ollama') { @@ -532,6 +542,10 @@ export const useSettingsStore = defineStore('settings', () => { return !!settings.value.VITE_API_ROUTE_API_KEY?.trim() } + if (settings.value.aiProvider === 'requesty') { + return !!settings.value.VITE_REQUESTY_API_KEY?.trim() + } + if (settings.value.aiProvider === 'ollama') { return !!settings.value.ollamaBaseUrl?.trim() } @@ -790,6 +804,10 @@ export const useSettingsStore = defineStore('settings', () => { PROVIDER_CONFIGS['api-route'].defaultModel settings.value.SUMMARIZATION_MODEL = PROVIDER_CONFIGS['api-route'].defaultModel + } else if (settings.value.aiProvider === 'requesty') { + settings.value.assistantModel = PROVIDER_CONFIGS.requesty.defaultModel + settings.value.SUMMARIZATION_MODEL = + PROVIDER_CONFIGS.requesty.defaultModel } else if (settings.value.aiProvider === 'codex') { settings.value.assistantModel = PROVIDER_CONFIGS.codex.defaultModel settings.value.SUMMARIZATION_MODEL = PROVIDER_CONFIGS.codex.defaultModel @@ -835,6 +853,7 @@ export const useSettingsStore = defineStore('settings', () => { key === 'VITE_MINIMAX_API_KEY' || key === 'VITE_DEEPSEEK_API_KEY' || key === 'VITE_API_ROUTE_API_KEY' || + key === 'VITE_REQUESTY_API_KEY' || key === 'ollamaBaseUrl' || key === 'lmStudioBaseUrl' || key === 'zaiBaseUrl' || @@ -881,6 +900,7 @@ export const useSettingsStore = defineStore('settings', () => { VITE_MINIMAX_API_KEY: settings.value.VITE_MINIMAX_API_KEY, VITE_DEEPSEEK_API_KEY: settings.value.VITE_DEEPSEEK_API_KEY, VITE_API_ROUTE_API_KEY: settings.value.VITE_API_ROUTE_API_KEY, + VITE_REQUESTY_API_KEY: settings.value.VITE_REQUESTY_API_KEY, VITE_GROQ_API_KEY: settings.value.VITE_GROQ_API_KEY, VITE_GOOGLE_API_KEY: settings.value.VITE_GOOGLE_API_KEY, sttProvider: settings.value.sttProvider, @@ -1034,6 +1054,13 @@ export const useSettingsStore = defineStore('settings', () => { isSaving.value = false return } + } else if (currentConfigForTest.aiProvider === 'requesty') { + if (!currentConfigForTest.VITE_REQUESTY_API_KEY?.trim()) { + error.value = `Essential setting '${settingKeyToLabelMap.VITE_REQUESTY_API_KEY}' is missing.` + generalStore.statusMessage = 'Requesty API Key is required.' + isSaving.value = false + return + } } else if (currentConfigForTest.aiProvider === 'codex') { if (!currentConfigForTest.codexAuthConnected) { error.value = `Essential setting '${settingKeyToLabelMap.codexAuthConnected}' is missing.` @@ -1161,6 +1188,7 @@ export const useSettingsStore = defineStore('settings', () => { VITE_MINIMAX_API_KEY?: string VITE_DEEPSEEK_API_KEY?: string VITE_API_ROUTE_API_KEY?: string + VITE_REQUESTY_API_KEY?: string sttProvider: 'openai' | 'groq' | 'google' | 'local' ttsProvider?: 'openai' | 'google' | 'local' embeddingProvider?: 'openai' | 'local' @@ -1187,6 +1215,8 @@ export const useSettingsStore = defineStore('settings', () => { onboardingData.VITE_DEEPSEEK_API_KEY || '' settings.value.VITE_API_ROUTE_API_KEY = onboardingData.VITE_API_ROUTE_API_KEY || '' + settings.value.VITE_REQUESTY_API_KEY = + onboardingData.VITE_REQUESTY_API_KEY || '' settings.value.sttProvider = onboardingData.sttProvider settings.value.aiProvider = onboardingData.aiProvider settings.value.VITE_GROQ_API_KEY = onboardingData.VITE_GROQ_API_KEY