mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
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:
parent
3b85d7d3d2
commit
f10a95e842
11 changed files with 592 additions and 23 deletions
|
|
@ -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...`
|
||||
|
||||
|
|
|
|||
|
|
@ -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: <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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AgentProviderId, keyof Config['providerKeys']> = {
|
||||
'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;
|
||||
|
|
|
|||
242
services/mana-research/src/routes/internal-research.ts
Normal file
242
services/mana-research/src/routes/internal-research.ts
Normal 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(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<HonoEnv>()
|
||||
.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,
|
||||
}));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue