mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-09-11 12:25:42 +03:00
Extract separate service for secrets
- add `buildContext` for getting/setting contexts by types - config -> secret migration attempted if secret not found
This commit is contained in:
parent
05506f49fa
commit
7f618fd248
@ -2,7 +2,7 @@ import { AnthropicAIClient } from '$lib/ai/anthropicClient';
|
||||
import { ButlerAIClient } from '$lib/ai/butlerClient';
|
||||
import { OpenAIClient } from '$lib/ai/openAIClient';
|
||||
import { SHORT_DEFAULT_BRANCH_TEMPLATE, SHORT_DEFAULT_COMMIT_TEMPLATE } from '$lib/ai/prompts';
|
||||
import { AIService, GitAIConfigKey, KeyOption, buildDiff } from '$lib/ai/service';
|
||||
import { AISecretHandle, AIService, GitAIConfigKey, KeyOption, buildDiff } from '$lib/ai/service';
|
||||
import {
|
||||
AnthropicModelName,
|
||||
ModelKind,
|
||||
@ -16,17 +16,21 @@ import { Hunk } from '$lib/vbranches/types';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { expect, test, describe, vi } from 'vitest';
|
||||
import type { GbConfig, GitConfigService } from '$lib/backend/gitConfigService';
|
||||
import type { SecretsService } from '$lib/secrets/secretsService';
|
||||
|
||||
const defaultGitConfig = Object.freeze({
|
||||
[GitAIConfigKey.ModelProvider]: ModelKind.OpenAI,
|
||||
[GitAIConfigKey.OpenAIKeyOption]: KeyOption.ButlerAPI,
|
||||
[GitAIConfigKey.OpenAIKey]: undefined,
|
||||
[GitAIConfigKey.OpenAIModelName]: OpenAIModelName.GPT35Turbo,
|
||||
[GitAIConfigKey.AnthropicKeyOption]: KeyOption.ButlerAPI,
|
||||
[GitAIConfigKey.AnthropicKey]: undefined,
|
||||
[GitAIConfigKey.AnthropicModelName]: AnthropicModelName.Haiku
|
||||
});
|
||||
|
||||
const defaultSecretsConfig = Object.freeze({
|
||||
[AISecretHandle.AnthropicKey]: undefined,
|
||||
[AISecretHandle.OpenAIKey]: undefined
|
||||
});
|
||||
|
||||
class DummyGitConfigService implements GitConfigService {
|
||||
constructor(private config: { [index: string]: string | undefined }) {}
|
||||
async getGbConfig(_projectId: string): Promise<GbConfig> {
|
||||
@ -46,6 +50,24 @@ class DummyGitConfigService implements GitConfigService {
|
||||
async set<T extends string>(key: string, value: T): Promise<T | undefined> {
|
||||
return (this.config[key] = value);
|
||||
}
|
||||
async remove(key: string): Promise<undefined> {
|
||||
delete this.config[key];
|
||||
}
|
||||
}
|
||||
|
||||
class DummySecretsService implements SecretsService {
|
||||
private config: { [index: string]: string | undefined };
|
||||
constructor(config?: { [index: string]: string | undefined }) {
|
||||
this.config = config || {};
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | undefined> {
|
||||
return this.config[key];
|
||||
}
|
||||
|
||||
async set(handle: string, secret: string): Promise<void> {
|
||||
this.config[handle] = secret;
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
@ -108,7 +130,8 @@ const exampleHunks = [hunk1, hunk2];
|
||||
|
||||
function buildDefaultAIService() {
|
||||
const gitConfig = new DummyGitConfigService(structuredClone(defaultGitConfig));
|
||||
return new AIService(gitConfig, cloud);
|
||||
const secretsService = new DummySecretsService(structuredClone(defaultSecretsConfig));
|
||||
return new AIService(gitConfig, secretsService, cloud);
|
||||
}
|
||||
|
||||
describe.concurrent('AIService', () => {
|
||||
@ -130,10 +153,10 @@ describe.concurrent('AIService', () => {
|
||||
test('When token is bring your own, When a openAI token is present. It returns OpenAIClient', async () => {
|
||||
const gitConfig = new DummyGitConfigService({
|
||||
...defaultGitConfig,
|
||||
[GitAIConfigKey.OpenAIKeyOption]: KeyOption.BringYourOwn,
|
||||
[GitAIConfigKey.OpenAIKey]: 'sk-asdfasdf'
|
||||
[GitAIConfigKey.OpenAIKeyOption]: KeyOption.BringYourOwn
|
||||
});
|
||||
const aiService = new AIService(gitConfig, cloud);
|
||||
const secretsService = new DummySecretsService({ [AISecretHandle.OpenAIKey]: 'sk-asdfasdf' });
|
||||
const aiService = new AIService(gitConfig, secretsService, cloud);
|
||||
|
||||
expect(unwrap(await aiService.buildClient())).toBeInstanceOf(OpenAIClient);
|
||||
});
|
||||
@ -141,10 +164,10 @@ describe.concurrent('AIService', () => {
|
||||
test('When token is bring your own, When a openAI token is blank. It returns undefined', async () => {
|
||||
const gitConfig = new DummyGitConfigService({
|
||||
...defaultGitConfig,
|
||||
[GitAIConfigKey.OpenAIKeyOption]: KeyOption.BringYourOwn,
|
||||
[GitAIConfigKey.OpenAIKey]: undefined
|
||||
[GitAIConfigKey.OpenAIKeyOption]: KeyOption.BringYourOwn
|
||||
});
|
||||
const aiService = new AIService(gitConfig, cloud);
|
||||
const secretsService = new DummySecretsService();
|
||||
const aiService = new AIService(gitConfig, secretsService, cloud);
|
||||
|
||||
expect(await aiService.buildClient()).toStrictEqual(
|
||||
buildFailureFromAny(
|
||||
@ -157,10 +180,12 @@ describe.concurrent('AIService', () => {
|
||||
const gitConfig = new DummyGitConfigService({
|
||||
...defaultGitConfig,
|
||||
[GitAIConfigKey.ModelProvider]: ModelKind.Anthropic,
|
||||
[GitAIConfigKey.AnthropicKeyOption]: KeyOption.BringYourOwn,
|
||||
[GitAIConfigKey.AnthropicKey]: 'sk-ant-api03-asdfasdf'
|
||||
[GitAIConfigKey.AnthropicKeyOption]: KeyOption.BringYourOwn
|
||||
});
|
||||
const aiService = new AIService(gitConfig, cloud);
|
||||
const secretsService = new DummySecretsService({
|
||||
[AISecretHandle.AnthropicKey]: 'test-key'
|
||||
});
|
||||
const aiService = new AIService(gitConfig, secretsService, cloud);
|
||||
|
||||
expect(unwrap(await aiService.buildClient())).toBeInstanceOf(AnthropicAIClient);
|
||||
});
|
||||
@ -169,10 +194,10 @@ describe.concurrent('AIService', () => {
|
||||
const gitConfig = new DummyGitConfigService({
|
||||
...defaultGitConfig,
|
||||
[GitAIConfigKey.ModelProvider]: ModelKind.Anthropic,
|
||||
[GitAIConfigKey.AnthropicKeyOption]: KeyOption.BringYourOwn,
|
||||
[GitAIConfigKey.AnthropicKey]: undefined
|
||||
[GitAIConfigKey.AnthropicKeyOption]: KeyOption.BringYourOwn
|
||||
});
|
||||
const aiService = new AIService(gitConfig, cloud);
|
||||
const secretsService = new DummySecretsService();
|
||||
const aiService = new AIService(gitConfig, secretsService, cloud);
|
||||
|
||||
expect(await aiService.buildClient()).toStrictEqual(
|
||||
buildFailureFromAny(
|
||||
|
@ -14,12 +14,12 @@ import {
|
||||
MessageRole,
|
||||
type Prompt
|
||||
} from '$lib/ai/types';
|
||||
import { invoke } from '$lib/backend/ipc';
|
||||
import { buildFailureFromAny, isFailure, ok, type Result } from '$lib/result';
|
||||
import { splitMessage } from '$lib/utils/commitMessage';
|
||||
import OpenAI from 'openai';
|
||||
import type { GitConfigService } from '$lib/backend/gitConfigService';
|
||||
import type { HttpClient } from '$lib/backend/httpClient';
|
||||
import type { SecretsService } from '$lib/secrets/secretsService';
|
||||
import type { Hunk } from '$lib/vbranches/types';
|
||||
|
||||
const maxDiffLengthLimitForAPI = 5000;
|
||||
@ -29,14 +29,17 @@ export enum KeyOption {
|
||||
ButlerAPI = 'butlerAPI'
|
||||
}
|
||||
|
||||
export enum AISecretHandle {
|
||||
OpenAIKey = 'aiOpenAIKey',
|
||||
AnthropicKey = 'aiAnthropicKey'
|
||||
}
|
||||
|
||||
export enum GitAIConfigKey {
|
||||
ModelProvider = 'gitbutler.aiModelProvider',
|
||||
OpenAIKeyOption = 'gitbutler.aiOpenAIKeyOption',
|
||||
OpenAIModelName = 'gitbutler.aiOpenAIModelName',
|
||||
OpenAIKey = 'gitbutler.aiOpenAIKey',
|
||||
AnthropicKeyOption = 'gitbutler.aiAnthropicKeyOption',
|
||||
AnthropicModelName = 'gitbutler.aiAnthropicModelName',
|
||||
AnthropicKey = 'gitbutler.aiAnthropicKey',
|
||||
DiffLengthLimit = 'gitbutler.diffLengthLimit',
|
||||
OllamaEndpoint = 'gitbutler.aiOllamaEndpoint',
|
||||
OllamaModelName = 'gitbutler.aiOllamaModelName'
|
||||
@ -73,6 +76,7 @@ function shuffle<T>(items: T[]): T[] {
|
||||
export class AIService {
|
||||
constructor(
|
||||
private gitConfig: GitConfigService,
|
||||
private secretsService: SecretsService,
|
||||
private cloud: HttpClient
|
||||
) {}
|
||||
|
||||
@ -91,17 +95,7 @@ export class AIService {
|
||||
}
|
||||
|
||||
async getOpenAIKey() {
|
||||
const secretInConfig = await this.gitConfig.get(GitAIConfigKey.OpenAIKey);
|
||||
if (secretInConfig !== undefined) {
|
||||
await invoke('secret_set_global', {
|
||||
handle: 'aiOpenAIKey',
|
||||
secret: secretInConfig
|
||||
});
|
||||
await this.gitConfig.remove(GitAIConfigKey.OpenAIKey);
|
||||
return secretInConfig;
|
||||
} else {
|
||||
return await invoke('secret_get_global', { handle: 'aiOpenAIKey' });
|
||||
}
|
||||
return await this.secretsService.get(AISecretHandle.OpenAIKey);
|
||||
}
|
||||
|
||||
async getOpenAIModleName() {
|
||||
@ -119,17 +113,7 @@ export class AIService {
|
||||
}
|
||||
|
||||
async getAnthropicKey() {
|
||||
const secretInConfig = await this.gitConfig.get(GitAIConfigKey.AnthropicKey);
|
||||
if (secretInConfig !== undefined) {
|
||||
await invoke('secret_set_global', {
|
||||
handle: 'aiAnthropicKey',
|
||||
secret: secretInConfig
|
||||
});
|
||||
await this.gitConfig.remove(GitAIConfigKey.AnthropicKey);
|
||||
return secretInConfig;
|
||||
} else {
|
||||
return await invoke('secret_get_global', { handle: 'aiAnthropicKey' });
|
||||
}
|
||||
return await this.secretsService.get(AISecretHandle.AnthropicKey);
|
||||
}
|
||||
|
||||
async getAnthropicModelName() {
|
||||
|
57
app/src/lib/secrets/secretsService.ts
Normal file
57
app/src/lib/secrets/secretsService.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { AISecretHandle } from '$lib/ai/service';
|
||||
import { invoke } from '$lib/backend/ipc';
|
||||
import { buildContext } from '$lib/utils/context';
|
||||
import type { GitConfigService } from '$lib/backend/gitConfigService';
|
||||
|
||||
const MIGRATION_HANDLES = [
|
||||
AISecretHandle.AnthropicKey.toString(),
|
||||
AISecretHandle.OpenAIKey.toString()
|
||||
];
|
||||
|
||||
export type SecretsService = {
|
||||
get(handle: string): Promise<string | undefined>;
|
||||
set(handle: string, secret: string): Promise<void>;
|
||||
};
|
||||
|
||||
export const [getSecretsService, setSecretsService] =
|
||||
buildContext<SecretsService>('secretsService');
|
||||
|
||||
export class RustSecretService implements SecretsService {
|
||||
constructor(private gitConfigService: GitConfigService) {}
|
||||
|
||||
async get(handle: string) {
|
||||
console.warn('getting ', handle);
|
||||
const secret = await invoke<string>('secret_get_global', { handle });
|
||||
if (secret) return secret;
|
||||
|
||||
console.warn('migrating', handle);
|
||||
if (MIGRATION_HANDLES.includes(handle)) {
|
||||
const key = 'gitbutler.' + handle;
|
||||
const migratedSecret = await this.migrate(key, handle);
|
||||
if (migratedSecret !== undefined) return migratedSecret;
|
||||
}
|
||||
}
|
||||
|
||||
async set(handle: string, secret: string) {
|
||||
await invoke('secret_set_global', {
|
||||
handle,
|
||||
secret
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates a specific key from git config to secrets.
|
||||
*
|
||||
* TODO: Remove this code and the dependency on GitConfigService in the future.
|
||||
*/
|
||||
private async migrate(key: string, handle: string): Promise<string | undefined> {
|
||||
const secretInConfig = await this.gitConfigService.get(key);
|
||||
if (secretInConfig === undefined) return;
|
||||
|
||||
this.set(handle, secretInConfig);
|
||||
await this.gitConfigService.remove(key);
|
||||
|
||||
console.warn(`Migrated Git config "${key}" to secret store.`);
|
||||
return secretInConfig;
|
||||
}
|
||||
}
|
@ -100,3 +100,15 @@ export function getContextStoreBySymbol<T, S extends Readable<T> = Readable<T>>(
|
||||
if (!instance) throw new Error(`no instance of \`Readable<${key.toString()}[]>\` in context`);
|
||||
return instance;
|
||||
}
|
||||
|
||||
export function buildContext<T>(name: string): [() => T, (value: T | undefined) => void] {
|
||||
const identifier = Symbol(name);
|
||||
return [
|
||||
() => {
|
||||
return svelteGetContext<T>(identifier);
|
||||
},
|
||||
(value: T | undefined) => {
|
||||
setContext(identifier, value);
|
||||
}
|
||||
];
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import { UpdaterService } from '$lib/backend/updater';
|
||||
import { LineManagerFactory } from '$lib/commitLines/lineManager';
|
||||
import { GitHubService } from '$lib/github/service';
|
||||
import { RemotesService } from '$lib/remotes/service';
|
||||
import { RustSecretService } from '$lib/secrets/secretsService';
|
||||
import { UserService } from '$lib/stores/user';
|
||||
import { mockTauri } from '$lib/testing/index';
|
||||
import lscache from 'lscache';
|
||||
@ -54,7 +55,8 @@ export async function load() {
|
||||
const githubService = new GitHubService(userService.accessToken$, remoteUrl$);
|
||||
|
||||
const gitConfig = new GitConfigService();
|
||||
const aiService = new AIService(gitConfig, httpClient);
|
||||
const secretsService = new RustSecretService(gitConfig);
|
||||
const aiService = new AIService(gitConfig, secretsService, httpClient);
|
||||
const remotesService = new RemotesService();
|
||||
const aiPromptService = new AIPromptService();
|
||||
const lineManagerFactory = new LineManagerFactory();
|
||||
|
@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { AIService, GitAIConfigKey, KeyOption } from '$lib/ai/service';
|
||||
import { AISecretHandle, AIService, GitAIConfigKey, KeyOption } from '$lib/ai/service';
|
||||
import { OpenAIModelName, AnthropicModelName, ModelKind } from '$lib/ai/types';
|
||||
import { GitConfigService } from '$lib/backend/gitConfigService';
|
||||
import AiPromptEdit from '$lib/components/AIPromptEdit/AIPromptEdit.svelte';
|
||||
import SectionCard from '$lib/components/SectionCard.svelte';
|
||||
import WelcomeSigninAction from '$lib/components/WelcomeSigninAction.svelte';
|
||||
import { getSecretsService } from '$lib/secrets/secretsService';
|
||||
import ContentWrapper from '$lib/settings/ContentWrapper.svelte';
|
||||
import Section from '$lib/settings/Section.svelte';
|
||||
import InfoMessage from '$lib/shared/InfoMessage.svelte';
|
||||
@ -15,10 +16,10 @@
|
||||
import TextBox from '$lib/shared/TextBox.svelte';
|
||||
import { UserService } from '$lib/stores/user';
|
||||
import { getContext } from '$lib/utils/context';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
import { onMount, tick } from 'svelte';
|
||||
|
||||
const gitConfigService = getContext(GitConfigService);
|
||||
const secretsService = getSecretsService();
|
||||
const aiService = getContext(AIService);
|
||||
const userService = getContext(UserService);
|
||||
const user = userService.user;
|
||||
@ -37,27 +38,24 @@
|
||||
|
||||
async function setConfiguration(key: GitAIConfigKey, value: string | undefined) {
|
||||
if (!initialized) return;
|
||||
gitConfigService.set(key, value || '');
|
||||
}
|
||||
|
||||
if (key === GitAIConfigKey.OpenAIKey || key === GitAIConfigKey.AnthropicKey) {
|
||||
await invoke('secret_set_global', {
|
||||
handle: key.split('.')[1],
|
||||
secret: value
|
||||
});
|
||||
} else {
|
||||
gitConfigService.set(key, value || '');
|
||||
}
|
||||
async function setSecret(handle: AISecretHandle, secret: string | undefined) {
|
||||
if (!initialized) return;
|
||||
secretsService.set(handle, secret || '');
|
||||
}
|
||||
|
||||
$: setConfiguration(GitAIConfigKey.ModelProvider, modelKind);
|
||||
|
||||
$: setConfiguration(GitAIConfigKey.OpenAIKeyOption, openAIKeyOption);
|
||||
$: setConfiguration(GitAIConfigKey.OpenAIModelName, openAIModelName);
|
||||
$: setConfiguration(GitAIConfigKey.OpenAIKey, openAIKey);
|
||||
$: setSecret(AISecretHandle.OpenAIKey, openAIKey);
|
||||
|
||||
$: setConfiguration(GitAIConfigKey.AnthropicKeyOption, anthropicKeyOption);
|
||||
$: setConfiguration(GitAIConfigKey.AnthropicModelName, anthropicModelName);
|
||||
$: setConfiguration(GitAIConfigKey.AnthropicKey, anthropicKey);
|
||||
$: setConfiguration(GitAIConfigKey.DiffLengthLimit, diffLengthLimit?.toString());
|
||||
$: setSecret(AISecretHandle.AnthropicKey, anthropicKey);
|
||||
|
||||
$: setConfiguration(GitAIConfigKey.OllamaEndpoint, ollamaEndpoint);
|
||||
$: setConfiguration(GitAIConfigKey.OllamaModelName, ollamaModel);
|
||||
|
Loading…
Reference in New Issue
Block a user