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.
165 lines
6.9 KiB
TypeScript
165 lines
6.9 KiB
TypeScript
// Shared server-side helpers for the OpenCode AI class.
|
|
//
|
|
// OpenCode (github.com/sst/opencode) runs as a local headless server
|
|
// (`opencode serve`) — the same runtime Paperclip drives as an agent adapter.
|
|
// Here it is used only as a one-shot chat backend for the mail assistant, so
|
|
// its HTTP surface is enough and no subprocess needs spawning from this app.
|
|
//
|
|
// IT IS NOT OpenAI-COMPATIBLE, despite `/v1/models` and `/v1/chat/completions`
|
|
// both answering 200: opencode serves a web UI from the same port with a
|
|
// catch-all route, so ANY unknown path returns the SPA's index.html with a 200.
|
|
// Checking `res.ok` alone therefore "verifies" endpoints that do not exist —
|
|
// verified the hard way, by believing exactly that before reading a body.
|
|
// Every probe here validates the parsed SHAPE, never the status code alone.
|
|
//
|
|
// The real API (from the server's own /doc OpenAPI spec):
|
|
// GET /api/model -> { data: [{ id, providerID, name, ... }] }
|
|
// POST /session -> { id: "ses_..." }
|
|
// POST /session/{id}/message -> { info, parts: [{ type: 'text', text }, ...] }
|
|
//
|
|
// Address resolution is deliberately narrow: loopback only. This class exists
|
|
// to reach a runtime on the user's OWN machine — pointing it at a remote host
|
|
// would silently turn "local, no keys, nothing leaves the device" into the
|
|
// opposite, so a non-loopback OPENCODE_BASE_URL is refused rather than honoured.
|
|
|
|
const DEFAULT_BASE_URLS = ['http://127.0.0.1:4096', 'http://localhost:4096'];
|
|
const PROBE_TIMEOUT_MS = 2500;
|
|
const PROMPT_TIMEOUT_MS = 120_000;
|
|
|
|
function isLoopback(raw: string): boolean {
|
|
try {
|
|
const url = new URL(raw);
|
|
return url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '::1';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Candidate addresses, honouring an explicit OPENCODE_BASE_URL when it is
|
|
* loopback. `opencode serve` defaults to a RANDOM port (`--port 0`), so the
|
|
* conventional 4096 only finds a server deliberately started there; the env
|
|
* var is how someone on another port points us at it. */
|
|
export function opencodeBaseUrls(): string[] {
|
|
const configured = process.env.OPENCODE_BASE_URL?.trim();
|
|
if (configured) {
|
|
if (!isLoopback(configured)) {
|
|
console.error('[opencode] ignoring non-loopback OPENCODE_BASE_URL:', configured);
|
|
return DEFAULT_BASE_URLS;
|
|
}
|
|
return [configured.replace(/\/+$/, ''), ...DEFAULT_BASE_URLS];
|
|
}
|
|
return DEFAULT_BASE_URLS;
|
|
}
|
|
|
|
interface OpencodeModelListResponse {
|
|
data?: Array<{ id?: string; providerID?: string; name?: string }>;
|
|
}
|
|
|
|
export interface OpencodeModel {
|
|
/** "providerID/modelID" — the reference shown in the picker and stored in
|
|
* settings, matching how opencode itself names models on the CLI. */
|
|
ref: string;
|
|
providerID: string;
|
|
modelID: string;
|
|
label: string;
|
|
}
|
|
|
|
/** Splits the stored "providerID/modelID" reference back into the pair the
|
|
* message API wants. Returns null for anything malformed rather than
|
|
* guessing, so a corrupted setting surfaces as a clear error. */
|
|
export function parseModelRef(ref: string): { providerID: string; modelID: string } | null {
|
|
const slash = ref.indexOf('/');
|
|
if (slash <= 0 || slash === ref.length - 1) return null;
|
|
return { providerID: ref.slice(0, slash), modelID: ref.slice(slash + 1) };
|
|
}
|
|
|
|
async function fetchJson(url: string, init: RequestInit, timeoutMs: number): Promise<unknown | null> {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
const res = await fetch(url, { ...init, signal: controller.signal });
|
|
if (!res.ok) return null;
|
|
// The SPA catch-all returns HTML with a 200 for unknown paths — see the
|
|
// module header. Content-type is what actually distinguishes a real API
|
|
// response from the web UI.
|
|
const contentType = res.headers.get('content-type') ?? '';
|
|
if (!contentType.includes('application/json')) return null;
|
|
return await res.json();
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
/** First reachable candidate that answers /api/model with a real model list. */
|
|
export async function findOpencodeServer(): Promise<{ baseUrl: string; models: OpencodeModel[] } | null> {
|
|
for (const baseUrl of opencodeBaseUrls()) {
|
|
const body = (await fetchJson(`${baseUrl}/api/model`, {}, PROBE_TIMEOUT_MS)) as OpencodeModelListResponse | null;
|
|
if (!body || !Array.isArray(body.data)) continue;
|
|
const models: OpencodeModel[] = body.data
|
|
.filter((m): m is { id: string; providerID: string; name?: string } =>
|
|
typeof m?.id === 'string' && !!m.id && typeof m?.providerID === 'string' && !!m.providerID)
|
|
.map((m) => ({
|
|
ref: `${m.providerID}/${m.id}`,
|
|
providerID: m.providerID,
|
|
modelID: m.id,
|
|
label: m.name ? `${m.name} (${m.providerID})` : `${m.providerID}/${m.id}`,
|
|
}));
|
|
if (models.length > 0) return { baseUrl, models };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
interface OpencodeMessageResponse {
|
|
parts?: Array<{ type?: string; text?: string }>;
|
|
}
|
|
|
|
/**
|
|
* One prompt, one answer. Creates a throwaway session per question — this is
|
|
* a stateless "ask about my mail" box, not a running conversation, and a fresh
|
|
* session keeps one question's context from leaking into the next.
|
|
*
|
|
* `system` is passed as opencode's own system field rather than as a message
|
|
* part, so the retrieved-mail prompt keeps the same shape it has for every
|
|
* other provider class (see buildPrompt in lib/ai/local-client.ts).
|
|
*/
|
|
export async function opencodePrompt(
|
|
baseUrl: string,
|
|
model: { providerID: string; modelID: string },
|
|
system: string | undefined,
|
|
userText: string,
|
|
): Promise<{ ok: true; answer: string } | { ok: false; error: string }> {
|
|
const session = (await fetchJson(
|
|
`${baseUrl}/session`,
|
|
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' },
|
|
PROBE_TIMEOUT_MS,
|
|
)) as { id?: string } | null;
|
|
if (!session?.id) return { ok: false, error: 'OpenCode would not start a session' };
|
|
|
|
const body = (await fetchJson(
|
|
`${baseUrl}/session/${encodeURIComponent(session.id)}/message`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model,
|
|
...(system ? { system } : {}),
|
|
parts: [{ type: 'text', text: userText }],
|
|
}),
|
|
},
|
|
PROMPT_TIMEOUT_MS,
|
|
)) as OpencodeMessageResponse | null;
|
|
|
|
if (!body) return { ok: false, error: 'OpenCode returned no usable response' };
|
|
// A reply carries several parts (step-start / reasoning / text / step-finish).
|
|
// Only the `text` parts are the answer; `reasoning` is the model's private
|
|
// chain of thought and must not be shown as the reply.
|
|
const answer = (body.parts ?? [])
|
|
.filter((p) => p.type === 'text' && typeof p.text === 'string' && p.text.trim())
|
|
.map((p) => (p.text as string).trim())
|
|
.join('\n\n');
|
|
if (!answer) return { ok: false, error: 'OpenCode returned no message content' };
|
|
return { ok: true, answer };
|
|
}
|