feat(research): Phase 3b openai-deep-research async + BYO-keys CRUD & UI

Two backlog items landed in one commit because an earlier amend in a
parallel terminal dropped the initial Phase 3b commit and the BYO-keys
work was blocked on the same wiring.

openai-deep-research (async):
- New research.async_jobs table persists the OpenAI response.id, query,
  reservation, and cached result/error.
- POST /v1/research/async reserves credits, submits to the Responses API
  with background=true, returns a taskId. Submit failure refunds.
- GET /v1/research/async/:taskId polls upstream, commits the reservation
  on completion, refunds on failure, short-circuits for terminal states.
- GET /v1/research/async lists the user's async tasks.

BYO-keys:
- research.provider_configs CRUD at /v1/provider-configs. Keys are masked
  (••••last4) on read so the raw secret never re-transits to the browser.
  Currently stored plaintext with a TODO for AES-GCM-256 via the shared
  KEK — single call site in storage/configs.ts.decryptKey().
- New frontend route /research-lab/keys lets the user paste a key per
  provider, toggle enabled, and set daily/monthly credit budgets.
- ListView grew a 🔑 link in the header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-17 15:43:12 +02:00
parent 10bdd64efb
commit 7d120225dc
9 changed files with 1032 additions and 3 deletions

View file

@ -84,6 +84,11 @@
persistent speichern.
</p>
</div>
<div class="header-actions">
<button type="button" class="keys-link" onclick={() => void goto('/research-lab/keys')}>
🔑 API-Keys
</button>
</div>
<div class="mode-toggle" role="tablist">
{#each ['search', 'extract', 'agent'] as const as m}
<button
@ -236,6 +241,27 @@
max-width: 40rem;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.keys-link {
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
}
.keys-link:hover {
background: hsl(var(--color-surface-hover));
border-color: hsl(var(--color-border-strong));
}
.mode-toggle {
display: inline-flex;
border: 1px solid hsl(var(--color-border));

View file

@ -113,3 +113,39 @@ export function rateResult(
body: JSON.stringify({ rating, notes }),
});
}
// ─── BYO-Keys provider configs ──────────────────────────────
export interface ProviderConfigDto {
id: string;
providerId: string;
enabled: boolean;
dailyBudgetCredits: number | null;
monthlyBudgetCredits: number | null;
maskedKey: string | null;
hasKey: boolean;
updatedAt?: string;
}
export function listProviderConfigs(): Promise<{ configs: ProviderConfigDto[] }> {
return request<{ configs: ProviderConfigDto[] }>('/api/v1/provider-configs');
}
export function upsertProviderConfig(input: {
providerId: string;
apiKey?: string;
enabled?: boolean;
dailyBudgetCredits?: number | null;
monthlyBudgetCredits?: number | null;
}): Promise<ProviderConfigDto> {
return request<ProviderConfigDto>('/api/v1/provider-configs', {
method: 'POST',
body: JSON.stringify(input),
});
}
export function deleteProviderConfig(providerId: string): Promise<{ success: boolean }> {
return request<{ success: boolean }>(`/api/v1/provider-configs/${providerId}`, {
method: 'DELETE',
});
}

View file

@ -0,0 +1,402 @@
<!--
/research-lab/keys — per-user BYO API-Key management for every research
provider. Keys land in research.provider_configs, masked on read, and
bypass mana-credits at call time (no charge for BYO-mode).
-->
<script lang="ts">
import { goto } from '$app/navigation';
import * as api from '$lib/modules/research-lab/api';
import type { ProviderConfigDto } from '$lib/modules/research-lab/api';
import type { ProviderInfo, ProvidersCatalog } from '$lib/modules/research-lab/types';
let catalog = $state<ProvidersCatalog | null>(null);
let configs = $state<ProviderConfigDto[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let savingFor = $state<string | null>(null);
type FormState = {
apiKey: string;
dailyBudget: string;
monthlyBudget: string;
enabled: boolean;
};
const forms = $state<Record<string, FormState>>({});
function flatProviders(): ProviderInfo[] {
if (!catalog) return [];
return [...catalog.search, ...catalog.extract, ...catalog.agent].filter(
(p) => p.requiresApiKey
);
}
function refreshForms(providers: ProviderInfo[], existing: ProviderConfigDto[]) {
const byProvider = new Map(existing.map((c) => [c.providerId, c] as const));
for (const p of providers) {
const match = byProvider.get(p.id);
if (!forms[p.id]) {
forms[p.id] = {
apiKey: '',
dailyBudget: match?.dailyBudgetCredits?.toString() ?? '',
monthlyBudget: match?.monthlyBudgetCredits?.toString() ?? '',
enabled: match?.enabled ?? true,
};
} else {
// keep user-typed apiKey untouched; refresh budget/enabled from server
forms[p.id].dailyBudget = match?.dailyBudgetCredits?.toString() ?? '';
forms[p.id].monthlyBudget = match?.monthlyBudgetCredits?.toString() ?? '';
forms[p.id].enabled = match?.enabled ?? true;
}
}
}
async function load() {
loading = true;
error = null;
try {
const [cat, cfgs] = await Promise.all([api.getProviders(), api.listProviderConfigs()]);
catalog = cat;
configs = cfgs.configs;
refreshForms(flatProviders(), configs);
} catch (err) {
error = err instanceof Error ? err.message : 'Laden fehlgeschlagen';
} finally {
loading = false;
}
}
$effect(() => {
void load();
});
function configFor(providerId: string): ProviderConfigDto | undefined {
return configs.find((c) => c.providerId === providerId);
}
async function save(providerId: string) {
const f = forms[providerId];
if (!f) return;
savingFor = providerId;
error = null;
try {
const updated = await api.upsertProviderConfig({
providerId,
apiKey: f.apiKey.trim() || undefined,
enabled: f.enabled,
dailyBudgetCredits: f.dailyBudget.trim() === '' ? null : Number(f.dailyBudget),
monthlyBudgetCredits: f.monthlyBudget.trim() === '' ? null : Number(f.monthlyBudget),
});
configs = configs.some((c) => c.providerId === providerId)
? configs.map((c) => (c.providerId === providerId ? updated : c))
: [...configs, updated];
// Clear the typed key from the form so the mask is visible
forms[providerId] = { ...f, apiKey: '' };
} catch (err) {
error = err instanceof Error ? err.message : 'Speichern fehlgeschlagen';
} finally {
savingFor = null;
}
}
async function remove(providerId: string) {
if (!confirm(`Konfiguration für ${providerId} wirklich löschen?`)) return;
savingFor = providerId;
try {
await api.deleteProviderConfig(providerId);
configs = configs.filter((c) => c.providerId !== providerId);
if (forms[providerId]) {
forms[providerId] = {
apiKey: '',
dailyBudget: '',
monthlyBudget: '',
enabled: true,
};
}
} catch (err) {
error = err instanceof Error ? err.message : 'Löschen fehlgeschlagen';
} finally {
savingFor = null;
}
}
</script>
<svelte:head>
<title>Research Keys · Mana</title>
</svelte:head>
<div class="page">
<header class="header">
<button type="button" class="back" onclick={() => void goto('/research-lab')}>
← Zurück zum Lab
</button>
<div class="title">
<h1>Research-Keys</h1>
<p class="subtitle">
Eigene API-Keys hinterlegen — deine Aufrufe gehen direkt an den Anbieter, ohne Credits zu
verbrauchen. Leer lassen, um den Server-Key (falls konfiguriert) weiter zu nutzen.
</p>
</div>
</header>
{#if error}
<div class="error">{error}</div>
{/if}
{#if loading}
<p class="loading">Lade …</p>
{:else}
{#each flatProviders() as provider (provider.id)}
{@const cfg = configFor(provider.id)}
{@const form = forms[provider.id]}
<section class="row">
<div class="provider-info">
<h3>{provider.id}</h3>
<span class="badge badge-{provider.category}">{provider.category}</span>
{#if cfg?.hasKey}
<span class="mask" title="Hinterlegter Key">{cfg.maskedKey}</span>
{:else}
<span class="mask mask-empty">kein eigener Key</span>
{/if}
</div>
{#if form}
<div class="field">
<label>
API-Key
<input
type="password"
autocomplete="new-password"
placeholder={cfg?.hasKey ? 'Leer lassen zum Beibehalten' : 'sk-…'}
bind:value={form.apiKey}
/>
</label>
</div>
<div class="field narrow">
<label>
Tagesbudget (¢)
<input
type="number"
min="0"
step="10"
placeholder="unbegrenzt"
bind:value={form.dailyBudget}
/>
</label>
</div>
<div class="field narrow">
<label>
Monatsbudget (¢)
<input
type="number"
min="0"
step="100"
placeholder="unbegrenzt"
bind:value={form.monthlyBudget}
/>
</label>
</div>
<div class="field toggle">
<label>
<input type="checkbox" bind:checked={form.enabled} />
Aktiv
</label>
</div>
<div class="actions">
<button
type="button"
class="primary"
disabled={savingFor === provider.id}
onclick={() => void save(provider.id)}
>
{savingFor === provider.id ? 'Speichere…' : 'Speichern'}
</button>
{#if cfg}
<button
type="button"
class="danger"
disabled={savingFor === provider.id}
onclick={() => void remove(provider.id)}
>
Löschen
</button>
{/if}
</div>
{/if}
</section>
{/each}
{/if}
</div>
<style>
.page {
max-width: 60rem;
margin: 0 auto;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
}
.header {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.back {
padding: 0.375rem 0.75rem;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
color: hsl(var(--color-foreground));
cursor: pointer;
font-size: 0.8125rem;
}
.back:hover {
background: hsl(var(--color-surface-hover));
}
.title h1 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.subtitle {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
max-width: 48rem;
}
.loading {
color: hsl(var(--color-muted-foreground));
}
.error {
padding: 0.625rem 0.875rem;
background: hsl(var(--color-error, 0 84% 60%) / 0.1);
border: 1px solid hsl(var(--color-error, 0 84% 60%) / 0.4);
color: hsl(var(--color-error, 0 84% 40%));
border-radius: 0.375rem;
}
.row {
display: grid;
grid-template-columns: minmax(14rem, 1fr) minmax(14rem, 1.4fr) auto auto auto auto;
gap: 0.75rem;
padding: 0.875rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
align-items: end;
}
.provider-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.provider-info h3 {
margin: 0;
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.9375rem;
}
.badge {
display: inline-block;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.04em;
width: fit-content;
}
.badge-search {
background: hsl(200 80% 50% / 0.15);
color: hsl(200 80% 40%);
}
.badge-extract {
background: hsl(270 60% 55% / 0.15);
color: hsl(270 60% 45%);
}
.badge-agent {
background: hsl(30 90% 55% / 0.15);
color: hsl(30 90% 40%);
}
.mask {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.75rem;
color: hsl(var(--color-foreground));
}
.mask-empty {
color: hsl(var(--color-muted-foreground));
font-style: italic;
}
.field label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.field input[type='password'],
.field input[type='number'] {
padding: 0.375rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.25rem;
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
}
.field.narrow input {
width: 8rem;
}
.field.toggle label {
flex-direction: row;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.actions {
display: flex;
gap: 0.375rem;
}
.actions button {
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
font-size: 0.8125rem;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
}
.actions .primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground, 0 0% 10%));
border-color: transparent;
}
.actions .primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.actions .danger {
background: transparent;
color: hsl(var(--color-error, 0 84% 40%));
border-color: hsl(var(--color-error, 0 84% 60%) / 0.4);
}
.actions .danger:hover {
background: hsl(var(--color-error, 0 84% 60%) / 0.08);
}
@media (max-width: 900px) {
.row {
grid-template-columns: 1fr;
}
.field.narrow input {
width: 100%;
}
}
</style>