Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions api/_lib/aiErrors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";

import { preStreamError } from "./aiErrors.js";

describe("preStreamError", () => {
it("maps each kind to its status and a code matching the kind", () => {
expect(preStreamError("method_not_allowed").status).toBe(405);
expect(preStreamError("disabled").status).toBe(503);
expect(preStreamError("server_config").status).toBe(500);
expect(preStreamError("bad_request").status).toBe(400);
expect(preStreamError("rate_limit").status).toBe(429);
expect(preStreamError("disabled").body.code).toBe("disabled");
});

it("returns copy that explains the problem and what to try", () => {
expect(preStreamError("disabled").body.error).toMatch(/turned off/i);
expect(preStreamError("bad_request").body.error).toMatch(/new chat/i);
expect(preStreamError("rate_limit").body.error).toMatch(/wait a moment/i);
expect(preStreamError("server_config").body.error).toMatch(/try again later/i);
});

it("never leaks internal configuration detail in the server_config copy", () => {
expect(preStreamError("server_config").body.error).not.toMatch(/api[_ ]?key|OPENROUTER/i);
});

it("carries retryAfter structurally rather than in the copy", () => {
const rl = preStreamError("rate_limit", 30);
expect(rl.body.retryAfter).toBe(30);
expect(rl.body.error).not.toMatch(/30/);
});

it("omits retryAfter entirely when none is given", () => {
expect("retryAfter" in preStreamError("rate_limit").body).toBe(false);
});
});
100 changes: 100 additions & 0 deletions api/_lib/aiErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// User-facing copy for every way an AI turn can fail — the pre-stream
// rejections (bad request, disabled, rate limited) and the mid-stream upstream
// failures. Kept in one module so the wording stays consistent and can be
// improved without touching route logic.

export interface AiErrorBody {
code: string;
error: string;
retryAfter?: number;
}

export type PreStreamErrorKind =
| "method_not_allowed"
| "disabled"
| "server_config"
| "bad_request"
| "rate_limit";

const PRE_STREAM_ERRORS: Record<PreStreamErrorKind, { status: number; error: string }> = {
method_not_allowed: {
status: 405,
error: "This endpoint only accepts POST requests.",
},
disabled: {
status: 503,
error: "The AI assistant is currently turned off. Please check back later.",
},
// Deliberately vague: the operator-facing cause (a missing OPENROUTER_API_KEY)
// is logged server-side and must not be echoed to the browser.
server_config: {
status: 500,
error:
"The AI assistant is temporarily unavailable due to a server configuration issue. " +
"Please try again later.",
},
bad_request: {
status: 400,
error: "Your message couldn't be processed. Try starting a new chat, then send it again.",
},
rate_limit: {
status: 429,
error: "You're sending messages too quickly. Please wait a moment and try again.",
},
};

/**
* Maps a pre-stream rejection to its HTTP status and client-facing body.
* `retryAfter` (seconds) is carried structurally rather than embedded in the
* copy — the client appends the precise countdown as "(retry in Xs)", so
* baking a number into the sentence would show it twice.
*/
export function preStreamError(
kind: PreStreamErrorKind,
retryAfter?: number,
): { status: number; body: AiErrorBody } {
const { status, error } = PRE_STREAM_ERRORS[kind];
return {
status,
body: { code: kind, error, ...(retryAfter !== undefined ? { retryAfter } : {}) },
};
}

// Reads the `retry-after` header (seconds) from an upstream error, tolerating
// both a Headers object and a plain record.
function retryAfterSeconds(err: unknown): number | undefined {
const headers = (err as { headers?: unknown }).headers;
let raw: string | null | undefined;
if (headers && typeof (headers as Headers).get === "function") {
raw = (headers as Headers).get("retry-after");
} else if (headers && typeof headers === "object") {
raw = (headers as Record<string, string>)["retry-after"];
}
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : undefined;
}

// Maps an error thrown during the turn to a client-facing SSE error. Upstream
// rate limits and overloads get a clear, actionable message (and retry timing)
// instead of an opaque failure.
export function describeTurnError(err: unknown): {
code: string;
message: string;
retryAfter?: number;
} {
const status = (err as { status?: number }).status;
if (status === 429) {
return {
code: "rate_limit",
message: "The AI service is rate limited. Please wait a moment and try again.",
retryAfter: retryAfterSeconds(err),
};
}
if (status === 502 || status === 503 || status === 529) {
return {
code: "overloaded",
message: "The AI service is temporarily overloaded. Please try again shortly.",
};
}
return { code: "server_error", message: (err as Error).message || "The request failed." };
}
29 changes: 29 additions & 0 deletions api/_lib/aiHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,4 +317,33 @@ describe("runAiTurn", () => {
]);
expect(handoff!.data.serverToolResults[0] as any).toMatchObject({ tool_call_id: "c1" });
});

it("announces each server tool with a status event before the turn completes", async () => {
const { sink, events } = collectingSink();
const client = fakeClient([
{
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "s1",
type: "function",
function: { name: "read_skill_reference", arguments: '{"name":"patterns"}' },
},
],
},
finish_reason: "tool_calls",
},
{ message: { role: "assistant", content: "done" }, finish_reason: "stop" },
]);
await runAiTurn(req, { ...deps, client }, sink);

// Server tools run entirely server-side, so without this event the client
// sees no activity at all while they execute.
const statusIdx = events.findIndex((e) => e.event === "status");
expect(statusIdx).toBeGreaterThanOrEqual(0);
expect(events[statusIdx].data.label).toBe("Reading design references");
expect(statusIdx).toBeLessThan(events.findIndex((e) => e.event === "done"));
});
});
23 changes: 12 additions & 11 deletions api/_lib/aiHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,17 +84,18 @@ export async function runAiTurn(
(o) => !SERVER_TOOL_NAMES.has(o.call.function.name),
);

const serverToolResults: OpenRouterMessage[] = [
...malformedResults,
...serverOutcomes.map((o) => {
const tool = SERVER_TOOLS.find((t) => t.name === o.call.function.name)!;
return {
role: "tool" as const,
tool_call_id: o.call.id,
content: tool.execute(o.parsed.value),
};
}),
];
const serverToolResults: OpenRouterMessage[] = [...malformedResults];
for (const o of serverOutcomes) {
const tool = SERVER_TOOLS.find((t) => t.name === o.call.function.name)!;
// Announce before executing: a server-tool-only round trip emits no
// handoff, so this is the client's only signal that work is happening.
sink.send("status", { label: tool.progressLabel });
serverToolResults.push({
role: "tool",
tool_call_id: o.call.id,
content: tool.execute(o.parsed.value),
});
}

if (clientOutcomes.length > 0 || malformedClientToolCalls.length > 0) {
sink.send("handoff", {
Expand Down
5 changes: 5 additions & 0 deletions api/_lib/serverTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ export interface ServerTool {
name: string;
description: string;
input_schema: Record<string, unknown>;
// Present-progressive label shown in the assistant's live status line while
// this tool runs. Server tools produce no client-side activity chip, so this
// is the only progress the user sees for them.
progressLabel: string;
execute(input: unknown): string;
}

Expand All @@ -30,6 +34,7 @@ const readSkillReference: ServerTool = {
},
required: ["name"],
},
progressLabel: "Reading design references",
execute(input) {
const name = (input as { name?: string })?.name;
if (!name || !VALID_REFERENCES.includes(name as SkillReferenceName)) {
Expand Down
65 changes: 17 additions & 48 deletions api/ai.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import type { VercelRequest, VercelResponse } from "@vercel/node";
import z from "zod";

import { describeTurnError, preStreamError, type PreStreamErrorKind } from "./_lib/aiErrors.js";
import { runAiTurn } from "./_lib/aiHandler.js";
import { bootstrapAiRoute } from "./_lib/aiRoute.js";
import type { OpenRouterLike } from "./_lib/openrouterClient.js";
import { createLimiter } from "./_lib/ratelimit.js";
import { AiRequestSchema } from "./_lib/schema.js";
import type { SseSink } from "./_lib/sse.js";

// Re-exported so the route module stays the public entry point for error
// description (api/ai.test.ts imports it from here).
export { describeTurnError };

function posIntEnv(value: string | undefined, fallback: number): number {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? n : fallback;
Expand All @@ -24,25 +29,28 @@ export async function handleAiRequest(args: {
}): Promise<void> {
const { method, body, ip, env, client, sink, respondError } = args;

if (method !== "POST") return respondError(405, { error: "Method not allowed" });
if ((env.AI_ENABLED ?? "true") === "false") {
return respondError(503, { error: "AI assistant is disabled" });
}
// All client-facing failure copy comes from aiErrors so the wording stays
// consistent with the mid-stream errors described by describeTurnError.
const fail = (kind: PreStreamErrorKind, retryAfter?: number) => {
const { status, body: errorBody } = preStreamError(kind, retryAfter);
respondError(status, errorBody);
};

if (method !== "POST") return fail("method_not_allowed");
if ((env.AI_ENABLED ?? "true") === "false") return fail("disabled");
if (!env.OPENROUTER_API_KEY) {
console.error(
"[api/ai] OPENROUTER_API_KEY is not set — returning 500. Set it in .env (dev) or the Vercel env (prod).",
);
return respondError(500, { error: "Server configuration error" });
return fail("server_config");
}

const { data, error } = z.safeParse(AiRequestSchema, body);
if (error) return respondError(400, { error: "Invalid request body" });
if (error) return fail("bad_request");

const limiter = createLimiter(env);
const rl = await limiter.limit(ip);
if (!rl.ok) {
return respondError(429, { error: "Rate limit exceeded", retryAfter: rl.retryAfterSeconds });
}
if (!rl.ok) return fail("rate_limit", rl.retryAfterSeconds);

try {
await runAiTurn(
Expand All @@ -62,45 +70,6 @@ export async function handleAiRequest(args: {
}
}

// Reads the `retry-after` header (seconds) from an upstream error, tolerating
// both a Headers object and a plain record.
function retryAfterSeconds(err: unknown): number | undefined {
const headers = (err as { headers?: unknown }).headers;
let raw: string | null | undefined;
if (headers && typeof (headers as Headers).get === "function") {
raw = (headers as Headers).get("retry-after");
} else if (headers && typeof headers === "object") {
raw = (headers as Record<string, string>)["retry-after"];
}
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : undefined;
}

// Maps an error thrown during the turn to a client-facing SSE error. Upstream
// rate limits and overloads get a clear, actionable message (and retry timing)
// instead of an opaque failure.
export function describeTurnError(err: unknown): {
code: string;
message: string;
retryAfter?: number;
} {
const status = (err as { status?: number }).status;
if (status === 429) {
return {
code: "rate_limit",
message: "The AI service is rate limited. Please wait a moment and try again.",
retryAfter: retryAfterSeconds(err),
};
}
if (status === 502 || status === 503 || status === 529) {
return {
code: "overloaded",
message: "The AI service is temporarily overloaded. Please try again shortly.",
};
}
return { code: "server_error", message: (err as Error).message || "The request failed." };
}

function clientIp(req: VercelRequest): string {
const realIp = req.headers["x-real-ip"];
if (typeof realIp === "string" && realIp.trim()) return realIp.trim();
Expand Down
4 changes: 3 additions & 1 deletion src/components/panels/assistant/AssistantMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,10 @@ export function AssistantMessage({
{message.state === "error" && (
<div className="flex items-start gap-1.5 rounded border border-destructive/40 bg-destructive/10 px-2 py-1 text-xs text-destructive">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
{/* The destructive styling and icon already signal failure; the
message itself is a complete, actionable sentence. */}
<span className="min-w-0 break-words">
Failed — {message.errorText ?? "something went wrong"}
{message.errorText ?? "Something went wrong. Please try again."}
</span>
</div>
)}
Expand Down
9 changes: 7 additions & 2 deletions src/components/panels/assistant/MessageList.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Loader2 } from "lucide-react";
import { useEffect, useRef } from "react";

import type { DisplayMessage } from "../../../services/assistant/store";
import { type DisplayMessage, useAssistantStore } from "../../../services/assistant/store";
import type { LocalParseService } from "../../../services/localparse";

import { AssistantMessage } from "./AssistantMessage";
Expand All @@ -17,6 +17,7 @@ export function MessageList({
busy: boolean;
localParseService: LocalParseService;
}) {
const activity = useAssistantStore((s) => s.activity);
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Some environments' scrollIntoView returns a Promise; an effect must
Expand All @@ -26,10 +27,14 @@ export function MessageList({
}, [messages, busy]);

const last = messages.length > 0 ? messages[messages.length - 1] : undefined;
const working =
// A specific activity ("Editing document", "Reading design references") always
// wins; the generic fallback covers the model's own thinking/streaming time,
// during which no tool is running.
const fallback =
last?.role === "assistant" && !last.text && last.toolActivity.length === 0
? "Thinking…"
: "Working…";
const working = activity ?? fallback;

return (
<div className="flex-1 space-y-3 overflow-auto p-3">
Expand Down
Loading
Loading