Skip to content
Merged
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
7 changes: 4 additions & 3 deletions docs/website/.vitepress/components/home/HomeAi.vue
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,10 @@ const workflows = [
<div>
<h3>Your choice of model.</h3>
<p>
Connect OpenAI, Anthropic, Gemini, DeepSeek, Mistral, or xAI with your
own API key. Or use a model running on your computer with Ollama or LM
Studio. Cloud API usage is billed by your provider.
Connect OpenAI, Anthropic, Gemini, DeepSeek, Mistral, xAI, or
OpenRouter with your own API key. Or use a model running on your
computer with Ollama or LM Studio. Cloud API usage is billed by your
provider.
</p>
</div>
<div>
Expand Down
1 change: 1 addition & 0 deletions docs/website/documentation/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Open **Preferences → AI assistant** and choose a provider.
| Provider | Connection |
| --- | --- |
| OpenAI, Anthropic, Google Gemini, DeepSeek, Mistral, xAI | Enter your provider API key. Requests use that provider’s API account and billing. |
| OpenRouter | Enter your OpenRouter API key. Requests use your OpenRouter account and billing. The model list includes only models with tool calling support; IDs use the `vendor/model` format, for example `anthropic/claude-sonnet-5.5`. |
| Ollama | Start the server and enter its API address, usually `http://localhost:11434/v1`. |
| LM Studio | Start its API server and enter the address, usually `http://localhost:1234/v1`. Enter a key if the server requires one. |

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"@lezer/markdown": "^1.6.3",
"@lydell/node-pty": "^1.1.0",
"@modelcontextprotocol/server": "^2.0.0",
"@openrouter/ai-sdk-provider": "^2.10.0",
"@sinclair/typebox": "^0.34.41",
"@types/d3-force": "^3.0.10",
"@types/d3-scale": "^4.0.9",
Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

218 changes: 217 additions & 1 deletion src/main/ai/__tests__/sdk.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { afterEach, expect, it, vi } from 'vitest'
import { AI_DEFAULT_URLS, aiMessageSchema } from '../../../shared/ai'
import { generateAiResponse, listAiModels, streamAiChat } from '../client'
import {
checkResponse,
generateAiResponse,
listAiModels,
streamAiChat,
} from '../client'
import { sdkMessages } from '../sdk'
import { wireResponse as response } from './fixtures/aiWire'

Expand All @@ -10,6 +15,7 @@ const providers = [
'deepseek',
'mistral',
'xai',
'openrouter',
] as const
const tool = {
type: 'function',
Expand Down Expand Up @@ -65,6 +71,162 @@ it.each(providers)(
expect(fetcher.mock.calls[0][1].redirect).toBe('error')
},
)
it('openrouter completes a tool loop after unsigned reasoning deltas', async () => {
const chunk = (delta: object, finish_reason: string | null = null) => ({
id: 'msg1',
model: 'test',
created: 1,
choices: [{ index: 0, delta, finish_reason }],
})
const reasoning = (text: string) =>
chunk({
role: 'assistant',
reasoning_details: [
{ type: 'reasoning.text', text, format: 'unknown', index: 0 },
],
})
const wire = [
reasoning('Думаю'),
reasoning(' дальше'),
chunk({
tool_calls: [
{
index: 0,
id: 'call12345',
type: 'function',
function: { name: 'read_http_context', arguments: '{}' },
},
],
}),
chunk({}, 'tool_calls'),
]
.map(event => `data: ${JSON.stringify(event)}\n\n`)
.join('')
const fetcher = vi
.fn()
.mockImplementationOnce(
async () =>
new Response(`${wire}data: [DONE]\n\n`, {
headers: { 'content-type': 'text/event-stream' },
}),
)
.mockImplementationOnce(async () => response('openrouter'))
vi.stubGlobal('fetch', fetcher)
const execute = vi.fn(async () => ({ status: 200, body: { id: 42 } }))
let answer = ''
await streamAiChat(
{
provider: 'openrouter',
baseURL: AI_DEFAULT_URLS.openrouter,
model: 'test',
apiKey: 'synthetic',
},
[{ role: 'user', content: 'Разбери ответ' }],
new AbortController().signal,
(text) => {
answer += text
},
undefined,
undefined,
1,
0,
undefined,
undefined,
{ tools: [tool], execute, remaining: 3 },
)
expect(answer).toBe('Ответ 😀')
expect(execute).toHaveBeenCalledExactlyOnceWith('read_http_context', '{}')
const second = JSON.stringify(JSON.parse(fetcher.mock.calls[1][1].body))
expect(second).toContain('reasoning_details')
expect(second).toContain('Думаю дальше')
})
it('openrouter keeps unsigned reasoning when the answer has no tool call', async () => {
const chunk = (delta: object, finish_reason: string | null = null) => ({
id: 'msg1',
model: 'test',
created: 1,
choices: [{ index: 0, delta, finish_reason }],
})
const reasoning = (text: string) =>
chunk({
role: 'assistant',
reasoning_details: [
{ type: 'reasoning.text', text, format: 'unknown', index: 0 },
],
})
const wire = [
reasoning('Думаю'),
reasoning(' дальше'),
chunk({ content: 'Ответ' }),
chunk({}, 'stop'),
]
.map(event => `data: ${JSON.stringify(event)}\n\n`)
.join('')
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(`${wire}data: [DONE]\n\n`, {
headers: { 'content-type': 'text/event-stream' },
}),
),
)
const result = await generateAiResponse(
{
provider: 'openrouter',
baseURL: AI_DEFAULT_URLS.openrouter,
model: 'test',
apiKey: 'synthetic',
},
{
instructions: 'test',
messages: [{ role: 'user', content: 'hello' }],
signal: new AbortController().signal,
onDelta: () => {},
operation: 'test',
},
)
expect(result.answer).toBe('Ответ')
expect(result.calls).toEqual([])
expect(JSON.stringify(result.replay)).toContain('Думаю дальше')
})
it.each(providers)(
'%s maps exhausted credits to the quota error',
async (provider) => {
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(
JSON.stringify({ error: { message: 'Insufficient credits' } }),
{ status: 402, headers: { 'content-type': 'application/json' } },
),
),
)
await expect(
generateAiResponse(
{
provider,
baseURL: AI_DEFAULT_URLS[provider],
model: 'test',
apiKey: 'synthetic',
},
{
instructions: 'test',
messages: [{ role: 'user', content: 'hello' }],
signal: new AbortController().signal,
onDelta: () => {},
operation: 'test',
},
),
).rejects.toMatchObject({ code: 'rateLimit' })
},
)
it('maps exhausted credits to the quota error for direct HTTP providers', async () => {
await expect(
checkResponse(new Response('{}', { status: 402 })),
).rejects.toMatchObject({ code: 'rateLimit' })
})
it.each(providers)(
'%s maps authentication failure without retry or leaking body',
async (provider) => {
Expand Down Expand Up @@ -220,6 +382,60 @@ it('lists Anthropic models using native authentication and pagination', async ()
'2023-06-01',
)
})
it('lists only tool-capable OpenRouter chat models', async () => {
const fetcher = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ data: {} })))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: [{ id: 'b/model' }, { id: 'a/model' }, { id: 'a/model:batch' }],
}),
),
)
vi.stubGlobal('fetch', fetcher)
expect(
await listAiModels(
{
provider: 'openrouter',
baseURL: AI_DEFAULT_URLS.openrouter,
model: '',
apiKey: 'synthetic',
},
new AbortController().signal,
),
).toEqual(['a/model', 'b/model'])
expect(fetcher.mock.calls[0][0]).toBe('https://openrouter.ai/api/v1/key')
expect(fetcher.mock.calls[0][1].headers.Authorization).toBe(
'Bearer synthetic',
)
expect(fetcher.mock.calls[1][0]).toBe(
'https://openrouter.ai/api/v1/models?supported_parameters=tools',
)
})
it('rejects an invalid OpenRouter key before listing public models', async () => {
const fetcher = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({ error: { message: 'User not found.', code: 401 } }),
{ status: 401 },
),
)
vi.stubGlobal('fetch', fetcher)
await expect(
listAiModels(
{
provider: 'openrouter',
baseURL: AI_DEFAULT_URLS.openrouter,
model: '',
apiKey: 'invalid',
},
new AbortController().signal,
),
).rejects.toMatchObject({ code: 'authentication' })
expect(fetcher).toHaveBeenCalledOnce()
})
it.each(providers)(
'%s rejects an empty/incomplete stream',
async (provider) => {
Expand Down
1 change: 1 addition & 0 deletions src/main/ai/__tests__/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ it('pins cloud credentials to official provider endpoints', async () => {
'deepseek',
'mistral',
'xai',
'openrouter',
] as const) {
expect(() =>
canonicalAiURL(provider, 'https://unrelated.invalid/v1'),
Expand Down
18 changes: 17 additions & 1 deletion src/main/ai/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export async function checkResponse(response: Response) {
const code
= response.status === 401 || response.status === 403
? 'authentication'
: response.status === 429
: response.status === 429 || response.status === 402
? 'rateLimit'
: response.status === 404
? 'modelUnavailable'
Expand Down Expand Up @@ -205,7 +205,20 @@ export async function listAiModels(
): Promise<string[]> {
if (page >= 20)
throw new AiError('outputLimit')
// OpenRouter serves /models without authentication, so it cannot confirm the key.
if (connection.provider === 'openrouter' && !cursor) {
const keyResponse = await fetch(`${connection.baseURL}/key`, {
headers: headers(connection),
redirect: 'error',
signal,
})
await checkResponse(keyResponse)
await keyResponse.body?.cancel().catch(() => {})
}
const url = new URL(`${connection.baseURL}/models`)
// The full OpenRouter catalog includes models that cannot run the assistant's tools.
if (connection.provider === 'openrouter')
url.searchParams.set('supported_parameters', 'tools')
if (cursor) {
url.searchParams.set(
connection.provider === 'gemini' ? 'pageToken' : 'after_id',
Expand Down Expand Up @@ -249,6 +262,9 @@ export async function listAiModels(
else {
const parsed = modelsSchema.parse(JSON.parse(body))
names = parsed.data.map(model => model.id)
// Batch variants are not served by chat completions and fail with 404.
if (connection.provider === 'openrouter')
names = names.filter(name => !name.endsWith(':batch'))
if (connection.provider === 'anthropic' && parsed.has_more) {
if (!parsed.last_id)
throw new AiError('invalidResponse')
Expand Down
Loading