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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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——结果数量和正文长度均可配置。

Expand Down
28 changes: 28 additions & 0 deletions packages/ai/src/agent/chat-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
Expand Down
36 changes: 36 additions & 0 deletions packages/ai/src/agent/chat-completion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/assets/provider-logos/requesty.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions packages/app/src/components/AiProviderBadge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<AiProviderBadge provider={provider({ id: "requesty", name: "Requesty" })} translate={(key) => 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(<AiProviderBadge provider={provider()} translate={(key) => key} />);

Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/components/AiProviderBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -34,6 +35,7 @@ const providerLogoByType: Partial<Record<AiProviderApiStyle, string>> = {

const providerLogoById: Partial<Record<string, string>> = {
orcarouter: orcaRouterLogo,
requesty: requestyLogo,
"aliyun-bailian": qwenLogo,
"volcengine": volcengineLogo,
"xiaomi-mimo": xiaomiMimoLogo
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/lib/settings/ai-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand Down
17 changes: 17 additions & 0 deletions packages/providers/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions packages/providers/src/provider-capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/providers/src/provider-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const defaultByApiStyle: Record<AiProviderApiStyle, ProviderCapabilities> = {

const providerOverrides: Record<string, Partial<ProviderCapabilities>> = {
orcarouter: { modelList: true },
requesty: { modelList: true },
"aliyun-bailian": { modelList: true, toolCalling: true },
volcengine: { modelList: true, toolCalling: true },
"xiaomi-mimo": { modelList: true, toolCalling: true }
Expand Down
26 changes: 26 additions & 0 deletions packages/providers/src/requests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
23 changes: 21 additions & 2 deletions packages/providers/src/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ const endpointByRequestStyle: Record<AiProviderRequestStyle, ProviderEndpoint |
"openai-responses": endpointByApiStyle.openai
};

// Requesty lists its curated managed models here; any id from the full /models catalog still works in chat.
const modelsPathByProviderId: Partial<Record<string, string>> = {
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.");
Expand All @@ -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)
};
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -274,6 +280,19 @@ function inferOpenRouterCapabilities(record: Record<string, unknown>, id: string
return normalizeAiModelCapabilities(capabilities, []);
}

// Infers capabilities from Requesty's supports_* model metadata.
function inferRequestyCapabilities(record: Record<string, unknown>, id: string): AiModelCapability[] {
if (typeof record.api === "string" && record.api !== "chat") return [];

const capabilities = inferCapabilitiesFromId(id);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Respect explicit false capability metadata

Starting from inferCapabilitiesFromId(id) adds capabilities before reading Requesty's metadata, and the subsequent checks only append capabilities when a field is true. As a result, an explicit false never removes a capability inferred from the model name. For example, the public managed catalog reports supports_vision: false for gpt-5-mini@eu, but this parser still marks it as vision-capable, allowing the app to send unsupported image inputs.

A synthetic chat model named mock-gpt-5-text-only with all supports_* fields set to false is parsed as ["text", "vision", "reasoning", "tools", "web"] instead of ["text"].

Please prioritize explicit boolean metadata and use name-based inference only when the relevant metadata is absent. Also ensure default-model capability enrichment does not reintroduce capabilities explicitly denied by the catalog. Add a regression case where the model name matches a heuristic but the corresponding metadata is false.

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();
Expand Down
56 changes: 54 additions & 2 deletions packages/providers/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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");
});
Expand All @@ -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",
Expand Down
10 changes: 6 additions & 4 deletions packages/providers/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
10 changes: 10 additions & 0 deletions packages/providers/src/thinking-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions packages/providers/src/thinking-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Distinguish explicitly disabled thinking from gateway defaults

For Requesty, thinkingEnabled: false returns null, so the generated options are {}, exactly the same as when thinking is unspecified. Models that reason by default therefore receive no instruction to disable or minimize reasoning, despite the UI setting being off.

Unlike the OrcaRouter assumption in the preceding comment, Requesty documents reasoning_effort: "none" / "min" as supported values that disable reasoning or select its minimum supported effort: https://docs.requesty.ai/features/reasoning.

Please preserve the distinction between unspecified (omit the parameter) and explicitly disabled (send the documented minimum/disable value), and cover the effective outgoing request, including the SDK route if applicable. The current test expecting {} for the disabled state locks in this loss of intent.


const normalizedModel = model.toLowerCase();
if (config.type === "ollama") return "ollama-think";
Expand Down
Loading