feat(ai): OpenCode provider class; fix retrieval reading the wrong account's index

Three things, all from running the real thing rather than trusting a status code.

1. OpenCode as a 4th AI class (lib/ai/opencode.ts + app/api/ai/opencode/*).
   A locally-running `opencode serve` — the same runtime Paperclip drives as
   an adapter. Its appeal over a BYOK profile is precisely what was broken
   before: opencode owns provider auth itself, so there is NO api key for
   this app to hold, and it reports a REAL model list (25 on this machine)
   instead of asking the user to type an exact provider-specific model id
   from memory. Typing "Sonnet 5" into a free-text box and getting a bare
   "Provider returned 401" is the failure this removes.

   IMPORTANT trap, documented in the module header and pinned by a test:
   opencode is NOT OpenAI-compatible. `/v1/models` and `/v1/chat/completions`
   both answer 200 — because a web-UI catch-all serves index.html for ANY
   unknown path. I built the first version against that assumed compatibility
   on the strength of two 200s and had to throw it away once I read a body.
   Every probe now validates the parsed shape and content-type, never the
   status alone. The real API is GET /api/model + POST /session +
   POST /session/{id}/message, and the reply's `reasoning` parts are stripped
   so a model's private chain of thought can never surface as the answer.

   Proxied through our own backend (like the `server` class) because the
   desktop renderer's origin is a random port that changes every launch;
   same-origin sidesteps opencode's CORS allowlist entirely. Loopback-only by
   construction: a non-loopback OPENCODE_BASE_URL is refused, since "local,
   no keys, nothing leaves the device" is the whole point of this class.

2. Retrieval read the WRONG ACCOUNT'S index. The indexer writes under the
   active account's cookie slot (catchUpIndex passes it) but fetchLocalLeg
   omitted `?slot=`, so search resolved to whichever account the multi-slot
   resolver found first. Single-account installs never noticed; a real
   multi-account/shared-mailbox setup reads an empty store every time. Both
   call sites now pass the active slot.

3. "No local mail index available in this session" was shown even when the
   index existed and simply matched nothing — actively misleading, and it
   masked the missing-SESSION_SECRET bug for hours. AskResult now carries
   retrievalState ('augmented' | 'no-match' | 'no-index') and the two cases
   get different words: build the index, versus rephrase (with the honest
   caveat that keyword search answers content questions better than recency
   ones like "the last mail").

Verified live against real opencode 1.18.14: discovery found 25 models and a
real prompt round-tripped the exact expected answer through the real helper
code, not curl. Gate: tsc clean, eslint clean, 2512/2512 unit tests (10 new,
incl. one that fails if the HTML catch-all is ever accepted as an API), build clean.
This commit is contained in:
Bernd Rodler
2026-08-06 20:18:23 +02:00
parent 1f199fdc1d
commit 98dcd3b1e9
12 changed files with 658 additions and 26 deletions
+94
View File
@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from 'next/server';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { configManager } from '@/lib/admin/config-manager';
import { findOpencodeServer, parseModelRef, opencodePrompt } from '@/lib/ai/opencode';
import { logger } from '@/lib/logger';
export const runtime = 'nodejs';
const MAX_BODY_BYTES = 200 * 1024;
interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
/**
* POST /api/ai/opencode/chat — one-shot chat against a locally-running
* `opencode serve`.
*
* Deliberately NOT entitlement-metered, unlike /api/ai/server/chat: this runs
* on the user's own machine against provider credentials opencode itself
* holds, so there is no centrally-borne cost for this app to bill — the same
* reasoning that leaves `local` unmetered (lib/ai/entitlement.ts's header).
*/
export async function POST(request: NextRequest) {
const auth = await getStalwartCredentials(request);
if (!auth) {
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
}
await configManager.ensureLoaded();
if (configManager.getAiConsoleConfig().classesEnabled.opencode === false) {
return NextResponse.json({ error: 'the OpenCode class is disabled by admin policy' }, { status: 403 });
}
const rawBody = await request.text();
if (rawBody.length > MAX_BODY_BYTES) {
return NextResponse.json({ error: 'request too large' }, { status: 413 });
}
let body: { model?: unknown; messages?: unknown };
try {
body = JSON.parse(rawBody);
} catch {
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
}
const model = typeof body.model === 'string' ? body.model : '';
const messages = Array.isArray(body.messages) ? (body.messages as ChatMessage[]) : null;
if (!model || !messages || messages.length === 0) {
return NextResponse.json({ error: 'model and messages are required' }, { status: 400 });
}
const found = await findOpencodeServer();
if (!found) {
return NextResponse.json(
{ error: 'No local OpenCode server found. Start one with: opencode serve --port 4096' },
{ status: 503 },
);
}
if (!found.models.some((m) => m.ref === model)) {
// The picker is populated from this same list, so a mismatch means the
// saved model was removed/renamed in opencode since it was chosen -
// clearer to say so than to forward it and surface opencode's own error.
return NextResponse.json(
{ error: `OpenCode no longer offers the model "${model}" \u2014 pick another in Settings.` },
{ status: 400 },
);
}
const parsed = parseModelRef(model);
if (!parsed) {
return NextResponse.json({ error: `Malformed model reference "${model}"` }, { status: 400 });
}
// Flatten our chat-messages shape onto opencode's (system field + text
// parts). Every non-system message is already just the built prompt.
const system = messages.filter((m) => m.role === 'system').map((m) => m.content).join('\n\n') || undefined;
const userText = messages.filter((m) => m.role !== 'system').map((m) => m.content).join('\n\n');
if (!userText.trim()) {
return NextResponse.json({ error: 'no user content to send' }, { status: 400 });
}
try {
const result = await opencodePrompt(found.baseUrl, parsed, system, userText);
if (!result.ok) {
logger.error('opencode prompt failed', { error: result.error });
return NextResponse.json({ error: result.error }, { status: 502 });
}
return NextResponse.json({ answer: result.answer });
} catch (cause) {
logger.error('opencode chat failed', { error: cause instanceof Error ? cause.message : String(cause) });
return NextResponse.json({ error: 'OpenCode server unreachable' }, { status: 502 });
}
}