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:
Mattias Granlund 2024-07-01 16:09:01 +02:00 committed by Sebastian Thiel
parent 05506f49fa
commit 7f618fd248
No known key found for this signature in database
GPG Key ID: 9CB5EE7895E8268B
6 changed files with 132 additions and 54 deletions

View File

@ -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(

View File

@ -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() {

View 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;
}
}

View File

@ -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);
}
];
}

View File

@ -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();

View File

@ -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);