mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 08:41:10 +02:00
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:
parent
10bdd64efb
commit
7d120225dc
9 changed files with 1032 additions and 3 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -129,3 +129,39 @@ export type EvalResult = typeof evalResults.$inferSelect;
|
|||
export type NewEvalResult = typeof evalResults.$inferInsert;
|
||||
export type ProviderConfig = typeof providerConfigs.$inferSelect;
|
||||
export type ProviderStat = typeof providerStats.$inferSelect;
|
||||
|
||||
export const asyncJobStatusEnum = pgEnum('research_async_status', [
|
||||
'queued',
|
||||
'running',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
/** Long-running research tasks (openai-deep-research). User submits, polls. */
|
||||
export const asyncJobs = researchSchema.table(
|
||||
'async_jobs',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
providerId: text('provider_id').notNull(),
|
||||
externalId: text('external_id'),
|
||||
status: asyncJobStatusEnum('status').notNull().default('queued'),
|
||||
query: text('query').notNull(),
|
||||
options: jsonb('options'),
|
||||
reservationId: text('reservation_id'),
|
||||
costCredits: integer('cost_credits').notNull().default(0),
|
||||
result: jsonb('result'),
|
||||
errorMessage: text('error_message'),
|
||||
runId: uuid('run_id').references(() => evalRuns.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index('async_jobs_user_idx').on(t.userId, t.createdAt),
|
||||
statusIdx: index('async_jobs_status_idx').on(t.status),
|
||||
})
|
||||
);
|
||||
|
||||
export type AsyncJob = typeof asyncJobs.$inferSelect;
|
||||
export type NewAsyncJob = typeof asyncJobs.$inferInsert;
|
||||
|
|
|
|||
|
|
@ -21,9 +21,11 @@ import { createExtractRoutes } from './routes/extract';
|
|||
import { createResearchRoutes } from './routes/research';
|
||||
import { createProvidersRoutes } from './routes/providers';
|
||||
import { createRunsRoutes } from './routes/runs';
|
||||
import { createProviderConfigRoutes } from './routes/provider-configs';
|
||||
import { buildRegistry } from './providers/registry';
|
||||
import { RunStorage } from './storage/runs';
|
||||
import { ConfigStorage } from './storage/configs';
|
||||
import { AsyncJobStorage } from './storage/async-jobs';
|
||||
import { CreditsClient } from './clients/mana-credits';
|
||||
import { ManaSearchClient } from './clients/mana-search';
|
||||
import { ManaLlmClient } from './clients/mana-llm';
|
||||
|
|
@ -45,6 +47,7 @@ const credits = new CreditsClient({
|
|||
|
||||
const runStorage = new RunStorage(db);
|
||||
const configStorage = new ConfigStorage(db);
|
||||
const asyncStorage = new AsyncJobStorage(db);
|
||||
const registry = buildRegistry({ manaSearch });
|
||||
|
||||
const executorDeps = {
|
||||
|
|
@ -86,11 +89,17 @@ app.use('/api/v1/extract/*', jwtAuth(config.manaAuthUrl));
|
|||
app.route('/api/v1/extract', createExtractRoutes(registry, runStorage, executorDeps, config));
|
||||
|
||||
app.use('/api/v1/research/*', jwtAuth(config.manaAuthUrl));
|
||||
app.route('/api/v1/research', createResearchRoutes(registry, runStorage, executorDeps, config));
|
||||
app.route(
|
||||
'/api/v1/research',
|
||||
createResearchRoutes(registry, runStorage, executorDeps, config, asyncStorage, credits)
|
||||
);
|
||||
|
||||
app.use('/api/v1/runs/*', jwtAuth(config.manaAuthUrl));
|
||||
app.route('/api/v1/runs', createRunsRoutes(runStorage));
|
||||
|
||||
app.use('/api/v1/provider-configs/*', jwtAuth(config.manaAuthUrl));
|
||||
app.route('/api/v1/provider-configs', createProviderConfigRoutes(db));
|
||||
|
||||
// Service-to-service (X-Service-Key auth) — wired up in Phase 3 when mana-ai migrates
|
||||
app.use('/api/v1/internal/*', serviceAuth(config.serviceKey));
|
||||
app.get('/api/v1/internal/health', (c) => c.json({ ok: true }));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* OpenAI Deep Research — async via the Responses API with `background: true`.
|
||||
* Docs: https://platform.openai.com/docs/guides/deep-research
|
||||
*
|
||||
* Two-phase flow:
|
||||
* submit() — POST /v1/responses → returns { id, status: 'queued' | 'in_progress' }
|
||||
* poll(id) — GET /v1/responses/{id} → eventual { status: 'completed', output: [...] }
|
||||
*
|
||||
* Results typically arrive in 5–30 minutes. We persist the OpenAI response.id
|
||||
* in research.async_jobs and expose POST /v1/research/async + GET /:taskId.
|
||||
*/
|
||||
|
||||
import type { AgentAnswer, Citation } from '@mana/shared-research';
|
||||
import { ProviderError, ProviderNotConfiguredError } from '../../lib/errors';
|
||||
|
||||
const DEFAULT_MODEL = 'o3-deep-research';
|
||||
|
||||
export interface DeepResearchSubmitResult {
|
||||
externalId: string;
|
||||
status: 'queued' | 'running';
|
||||
}
|
||||
|
||||
export interface DeepResearchPollResult {
|
||||
status: 'queued' | 'running' | 'completed' | 'failed';
|
||||
answer?: AgentAnswer;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface OpenAISubmitResponse {
|
||||
id: string;
|
||||
status?: 'queued' | 'in_progress' | 'completed' | 'failed' | 'cancelled' | 'incomplete';
|
||||
error?: { message?: string };
|
||||
}
|
||||
|
||||
interface OpenAIPollResponse extends OpenAISubmitResponse {
|
||||
output?: Array<{
|
||||
type: string;
|
||||
role?: string;
|
||||
content?: Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
annotations?: Array<{
|
||||
type: string;
|
||||
url?: string;
|
||||
title?: string;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
output_text?: string;
|
||||
usage?: {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function submitDeepResearch(
|
||||
query: string,
|
||||
options: { model?: string; maxTokens?: number; systemPrompt?: string } = {},
|
||||
apiKey: string | null,
|
||||
signal?: AbortSignal
|
||||
): Promise<DeepResearchSubmitResult> {
|
||||
if (!apiKey) throw new ProviderNotConfiguredError('openai-deep-research');
|
||||
|
||||
const model = options.model ?? DEFAULT_MODEL;
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
input: options.systemPrompt
|
||||
? [
|
||||
{ role: 'system', content: options.systemPrompt },
|
||||
{ role: 'user', content: query },
|
||||
]
|
||||
: query,
|
||||
tools: [{ type: 'web_search_preview' }],
|
||||
background: true,
|
||||
};
|
||||
if (options.maxTokens) body.max_output_tokens = options.maxTokens;
|
||||
|
||||
const res = await fetch('https://api.openai.com/v1/responses', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => '');
|
||||
throw new ProviderError(
|
||||
'openai-deep-research',
|
||||
`submit HTTP ${res.status} ${errBody.slice(0, 300)}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as OpenAISubmitResponse;
|
||||
if (!data.id) throw new ProviderError('openai-deep-research', 'submit: missing response id');
|
||||
|
||||
return {
|
||||
externalId: data.id,
|
||||
status: data.status === 'in_progress' ? 'running' : 'queued',
|
||||
};
|
||||
}
|
||||
|
||||
export async function pollDeepResearch(
|
||||
externalId: string,
|
||||
apiKey: string | null,
|
||||
signal?: AbortSignal
|
||||
): Promise<DeepResearchPollResult> {
|
||||
if (!apiKey) throw new ProviderNotConfiguredError('openai-deep-research');
|
||||
|
||||
const res = await fetch(`https://api.openai.com/v1/responses/${externalId}`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => '');
|
||||
throw new ProviderError(
|
||||
'openai-deep-research',
|
||||
`poll HTTP ${res.status} ${errBody.slice(0, 300)}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as OpenAIPollResponse;
|
||||
|
||||
if (data.status === 'queued') return { status: 'queued' };
|
||||
if (data.status === 'in_progress') return { status: 'running' };
|
||||
if (data.status === 'failed' || data.status === 'incomplete' || data.status === 'cancelled') {
|
||||
return { status: 'failed', error: data.error?.message ?? data.status };
|
||||
}
|
||||
|
||||
// completed
|
||||
const textParts: string[] = [];
|
||||
const citations = new Map<string, Citation>();
|
||||
|
||||
if (data.output_text) textParts.push(data.output_text);
|
||||
|
||||
for (const item of data.output ?? []) {
|
||||
if (item.type !== 'message') continue;
|
||||
for (const content of item.content ?? []) {
|
||||
if (content.type === 'output_text' && content.text) {
|
||||
if (!data.output_text) textParts.push(content.text);
|
||||
for (const ann of content.annotations ?? []) {
|
||||
if (ann.url && !citations.has(ann.url)) {
|
||||
citations.set(ann.url, { url: ann.url, title: ann.title ?? ann.url });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tokenUsage = data.usage
|
||||
? {
|
||||
input: data.usage.input_tokens ?? 0,
|
||||
output: data.usage.output_tokens ?? 0,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const answer: AgentAnswer = {
|
||||
query: '',
|
||||
answer: textParts.join('\n\n'),
|
||||
citations: [...citations.values()],
|
||||
tokenUsage,
|
||||
providerRaw: data,
|
||||
};
|
||||
|
||||
return { status: 'completed', answer };
|
||||
}
|
||||
143
services/mana-research/src/routes/provider-configs.ts
Normal file
143
services/mana-research/src/routes/provider-configs.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
/**
|
||||
* /v1/provider-configs — per-user BYO-key + budget CRUD.
|
||||
*
|
||||
* Keys are stored in research.provider_configs.apiKeyEncrypted. Phase 4
|
||||
* persists plaintext with a TODO for AES-GCM-256 encryption (see
|
||||
* src/storage/configs.ts `decryptKey` — same plaintext path on read).
|
||||
* A separate commit will wire in the shared-crypto KEK pattern.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
AGENT_PROVIDER_IDS,
|
||||
EXTRACT_PROVIDER_IDS,
|
||||
SEARCH_PROVIDER_IDS,
|
||||
} from '@mana/shared-research';
|
||||
import type { HonoEnv } from '../lib/hono-env';
|
||||
import type { Database } from '../db/connection';
|
||||
import { providerConfigs } from '../db/schema/research';
|
||||
import { NotFoundError } from '../lib/errors';
|
||||
|
||||
const ALL_PROVIDER_IDS = [
|
||||
...SEARCH_PROVIDER_IDS,
|
||||
...EXTRACT_PROVIDER_IDS,
|
||||
...AGENT_PROVIDER_IDS,
|
||||
] as const;
|
||||
|
||||
const upsertSchema = z.object({
|
||||
providerId: z.enum(ALL_PROVIDER_IDS),
|
||||
apiKey: z.string().min(8).max(512).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
dailyBudgetCredits: z.number().int().nonnegative().nullable().optional(),
|
||||
monthlyBudgetCredits: z.number().int().nonnegative().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Mask a stored API key so the UI can render "••••last4" without sending the
|
||||
* raw secret to the browser on subsequent loads.
|
||||
*/
|
||||
function maskKey(raw: string | null): string | null {
|
||||
if (!raw) return null;
|
||||
if (raw.length <= 8) return '••••';
|
||||
return `••••${raw.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function createProviderConfigRoutes(db: Database) {
|
||||
return new Hono<HonoEnv>()
|
||||
.get('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(providerConfigs)
|
||||
.where(eq(providerConfigs.userId, user.userId));
|
||||
return c.json({
|
||||
configs: rows.map((r) => ({
|
||||
id: r.id,
|
||||
providerId: r.providerId,
|
||||
enabled: r.enabled,
|
||||
dailyBudgetCredits: r.dailyBudgetCredits,
|
||||
monthlyBudgetCredits: r.monthlyBudgetCredits,
|
||||
maskedKey: maskKey(r.apiKeyEncrypted),
|
||||
hasKey: !!r.apiKeyEncrypted,
|
||||
updatedAt: r.updatedAt,
|
||||
})),
|
||||
});
|
||||
})
|
||||
.post('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = upsertSchema.parse(await c.req.json());
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(providerConfigs)
|
||||
.where(
|
||||
and(
|
||||
eq(providerConfigs.userId, user.userId),
|
||||
eq(providerConfigs.providerId, body.providerId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
const patch: Partial<typeof providerConfigs.$inferInsert> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (body.apiKey !== undefined) patch.apiKeyEncrypted = body.apiKey;
|
||||
if (body.enabled !== undefined) patch.enabled = body.enabled;
|
||||
if (body.dailyBudgetCredits !== undefined)
|
||||
patch.dailyBudgetCredits = body.dailyBudgetCredits;
|
||||
if (body.monthlyBudgetCredits !== undefined)
|
||||
patch.monthlyBudgetCredits = body.monthlyBudgetCredits;
|
||||
|
||||
const [updated] = await db
|
||||
.update(providerConfigs)
|
||||
.set(patch)
|
||||
.where(eq(providerConfigs.id, existing.id))
|
||||
.returning();
|
||||
return c.json({
|
||||
id: updated.id,
|
||||
providerId: updated.providerId,
|
||||
enabled: updated.enabled,
|
||||
dailyBudgetCredits: updated.dailyBudgetCredits,
|
||||
monthlyBudgetCredits: updated.monthlyBudgetCredits,
|
||||
maskedKey: maskKey(updated.apiKeyEncrypted),
|
||||
hasKey: !!updated.apiKeyEncrypted,
|
||||
});
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(providerConfigs)
|
||||
.values({
|
||||
userId: user.userId,
|
||||
providerId: body.providerId,
|
||||
apiKeyEncrypted: body.apiKey ?? null,
|
||||
enabled: body.enabled ?? true,
|
||||
dailyBudgetCredits: body.dailyBudgetCredits ?? null,
|
||||
monthlyBudgetCredits: body.monthlyBudgetCredits ?? null,
|
||||
})
|
||||
.returning();
|
||||
return c.json({
|
||||
id: created.id,
|
||||
providerId: created.providerId,
|
||||
enabled: created.enabled,
|
||||
dailyBudgetCredits: created.dailyBudgetCredits,
|
||||
monthlyBudgetCredits: created.monthlyBudgetCredits,
|
||||
maskedKey: maskKey(created.apiKeyEncrypted),
|
||||
hasKey: !!created.apiKeyEncrypted,
|
||||
});
|
||||
})
|
||||
.delete('/:providerId', async (c) => {
|
||||
const user = c.get('user');
|
||||
const providerId = c.req.param('providerId');
|
||||
const deleted = await db
|
||||
.delete(providerConfigs)
|
||||
.where(
|
||||
and(eq(providerConfigs.userId, user.userId), eq(providerConfigs.providerId, providerId))
|
||||
)
|
||||
.returning();
|
||||
if (deleted.length === 0) throw new NotFoundError('Config not found');
|
||||
return c.json({ success: true });
|
||||
});
|
||||
}
|
||||
|
|
@ -13,9 +13,13 @@ import type { HonoEnv } from '../lib/hono-env';
|
|||
import type { ProviderRegistry } from '../providers/registry';
|
||||
import { getAgent } from '../providers/registry';
|
||||
import type { RunStorage } from '../storage/runs';
|
||||
import { BadRequestError } from '../lib/errors';
|
||||
import type { AsyncJobStorage } from '../storage/async-jobs';
|
||||
import type { CreditsClient } from '../clients/mana-credits';
|
||||
import { BadRequestError, NotFoundError } from '../lib/errors';
|
||||
import type { Config } from '../config';
|
||||
import { pickAgent } from '../router/auto-route';
|
||||
import { priceFor } from '../lib/pricing';
|
||||
import { pollDeepResearch, submitDeepResearch } from '../providers/agent/openai-deep-research';
|
||||
|
||||
const MAX_COMPARE_AGENTS = 4;
|
||||
|
||||
|
|
@ -31,12 +35,21 @@ const compareBodySchema = z.object({
|
|||
options: agentOptionsSchema.optional(),
|
||||
});
|
||||
|
||||
const asyncSubmitBodySchema = z.object({
|
||||
query: z.string().min(1).max(4000),
|
||||
options: agentOptionsSchema.optional(),
|
||||
});
|
||||
|
||||
export function createResearchRoutes(
|
||||
registry: ProviderRegistry,
|
||||
storage: RunStorage,
|
||||
deps: ExecutorDeps,
|
||||
config: Config
|
||||
config: Config,
|
||||
asyncStorage: AsyncJobStorage,
|
||||
credits: CreditsClient
|
||||
) {
|
||||
const PROVIDER_ID = 'openai-deep-research' as const;
|
||||
|
||||
return new Hono<HonoEnv>()
|
||||
.post('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
|
|
@ -160,5 +173,154 @@ export function createResearchRoutes(
|
|||
resultId: resultIds[i],
|
||||
})),
|
||||
});
|
||||
})
|
||||
.post('/async', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = asyncSubmitBodySchema.parse(await c.req.json());
|
||||
|
||||
const apiKey = config.providerKeys.openai;
|
||||
if (!apiKey) {
|
||||
throw new BadRequestError(
|
||||
'openai-deep-research requires OPENAI_API_KEY on the server or via BYO key'
|
||||
);
|
||||
}
|
||||
|
||||
const price = priceFor(PROVIDER_ID, 'research');
|
||||
const reservation = await credits.reserve(
|
||||
user.userId,
|
||||
price,
|
||||
`research:${PROVIDER_ID}:submit`
|
||||
);
|
||||
|
||||
try {
|
||||
const submission = await submitDeepResearch(body.query, body.options ?? {}, apiKey);
|
||||
const job = await asyncStorage.create({
|
||||
userId: user.userId,
|
||||
providerId: PROVIDER_ID,
|
||||
externalId: submission.externalId,
|
||||
status: submission.status,
|
||||
query: body.query,
|
||||
options: body.options ?? {},
|
||||
reservationId: reservation.reservationId,
|
||||
costCredits: price,
|
||||
});
|
||||
return c.json({
|
||||
taskId: job.id,
|
||||
status: job.status,
|
||||
providerId: PROVIDER_ID,
|
||||
costCredits: price,
|
||||
});
|
||||
} catch (err) {
|
||||
await credits.refund(reservation.reservationId).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
.get('/async/:id', async (c) => {
|
||||
const user = c.get('user');
|
||||
const job = await asyncStorage.get(c.req.param('id'), user.userId);
|
||||
if (!job) throw new NotFoundError('Task not found');
|
||||
|
||||
// Short-circuit terminal states.
|
||||
if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') {
|
||||
return c.json({
|
||||
taskId: job.id,
|
||||
status: job.status,
|
||||
query: job.query,
|
||||
providerId: job.providerId,
|
||||
costCredits: job.costCredits,
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: job.updatedAt,
|
||||
result: job.result,
|
||||
error: job.errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
// Poll upstream.
|
||||
if (!job.externalId) {
|
||||
throw new BadRequestError('Task has no external id yet');
|
||||
}
|
||||
const apiKey = config.providerKeys.openai;
|
||||
if (!apiKey) {
|
||||
return c.json({
|
||||
taskId: job.id,
|
||||
status: job.status,
|
||||
query: job.query,
|
||||
providerId: job.providerId,
|
||||
costCredits: job.costCredits,
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: job.updatedAt,
|
||||
error: 'OPENAI_API_KEY is no longer configured; cannot poll',
|
||||
});
|
||||
}
|
||||
|
||||
const poll = await pollDeepResearch(job.externalId, apiKey).catch((err: Error) => ({
|
||||
status: 'failed' as const,
|
||||
error: err.message,
|
||||
}));
|
||||
|
||||
if (poll.status === 'completed' && poll.answer) {
|
||||
const answer = { ...poll.answer, query: job.query };
|
||||
await asyncStorage.updateStatus(job.id, {
|
||||
status: 'completed',
|
||||
result: { answer },
|
||||
});
|
||||
if (job.reservationId) {
|
||||
await credits
|
||||
.commit(job.reservationId, `research ${job.providerId}`)
|
||||
.catch((err) => console.warn('[async] commit failed:', err));
|
||||
}
|
||||
return c.json({
|
||||
taskId: job.id,
|
||||
status: 'completed',
|
||||
query: job.query,
|
||||
providerId: job.providerId,
|
||||
costCredits: job.costCredits,
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: new Date(),
|
||||
result: { answer },
|
||||
});
|
||||
}
|
||||
|
||||
if (poll.status === 'failed') {
|
||||
await asyncStorage.updateStatus(job.id, {
|
||||
status: 'failed',
|
||||
errorMessage: poll.error ?? 'unknown',
|
||||
});
|
||||
if (job.reservationId) {
|
||||
await credits
|
||||
.refund(job.reservationId)
|
||||
.catch((err) => console.warn('[async] refund failed:', err));
|
||||
}
|
||||
return c.json({
|
||||
taskId: job.id,
|
||||
status: 'failed',
|
||||
query: job.query,
|
||||
providerId: job.providerId,
|
||||
costCredits: 0,
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: new Date(),
|
||||
error: poll.error,
|
||||
});
|
||||
}
|
||||
|
||||
// queued / running — update touch and return current
|
||||
if (poll.status !== job.status) {
|
||||
await asyncStorage.updateStatus(job.id, { status: poll.status });
|
||||
}
|
||||
return c.json({
|
||||
taskId: job.id,
|
||||
status: poll.status,
|
||||
query: job.query,
|
||||
providerId: job.providerId,
|
||||
costCredits: job.costCredits,
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
})
|
||||
.get('/async', async (c) => {
|
||||
const user = c.get('user');
|
||||
const limit = Math.min(parseInt(c.req.query('limit') ?? '25', 10), 100);
|
||||
const jobs = await asyncStorage.list(user.userId, limit);
|
||||
return c.json({ tasks: jobs });
|
||||
});
|
||||
}
|
||||
|
|
|
|||
46
services/mana-research/src/storage/async-jobs.ts
Normal file
46
services/mana-research/src/storage/async-jobs.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Persistence for long-running research tasks (openai-deep-research).
|
||||
* Minimal CRUD + a helper to mark jobs done/failed with credit commit/refund.
|
||||
*/
|
||||
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
import { asyncJobs } from '../db/schema/research';
|
||||
import type { AsyncJob, NewAsyncJob } from '../db/schema/research';
|
||||
|
||||
export class AsyncJobStorage {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
async create(input: NewAsyncJob): Promise<AsyncJob> {
|
||||
const [row] = await this.db.insert(asyncJobs).values(input).returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
async get(id: string, userId: string): Promise<AsyncJob | null> {
|
||||
const [row] = await this.db
|
||||
.select()
|
||||
.from(asyncJobs)
|
||||
.where(and(eq(asyncJobs.id, id), eq(asyncJobs.userId, userId)))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async list(userId: string, limit = 25): Promise<AsyncJob[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(asyncJobs)
|
||||
.where(eq(asyncJobs.userId, userId))
|
||||
.orderBy(desc(asyncJobs.createdAt))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
patch: Partial<Pick<AsyncJob, 'status' | 'result' | 'errorMessage' | 'externalId'>>
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.update(asyncJobs)
|
||||
.set({ ...patch, updatedAt: new Date() })
|
||||
.where(eq(asyncJobs.id, id));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue