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
8 changes: 8 additions & 0 deletions app/api/songs/analyze/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { postFlamingoGenerateHandler } from "@/lib/flamingo/postFlamingoGenerateHandler";

/**
* `full_report` runs its 13 sections as one batch, one after another on one
* model container, so it can outlast the default function duration. Kept
* above the Modal endpoint's 600 s timeout so the API never abandons a report
* the model finishes.
*/
export const maxDuration = 800;

/**
* OPTIONS handler for CORS preflight requests.
*
Expand Down
5 changes: 3 additions & 2 deletions app/mcp/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import { createOAuthToolServices } from "@/lib/mcp/oauth/createOAuthToolServices
import { validateMcpJsonBody } from "@/lib/mcp/validateMcpJsonBody";
import { verifyOAuthBearer } from "@/lib/oauth/verifyOAuthBearer";

// Site generation can take several minutes, matching the HTTP generation route.
export const maxDuration = 300;
// Site generation and analyze_music's full_report (13 sections run one after
// another in one model batch) can take several minutes, matching their HTTP routes.
export const maxDuration = 800;

const baseHandler = createMcpHandler(
server => {
Expand Down
4 changes: 2 additions & 2 deletions lib/contracts/fixtures/published-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -1233,13 +1233,13 @@
"content/docs/source/api-reference/openapi/accounts.json": "1a5a04f9ec358a15b8339b7029c9fed5fe69c6f042c8b03b6b4f03f3aafdeeed",
"content/docs/source/api-reference/openapi/content.json": "f790ee44d1b881709e393a65500bec23493e21a391c7b66555e0c78750f95239",
"content/docs/source/api-reference/openapi/research.json": "f301a714e994e99b1dbcfc63ee18824537cf42813d8df3906ac7c2436a481ae0",
"content/docs/source/api-reference/openapi/releases.json": "85b1ca7af667bac8a8f67dd04b0bd986cbd03f8bd287066401de2132d825ace6",
"content/docs/source/api-reference/openapi/releases.json": "03be8efc610d3625bbfc3cf4e71723193e31c4d1cab9b1216b1fd9b3622ab8e4",
"content/docs/source/api-reference/openapi/social.json": "5bd167a78cf6a17c46724486e2921882be20969ea32f3c0297f5b7e7ba9a30aa",
"content/docs/source/api-reference/openapi/context.json": "7784ee1d8c4b6753cc47bf518f468effa58d50fb9d0289009edf0caad3d05a67",
"content/docs/source/api-reference/openapi/projects.json": "b059cf8baa83a7f73a3c15db19f54fa56e46a3840e91bd42979b83550f543ace",
"content/docs/source/api-reference/openapi/sessions.json": "85af56cc150374b28c25541c45db15849c71fdeb6288cf90dc2beb08f494e83e",
"content/docs/source/api-reference/sites/overview.mdx": "1430730142df476e6cec9ea40aab751b0a48373d1479254da70bd8a545ae51b2",
"content/docs/source/credits.mdx": "2a0cdf8ddd408b7a7ebfe0abb0e185a23c66af4035790223c4f5f595c41a9b7c",
"content/docs/source/credits.mdx": "8f56d2221e238f4f0deafe0b516ac29a0afa6124fd58ccabfc96d029d58286f5",
"content/docs/source/api-reference/admins/agent-signups.mdx": "a6b65f3ad1b0ead873b83cdccf2254cdd3afa1556ae94738502c9b1fc46954bc"
},
"examples": {
Expand Down
68 changes: 68 additions & 0 deletions lib/flamingo/__tests__/callFlamingoGenerateBatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,60 @@ describe("callFlamingoGenerateBatch", () => {
});
});

it("sends a prompt's clip window and its own settings only when the prompt has them", async () => {
mockFetch.mockResolvedValue({ ok: true, json: async () => RESULT });

await callFlamingoGenerateBatch({
...BODY,
prompts: [
{
id: "climax",
prompt: "Describe this clip.",
max_new_tokens: 256,
clip: { start: 143.36, end: 158.1 },
},
{
id: "pitch",
prompt: "Pitch it.",
max_new_tokens: 512,
temperature: 0.6,
top_p: 0.9,
do_sample: true,
},
{ id: "key", prompt: "Key?", max_new_tokens: 32 },
],
});

expect(JSON.parse(mockFetch.mock.calls[0][1].body).prompts).toEqual([
{
id: "climax",
prompt: "Describe this clip.",
max_new_tokens: 256,
clip_start: 143.36,
clip_end: 158.1,
},
{
id: "pitch",
prompt: "Pitch it.",
max_new_tokens: 512,
temperature: 0.6,
top_p: 0.9,
do_sample: true,
},
{ id: "key", prompt: "Key?", max_new_tokens: 32 },
]);
});

it("accepts an answer that echoes both ends of its clip", async () => {
const clipped = {
answers: [{ id: "a", response: "x", elapsed_seconds: 1, clip_start: 2, clip_end: 9.5 }],
elapsed_seconds: 1,
};
mockFetch.mockResolvedValue({ ok: true, json: async () => clipped });

await expect(callFlamingoGenerateBatch(BODY)).resolves.toEqual(clipped);
});

it("passes an abort signal through when given one", async () => {
mockFetch.mockResolvedValue({ ok: true, json: async () => RESULT });
const controller = new AbortController();
Expand Down Expand Up @@ -117,6 +171,20 @@ describe("callFlamingoGenerateBatch", () => {
{ answers: [{ id: "a", response: 42, elapsed_seconds: 1 }], elapsed_seconds: 1 },
],
["an answer without seconds", { answers: [{ id: "a", response: "x" }], elapsed_seconds: 1 }],
[
"an answer with only one end of its clip",
{
answers: [{ id: "a", response: "x", elapsed_seconds: 1, clip_start: 2 }],
elapsed_seconds: 1,
},
],
[
"an answer whose clip end is not a number",
{
answers: [{ id: "a", response: "x", elapsed_seconds: 1, clip_start: 2, clip_end: "9" }],
elapsed_seconds: 1,
},
],
["null", null],
])("throws on an unexpected response shape: %s", async (_name, payload) => {
mockFetch.mockResolvedValue({ ok: true, json: async () => payload });
Expand Down
44 changes: 44 additions & 0 deletions lib/flamingo/__tests__/chargeForBatchAnswers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { chargeForBatchAnswers } from "../chargeForBatchAnswers";
import { chargeForFlamingoBatch } from "@/lib/flamingo/chargeForFlamingoBatch";

vi.mock("@/lib/flamingo/chargeForFlamingoBatch", () => ({ chargeForFlamingoBatch: vi.fn() }));

describe("chargeForBatchAnswers", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(chargeForFlamingoBatch).mockResolvedValue(undefined);
});

it("charges one batch on the seconds of the answers that were used", async () => {
await chargeForBatchAnswers({
accountId: "acc_1",
answers: [{ elapsed_seconds: 4.5 }, { elapsed_seconds: 2.25 }],
audioUrl: "https://example.com/song.mp3",
});

expect(chargeForFlamingoBatch).toHaveBeenCalledTimes(1);
expect(chargeForFlamingoBatch).toHaveBeenCalledWith({
accountId: "acc_1",
elapsedSeconds: 6.75,
promptCount: 2,
audioUrl: "https://example.com/song.mp3",
});
});

it("charges nothing when no answer was usable", async () => {
await chargeForBatchAnswers({ accountId: "acc_1", answers: [] });

expect(chargeForFlamingoBatch).not.toHaveBeenCalled();
});

it("leaves the audio URL off the usage record when none is given", async () => {
await chargeForBatchAnswers({ accountId: "acc_1", answers: [{ elapsed_seconds: 3 }] });

expect(chargeForFlamingoBatch).toHaveBeenCalledWith({
accountId: "acc_1",
elapsedSeconds: 3,
promptCount: 1,
});
});
});
82 changes: 64 additions & 18 deletions lib/flamingo/__tests__/executeFullReport.failed.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { executeFullReport } from "../executeFullReport";
import { chargeForFlamingoCall } from "@/lib/flamingo/chargeForFlamingoCall";
import { callFlamingoGenerate } from "@/lib/flamingo/callFlamingoGenerate";
import { chargeForBatchAnswers } from "@/lib/flamingo/chargeForBatchAnswers";
import { callFlamingoGenerateBatch } from "@/lib/flamingo/callFlamingoGenerateBatch";
import { getPreset } from "@/lib/flamingo/presets";
import { FULL_REPORT_SECTIONS } from "@/lib/flamingo/presets/fullReport";
import type { FlamingoBatchAnswer } from "@/lib/flamingo/isFlamingoGenerateBatchResult";

vi.mock("@/lib/flamingo/callFlamingoGenerate", () => ({ callFlamingoGenerate: vi.fn() }));
vi.mock("@/lib/flamingo/chargeForFlamingoCall", () => ({ chargeForFlamingoCall: vi.fn() }));
vi.mock("@/lib/flamingo/callFlamingoGenerateBatch", () => ({
callFlamingoGenerateBatch: vi.fn(),
}));
vi.mock("@/lib/flamingo/chargeForBatchAnswers", () => ({ chargeForBatchAnswers: vi.fn() }));
vi.mock("@/lib/flamingo/presets", () => ({ getPreset: vi.fn() }));

const AUDIO = "https://example.com/song.mp3";
Expand All @@ -19,43 +22,86 @@ const plainPreset = (name: string) => ({
describe("executeFullReport — failed sections", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(chargeForFlamingoCall).mockResolvedValue(undefined);
vi.mocked(chargeForBatchAnswers).mockResolvedValue(undefined);
vi.mocked(getPreset).mockImplementation(name => plainPreset(name) as never);
vi.spyOn(console, "error").mockImplementation(() => {});
});

it("lists no failed sections when every call succeeds", async () => {
vi.mocked(callFlamingoGenerate).mockResolvedValue({ response: "ok", elapsed_seconds: 1 });
it("lists no failed sections when every section answers", async () => {
vi.mocked(callFlamingoGenerateBatch).mockResolvedValue({
answers: FULL_REPORT_SECTIONS.map(s => ({
id: s.reportKey,
response: "ok",
elapsed_seconds: 1,
})),
elapsed_seconds: 14,
});

const { failed } = await executeFullReport(AUDIO, "acc_1");

expect(failed).toEqual([]);
});

it("lists the report keys of the sections that failed, in report order", async () => {
const failing = new Set([FULL_REPORT_SECTIONS[0].preset, FULL_REPORT_SECTIONS[4].preset]);
vi.mocked(callFlamingoGenerate).mockImplementation(async ({ prompt }) => {
const name = prompt.replace("prompt for ", "");
if (failing.has(name)) throw new Error("Music analysis failed (status 500)");
return { response: "ok", elapsed_seconds: 1 };
it("lists a section the batch returned no answer for, in report order", async () => {
const missing = new Set([FULL_REPORT_SECTIONS[0].reportKey, FULL_REPORT_SECTIONS[4].reportKey]);
vi.mocked(callFlamingoGenerateBatch).mockResolvedValue({
answers: FULL_REPORT_SECTIONS.filter(s => !missing.has(s.reportKey)).map(s => ({
id: s.reportKey,
response: "ok",
elapsed_seconds: 1,
})),
elapsed_seconds: 12,
});

const { report, failed } = await executeFullReport(AUDIO, "acc_1");

expect(failed).toEqual([FULL_REPORT_SECTIONS[0].reportKey, FULL_REPORT_SECTIONS[4].reportKey]);
expect(report[FULL_REPORT_SECTIONS[0].reportKey]).toEqual({
error: "Section failed: Music analysis failed (status 500)",
error: "Section failed: the model returned no answer for this section",
});
});

it("lists every section when the model fails every call", async () => {
vi.mocked(callFlamingoGenerate).mockRejectedValue(
new Error("Music analysis failed (status 500)"),
it("counts a section whose parsed answer is only an { error } as failed and does not charge it", async () => {
// A model reply of {"error": "..."} parses into the same shape the report
// uses for a failed section, so it is reported as failed and not billed.
vi.mocked(getPreset).mockImplementation(name =>
name === FULL_REPORT_SECTIONS[1].preset
? ({ ...plainPreset(name), parseResponse: (raw: string) => JSON.parse(raw) } as never)
: (plainPreset(name) as never),
);
vi.mocked(callFlamingoGenerateBatch).mockResolvedValue({
answers: FULL_REPORT_SECTIONS.map((s, i) => ({
id: s.reportKey,
response: i === 1 ? '{"error": "cannot analyze"}' : "ok",
elapsed_seconds: 1,
})),
elapsed_seconds: 14,
});

const { failed } = await executeFullReport(AUDIO, "acc_1");

expect(failed).toEqual([FULL_REPORT_SECTIONS[1].reportKey]);
const [charge] = vi.mocked(chargeForBatchAnswers).mock.calls[0];
expect((charge.answers as FlamingoBatchAnswer[]).map(a => a.id)).toEqual(
FULL_REPORT_SECTIONS.filter((_, i) => i !== 1).map(s => s.reportKey),
);
});

it("fails every section, with the batch's error, and charges nothing when the batch fails", async () => {
vi.mocked(callFlamingoGenerateBatch).mockRejectedValue(
new Error("Music analysis failed (status 500)"),
);

const { report, failed } = await executeFullReport(AUDIO, "acc_1");

expect(failed).toEqual(FULL_REPORT_SECTIONS.map(s => s.reportKey));
expect(chargeForFlamingoCall).not.toHaveBeenCalled();
expect(report[FULL_REPORT_SECTIONS[2].reportKey]).toEqual({
error: "Section failed: Music analysis failed (status 500)",
});
expect(chargeForBatchAnswers).toHaveBeenCalledWith({
accountId: "acc_1",
answers: [],
audioUrl: AUDIO,
});
});
});
Loading
Loading