feat(mana-research): add Gemini 3.1 Pro Deep Research async providers

- New providers gemini-deep-research + gemini-deep-research-max on the
  Interactions API (preview-04-2026). Submit/poll split, tier parameter
  selects between standard (~minutes, $1–3) and max (up to 60 min, $3–7).
- Parser matches the real response shape: flat `outputs` array of
  thought|text|image items, url_citation annotations without title,
  `usage.total_input_tokens` / `total_output_tokens`.
- Route generalisation: /v1/research/async accepts `provider` with
  default 'openai-deep-research' (backward compatible) and dispatches
  to the right submit/poll pair.
- New internal service-to-service endpoint /v1/internal/research/async
  gated by X-Service-Key + X-User-Id for credit accounting. Enables
  mana-ai to drive deep-research jobs on the mission owner's wallet
  without requiring a user JWT.
- Pricing: 300 credits (standard) / 1500 credits (max). Conservative
  markup over the ~$3/$7 ceiling so the first runs can't surprise us.
- Docs: AGENT_PROVIDER_IDS + pricing + env map + auto-router stay in
  sync; CLAUDE.md Phase 3b now current; API_KEYS.md references the
  new providers under GOOGLE_GENAI_API_KEY.

Verified with a real smoke test against the Gemini API: submit + poll
both succeed, completed response parsed cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-22 17:55:30 +02:00
parent 3b85d7d3d2
commit f10a95e842
11 changed files with 592 additions and 23 deletions

View file

@ -20,6 +20,8 @@ export const AGENT_PROVIDER_IDS = [
'openai-responses', 'openai-responses',
'gemini-grounding', 'gemini-grounding',
'openai-deep-research', 'openai-deep-research',
'gemini-deep-research',
'gemini-deep-research-max',
] as const; ] as const;
export type SearchProviderId = (typeof SEARCH_PROVIDER_IDS)[number]; export type SearchProviderId = (typeof SEARCH_PROVIDER_IDS)[number];

View file

@ -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) | | [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 | | [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 | | [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 | | [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 | | [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) | | [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.010.10.
- Grounding-Query: $35/1000 Suchen - Grounding-Query: $35/1000 Suchen
- `gemini-1.5-pro`: teurer - `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` (~$13/Task, Standard) und
`gemini-deep-research-max` (~$37/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 **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...` **Env-Var:** `GOOGLE_GENAI_API_KEY=AIza...`

View file

@ -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 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 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 3a** ✅ — 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 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. - **Phase 4** — Research Lab UI + Settings for BYO-keys.
## API Endpoints ## API Endpoints
@ -65,7 +65,15 @@ bun run db:studio
### Service-to-service (X-Service-Key) ### 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: <MANA_SERVICE_KEY>`. Endpoints that touch per-user state additionally require `X-User-Id: <userId>` 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 ## 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. | | `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. | | `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. | | `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 ## Auto-routing

View file

@ -17,6 +17,8 @@ export function mapEnvKey(providerId: ProviderId): keyof Config['providerKeys']
'openai-responses': 'openai', 'openai-responses': 'openai',
'openai-deep-research': 'openai', 'openai-deep-research': 'openai',
'gemini-grounding': 'googleGenai', 'gemini-grounding': 'googleGenai',
'gemini-deep-research': 'googleGenai',
'gemini-deep-research-max': 'googleGenai',
'jina-reader': 'jina', 'jina-reader': 'jina',
firecrawl: 'firecrawl', firecrawl: 'firecrawl',
scrapingbee: 'scrapingbee', scrapingbee: 'scrapingbee',

View file

@ -19,6 +19,7 @@ import { healthRoutes } from './routes/health';
import { createSearchRoutes } from './routes/search'; import { createSearchRoutes } from './routes/search';
import { createExtractRoutes } from './routes/extract'; import { createExtractRoutes } from './routes/extract';
import { createResearchRoutes } from './routes/research'; import { createResearchRoutes } from './routes/research';
import { createInternalResearchRoutes } from './routes/internal-research';
import { createProvidersRoutes } from './routes/providers'; import { createProvidersRoutes } from './routes/providers';
import { createRunsRoutes } from './routes/runs'; import { createRunsRoutes } from './routes/runs';
import { createProviderConfigRoutes } from './routes/provider-configs'; 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.use('/api/v1/provider-configs/*', jwtAuth(config.manaAuthUrl));
app.route('/api/v1/provider-configs', createProviderConfigRoutes(db)); 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.use('/api/v1/internal/*', serviceAuth(config.serviceKey));
app.get('/api/v1/internal/health', (c) => c.json({ ok: true })); app.get('/api/v1/internal/health', (c) => c.json({ ok: true }));
app.route('/api/v1/internal/research', createInternalResearchRoutes(config, asyncStorage, credits));
// ─── Start ────────────────────────────────────────────────── // ─── Start ──────────────────────────────────────────────────

View file

@ -31,6 +31,12 @@ export const PROVIDER_PRICING: Record<
'openai-responses': { research: 200 }, 'openai-responses': { research: 200 },
'gemini-grounding': { research: 100 }, 'gemini-grounding': { research: 100 },
'openai-deep-research': { research: 1000 }, 'openai-deep-research': { research: 1000 },
// Gemini Deep Research (preview-04-2026). Google lists $13 per standard
// task and $37 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( export function priceFor(

View file

@ -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, $13/task) uses
* deep-research-preview-04-2026; `max` (~20min typical, up to 60min,
* $37/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<GeminiDeepTier, string> = {
standard: 'deep-research-preview-04-2026',
max: 'deep-research-max-preview-04-2026',
};
const PROVIDER_IDS: Record<GeminiDeepTier, 'gemini-deep-research' | 'gemini-deep-research-max'> = {
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: <base64> }
* - { } (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<string, string> {
// 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<GeminiDeepSubmitResult> {
const providerId = PROVIDER_IDS[tier];
if (!apiKey) throw new ProviderNotConfiguredError(providerId);
const body: Record<string, unknown> = {
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<GeminiDeepPollResult> {
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<string, Citation>();
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;
}
}

View file

@ -78,16 +78,23 @@ export const AGENT_DEFAULT_ORDER: AgentProviderId[] = [
'gemini-grounding', // cheap with Google Search 'gemini-grounding', // cheap with Google Search
'openai-responses', // Responses API + web_search_preview 'openai-responses', // Responses API + web_search_preview
'claude-web-search', // high quality, higher cost '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 { 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<AgentProviderId, keyof Config['providerKeys']> = { const envMap: Record<AgentProviderId, keyof Config['providerKeys']> = {
'perplexity-sonar': 'perplexity', 'perplexity-sonar': 'perplexity',
'claude-web-search': 'anthropic', 'claude-web-search': 'anthropic',
'openai-responses': 'openai', 'openai-responses': 'openai',
'gemini-grounding': 'googleGenai', 'gemini-grounding': 'googleGenai',
'openai-deep-research': 'openai', 'openai-deep-research': 'openai',
'gemini-deep-research': 'googleGenai',
'gemini-deep-research-max': 'googleGenai',
}; };
for (const id of AGENT_DEFAULT_ORDER) { for (const id of AGENT_DEFAULT_ORDER) {
if (config.providerKeys[envMap[id]]) return id; if (config.providerKeys[envMap[id]]) return id;

View file

@ -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<HonoEnv>()
.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(),
});
});
}

View file

@ -51,6 +51,8 @@ export function createProvidersRoutes(registry: ProviderRegistry, config: Config
'openai-responses': !!keys.openai, 'openai-responses': !!keys.openai,
'openai-deep-research': !!keys.openai, 'openai-deep-research': !!keys.openai,
'gemini-grounding': !!keys.googleGenai, 'gemini-grounding': !!keys.googleGenai,
'gemini-deep-research': !!keys.googleGenai,
'gemini-deep-research-max': !!keys.googleGenai,
}; };
const list = listProviders(registry); const list = listProviders(registry);

View file

@ -20,9 +20,20 @@ import type { Config } from '../config';
import { pickAgent } from '../router/auto-route'; import { pickAgent } from '../router/auto-route';
import { priceFor } from '../lib/pricing'; import { priceFor } from '../lib/pricing';
import { pollDeepResearch, submitDeepResearch } from '../providers/agent/openai-deep-research'; import { pollDeepResearch, submitDeepResearch } from '../providers/agent/openai-deep-research';
import {
pollGeminiDeepResearch,
submitGeminiDeepResearch,
} from '../providers/agent/gemini-deep-research';
const MAX_COMPARE_AGENTS = 4; 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({ const researchBodySchema = z.object({
query: z.string().min(1).max(2000), query: z.string().min(1).max(2000),
provider: z.enum(AGENT_PROVIDER_IDS).optional(), provider: z.enum(AGENT_PROVIDER_IDS).optional(),
@ -37,9 +48,59 @@ const compareBodySchema = z.object({
const asyncSubmitBodySchema = z.object({ const asyncSubmitBodySchema = z.object({
query: z.string().min(1).max(4000), query: z.string().min(1).max(4000),
provider: z.enum(ASYNC_PROVIDER_IDS).optional().default('openai-deep-research'),
options: agentOptionsSchema.optional(), 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( export function createResearchRoutes(
registry: ProviderRegistry, registry: ProviderRegistry,
storage: RunStorage, storage: RunStorage,
@ -48,8 +109,6 @@ export function createResearchRoutes(
asyncStorage: AsyncJobStorage, asyncStorage: AsyncJobStorage,
credits: CreditsClient credits: CreditsClient
) { ) {
const PROVIDER_ID = 'openai-deep-research' as const;
return new Hono<HonoEnv>() return new Hono<HonoEnv>()
.post('/', async (c) => { .post('/', async (c) => {
const user = c.get('user'); const user = c.get('user');
@ -178,25 +237,24 @@ export function createResearchRoutes(
const user = c.get('user'); const user = c.get('user');
const body = asyncSubmitBodySchema.parse(await c.req.json()); const body = asyncSubmitBodySchema.parse(await c.req.json());
const apiKey = config.providerKeys.openai; const providerId = body.provider;
if (!apiKey) { const dispatch = dispatchAsync(providerId, config);
throw new BadRequestError( if (!dispatch.apiKey) {
'openai-deep-research requires OPENAI_API_KEY on the server or via BYO key' throw new BadRequestError(dispatch.missingKeyMessage);
);
} }
const price = priceFor(PROVIDER_ID, 'research'); const price = priceFor(providerId, 'research');
const reservation = await credits.reserve( const reservation = await credits.reserve(
user.userId, user.userId,
price, price,
`research:${PROVIDER_ID}:submit` `research:${providerId}:submit`
); );
try { 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({ const job = await asyncStorage.create({
userId: user.userId, userId: user.userId,
providerId: PROVIDER_ID, providerId,
externalId: submission.externalId, externalId: submission.externalId,
status: submission.status, status: submission.status,
query: body.query, query: body.query,
@ -207,7 +265,7 @@ export function createResearchRoutes(
return c.json({ return c.json({
taskId: job.id, taskId: job.id,
status: job.status, status: job.status,
providerId: PROVIDER_ID, providerId,
costCredits: price, costCredits: price,
}); });
} catch (err) { } catch (err) {
@ -239,8 +297,12 @@ export function createResearchRoutes(
if (!job.externalId) { if (!job.externalId) {
throw new BadRequestError('Task has no external id yet'); throw new BadRequestError('Task has no external id yet');
} }
const apiKey = config.providerKeys.openai; const jobProviderId = job.providerId as AsyncProviderId;
if (!apiKey) { 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({ return c.json({
taskId: job.id, taskId: job.id,
status: job.status, status: job.status,
@ -249,11 +311,11 @@ export function createResearchRoutes(
costCredits: job.costCredits, costCredits: job.costCredits,
createdAt: job.createdAt, createdAt: job.createdAt,
updatedAt: job.updatedAt, 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, status: 'failed' as const,
error: err.message, error: err.message,
})); }));