Files
accounted/app/api/agent/ask/route.ts
T
a071a0220b feat(agent): provider-agnostic single-call assistant endpoint (runs on a local model) (#1759)
WS1 rip track, RIP-2 (audit Option A), stacked on the AI provider
abstraction (#1740). The first replacement for the streaming Anthropic chat
runtime: a page-scoped, single-call assistant answer.

- lib/agent/ask/ask-service.ts: answerAssistantQuestion() uses
  getAiService().generateText, so it runs on whatever backend is configured:
  AWS Bedrock, the direct Anthropic API, OR any OpenAI-compatible endpoint,
  including a local model (Qwen behind llama.cpp/Ollama/vLLM). No tool loop,
  no Anthropic wire format, nothing to translate per provider. The caller (a
  page) supplies the context; the service reads only the company's own
  profile for grounding, and the system prompt forbids inventing figures.
- POST /api/agent/ask: same auth/rate-limit/sandbox/paywall gates as
  /invoke, but gated on getAiStatus().configured (not assistantAvailable),
  because ANY provider works here. That is the difference that lets the
  assistant answer on a local model where the streaming /invoke returns 503.

This is the non-UI foundation of the rip: the thin /chat console and the
page-scoped actions (RIP-3, UI, gated on visual sign-off) will consume this
endpoint; run-turn.ts's streaming path and the intents' getAnthropic() usage
are removed once nothing calls them.

Verified: 10 unit tests (service prompt shape + tier + context-as-data +
truncation; route 401/429/400/403 paywall/200-on-openai-compatible/503
unconfigured) + a live smoke against a local OpenAI-compatible mock (resolved
provider openai-compatible, POSTed model qwen3.8 with a placeholder key,
returned an answer). 421 agent/ai tests green; tsc, guards, lint clean.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 19:48:43 +02:00

89 lines
3.4 KiB
TypeScript

import { NextResponse } from 'next/server'
import { z } from 'zod'
import { requireAuth } from '@/lib/auth/require-auth'
import { getActiveCompanyId } from '@/lib/company/context'
import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent'
import { guardSandbox } from '@/lib/sandbox/guard'
import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { getAiStatus } from '@/lib/ai'
import { answerAssistantQuestion } from '@/lib/agent/ask/ask-service'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* POST /api/agent/ask: a single-call, provider-agnostic assistant answer.
*
* Unlike POST /api/agent/invoke (the streaming Anthropic chat runtime, which
* is gated on `assistantAvailable` and only runs on the Anthropic family),
* this endpoint uses getAiService().generateText, so it runs on ANY configured
* backend, including an OpenAI-compatible local model. It is therefore gated
* on `configured`, not `assistantAvailable`. This is the replacement chat
* surface's server side (audit Option A / rip): a page posts its context and
* a question, gets one answer back.
*/
const Schema = z.object({
question: z.string().min(1).max(4000),
context: z.string().max(24_000).optional(),
tier: z.enum(['assistant', 'heavy']).optional(),
company_id: z.string().uuid().optional(),
})
export async function POST(request: Request): Promise<Response> {
const { user, supabase, error } = await requireAuth()
if (error) return error
const rate = await checkAgentRateLimit(supabase, user.id)
if (!rate.ok) return NextResponse.json(agentRateLimitResponseBody(rate), { status: 429 })
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const parsed = Schema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Ogiltig fråga.', type: 'validation_error' }, { status: 400 })
}
const companyId = parsed.data.company_id ?? (await getActiveCompanyId(supabase, user.id))
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
const { data: membership } = await supabase
.from('company_members')
.select('user_id')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (!membership) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const blocked = await guardSandbox(supabase, companyId)
if (blocked) return blocked
const capBlocked = await requireCapability(supabase, companyId, CAPABILITY.ai)
if (capBlocked) return capBlocked
// Distinct from the paywall: no AI backend configured at all. Unlike the
// chat loop, ANY provider works here, so we gate on `configured`.
if (!getAiStatus().configured) {
return NextResponse.json(
{ error: 'Assistenten är inte konfigurerad på den här installationen.', code: 'ai_unconfigured' },
{ status: 503 },
)
}
try {
const result = await answerAssistantQuestion({
supabase,
companyId,
question: parsed.data.question,
pageContext: parsed.data.context,
tier: parsed.data.tier,
})
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json({ error: getUserErrorMessage(err) }, { status: 500 })
}
}