Skip to content
Draft
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
43 changes: 40 additions & 3 deletions apps/desktop/src/components/profileSettings/AiSettings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import AuthorizationBanner from "$components/AuthorizationBanner.svelte";
import SettingsSection from "$components/SettingsSection.svelte";
import { AISecretHandle, AI_SERVICE, GitAIConfigKey, KeyOption } from "$lib/ai/service";
import { OpenAIModelName, AnthropicModelName, ModelKind } from "$lib/ai/types";
import { OpenAIModelName, AnthropicModelName, ModelKind, type OpenRouterModelName } from "$lib/ai/types";
import { GIT_CONFIG_SERVICE } from "$lib/config/gitConfigService";
import { SECRET_SERVICE } from "$lib/secrets/secretsService";
import { USER_SERVICE } from "$lib/user/userService";
Expand Down Expand Up @@ -44,6 +44,8 @@
let ollamaModel: string | undefined = $state();
let lmStudioEndpoint: string | undefined = $state();
let lmStudioModel: string | undefined = $state();
let openRouterKey: string | undefined = $state();
let openRouterModel: string | undefined = $state();

async function setConfiguration(key: GitAIConfigKey, value: string | undefined) {
if (!initialized) return;
Expand Down Expand Up @@ -75,6 +77,9 @@
lmStudioEndpoint = await aiService.getLMStudioEndpoint();
lmStudioModel = await aiService.getLMStudioModelName();

openRouterKey = await aiService.getOpenRouterKey();
openRouterModel = await aiService.getOpenRouterModelName();

// Ensure reactive declarations have finished running before we set initialized to true
await tick();

Expand Down Expand Up @@ -191,6 +196,12 @@
run(() => {
setConfiguration(GitAIConfigKey.LMStudioModelName, lmStudioModel);
});
run(() => {
setSecret(AISecretHandle.OpenRouterKey, openRouterKey);
});
run(() => {
setConfiguration(GitAIConfigKey.OpenRouterModelName, openRouterModel);
});
run(() => {
if (form) form.modelKind.value = modelKind;
});
Expand All @@ -204,8 +215,8 @@
{/snippet}

<p class="text-13 text-body ai-settings__about-text">
GitButler supports multiple AI providers: OpenAI and Anthropic (via API or your own key), plus
local models through Ollama and LM Studio.
GitButler supports multiple AI providers: OpenAI and Anthropic (via API or your own key),
OpenRouter for access to hundreds of models, plus local models through Ollama and LM Studio.
</p>

<CardGroup>
Expand Down Expand Up @@ -417,6 +428,32 @@
</CardGroup.Item>
{/if}

<CardGroup.Item labelFor="openrouter">
{#snippet title()}
OpenRouter
{/snippet}
{#snippet actions()}
<RadioButton name="modelKind" id="openrouter" value={ModelKind.OpenRouter} />
{/snippet}
</CardGroup.Item>
{#if modelKind === ModelKind.OpenRouter}
<CardGroup.Item>
<Textbox
label="API key"
type="password"
bind:value={openRouterKey}
required
placeholder="sk-or-..."
/>

<Textbox
label="Model"
bind:value={openRouterModel}
placeholder="openai/gpt-4.1-mini"
/>
</CardGroup.Item>
{/if}

<CardGroup.Item>
<AiCredentialCheck />
</CardGroup.Item>
Expand Down
16 changes: 13 additions & 3 deletions apps/desktop/src/lib/ai/openAIClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import {
SHORT_DEFAULT_PR_TEMPLATE,
} from "$lib/ai/prompts";
import OpenAI from "openai";
import type { OpenAIModelName, Prompt, AIClient, AIEvalOptions } from "$lib/ai/types";
import type {
OpenAIModelName,
OpenRouterModelName,
Prompt,
AIClient,
AIEvalOptions,
} from "$lib/ai/types";

const DEFAULT_MAX_TOKENS = 1024;

Expand All @@ -15,9 +21,13 @@ export class OpenAIClient implements AIClient {

private client: OpenAI;
private openAIKey: string;
private modelName: OpenAIModelName;
private modelName: OpenAIModelName | OpenRouterModelName;

constructor(openAIKey: string, modelName: OpenAIModelName, baseURL: string | undefined) {
constructor(
openAIKey: string,
modelName: OpenAIModelName | OpenRouterModelName,
baseURL: string | undefined,
) {
this.openAIKey = openAIKey;
this.modelName = modelName;
this.client = new OpenAI({ apiKey: openAIKey, dangerouslyAllowBrowser: true, baseURL });
Expand Down
33 changes: 33 additions & 0 deletions apps/desktop/src/lib/ai/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const defaultGitConfig = Object.freeze({
const defaultSecretsConfig = Object.freeze({
[AISecretHandle.AnthropicKey]: undefined,
[AISecretHandle.OpenAIKey]: undefined,
[AISecretHandle.OpenRouterKey]: undefined,
});

class DummyGitConfigService extends GitConfigService {
Expand Down Expand Up @@ -248,6 +249,38 @@ describe("AIService", () => {
),
);
});

test("When ai provider is OpenRouter, When an API key is present. It returns OpenAIClient", async () => {
const gitConfig = new DummyGitConfigService({
...defaultGitConfig,
[GitAIConfigKey.ModelProvider]: ModelKind.OpenRouter,
});
const secretsService = new DummySecretsService({
[AISecretHandle.OpenRouterKey]: "sk-or-test-key",
});
const tokenMemoryService = new TokenMemoryService();
const fetchMock = vi.fn();
const cloud = new HttpClient(fetchMock, "https://www.example.com", tokenMemoryService.token);
const aiService = new AIService(gitConfig, secretsService, cloud, tokenMemoryService);

expect(await aiService.buildClient()).toBeInstanceOf(OpenAIClient);
});

test("When ai provider is OpenRouter, When an API key is blank. It throws an error", async () => {
const gitConfig = new DummyGitConfigService({
...defaultGitConfig,
[GitAIConfigKey.ModelProvider]: ModelKind.OpenRouter,
});
const secretsService = new DummySecretsService();
const tokenMemoryService = new TokenMemoryService();
const fetchMock = vi.fn();
const cloud = new HttpClient(fetchMock, "https://www.example.com", tokenMemoryService.token);
const aiService = new AIService(gitConfig, secretsService, cloud, tokenMemoryService);

await expect(aiService.buildClient.bind(aiService)).rejects.toThrowError(
new Error("When using OpenRouter, you must provide a valid API key"),
);
});
});

describe.concurrent("#summarizeCommit", async () => {
Expand Down
36 changes: 35 additions & 1 deletion apps/desktop/src/lib/ai/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
AnthropicModelName,
ModelKind,
MessageRole,
type OpenRouterModelName,
type Prompt,
type PromptMessage,
type FileChange,
Expand All @@ -47,6 +48,7 @@ export enum KeyOption {
export enum AISecretHandle {
OpenAIKey = "aiOpenAIKey",
AnthropicKey = "aiAnthropicKey",
OpenRouterKey = "aiOpenRouterKey",
}

export enum GitAIConfigKey {
Expand All @@ -61,6 +63,7 @@ export enum GitAIConfigKey {
OllamaModelName = "gitbutler.aiOllamaModelName",
LMStudioEndpoint = "gitbutler.aiLMStudioEndpoint",
LMStudioModelName = "gitbutler.aiLMStudioModelName",
OpenRouterModelName = "gitbutler.aiOpenRouterModelName",
}

interface BaseAIServiceOpts {
Expand Down Expand Up @@ -228,6 +231,17 @@ export class AIService {
);
}

async getOpenRouterKey() {
return await this.secretsService.get(AISecretHandle.OpenRouterKey);
}

async getOpenRouterModelName() {
return await this.gitConfig.getWithDefault<string>(
GitAIConfigKey.OpenRouterModelName,
"openai/gpt-4.1-mini",
);
}

async usingGitButlerAPI() {
const modelKind = await this.getModelKind();
const openAIKeyOption = await this.getOpenAIKeyOption();
Expand Down Expand Up @@ -258,12 +272,15 @@ export class AIService {
modelKind === ModelKind.Ollama && !!ollamaEndpoint && !!ollamaModelName;
const lmStudioActiveAndEndpointProvided =
modelKind === ModelKind.LMStudio && !!lmStudioEndpoint && !!lmStudioModelName;
const openRouterActiveAndKeyProvided =
modelKind === ModelKind.OpenRouter && !!(await this.getOpenRouterKey());

return (
openAIActiveAndKeyProvided ||
anthropicActiveAndKeyProvided ||
ollamaActiveAndEndpointProvided ||
lmStudioActiveAndEndpointProvided
lmStudioActiveAndEndpointProvided ||
openRouterActiveAndKeyProvided
);
}

Expand Down Expand Up @@ -334,6 +351,23 @@ export class AIService {
return new AnthropicAIClient(anthropicKey, anthropicModelName);
}

if (modelKind === ModelKind.OpenRouter) {
const openRouterKey = await this.getOpenRouterKey();
const openRouterModelName = await this.getOpenRouterModelName();

if (!openRouterKey) {
throw new Error(
"When using OpenRouter, you must provide a valid API key",
);
}

return new OpenAIClient(
openRouterKey,
openRouterModelName as OpenRouterModelName,
Copy link
Contributor

Choose a reason for hiding this comment

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

P2 Badge Validate OpenRouter model name before constructing client

buildClient() force-casts openRouterModelName to OpenRouterModelName without any runtime check, while the settings UI accepts arbitrary free-form text for that field. If a user saves an invalid value (for example missing the provider/model format), configuration still appears valid and failures only surface as downstream OpenRouter API errors during generation. Add a format check (or fallback to the default model) before creating OpenAIClient so misconfiguration is caught deterministically.

Useful? React with 👍 / 👎.

"https://openrouter.ai/api/v1",
);
}

return undefined;
}

Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/lib/ai/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ export enum ModelKind {
Anthropic = "anthropic",
Ollama = "ollama",
LMStudio = "lmstudio",
OpenRouter = "openrouter",
}

// OpenRouter model names follow the `provider/model` format (e.g. `openai/gpt-4.1-mini`)
export type OpenRouterModelName = `${string}/${string}`;

// https://platform.openai.com/docs/models
export enum OpenAIModelName {
O3mini = "o3-mini",
Expand Down
35 changes: 35 additions & 0 deletions crates/but-llm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mod lmstudio;
mod ollama;
mod openai;
mod openai_utils;
mod openrouter;

use std::sync::Arc;

Expand All @@ -24,6 +25,7 @@ pub enum LLMProviderKind {
Anthropic,
Ollama,
LMStudio,
OpenRouter,
}

impl LLMProviderKind {
Expand All @@ -33,6 +35,7 @@ impl LLMProviderKind {
"anthropic" => Some(LLMProviderKind::Anthropic),
"ollama" => Some(LLMProviderKind::Ollama),
"lmstudio" => Some(LLMProviderKind::LMStudio),
"openrouter" => Some(LLMProviderKind::OpenRouter),
_ => None,
}
}
Expand All @@ -44,6 +47,7 @@ pub enum LLMProviderConfig {
Anthropic(Option<anthropic::CredentialsKind>),
Ollama(Option<ollama::OllamaConfig>),
LMStudio(Option<lmstudio::LMStudioConfig>),
OpenRouter(Option<openrouter::OpenRouterConfig>),
}

#[derive(Debug, Clone)]
Expand All @@ -52,6 +56,7 @@ pub enum LLMClientType {
Anthropic(Arc<anthropic::AnthropicProvider>),
Ollama(Arc<ollama::OllamaProvider>),
LMStudio(Arc<lmstudio::LMStudioProvider>),
OpenRouter(Arc<openrouter::OpenRouterProvider>),
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -95,6 +100,10 @@ impl LLMProvider {
}
LLMProviderConfig::LMStudio(config) => lmstudio::LMStudioProvider::with(config, None)
.map(|p| LLMClientType::LMStudio(Arc::new(p)))?,
LLMProviderConfig::OpenRouter(config) => {
openrouter::OpenRouterProvider::with(config, None)
.map(|p| LLMClientType::OpenRouter(Arc::new(p)))?
}
};
Some(Self { client })
}
Expand Down Expand Up @@ -151,6 +160,12 @@ impl LLMProvider {
client: LLMClientType::LMStudio(Arc::new(client)),
})
}
Some(LLMProviderKind::OpenRouter) => {
let client = openrouter::OpenRouterProvider::from_git_config(config)?;
Some(Self {
client: LLMClientType::OpenRouter(Arc::new(client)),
})
}
None => None,
}
}
Expand All @@ -175,6 +190,7 @@ impl LLMProvider {
LLMClientType::Anthropic(client) => client.model(),
LLMClientType::Ollama(client) => client.model(),
LLMClientType::LMStudio(client) => client.model(),
LLMClientType::OpenRouter(client) => client.model(),
}
}

Expand Down Expand Up @@ -291,6 +307,13 @@ impl LLMProvider {
model,
on_token,
),
LLMClientType::OpenRouter(client) => client.tool_calling_loop_stream(
system_message,
chat_messages,
tool_set,
model,
on_token,
),
}
}

Expand Down Expand Up @@ -338,6 +361,9 @@ impl LLMProvider {
LLMClientType::LMStudio(client) => {
client.tool_calling_loop(system_message, chat_messages, tool_set, model)
}
LLMClientType::OpenRouter(client) => {
client.tool_calling_loop(system_message, chat_messages, tool_set, model)
}
}
}

Expand Down Expand Up @@ -379,6 +405,9 @@ impl LLMProvider {
LLMClientType::LMStudio(client) => {
client.stream_response(system_message, chat_messages, model, on_token)
}
LLMClientType::OpenRouter(client) => {
client.stream_response(system_message, chat_messages, model, on_token)
}
}
}

Expand Down Expand Up @@ -428,6 +457,9 @@ impl LLMProvider {
LLMClientType::LMStudio(client) => {
client.structured_output::<T>(system_message, chat_messages, model)
}
LLMClientType::OpenRouter(client) => {
client.structured_output::<T>(system_message, chat_messages, model)
}
}
}

Expand Down Expand Up @@ -463,6 +495,9 @@ impl LLMProvider {
LLMClientType::LMStudio(client) => {
client.response(system_message, chat_messages, model)
}
LLMClientType::OpenRouter(client) => {
client.response(system_message, chat_messages, model)
}
}
}
}
Loading
Loading