diff --git a/packages/shared-research/src/ids.ts b/packages/shared-research/src/ids.ts index 45cc1fbf2..e110aeeaa 100644 --- a/packages/shared-research/src/ids.ts +++ b/packages/shared-research/src/ids.ts @@ -20,6 +20,8 @@ export const AGENT_PROVIDER_IDS = [ 'openai-responses', 'gemini-grounding', 'openai-deep-research', + 'gemini-deep-research', + 'gemini-deep-research-max', ] as const; export type SearchProviderId = (typeof SEARCH_PROVIDER_IDS)[number]; diff --git a/services/mana-research/API_KEYS.md b/services/mana-research/API_KEYS.md index c3ce7f32c..e9615bb27 100644 --- a/services/mana-research/API_KEYS.md +++ b/services/mana-research/API_KEYS.md @@ -19,7 +19,7 @@ Alle Provider, die in `mana-research` einen externen API-Key brauchen, wo du den | [Jina Reader](#5-jina-reader) | `JINA_API_KEY` | $0.02/1M Tokens | 1M Tokens/Monat + unauthed | Extract | niedrig (läuft auch ohne Key) | | [Firecrawl](#6-firecrawl) | `FIRECRAWL_API_KEY` | $16 = 2000 Credits PAYG | 500 Credits Signup | Extract (JS-Render) | mittel | | [Perplexity Sonar](#7-perplexity-sonar) | `PERPLEXITY_API_KEY` | $5 Token-Credit + $5/1k Suchen | Keiner | Agent | ⭐ hoch | -| [Google Gemini](#8-google-gemini) | `GOOGLE_GENAI_API_KEY` | Token + per-Grounding | großzügig (`gemini-2.0-flash`) | Agent | ⭐ hoch | +| [Google Gemini](#8-google-gemini) | `GOOGLE_GENAI_API_KEY` | Token + per-Grounding | großzügig (`gemini-2.0-flash`) | Agent + Async | ⭐ hoch | | [Anthropic Claude](#9-anthropic-claude) | `ANTHROPIC_API_KEY` | $10/1k web_search + Tokens | Variabel ($5 Guthaben bei Start) | Agent | hoch | | [OpenAI](#10-openai) | `OPENAI_API_KEY` | Token + per-Tool | Nein (ab ~2024) | Agent + Async | hoch | | [ScrapingBee](#11-scrapingbee-deferred) | `SCRAPINGBEE_API_KEY` | ab $49/Mo | 1000 Credits Signup | Extract | ❌ **deferred** (Abo-Pflicht) | @@ -217,7 +217,17 @@ Eine typische Anfrage kostet ~$0.01–0.10. - Grounding-Query: $35/1000 Suchen - `gemini-1.5-pro`: teurer +**Deep Research & Deep Research Max (derselbe Key):** +Seit 2026-04-21 deckt derselbe `GOOGLE_GENAI_API_KEY` auch die zwei neuen +async Agents ab — `gemini-deep-research` (~$1–3/Task, Standard) und +`gemini-deep-research-max` (~$3–7/Task, nächtliche Tiefenrecherche). Beide +laufen über die Interactions API mit `background=true` und sind im Service +über `POST /v1/research/async { provider: "gemini-deep-research" | "gemini-deep-research-max" }` +erreichbar. Preview-Status — Rate-Limits niedrig, Modell-IDs enden auf +`-preview-04-2026`. Details: [`docs/reports/gemini-deep-research.md`](../../docs/reports/gemini-deep-research.md). + **Dokumentation:** https://ai.google.dev/gemini-api/docs/api-key +**Deep-Research-Doku:** https://ai.google.dev/gemini-api/docs/deep-research **Env-Var:** `GOOGLE_GENAI_API_KEY=AIza...` diff --git a/services/mana-research/CLAUDE.md b/services/mana-research/CLAUDE.md index 6e34ded6d..470026b7c 100644 --- a/services/mana-research/CLAUDE.md +++ b/services/mana-research/CLAUDE.md @@ -34,8 +34,8 @@ bun run db:studio - **Phase 1** ✅ — 4 search providers (`searxng`, `duckduckgo`, `brave`, `tavily`), `/v1/search`, `/v1/search/compare`, `/v1/runs`, `/v1/providers`, `mana-credits` reserve/commit/refund. - **Phase 2** ✅ — +2 search providers (`exa`, `serper`), 3 extract providers (`readability`, `jina-reader`, `firecrawl`), `/v1/extract`, `/v1/extract/compare`, query classifier + auto-router, `/v1/providers/health`. -- **Phase 3a (current)** ✅ — 4 sync research agents (`perplexity-sonar`, `claude-web-search`, `openai-responses`, `gemini-grounding`), `/v1/research`, `/v1/research/compare`, agent auto-router. -- **Phase 3b** — `openai-deep-research` (async via job queue), mana-ai migration to call mana-research, `research_news` tool gets `depth: shallow|deep` option, mana-api news-research becomes thin adapter. +- **Phase 3a** ✅ — 4 sync research agents (`perplexity-sonar`, `claude-web-search`, `openai-responses`, `gemini-grounding`), `/v1/research`, `/v1/research/compare`, agent auto-router. +- **Phase 3b (current)** ✅ — async agents `openai-deep-research`, `gemini-deep-research`, `gemini-deep-research-max` via `research.async_jobs` queue. User-facing `/v1/research/async`, service-to-service `/v1/internal/research/async` (used by mana-ai's cross-tick deep-research flow). See [`docs/reports/gemini-deep-research.md`](../../docs/reports/gemini-deep-research.md). - **Phase 4** — Research Lab UI + Settings for BYO-keys. ## API Endpoints @@ -65,7 +65,15 @@ bun run db:studio ### Service-to-service (X-Service-Key) -Reserved for Phase 3 when `mana-ai` migrates to call this service directly. `/api/v1/internal/health` exists as a placeholder. +All `/api/v1/internal/*` routes require `X-Service-Key: `. Endpoints that touch per-user state additionally require `X-User-Id: ` so credit reservations + eval-run rows land on the right user. + +| Method | Path | Description | +|---|---|---| +| GET | `/api/v1/internal/health` | Placeholder health probe. | +| POST | `/api/v1/internal/research/async` | Submit async research job. Body: `{ query, provider, options? }` where `provider ∈ { openai-deep-research, gemini-deep-research, gemini-deep-research-max }`. Requires `X-User-Id`. | +| GET | `/api/v1/internal/research/async/:id` | Poll status / read completed result. Requires `X-User-Id` (same user as submit). | + +Caller today: **mana-ai** (`ManaResearchClient`), which fires deep-research-max tasks from the tick-loop's pre-planning step for missions that opt in via `DEEP_RESEARCH_TRIGGER`. ## Providers @@ -96,7 +104,9 @@ Reserved for Phase 3 when `mana-ai` migrates to call this service directly. `/ap | `gemini-grounding` | `GOOGLE_GENAI_API_KEY` | 100 | Gemini + Google Search grounding. Single-step. | | `openai-responses` | `OPENAI_API_KEY` | 200 | Responses API with `web_search_preview` tool. Multi-step. | | `claude-web-search` | `ANTHROPIC_API_KEY` | 200 | Claude + `web_search_20250305` tool, up to 5 searches/call. | -| `openai-deep-research` | `OPENAI_API_KEY` | 1000 | ⏳ Phase 3b — async, returns taskId to poll. | +| `openai-deep-research` | `OPENAI_API_KEY` | 1000 | async, returns taskId to poll. | +| `gemini-deep-research` | `GOOGLE_GENAI_API_KEY` | 300 | async, Gemini 3.1 Pro preview (04-2026). Standard tier, ~minutes. | +| `gemini-deep-research-max` | `GOOGLE_GENAI_API_KEY` | 1500 | async, Gemini 3.1 Pro preview (04-2026). Max tier, up to 60 min, deep synthesis. | ## Auto-routing diff --git a/services/mana-research/src/executor/env-map.ts b/services/mana-research/src/executor/env-map.ts index d9822edfb..a76a57029 100644 --- a/services/mana-research/src/executor/env-map.ts +++ b/services/mana-research/src/executor/env-map.ts @@ -17,6 +17,8 @@ export function mapEnvKey(providerId: ProviderId): keyof Config['providerKeys'] 'openai-responses': 'openai', 'openai-deep-research': 'openai', 'gemini-grounding': 'googleGenai', + 'gemini-deep-research': 'googleGenai', + 'gemini-deep-research-max': 'googleGenai', 'jina-reader': 'jina', firecrawl: 'firecrawl', scrapingbee: 'scrapingbee', diff --git a/services/mana-research/src/index.ts b/services/mana-research/src/index.ts index 54e3cbb53..f3ce39df1 100644 --- a/services/mana-research/src/index.ts +++ b/services/mana-research/src/index.ts @@ -19,6 +19,7 @@ import { healthRoutes } from './routes/health'; import { createSearchRoutes } from './routes/search'; import { createExtractRoutes } from './routes/extract'; import { createResearchRoutes } from './routes/research'; +import { createInternalResearchRoutes } from './routes/internal-research'; import { createProvidersRoutes } from './routes/providers'; import { createRunsRoutes } from './routes/runs'; import { createProviderConfigRoutes } from './routes/provider-configs'; @@ -100,9 +101,11 @@ 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 +// Service-to-service (X-Service-Key auth). Callers pass the target user +// in X-User-Id so credit accounting still lands on the right wallet. app.use('/api/v1/internal/*', serviceAuth(config.serviceKey)); app.get('/api/v1/internal/health', (c) => c.json({ ok: true })); +app.route('/api/v1/internal/research', createInternalResearchRoutes(config, asyncStorage, credits)); // ─── Start ────────────────────────────────────────────────── diff --git a/services/mana-research/src/lib/pricing.ts b/services/mana-research/src/lib/pricing.ts index 4096ef27a..d1f0d9041 100644 --- a/services/mana-research/src/lib/pricing.ts +++ b/services/mana-research/src/lib/pricing.ts @@ -31,6 +31,12 @@ export const PROVIDER_PRICING: Record< 'openai-responses': { research: 200 }, 'gemini-grounding': { research: 100 }, 'openai-deep-research': { research: 1000 }, + // Gemini Deep Research (preview-04-2026). Google lists $1–3 per standard + // task and $3–7 per Max task. 1 credit ≈ 1 cent EUR, so $3 ≈ 300 credits + // and a Max task can hit ~$7 ≈ 700 credits with markup → start + // conservative (see docs/reports/gemini-deep-research.md §4). + 'gemini-deep-research': { research: 300 }, + 'gemini-deep-research-max': { research: 1500 }, }; export function priceFor( diff --git a/services/mana-research/src/providers/agent/gemini-deep-research.ts b/services/mana-research/src/providers/agent/gemini-deep-research.ts new file mode 100644 index 000000000..58d0c3fa2 --- /dev/null +++ b/services/mana-research/src/providers/agent/gemini-deep-research.ts @@ -0,0 +1,223 @@ +/** + * Gemini Deep Research / Deep Research Max — async via the Interactions API + * (Gemini 3.1 Pro, public preview 04-2026). + * + * Docs: https://ai.google.dev/gemini-api/docs/deep-research + * + * Two-phase flow identical in shape to openai-deep-research: + * submit() — POST /v1beta/interactions with background=true → { id, status } + * poll(id) — GET /v1beta/interactions/{id} → eventual { status: 'completed', output: [...] } + * + * One provider, two tiers: the `standard` tier (~minutes, $1–3/task) uses + * deep-research-preview-04-2026; `max` (~20min typical, up to 60min, + * $3–7/task) uses deep-research-max-preview-04-2026. Price/rate-limit + * tradeoffs live in the caller — this module is tier-agnostic beyond the + * model-id mapping. + * + * NOTE: model-ids end in `-preview-04-2026`; Google will publish GA model + * names in a follow-up release. Bump the constants below when that lands. + */ + +import type { AgentAnswer, Citation } from '@mana/shared-research'; +import { ProviderError, ProviderNotConfiguredError } from '../../lib/errors'; + +export type GeminiDeepTier = 'standard' | 'max'; + +const MODEL_IDS: Record = { + standard: 'deep-research-preview-04-2026', + max: 'deep-research-max-preview-04-2026', +}; + +const PROVIDER_IDS: Record = { + standard: 'gemini-deep-research', + max: 'gemini-deep-research-max', +}; + +export interface GeminiDeepSubmitResult { + externalId: string; + status: 'queued' | 'running'; +} + +export interface GeminiDeepPollResult { + status: 'queued' | 'running' | 'completed' | 'failed'; + answer?: AgentAnswer; + error?: string; +} + +interface GeminiInteractionSubmitResponse { + id?: string; + name?: string; + status?: 'queued' | 'in_progress' | 'completed' | 'failed' | 'cancelled' | 'incomplete'; + error?: { message?: string }; +} + +/** + * Items in the `outputs` array of a completed Gemini interaction. + * Observed shapes (2026-04-22 preview): + * - { type: 'thought', signature, summary: [{ type: 'text', text }, ...] } + * - { type: 'text', text, annotations?: [{ type: 'url_citation', url, start_index, end_index }, ...] } + * - { type: 'image', mime_type: 'image/png', data: } + * - { } (occasional empty items — we ignore them) + */ +interface GeminiOutputItem { + type?: 'thought' | 'text' | 'image' | string; + text?: string; + annotations?: Array<{ + type?: string; + url?: string; + start_index?: number; + end_index?: number; + }>; + mime_type?: string; + data?: string; +} + +interface GeminiInteractionPollResponse extends GeminiInteractionSubmitResponse { + outputs?: GeminiOutputItem[]; + usage?: { + total_tokens?: number; + total_input_tokens?: number; + total_output_tokens?: number; + total_cached_tokens?: number; + total_tool_use_tokens?: number; + total_thought_tokens?: number; + }; +} + +const API_BASE = 'https://generativelanguage.googleapis.com/v1beta'; + +function authHeaders(apiKey: string): Record { + // Gemini accepts either `?key=...` or `x-goog-api-key`. Header is cleaner + // for logging and avoids url-encoding. + return { + 'Content-Type': 'application/json', + 'x-goog-api-key': apiKey, + }; +} + +export async function submitGeminiDeepResearch( + tier: GeminiDeepTier, + query: string, + options: { systemPrompt?: string; maxTokens?: number } = {}, + apiKey: string | null, + signal?: AbortSignal +): Promise { + const providerId = PROVIDER_IDS[tier]; + if (!apiKey) throw new ProviderNotConfiguredError(providerId); + + const body: Record = { + agent: MODEL_IDS[tier], + input: options.systemPrompt ? `${options.systemPrompt}\n\n${query}` : query, + background: true, + store: true, + agent_config: { + type: 'deep-research', + thinking_summaries: 'auto', + visualization: 'auto', + collaborative_planning: false, + }, + tools: [{ type: 'google_search' }, { type: 'url_context' }, { type: 'code_execution' }], + }; + if (options.maxTokens) body.max_output_tokens = options.maxTokens; + + const res = await fetch(`${API_BASE}/interactions`, { + method: 'POST', + headers: authHeaders(apiKey), + body: JSON.stringify(body), + signal, + }); + + if (!res.ok) { + const errBody = await res.text().catch(() => ''); + throw new ProviderError(providerId, `submit HTTP ${res.status} ${errBody.slice(0, 300)}`); + } + + const data = (await res.json()) as GeminiInteractionSubmitResponse; + const externalId = data.id ?? data.name?.replace(/^interactions\//, ''); + if (!externalId) throw new ProviderError(providerId, 'submit: missing interaction id'); + + return { + externalId, + status: data.status === 'in_progress' ? 'running' : 'queued', + }; +} + +export async function pollGeminiDeepResearch( + tier: GeminiDeepTier, + externalId: string, + apiKey: string | null, + signal?: AbortSignal +): Promise { + const providerId = PROVIDER_IDS[tier]; + if (!apiKey) throw new ProviderNotConfiguredError(providerId); + + const res = await fetch(`${API_BASE}/interactions/${encodeURIComponent(externalId)}`, { + headers: authHeaders(apiKey), + signal, + }); + + if (!res.ok) { + const errBody = await res.text().catch(() => ''); + throw new ProviderError(providerId, `poll HTTP ${res.status} ${errBody.slice(0, 300)}`); + } + + const data = (await res.json()) as GeminiInteractionPollResponse; + + 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 — walk the flat `outputs` array: + // - `thought` items are the live-streamed reasoning summaries, skipped + // because we already have the final report. + // - `text` items carry the report prose + url_citation annotations. + // - `image` items carry charts/infographics (base64 PNG); surfaced + // via providerRaw for now, not inlined into the answer text. + const textParts: string[] = []; + const citations = new Map(); + + for (const item of data.outputs ?? []) { + if (item.type === 'text' && item.text) { + textParts.push(item.text); + for (const ann of item.annotations ?? []) { + if (ann.type === 'url_citation' && ann.url && !citations.has(ann.url)) { + citations.set(ann.url, { + url: ann.url, + title: hostnameFromUrl(ann.url) ?? ann.url, + }); + } + } + } + // `thought` and `image` handled by leaving them in providerRaw + // where a downstream consumer (future Research Lab UI) can surface + // them without us having to model them here. + } + + const usage = data.usage; + const tokenUsage = usage + ? { + input: usage.total_input_tokens ?? 0, + output: usage.total_output_tokens ?? 0, + } + : undefined; + + const answer: AgentAnswer = { + query: '', + answer: textParts.join('').trim(), + citations: [...citations.values()], + tokenUsage, + providerRaw: data, + }; + + return { status: 'completed', answer }; +} + +function hostnameFromUrl(url: string): string | null { + try { + return new URL(url).hostname; + } catch { + return null; + } +} diff --git a/services/mana-research/src/router/auto-route.ts b/services/mana-research/src/router/auto-route.ts index b1f1ea388..1bf5691ac 100644 --- a/services/mana-research/src/router/auto-route.ts +++ b/services/mana-research/src/router/auto-route.ts @@ -78,16 +78,23 @@ export const AGENT_DEFAULT_ORDER: AgentProviderId[] = [ 'gemini-grounding', // cheap with Google Search 'openai-responses', // Responses API + web_search_preview 'claude-web-search', // high quality, higher cost - 'openai-deep-research', // last: async, very expensive + // Async agents (openai-deep-research, gemini-deep-research, + // gemini-deep-research-max) are explicitly NOT in this list — they + // run via /v1/research/async with its own dispatch. ]; export function pickAgent(config: Config): AgentProviderId | null { + // Async agents (openai-deep-research, gemini-deep-research*) are only + // reachable via POST /v1/research/async, so they are intentionally + // absent from AGENT_DEFAULT_ORDER and therefore never auto-picked here. const envMap: Record = { 'perplexity-sonar': 'perplexity', 'claude-web-search': 'anthropic', 'openai-responses': 'openai', 'gemini-grounding': 'googleGenai', 'openai-deep-research': 'openai', + 'gemini-deep-research': 'googleGenai', + 'gemini-deep-research-max': 'googleGenai', }; for (const id of AGENT_DEFAULT_ORDER) { if (config.providerKeys[envMap[id]]) return id; diff --git a/services/mana-research/src/routes/internal-research.ts b/services/mana-research/src/routes/internal-research.ts new file mode 100644 index 000000000..bd3597348 --- /dev/null +++ b/services/mana-research/src/routes/internal-research.ts @@ -0,0 +1,242 @@ +/** + * Internal service-to-service research routes. + * + * POST /api/v1/internal/research/async — submit an async research job for a user + * GET /api/v1/internal/research/async/:id — poll a job (scoped to the X-User-Id header) + * + * Callers (mana-ai today) authenticate via `X-Service-Key` and pass the + * target user id in `X-User-Id`. Credits are reserved against that user + * exactly like the user-facing path; the difference is only in how the + * caller is authorised. + * + * Keep the logic here a thin wrapper over the same submit/poll helpers + * the user-facing /async route uses — divergence would be silent and + * surprising for anyone debugging a mana-ai request against the user API + * later. + */ + +import { Hono } from 'hono'; +import { z } from 'zod'; +import { agentOptionsSchema } from '@mana/shared-research'; +import type { HonoEnv } from '../lib/hono-env'; +import type { AsyncJobStorage } from '../storage/async-jobs'; +import type { CreditsClient } from '../clients/mana-credits'; +import { BadRequestError, NotFoundError, UnauthorizedError } from '../lib/errors'; +import type { Config } from '../config'; +import { priceFor } from '../lib/pricing'; +import { pollDeepResearch, submitDeepResearch } from '../providers/agent/openai-deep-research'; +import { + pollGeminiDeepResearch, + submitGeminiDeepResearch, +} from '../providers/agent/gemini-deep-research'; + +const ASYNC_PROVIDER_IDS = [ + 'openai-deep-research', + 'gemini-deep-research', + 'gemini-deep-research-max', +] as const; +type AsyncProviderId = (typeof ASYNC_PROVIDER_IDS)[number]; + +const submitBodySchema = z.object({ + query: z.string().min(1).max(4000), + provider: z.enum(ASYNC_PROVIDER_IDS), + options: agentOptionsSchema.optional(), +}); + +interface AsyncDispatch { + apiKey: string | null; + missingKeyMessage: string; + submit( + query: string, + options: { systemPrompt?: string; maxTokens?: number; model?: string }, + apiKey: string, + signal?: AbortSignal + ): Promise<{ externalId: string; status: 'queued' | 'running' }>; + poll( + externalId: string, + apiKey: string, + signal?: AbortSignal + ): Promise<{ + status: 'queued' | 'running' | 'completed' | 'failed'; + answer?: import('@mana/shared-research').AgentAnswer; + error?: string; + }>; +} + +function dispatchAsync(providerId: AsyncProviderId, config: Config): AsyncDispatch { + switch (providerId) { + case 'openai-deep-research': + return { + apiKey: config.providerKeys.openai ?? null, + missingKeyMessage: 'openai-deep-research requires OPENAI_API_KEY', + submit: (q, o, k, s) => submitDeepResearch(q, o, k, s), + poll: (id, k, s) => pollDeepResearch(id, k, s), + }; + case 'gemini-deep-research': + return { + apiKey: config.providerKeys.googleGenai ?? null, + missingKeyMessage: 'gemini-deep-research requires GOOGLE_GENAI_API_KEY', + submit: (q, o, k, s) => submitGeminiDeepResearch('standard', q, o, k, s), + poll: (id, k, s) => pollGeminiDeepResearch('standard', id, k, s), + }; + case 'gemini-deep-research-max': + return { + apiKey: config.providerKeys.googleGenai ?? null, + missingKeyMessage: 'gemini-deep-research-max requires GOOGLE_GENAI_API_KEY', + submit: (q, o, k, s) => submitGeminiDeepResearch('max', q, o, k, s), + poll: (id, k, s) => pollGeminiDeepResearch('max', id, k, s), + }; + } +} + +function requireUserId(userId: string | undefined): string { + if (!userId) { + throw new UnauthorizedError('X-User-Id header is required on internal research routes'); + } + return userId; +} + +export function createInternalResearchRoutes( + config: Config, + asyncStorage: AsyncJobStorage, + credits: CreditsClient +) { + return new Hono() + .post('/async', async (c) => { + const userId = requireUserId(c.req.header('X-User-Id')); + const body = submitBodySchema.parse(await c.req.json()); + + const providerId = body.provider; + const dispatch = dispatchAsync(providerId, config); + if (!dispatch.apiKey) throw new BadRequestError(dispatch.missingKeyMessage); + + const price = priceFor(providerId, 'research'); + const reservation = await credits.reserve( + userId, + price, + `research:${providerId}:internal-submit` + ); + + try { + const submission = await dispatch.submit(body.query, body.options ?? {}, dispatch.apiKey); + const job = await asyncStorage.create({ + userId, + providerId, + 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, + costCredits: price, + }); + } catch (err) { + await credits.refund(reservation.reservationId).catch(() => {}); + throw err; + } + }) + .get('/async/:id', async (c) => { + const userId = requireUserId(c.req.header('X-User-Id')); + const job = await asyncStorage.get(c.req.param('id'), userId); + if (!job) throw new NotFoundError('Task not found'); + + 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, + }); + } + + if (!job.externalId) throw new BadRequestError('Task has no external id yet'); + + const jobProviderId = job.providerId as AsyncProviderId; + if (!(ASYNC_PROVIDER_IDS as readonly string[]).includes(jobProviderId)) { + throw new BadRequestError(`Unknown async provider on job: ${job.providerId}`); + } + const dispatch = dispatchAsync(jobProviderId, config); + if (!dispatch.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: `${dispatch.missingKeyMessage}; cannot poll`, + }); + } + + const poll = await dispatch.poll(job.externalId, dispatch.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('[internal] 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('[internal] 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, + }); + } + + 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(), + }); + }); +} diff --git a/services/mana-research/src/routes/providers.ts b/services/mana-research/src/routes/providers.ts index 47f6bcb81..38f8f4dd3 100644 --- a/services/mana-research/src/routes/providers.ts +++ b/services/mana-research/src/routes/providers.ts @@ -51,6 +51,8 @@ export function createProvidersRoutes(registry: ProviderRegistry, config: Config 'openai-responses': !!keys.openai, 'openai-deep-research': !!keys.openai, 'gemini-grounding': !!keys.googleGenai, + 'gemini-deep-research': !!keys.googleGenai, + 'gemini-deep-research-max': !!keys.googleGenai, }; const list = listProviders(registry); diff --git a/services/mana-research/src/routes/research.ts b/services/mana-research/src/routes/research.ts index 40226c3de..6f86e6134 100644 --- a/services/mana-research/src/routes/research.ts +++ b/services/mana-research/src/routes/research.ts @@ -20,9 +20,20 @@ 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'; +import { + pollGeminiDeepResearch, + submitGeminiDeepResearch, +} from '../providers/agent/gemini-deep-research'; const MAX_COMPARE_AGENTS = 4; +const ASYNC_PROVIDER_IDS = [ + 'openai-deep-research', + 'gemini-deep-research', + 'gemini-deep-research-max', +] as const; +type AsyncProviderId = (typeof ASYNC_PROVIDER_IDS)[number]; + const researchBodySchema = z.object({ query: z.string().min(1).max(2000), provider: z.enum(AGENT_PROVIDER_IDS).optional(), @@ -37,9 +48,59 @@ const compareBodySchema = z.object({ const asyncSubmitBodySchema = z.object({ query: z.string().min(1).max(4000), + provider: z.enum(ASYNC_PROVIDER_IDS).optional().default('openai-deep-research'), options: agentOptionsSchema.optional(), }); +interface AsyncDispatch { + apiKey: string | null; + missingKeyMessage: string; + submit( + query: string, + options: { systemPrompt?: string; maxTokens?: number; model?: string }, + apiKey: string, + signal?: AbortSignal + ): Promise<{ externalId: string; status: 'queued' | 'running' }>; + poll( + externalId: string, + apiKey: string, + signal?: AbortSignal + ): Promise<{ + status: 'queued' | 'running' | 'completed' | 'failed'; + answer?: import('@mana/shared-research').AgentAnswer; + error?: string; + }>; +} + +function dispatchAsync(providerId: AsyncProviderId, config: Config): AsyncDispatch { + switch (providerId) { + case 'openai-deep-research': + return { + apiKey: config.providerKeys.openai ?? null, + missingKeyMessage: + 'openai-deep-research requires OPENAI_API_KEY on the server or via BYO key', + submit: (q, o, k, s) => submitDeepResearch(q, o, k, s), + poll: (id, k, s) => pollDeepResearch(id, k, s), + }; + case 'gemini-deep-research': + return { + apiKey: config.providerKeys.googleGenai ?? null, + missingKeyMessage: + 'gemini-deep-research requires GOOGLE_GENAI_API_KEY on the server or via BYO key', + submit: (q, o, k, s) => submitGeminiDeepResearch('standard', q, o, k, s), + poll: (id, k, s) => pollGeminiDeepResearch('standard', id, k, s), + }; + case 'gemini-deep-research-max': + return { + apiKey: config.providerKeys.googleGenai ?? null, + missingKeyMessage: + 'gemini-deep-research-max requires GOOGLE_GENAI_API_KEY on the server or via BYO key', + submit: (q, o, k, s) => submitGeminiDeepResearch('max', q, o, k, s), + poll: (id, k, s) => pollGeminiDeepResearch('max', id, k, s), + }; + } +} + export function createResearchRoutes( registry: ProviderRegistry, storage: RunStorage, @@ -48,8 +109,6 @@ export function createResearchRoutes( asyncStorage: AsyncJobStorage, credits: CreditsClient ) { - const PROVIDER_ID = 'openai-deep-research' as const; - return new Hono() .post('/', async (c) => { const user = c.get('user'); @@ -178,25 +237,24 @@ export function createResearchRoutes( 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 providerId = body.provider; + const dispatch = dispatchAsync(providerId, config); + if (!dispatch.apiKey) { + throw new BadRequestError(dispatch.missingKeyMessage); } - const price = priceFor(PROVIDER_ID, 'research'); + const price = priceFor(providerId, 'research'); const reservation = await credits.reserve( user.userId, price, - `research:${PROVIDER_ID}:submit` + `research:${providerId}:submit` ); try { - const submission = await submitDeepResearch(body.query, body.options ?? {}, apiKey); + const submission = await dispatch.submit(body.query, body.options ?? {}, dispatch.apiKey); const job = await asyncStorage.create({ userId: user.userId, - providerId: PROVIDER_ID, + providerId, externalId: submission.externalId, status: submission.status, query: body.query, @@ -207,7 +265,7 @@ export function createResearchRoutes( return c.json({ taskId: job.id, status: job.status, - providerId: PROVIDER_ID, + providerId, costCredits: price, }); } catch (err) { @@ -239,8 +297,12 @@ export function createResearchRoutes( if (!job.externalId) { throw new BadRequestError('Task has no external id yet'); } - const apiKey = config.providerKeys.openai; - if (!apiKey) { + const jobProviderId = job.providerId as AsyncProviderId; + if (!(ASYNC_PROVIDER_IDS as readonly string[]).includes(jobProviderId)) { + throw new BadRequestError(`Unknown async provider on job: ${job.providerId}`); + } + const dispatch = dispatchAsync(jobProviderId, config); + if (!dispatch.apiKey) { return c.json({ taskId: job.id, status: job.status, @@ -249,11 +311,11 @@ export function createResearchRoutes( costCredits: job.costCredits, createdAt: job.createdAt, updatedAt: job.updatedAt, - error: 'OPENAI_API_KEY is no longer configured; cannot poll', + error: `${dispatch.missingKeyMessage.replace(' requires ', ' is missing ')}; cannot poll`, }); } - const poll = await pollDeepResearch(job.externalId, apiKey).catch((err: Error) => ({ + const poll = await dispatch.poll(job.externalId, dispatch.apiKey).catch((err: Error) => ({ status: 'failed' as const, error: err.message, }));