From a7b04d304f70ae03268c2a36f4978bc1dbf7de18 Mon Sep 17 00:00:00 2001 From: Thibault Jaigu Date: Thu, 24 Sep 2026 17:03:11 +0200 Subject: [PATCH] feat(ai): add Requesty provider Add a disabled built-in Requesty entry on the existing OpenAI-compatible transport, list models from the managed models endpoint with capabilities from its metadata, keep saved settings intact during migration, and map reasoning to reasoning_effort. --- README.md | 2 +- README.zh-CN.md | 2 +- packages/ai/src/agent/chat-adapters.test.ts | 28 ++++++++++ packages/ai/src/agent/chat-completion.test.ts | 36 ++++++++++++ .../src/assets/provider-logos/requesty.svg | 7 +++ .../src/components/AiProviderBadge.test.tsx | 5 ++ .../app/src/components/AiProviderBadge.tsx | 2 + .../app/src/lib/settings/ai-settings.test.ts | 2 +- packages/providers/src/catalog.ts | 17 ++++++ .../src/provider-capabilities.test.ts | 9 +++ .../providers/src/provider-capabilities.ts | 1 + packages/providers/src/requests.test.ts | 26 +++++++++ packages/providers/src/requests.ts | 23 +++++++- packages/providers/src/settings.test.ts | 56 ++++++++++++++++++- packages/providers/src/settings.ts | 10 ++-- .../providers/src/thinking-format.test.ts | 10 ++++ packages/providers/src/thinking-format.ts | 2 + 17 files changed, 227 insertions(+), 11 deletions(-) create mode 100644 packages/app/src/assets/provider-logos/requesty.svg diff --git a/README.md b/README.md index e8663f90..6aa6e7f2 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ Typora compatibility covers basic appearance, not every theme rule. Complex layo Supports cloud models, local models, and any OpenAI-compatible endpoint. Separate model selection for inline editing and the side panel. -**Built-in providers:** OpenAI · Anthropic · Google Gemini · DeepSeek · Mistral · Groq · OpenRouter · OrcaRouter · Together.ai · Qwen · Xiaomi MiMo · Volcengine Ark · xAI · Azure OpenAI · Ollama +**Built-in providers:** OpenAI · Anthropic · Google Gemini · DeepSeek · Mistral · Groq · OpenRouter · OrcaRouter · Requesty · Together.ai · Qwen · Xiaomi MiMo · Volcengine Ark · xAI · Azure OpenAI · Ollama **Web search:** Provider-native search, Bing, and SearXNG — with configurable result and content limits. diff --git a/README.zh-CN.md b/README.zh-CN.md index 59eb55ee..c9fbab13 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -211,7 +211,7 @@ Typora 兼容以基础外观为范围:复杂布局、条件规则、主题附 支持云端模型、本地模型和任意 OpenAI 兼容接口,内联编辑和侧边栏可分别选择模型。 -**内置服务商:** OpenAI · Anthropic · Google Gemini · DeepSeek · Mistral · Groq · OpenRouter · OrcaRouter · Together.ai · Qwen · Xiaomi MiMo · Volcengine Ark · xAI · Azure OpenAI · Ollama +**内置服务商:** OpenAI · Anthropic · Google Gemini · DeepSeek · Mistral · Groq · OpenRouter · OrcaRouter · Requesty · Together.ai · Qwen · Xiaomi MiMo · Volcengine Ark · xAI · Azure OpenAI · Ollama **联网搜索:** 服务商原生搜索、Bing 和 SearXNG——结果数量和正文长度均可配置。 diff --git a/packages/ai/src/agent/chat-adapters.test.ts b/packages/ai/src/agent/chat-adapters.test.ts index 9b12c21c..66169931 100644 --- a/packages/ai/src/agent/chat-adapters.test.ts +++ b/packages/ai/src/agent/chat-adapters.test.ts @@ -80,6 +80,34 @@ describe("AI chat adapters", () => { expect(agent.body).not.toHaveProperty("thinking"); }); + it("uses the compatible adapter for built-in Requesty inline and agent requests", () => { + const config = createDefaultAiSettings().providers.find((item) => item.id === "requesty"); + expect(config).toBeDefined(); + if (!config) throw new Error("Missing Requesty provider"); + const configured = { ...config, apiKey: "mock-key" }; + const adapter = getChatAdapterForProvider(configured); + expect(adapter).toBe(getChatAdapter("openai-compatible")); + const inline = adapter.buildRequest(configured, "openai/gpt-4o-mini", messages, { stream: true }); + expect(inline).toEqual({ + url: "https://router.requesty.ai/v1/chat/completions", + headers: { Authorization: "Bearer mock-key", "content-type": "application/json" }, + body: { messages, model: "openai/gpt-4o-mini", temperature: 0.7, stream: true } + }); + const agent = adapter.buildRequest(configured, "claude-sonnet-5", messages, { + stream: true, + thinkingEnabled: true, + tools: [readDocumentTool] + }); + expect(agent.body).toMatchObject({ + model: "claude-sonnet-5", + stream: true, + reasoning_effort: "high", + tools: [{ type: "function", function: { name: "read_document" } }] + }); + expect(agent.body).not.toHaveProperty("reasoning"); + expect(agent.body).not.toHaveProperty("thinking"); + }); + it("builds OpenAI-compatible chat completion requests with JSON headers", () => { const request = getChatAdapter("openai-compatible").buildRequest( provider({ baseUrl: "https://proxy.example.test/v1", type: "openai-compatible" }), diff --git a/packages/ai/src/agent/chat-completion.test.ts b/packages/ai/src/agent/chat-completion.test.ts index 99e3e392..c67a5598 100644 --- a/packages/ai/src/agent/chat-completion.test.ts +++ b/packages/ai/src/agent/chat-completion.test.ts @@ -55,6 +55,42 @@ describe("chatCompletion", () => { }), expect.any(Function)); }); + it("streams Requesty text and tool arguments through the existing compatible transport", async () => { + const config = createDefaultAiSettings().providers.find((item) => item.id === "requesty"); + expect(config).toBeDefined(); + if (!config) throw new Error("Missing Requesty provider"); + const onDelta = vi.fn(); + const onThinkingDelta = vi.fn(); + const streamTransport = vi.fn(async (_request, onChunk) => { + const events = [ + { choices: [{ delta: { reasoning_content: "Mock reasoning" } }] }, + { choices: [{ delta: { content: "Mock answer" } }] }, + { choices: [{ delta: { tool_calls: [{ index: 0, id: "mock-call", function: { name: "read_document", arguments: "{" } }] } }] }, + { choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: "}" } }] }, finish_reason: "tool_calls" }] } + ]; + onChunk(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") + "data: [DONE]\n\n"); + return { status: 200 }; + }); + + await expect(chatCompletionStream( + { ...config, apiKey: "mock-key" }, + "claude-sonnet-5", + [{ role: "user", content: "Read the mock document." }], + { onDelta, onThinkingDelta, streamTransport, thinkingEnabled: true } + )).resolves.toEqual({ + content: "Mock answer", + finishReason: "toolUse", + toolCalls: [{ id: "mock-call", name: "read_document", arguments: {} }] + }); + expect(onDelta).toHaveBeenCalledWith("Mock answer"); + expect(onThinkingDelta).toHaveBeenCalledWith("Mock reasoning"); + expect(streamTransport).toHaveBeenCalledWith(expect.objectContaining({ + url: "https://router.requesty.ai/v1/chat/completions", + headers: { Authorization: "Bearer mock-key", "content-type": "application/json" }, + body: expect.stringContaining('"reasoning_effort":"high"') + }), expect.any(Function)); + }); + it("sends a native POST request and parses the provider response", async () => { const transport = vi.fn().mockResolvedValue({ body: { diff --git a/packages/app/src/assets/provider-logos/requesty.svg b/packages/app/src/assets/provider-logos/requesty.svg new file mode 100644 index 00000000..d97fd875 --- /dev/null +++ b/packages/app/src/assets/provider-logos/requesty.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/app/src/components/AiProviderBadge.test.tsx b/packages/app/src/components/AiProviderBadge.test.tsx index bf4554d9..68482e92 100644 --- a/packages/app/src/components/AiProviderBadge.test.tsx +++ b/packages/app/src/components/AiProviderBadge.test.tsx @@ -19,6 +19,11 @@ describe("AiProviderBadge", () => { expect(screen.getByRole("img", { name: "OrcaRouter settings.ai.providerLogo" })).toHaveAttribute("src", expect.stringContaining("orcarouter")); }); + it("shows the Requesty logo for its compatible provider entry", () => { + render( key} />); + expect(screen.getByRole("img", { name: "Requesty settings.ai.providerLogo" })).toHaveAttribute("src", expect.stringMatching(/^data:image\/svg\+xml/)); + }); + it("uses safe line height for fallback provider initials", () => { render( key} />); diff --git a/packages/app/src/components/AiProviderBadge.tsx b/packages/app/src/components/AiProviderBadge.tsx index 71becf2a..53b4873a 100644 --- a/packages/app/src/components/AiProviderBadge.tsx +++ b/packages/app/src/components/AiProviderBadge.tsx @@ -10,6 +10,7 @@ import openAiLogo from "../assets/provider-logos/openai.svg"; import openRouterLogo from "../assets/provider-logos/openrouter.svg"; import orcaRouterLogo from "../assets/provider-logos/orcarouter.png"; import qwenLogo from "../assets/provider-logos/qwen.svg"; +import requestyLogo from "../assets/provider-logos/requesty.svg"; import togetherLogo from "../assets/provider-logos/together.svg"; import volcengineLogo from "../assets/provider-logos/volcengine.svg"; import xiaomiMimoLogo from "../assets/provider-logos/xiaomi-mimo.svg"; @@ -34,6 +35,7 @@ const providerLogoByType: Partial> = { const providerLogoById: Partial> = { orcarouter: orcaRouterLogo, + requesty: requestyLogo, "aliyun-bailian": qwenLogo, "volcengine": volcengineLogo, "xiaomi-mimo": xiaomiMimoLogo diff --git a/packages/app/src/lib/settings/ai-settings.test.ts b/packages/app/src/lib/settings/ai-settings.test.ts index c1f193c9..d457e3c3 100644 --- a/packages/app/src/lib/settings/ai-settings.test.ts +++ b/packages/app/src/lib/settings/ai-settings.test.ts @@ -142,7 +142,7 @@ describe("AI settings", () => { const settings = await getStoredAiSettings(); expect(settings.defaultProviderId).toBe("custom-provider-1"); - expect(settings.providers.map((provider) => provider.id)).toEqual(["custom-provider-1", "orcarouter"]); + expect(settings.providers.map((provider) => provider.id)).toEqual(["custom-provider-1", "orcarouter", "requesty"]); expect(settings.providers[0]?.type).toBe("openai-compatible"); }); diff --git a/packages/providers/src/catalog.ts b/packages/providers/src/catalog.ts index 3863d15a..8a673101 100644 --- a/packages/providers/src/catalog.ts +++ b/packages/providers/src/catalog.ts @@ -170,6 +170,23 @@ export const defaultProviderTemplates: AiProviderConfigSeed[] = [ apiStyle: "openai-compatible", type: "openai-compatible" }, + { + apiKey: "", + baseUrl: "https://router.requesty.ai/v1", + defaultModelId: "gpt-5.6-sol", + enabled: false, + id: "requesty", + models: [ + { capabilities: ["text", "vision", "reasoning", "tools"], enabled: true, id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }, + { capabilities: ["text", "vision", "reasoning", "tools"], enabled: true, id: "claude-fable-5", name: "Claude Fable 5" }, + { capabilities: ["text", "vision", "reasoning", "tools"], enabled: true, id: "claude-opus-5", name: "Claude Opus 5" }, + { capabilities: ["text", "vision", "reasoning", "tools"], enabled: true, id: "claude-sonnet-5", name: "Claude Sonnet 5" }, + { capabilities: ["text", "vision", "reasoning", "tools"], enabled: true, id: "gemini-3.6-flash", name: "Gemini 3.6 Flash" } + ], + name: "Requesty", + apiStyle: "openai-compatible", + type: "openai-compatible" + }, { apiKey: "", baseUrl: "https://api.together.xyz/v1", diff --git a/packages/providers/src/provider-capabilities.test.ts b/packages/providers/src/provider-capabilities.test.ts index 108d8dfa..c7244fd7 100644 --- a/packages/providers/src/provider-capabilities.test.ts +++ b/packages/providers/src/provider-capabilities.test.ts @@ -10,6 +10,15 @@ describe("provider capabilities", () => { }); }); + it("enables model discovery for the built-in Requesty gateway", () => { + expect(getProviderCapabilities("requesty", "openai-compatible")).toEqual({ + chat: true, + modelList: true, + streaming: true, + toolCalling: true + }); + }); + it("uses api-style defaults and provider-level overrides", () => { expect(getProviderCapabilities("custom-provider-1", "openai-compatible")).toEqual({ chat: true, diff --git a/packages/providers/src/provider-capabilities.ts b/packages/providers/src/provider-capabilities.ts index d9faaaaa..e61d82c4 100644 --- a/packages/providers/src/provider-capabilities.ts +++ b/packages/providers/src/provider-capabilities.ts @@ -24,6 +24,7 @@ const defaultByApiStyle: Record = { const providerOverrides: Record> = { orcarouter: { modelList: true }, + requesty: { modelList: true }, "aliyun-bailian": { modelList: true, toolCalling: true }, volcengine: { modelList: true, toolCalling: true }, "xiaomi-mimo": { modelList: true, toolCalling: true } diff --git a/packages/providers/src/requests.test.ts b/packages/providers/src/requests.test.ts index 6da940b4..111ce5db 100644 --- a/packages/providers/src/requests.test.ts +++ b/packages/providers/src/requests.test.ts @@ -39,6 +39,32 @@ describe("AI provider requests", () => { }); }); + it("discovers Requesty managed models with capabilities from their metadata", async () => { + const config = createDefaultAiSettings().providers.find((item) => item.id === "requesty"); + expect(config).toBeDefined(); + if (!config) throw new Error("Missing Requesty provider"); + const transport = vi.fn().mockResolvedValue({ + status: 200, + body: { + object: "list", + data: [ + { api: "chat", id: "mock-writer", supports_reasoning: true, supports_tool_calling: true, supports_vision: true }, + { api: "chat", id: "mock-basic", supports_reasoning: false, supports_tool_calling: false, supports_vision: false }, + { api: "embedding", id: "mock-embedder" } + ] + } + }); + await expect(fetchAiProviderModels({ ...config, apiKey: "mock-key" }, transport)).resolves.toEqual([ + { capabilities: ["text", "vision", "reasoning", "tools"], enabled: true, id: "mock-writer", name: "mock-writer" }, + { capabilities: ["text"], enabled: true, id: "mock-basic", name: "mock-basic" } + ]); + expect(transport).toHaveBeenCalledWith({ + method: "GET", + url: "https://router.requesty.ai/v1/models/managed", + headers: { Authorization: "Bearer mock-key" } + }); + }); + it("ships mainstream providers by default", () => { const settings = createDefaultAiSettings(); const providerIds = settings.providers.map((item) => item.id); diff --git a/packages/providers/src/requests.ts b/packages/providers/src/requests.ts index 657161a9..5115f5fc 100644 --- a/packages/providers/src/requests.ts +++ b/packages/providers/src/requests.ts @@ -100,6 +100,11 @@ const endpointByRequestStyle: Record> = { + requesty: "/models/managed" +}; + export function buildAiProviderModelsRequest(provider: AiProviderConfig): AiProviderHttpRequest { const endpoint = provider.apiStyle ? endpointByRequestStyle[provider.apiStyle] : endpointByApiStyle[provider.type]; if (!endpoint) throw new Error("Model list is not available for this API style."); @@ -112,7 +117,7 @@ export function buildAiProviderModelsRequest(provider: AiProviderConfig): AiProv ...readAiProviderCustomHeaders(provider) }, method: "GET", - url: joinApiUrl(baseUrl, endpoint.path) + url: joinApiUrl(baseUrl, modelsPathByProviderId[provider.id] ?? endpoint.path) }; } @@ -156,7 +161,8 @@ export function parseAiProviderModels(provider: AiProviderConfig, body: unknown) const id = readModelId(apiStyle, record); if (!id || seenIds.has(id)) continue; - const capabilities = inferModelCapabilities(apiStyle, record, id); + const capabilities = + provider.id === "requesty" ? inferRequestyCapabilities(record, id) : inferModelCapabilities(apiStyle, record, id); if (capabilities.length === 0) continue; seenIds.add(id); @@ -274,6 +280,19 @@ function inferOpenRouterCapabilities(record: Record, id: string return normalizeAiModelCapabilities(capabilities, []); } +// Infers capabilities from Requesty's supports_* model metadata. +function inferRequestyCapabilities(record: Record, id: string): AiModelCapability[] { + if (typeof record.api === "string" && record.api !== "chat") return []; + + const capabilities = inferCapabilitiesFromId(id); + if (capabilities.length === 0) return []; + if (record.supports_vision === true) capabilities.push("vision"); + if (record.supports_reasoning === true) capabilities.push("reasoning"); + if (record.supports_tool_calling === true) capabilities.push("tools"); + + return normalizeAiModelCapabilities(capabilities, []); +} + // Infers capabilities from a model id when provider metadata is sparse. function inferCapabilitiesFromId(id: string): AiModelCapability[] { const normalizedId = id.toLowerCase(); diff --git a/packages/providers/src/settings.test.ts b/packages/providers/src/settings.test.ts index e6a33d75..ba8feadf 100644 --- a/packages/providers/src/settings.test.ts +++ b/packages/providers/src/settings.test.ts @@ -30,7 +30,7 @@ describe("AI provider settings", () => { const { providers: savedProviders, ...selections } = saved; expect(settings).toMatchObject(selections); expect(settings.providers[0]).toEqual(savedProviders[0]); - expect(settings.providers).toHaveLength(2); + expect(settings.providers).toHaveLength(3); expect(settings.providers[1]).toMatchObject({ apiKey: "", apiStyle: "openai-compatible", @@ -61,7 +61,7 @@ describe("AI provider settings", () => { }] }; const settings = normalizeAiSettings(saved); - expect(settings.providers).toHaveLength(1); + expect(settings.providers.filter((provider) => provider.id === "orcarouter")).toHaveLength(1); expect(settings.providers[0]).toMatchObject(saved.providers[0]); expect(settings.defaultModelId).toBe("mock/model"); }); @@ -76,6 +76,58 @@ describe("AI provider settings", () => { expect(first?.models.some((model) => model.enabled && model.id === first.defaultModelId)).toBe(true); }); + it("adds disabled Requesty to existing settings without changing saved selections", () => { + const saved = { + defaultProviderId: "custom-provider-1", + defaultModelId: "mock-writer", + providers: [{ + apiKey: "mock-key", + baseUrl: "https://gateway.example.test/v1", + defaultModelId: "mock-writer", + enabled: true, + id: "custom-provider-1", + models: [{ capabilities: ["text"], enabled: true, id: "mock-writer", name: "Mock Writer" }], + name: "Mock Gateway", + type: "openai-compatible" as const + }] + }; + + const settings = normalizeAiSettings(saved); + expect(settings.defaultProviderId).toBe("custom-provider-1"); + expect(settings.defaultModelId).toBe("mock-writer"); + expect(settings.providers.find((provider) => provider.id === "requesty")).toMatchObject({ + apiKey: "", + apiStyle: "openai-compatible", + baseUrl: "https://router.requesty.ai/v1", + defaultModelId: "gpt-5.6-sol", + enabled: false, + name: "Requesty", + type: "openai-compatible" + }); + expect(normalizeAiSettings(settings)).toEqual(settings); + }); + + it("preserves saved Requesty configuration without duplicating or resetting it", () => { + const saved = { + defaultProviderId: "requesty", + defaultModelId: "mock/model", + providers: [{ + apiKey: "mock-requesty-key", + baseUrl: "https://router.eu.requesty.ai/v1", + defaultModelId: "mock/model", + enabled: true, + id: "requesty", + models: [{ capabilities: ["text"], enabled: true, id: "mock/model", name: "Mock model" }], + name: "My router", + type: "openai-compatible" + }] + }; + const settings = normalizeAiSettings(saved); + expect(settings.providers.filter((provider) => provider.id === "requesty")).toHaveLength(1); + expect(settings.providers.find((provider) => provider.id === "requesty")).toMatchObject(saved.providers[0]); + expect(settings.defaultModelId).toBe("mock/model"); + }); + it("normalizes legacy provider types into the new request API styles", () => { const settings = normalizeAiSettings({ defaultProviderId: "openai", diff --git a/packages/providers/src/settings.ts b/packages/providers/src/settings.ts index 718f0e91..c8a77b28 100644 --- a/packages/providers/src/settings.ts +++ b/packages/providers/src/settings.ts @@ -56,10 +56,12 @@ export function normalizeAiSettings(value: unknown): AiProviderSettings { const providers = value.providers.map(normalizeProvider).filter((provider): provider is AiProviderConfig => Boolean(provider)); if (providers.length === 0) return createDefaultAiSettings(); - // Existing settings predate this catalog entry; append it without replacing saved providers or selections. - const orcaRouterTemplate = defaultProviderTemplateForProviderId("orcarouter"); - if (orcaRouterTemplate && !providers.some((provider) => provider.id === orcaRouterTemplate.id)) { - providers.push(cloneProvider(orcaRouterTemplate)); + // Existing settings predate these catalog entries; append them without replacing saved providers or selections. + for (const providerId of ["orcarouter", "requesty"]) { + const template = defaultProviderTemplateForProviderId(providerId); + if (template && !providers.some((provider) => provider.id === template.id)) { + providers.push(cloneProvider(template)); + } } const defaultProviderId = diff --git a/packages/providers/src/thinking-format.test.ts b/packages/providers/src/thinking-format.test.ts index c35b5835..01ac1e66 100644 --- a/packages/providers/src/thinking-format.test.ts +++ b/packages/providers/src/thinking-format.test.ts @@ -31,6 +31,16 @@ describe("provider thinking formats", () => { } ); + it.each(["gpt-5.6-sol", "claude-sonnet-5", "gemini-3.6-flash", "openai/gpt-4o-mini"])( + "uses Requesty reasoning effort for %s without upstream-specific thinking fields", + (model) => { + const config = provider({ id: "requesty", type: "openai-compatible", baseUrl: "https://router.requesty.ai/v1" }); + expect(buildOpenAiCompatibleThinkingRequestOptions(config, model, { thinkingEnabled: true })).toEqual({ reasoning_effort: "high" }); + expect(buildOpenAiCompatibleThinkingRequestOptions(config, model, { thinkingEnabled: false })).toEqual({}); + expect(buildOpenAiCompatibleThinkingRequestOptions(config, model, {})).toEqual({}); + } + ); + it("resolves OpenAI-compatible thinking formats before request shaping", () => { expect(getOpenAiCompatibleThinkingFormat(provider({ type: "ollama" }), "llama3.3", true)).toBe("ollama-think"); expect(getOpenAiCompatibleThinkingFormat(provider({ diff --git a/packages/providers/src/thinking-format.ts b/packages/providers/src/thinking-format.ts index 455e07ef..5e1fb4a3 100644 --- a/packages/providers/src/thinking-format.ts +++ b/packages/providers/src/thinking-format.ts @@ -46,6 +46,8 @@ export function getOpenAiCompatibleThinkingFormat( // OrcaRouter translates effort itself; upstream model-name fallbacks send incompatible native fields. // Omit effort when off: the gateway does not document a portable "none" value across upstreams. if (config.id === "orcarouter") return thinkingState === "enabled" ? "reasoning-effort" : null; + // Requesty also maps reasoning_effort per upstream, so the same rule applies. + if (config.id === "requesty") return thinkingState === "enabled" ? "reasoning-effort" : null; const normalizedModel = model.toLowerCase(); if (config.type === "ollama") return "ollama-think";