feat(providers): Agnes 3.0 Flash catalog, CN host, live /v1/models (#13120)

Live-verified end to end: 1.5 gone from `GET /v1/models` with a 503, 3.0 answering 200 on a pong probe, and the CN-region host difference across 10 accounts. Aliasing 1.5 to 3.0 through `BUILT_IN_ALIASES` rather than leaving a dead id is the right retirement path, and noting that the #11503 as-is guard must not skip the rewrite is the detail that makes it work.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded with the other 19 PRs of this batch. Two in-batch conflicts, both additive and resolved by keeping each side: the `ENVIRONMENT.md` table (#13035 + #13011) and the `chatHelpers.ts` import block (#12975 on the tip + #13017).

- `typecheck:core` clean; `check:dashboard-typecheck` OK (206 pre-existing, within baseline); `check:changelog-integrity` OK; `check:docs-counts` migrations ✓
- complexity 2816 / baseline 3218 and cognitive-complexity 1271 / baseline 1437 — both under baseline
- 531 of 532 focused assertions green across the batch's 46 test files
- `check-file-size` rebaselined for the batch's real growth (annotation `_rebaseline_2026_09_11_mergebatch_v3851_houminxi`, landed on #13038), attributed per PR

The single red is **not this batch**: `tests/unit/combo/quota-weighted-strategy.test.ts` → "A/B isolation: 7 hard-empty + 2 at 0.5% + 1 at 40%, floor=1" asserts an order between two connections of identical weight and flakes on the pure tip too — 2 failures in 4 runs at `origin/release/v3.8.51` with nothing from this batch applied.

⚠️ base-red inherited: #12732 — `Docs Gates`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` reproduce on the pure tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098, untouched here).

Thanks @HouMinXi — the live evidence on these (X500 logs, `storage.sqlite` state, real `/v1/models` probes, the 36-minute outage write-up) is what let a 20-PR batch be reviewed as a unit.
This commit is contained in:
Bob.Hou
2026-09-11 18:28:10 -04:00
committed by GitHub
parent 9adc2f87d0
commit 9b4210a53f
12 changed files with 243 additions and 36 deletions

View File

@@ -0,0 +1 @@
- feat(providers): **list Agnes 3.0 Flash as the current free chat model, drop retired 1.5 Flash, add Image 2.0/2.5 Flash plus Video 2.5/2.5 Flash, and discover the live `/v1/models` catalog (including the CN host `api.agnes-ai.cn`).** `agnes-1.5-flash` now forwards to `agnes-3.0-flash`. Video 2.5 polls `GET /v1/videos/{id}` (not the V2.0 `/agnesapi` contract). Live `/v1/models` (2026-09-09) no longer serves 1.5; the wiki marks it deprecated. 3.0 Flash is 512K context / 65,536 max output, same window as 2.5. CN-region keys use the existing per-connection base-URL field, default stays `apihub.agnes-ai.com`.

View File

@@ -22,7 +22,7 @@ import type { FreeModelBudget } from "./freeModelCatalog.ts";
* rewrites file timestamps on every deploy, which would report a months-old
* catalog as "updated today". Bump this whenever the entries below change.
*/
export const FREE_CATALOG_CURATED_AT = "2026-09-03";
export const FREE_CATALOG_CURATED_AT = "2026-09-09";
export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "agentrouter", modelId: "claude-opus-4-8", displayName: "Claude Opus 4.8", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
@@ -458,9 +458,11 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "ovhcloud", modelId: "Qwen3.6-27B", displayName: "Qwen3.6 27B (OVH anonymous)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "ovhcloud-anon", tos: "ok" },
{ provider: "ovhcloud", modelId: "Mistral-Small-3.2-24B-Instruct-2506", displayName: "Mistral Small 3.2 24B (OVH anonymous)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "ovhcloud-anon", tos: "ok" },
{ provider: "ovhcloud", modelId: "Qwen2.5-VL-72B-Instruct", displayName: "Qwen2.5 VL 72B (OVH anonymous)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "ovhcloud-anon", tos: "ok" },
{ provider: "agnes", modelId: "agnes-1.5-flash", displayName: "Agnes 1.5 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "agnes-free", tos: "ok" },
// evidence: public-page wiki.agnes-ai.com/docs/pricing 2026-09-09 current $0
// for 2.0/2.5 flash; live GET /v1/models lists 3.0-flash (1.5-flash 503, retired).
{ provider: "agnes", modelId: "agnes-2.0-flash", displayName: "Agnes 2.0 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "agnes-free", tos: "ok" },
{ provider: "agnes", modelId: "agnes-2.5-flash", displayName: "Agnes 2.5 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "agnes-free", tos: "ok" },
{ provider: "agnes", modelId: "agnes-3.0-flash", displayName: "Agnes 3.0 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "agnes-free", tos: "ok" },
{ provider: "glm", modelId: "glm-4.7-flash", displayName: "GLM-4.7-Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "zhipu-flash-free", tos: "ok" },
{ provider: "glm", modelId: "glm-4.5-flash", displayName: "GLM-4.5-Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "zhipu-flash-free", tos: "ok" },
{ provider: "navy", modelId: "shared-pool", displayName: "NavyAI free pool (150K tokens/day, shared)", monthlyTokens: 4500000, creditTokens: 0, freeType: "recurring-daily", poolKey: "navy-free", tos: "ok" },

View File

@@ -168,12 +168,24 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
authHeader: "bearer",
format: "agnes-image",
models: [
{
id: "agnes-image-2.0-flash",
name: "Agnes Image 2.0 Flash",
inputModalities: ["text", "image"],
description: "Agnes text-to-image, image-to-image, and multi-image composition model",
},
{
id: "agnes-image-2.1-flash",
name: "Agnes Image 2.1 Flash",
inputModalities: ["text", "image"],
description: "Agnes text-to-image, image-to-image, and multi-image composition model",
},
{
id: "agnes-image-2.5-flash",
name: "Agnes Image 2.5 Flash",
inputModalities: ["text", "image"],
description: "Agnes text-to-image, image-to-image, and multi-image composition model",
},
],
supportedSizes: ["1K", "2K", "3K", "4K"],
},

View File

@@ -5,17 +5,10 @@ export const agnesProvider: RegistryEntry = {
format: "openai",
executor: "default",
baseUrl: "https://apihub.agnes-ai.com/v1/chat/completions",
modelsUrl: "https://apihub.agnes-ai.com/v1/models",
authType: "apikey",
authHeader: "bearer",
models: [
{
id: "agnes-1.5-flash",
name: "Agnes 1.5 Flash",
contextLength: 262144,
maxOutputTokens: 65536,
supportsVision: true,
toolCalling: true,
},
{
id: "agnes-2.0-flash",
name: "Agnes 2.0 Flash",
@@ -35,5 +28,17 @@ export const agnesProvider: RegistryEntry = {
toolCalling: true,
interleavedField: "reasoning_content",
},
{
// Wiki (2026-09-10) lists agnes-3.0-flash at 512K context / 65,536 max
// output, same window as 2.5 Flash. Live GET /v1/models includes it.
id: "agnes-3.0-flash",
name: "Agnes 3.0 Flash",
contextLength: 524288,
maxOutputTokens: 65536,
supportsReasoning: true,
supportsVision: true,
toolCalling: true,
interleavedField: "reasoning_content",
},
],
};

View File

@@ -16,6 +16,8 @@ interface VideoModel {
isMarket?: boolean;
supportedSizes?: string[];
mediaCapabilities?: Record<string, unknown>;
/** Override the provider-level job preset for this model. */
jobPreset?: string;
}
interface VideoProvider {
@@ -48,6 +50,16 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
id: "agnes-video-v2.0",
name: "Agnes Video V2.0",
},
{
id: "agnes-video-2.5-flash",
name: "Agnes Video 2.5 Flash",
jobPreset: "agnes-video-2.5-job",
},
{
id: "agnes-video-2.5",
name: "Agnes Video 2.5",
jobPreset: "agnes-video-2.5-job",
},
],
},

View File

@@ -195,10 +195,13 @@ export async function handleVideoGeneration({ body, credentials, log, resolvedPr
log,
});
}
if (getVideoJobPreset(providerConfig.format)) {
const modelJobPreset = providerConfig.models.find((entry) => entry.id === model)?.jobPreset;
const jobPresetName =
typeof modelJobPreset === "string" && modelJobPreset ? modelJobPreset : providerConfig.format;
if (getVideoJobPreset(jobPresetName)) {
return handleVideoJobGeneration({
model,
presetName: providerConfig.format,
presetName: jobPresetName,
body,
credentials,
log,

View File

@@ -129,6 +129,36 @@ const VIDEO_JOB_PRESETS: Record<string, VideoJobPreset> = {
maxPolls: 60,
pollIntervalMs: 2000,
},
"agnes-video-2.5-job": {
id: "agnes-video-2.5-job",
displayName: "Agnes Video 2.5",
authHeaderName: "Authorization",
authScheme: "bearer",
// Wiki 2026-09-09 + live probe: POST /v1/videos returns `id`, poll GET /v1/videos/{id}, result `url`.
// seconds is a string. Do not reuse agnes-video-job (video_id + /agnesapi).
baseUrlFallback: "https://apihub.agnes-ai.com",
submit: {
method: "POST",
path: "/v1/videos",
buildBody: ({ model, prompt, extras }) => {
const seconds = extras.seconds;
return {
model,
prompt,
...extras,
...(typeof seconds === "number" ? { seconds: String(seconds) } : {}),
};
},
},
taskIdPath: "id",
poll: { pathTemplate: "/v1/videos/{taskId}" },
statusPath: "status",
statusDone: ["completed"],
statusFailed: ["failed"],
resultPath: "url",
maxPolls: 60,
pollIntervalMs: 2000,
},
"muapi-video-job": {
id: "muapi-video-job",
displayName: "muapi.ai",

View File

@@ -65,9 +65,13 @@ const BUILT_IN_ALIASES: Record<string, string> = {
// Llama short aliases
"llama-3.3": "llama-3.3-70b-versatile",
"llama-3-70b": "llama-3.3-70b-versatile",
// #11503: llama3-8b-8192 was deprecated by Groq on 2025-08-30 and is not in the
// #11503: llama3-8b-8192 deprecated on Groq 2025-08-30 and not in the
// catalog; llama-3.1-8b-instant is the replacement Groq names.
"llama-3-8b": "llama-3.1-8b-instant",
// Agnes 1.5 Flash: wiki marks deprecated; live GET /v1/models
// (2026-09-09) no longer lists it (503 no channel).
"agnes-1.5-flash": "agnes-3.0-flash",
};
// ── Custom Aliases (persisted via Settings API) ─────────────────────────────

View File

@@ -244,6 +244,10 @@ export const CONFIGURABLE_BASE_URL_PROVIDERS = new Set([
// the existing override affordance for these two ids.
"kimi",
"moonshot",
// Agnes CN-region keys (agnes-ai.cn) use a separate host from
// the international apihub.agnes-ai.com default. Same always-on field as
// #7447 Kimi/Moonshot so Add-connection can point at api.agnes-ai.cn.
"agnes",
]);
export const DEFAULT_PROVIDER_BASE_URLS: Record<string, string> = {
@@ -261,6 +265,7 @@ export const DEFAULT_PROVIDER_BASE_URLS: Record<string, string> = {
// before; a CN-region user overrides it (see placeholder hint below).
kimi: "https://api.moonshot.ai/v1",
moonshot: "https://api.moonshot.ai/v1",
agnes: "https://apihub.agnes-ai.com/v1",
};
export function getLocalProviderMetadata(providerId?: string | null) {
@@ -372,6 +377,8 @@ export function getProviderBaseUrlPlaceholder(providerId?: string | null) {
// #7447 — surfaces the CN-region alternative host as the placeholder
// example (mirrors the siliconflow.com/siliconflow.cn pattern above).
return "https://api.moonshot.cn/v1";
case "agnes":
return "https://api.agnes-ai.cn/v1";
default:
return "";
}

View File

@@ -100,6 +100,11 @@ export const NAMED_OPENAI_STYLE_PROVIDERS = new Set([
// (11 chat-capable). Live fetch keeps it fresh; the registry seed stays as the
// offline fallback.
"logfare",
// Agnes hosts a live OpenAI-style /v1/models catalog on both the
// international (apihub.agnes-ai.com) and CN (api.agnes-ai.cn) hosts.
// Without this, sync-models serves the static registry seed and CN
// connections never discover 2.5/3.0 Flash.
"agnes",
]);
export function isNamedOpenAIStyleProvider(provider: string): boolean {

View File

@@ -308,13 +308,19 @@ export const providerModelMutationSchema = z.object({
.optional(),
// #9820: optional async video-generation job preset for a custom
// OpenAI-compatible provider whose /videos surface is a submit→poll API
// (agnes-video-job, muapi-video-job, sora-job). Persisted on the custom model
// (agnes-video-job, agnes-video-2.5-job, muapi-video-job, sora-job). Persisted on the custom model
// row; the /v1/videos/generations handler branches on it between the
// synchronous OpenAI-compatible path and the job/poll path. `"openai-video"`
// is a legacy no-op value that keeps the sync handler selected.
generationConfig: z
.object({
preset: z.enum(["agnes-video-job", "muapi-video-job", "sora-job", "openai-video"]),
preset: z.enum([
"agnes-video-job",
"agnes-video-2.5-job",
"muapi-video-job",
"sora-job",
"openai-video",
]),
})
.optional(),
});

View File

@@ -20,6 +20,7 @@ const { handleImageGeneration } = await import("../../open-sse/handlers/imageGen
const { handleVideoGeneration } = await import("../../open-sse/handlers/videoGeneration.ts");
const { resolveChatCoreTargetFormat } =
await import("../../open-sse/handlers/chatCore/targetFormat.ts");
const { resolveModelAlias } = await import("../../open-sse/services/modelDeprecation.ts");
const dbCore = await import("../../src/lib/db/core.ts");
test.after(() => {
@@ -28,6 +29,8 @@ test.after(() => {
});
const AGNES_CHAT_URL = "https://apihub.agnes-ai.com/v1/chat/completions";
const AGNES_MODELS_URL = "https://apihub.agnes-ai.com/v1/models";
const AGNES_CN_BASE_URL = "https://api.agnes-ai.cn/v1";
test("agnes is registered as an API-key provider with complete metadata", () => {
const entry = APIKEY_PROVIDERS.agnes;
@@ -72,20 +75,13 @@ test("agnes routes Chat Completions clients through its OpenAI chat upstream", (
);
});
test("agnes ships the current public chat models with correct capabilities", () => {
test("agnes ships the current public chat models with the correct capabilities", () => {
const entry = providerRegistry.agnes;
assert.deepEqual(
entry.models.map((model) => model.id),
["agnes-1.5-flash", "agnes-2.0-flash", "agnes-2.5-flash"]
["agnes-2.0-flash", "agnes-2.5-flash", "agnes-3.0-flash"]
);
const flash15 = entry.models.find((m) => m.id === "agnes-1.5-flash");
assert.ok(flash15, "agnes-1.5-flash must be defined");
assert.equal(flash15.contextLength, 262144);
assert.equal(flash15.maxOutputTokens, 65536);
assert.equal(flash15.supportsVision, true);
assert.equal(flash15.toolCalling, true);
const flash20 = entry.models.find((m) => m.id === "agnes-2.0-flash");
assert.ok(flash20, "agnes-2.0-flash must be defined");
assert.equal(flash20.contextLength, 262144);
@@ -98,12 +94,59 @@ test("agnes ships the current public chat models with correct capabilities", ()
assert.ok(flash25, "agnes-2.5-flash must be defined");
assert.equal(flash25.contextLength, 524288);
assert.equal(flash25.maxOutputTokens, 65536);
const flash30 = entry.models.find((m) => m.id === "agnes-3.0-flash");
assert.ok(flash30, "agnes-3.0-flash must be defined");
assert.equal(flash30.contextLength, 524288);
assert.equal(flash30.maxOutputTokens, 65536);
assert.equal(flash30.supportsReasoning, true);
assert.equal(flash30.supportsVision, true);
assert.equal(flash30.toolCalling, true);
assert.equal(flash30.interleavedField, "reasoning_content");
});
test("agnes registry advertises the live OpenAI-style /models endpoint", () => {
const entry = providerRegistry.agnes;
assert.equal(entry.modelsUrl, AGNES_MODELS_URL);
});
test("agnes is classified for live OpenAI-style /models discovery", async () => {
const { isNamedOpenAIStyleProvider } = await import(
"../../src/app/api/providers/[id]/models/discovery/providerSets.ts"
);
assert.equal(isNamedOpenAIStyleProvider("agnes"), true);
});
test("agnes honors per-connection CN base URL override", () => {
const url = new DefaultExecutor("agnes").buildUrl("agnes-3.0-flash", true, 0, {
providerSpecificData: { baseUrl: AGNES_CN_BASE_URL },
});
assert.equal(url, `${AGNES_CN_BASE_URL}/chat/completions`);
});
test("agnes base-URL field is always-on so CN keys can point at api.agnes-ai.cn", async () => {
const helpers = await import(
"../../src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts"
);
assert.equal(helpers.isBaseUrlConfigurableProvider("agnes"), true);
assert.equal(helpers.getProviderBaseUrlDefault("agnes"), "https://apihub.agnes-ai.com/v1");
assert.equal(helpers.getProviderBaseUrlPlaceholder("agnes"), AGNES_CN_BASE_URL);
});
test("agnes-1.5-flash is retired and forwards to agnes-3.0-flash", () => {
const entry = providerRegistry.agnes;
assert.equal(
entry.models.some((model) => model.id === "agnes-1.5-flash"),
false
);
assert.equal(resolveModelAlias("agnes-1.5-flash", "agnes"), "agnes-3.0-flash");
});
test("agnes free catalog exposes the current free chat models through one shared pool", () => {
const rows = FREE_MODEL_BUDGETS.filter((model) => model.provider === "agnes");
assert.deepEqual(
rows.map((model) => model.modelId),
["agnes-1.5-flash", "agnes-2.0-flash", "agnes-2.5-flash"]
["agnes-2.0-flash", "agnes-2.5-flash", "agnes-3.0-flash"]
);
assert.ok(rows.every((model) => model.poolKey === "agnes-free"));
});
@@ -123,22 +166,19 @@ test("agnes has no collision with zenmux-free sapiens-ai prefixed models", (t) =
}
});
test("agnes registers Image 2.1 Flash on the current image-generation contract", () => {
test("agnes registers Image 2.x Flash models on the current image-generation contract", () => {
const entry = IMAGE_PROVIDERS.agnes;
assert.ok(entry, "IMAGE_PROVIDERS.agnes must be defined");
assert.equal(entry.baseUrl, "https://apihub.agnes-ai.com/v1/images/generations");
assert.equal(entry.authHeader, "bearer");
assert.equal(entry.format, "agnes-image");
assert.deepEqual(entry.supportedSizes, ["1K", "2K", "3K", "4K"]);
assert.deepEqual(entry.models, [
{
id: "agnes-image-2.1-flash",
name: "Agnes Image 2.1 Flash",
inputModalities: ["text", "image"],
description: "Agnes text-to-image, image-to-image, and multi-image composition model",
},
]);
assert.deepEqual(
entry.models.map((model) => model.id),
["agnes-image-2.0-flash", "agnes-image-2.1-flash", "agnes-image-2.5-flash"]
);
assert.ok(getAllImageModels().some((model) => model.id === "agnes/agnes-image-2.1-flash"));
assert.ok(getAllImageModels().some((model) => model.id === "agnes/agnes-image-2.5-flash"));
});
test("agnes Image 2.1 maps standard image inputs into extra_body", async () => {
@@ -212,16 +252,21 @@ test("agnes Image 2.1 requires the current size parameter", async () => {
assert.equal(result.error, "Size is required for Agnes Image 2.1 Flash");
});
test("agnes registers Video V2.0 on the current video_id job contract", () => {
test("agnes registers Video V2.0 and Video 2.5 on the current job contracts", () => {
const entry = VIDEO_PROVIDERS.agnes;
assert.ok(entry, "VIDEO_PROVIDERS.agnes must be defined");
assert.equal(entry.baseUrl, "https://apihub.agnes-ai.com");
assert.equal(entry.statusUrl, "https://apihub.agnes-ai.com/agnesapi");
assert.equal(entry.authHeader, "bearer");
assert.equal(entry.format, "agnes-video-job");
assert.deepEqual(entry.models, [{ id: "agnes-video-v2.0", name: "Agnes Video V2.0" }]);
assert.deepEqual(
entry.models.map((model) => model.id),
["agnes-video-v2.0", "agnes-video-2.5-flash", "agnes-video-2.5"]
);
assert.equal(VIDEO_PROVIDER_IDS.has("agnes"), true);
assert.ok(getAllVideoModels().some((model) => model.id === "agnes/agnes-video-v2.0"));
assert.ok(getAllVideoModels().some((model) => model.id === "agnes/agnes-video-2.5-flash"));
assert.ok(getAllVideoModels().some((model) => model.id === "agnes/agnes-video-2.5"));
});
test("agnes Video V2.0 submits with Bearer auth and polls by video_id and model_name", async () => {
@@ -321,3 +366,78 @@ test("agnes Video V2.0 submits with Bearer auth and polls by video_id and model_
globalThis.setTimeout = originalSetTimeout;
}
});
test("agnes Video 2.5-flash submits Bearer auth and polls /v1/videos/{id}", async () => {
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.setTimeout;
const calls: Array<{
url: string;
method: string;
headers: Record<string, string>;
body?: Record<string, unknown>;
}> = [];
globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _ms?: number, ...args) => {
callback(...args);
return 0;
}) as typeof setTimeout;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
const call = {
url: String(url),
method: init?.method || "GET",
headers: (init?.headers || {}) as Record<string, string>,
...(init?.body ? { body: JSON.parse(String(init.body)) as Record<string, unknown> } : {}),
};
calls.push(call);
if (call.method === "POST") {
return new Response(
JSON.stringify({
id: "task_nEV6cJjyzWnix1g1O9QHjnHzTstegDGM",
status: "queued",
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
}
return new Response(
JSON.stringify({
id: "task_nEV6cJjyzWnix1g1O9QHjnHzTstegDGM",
status: "completed",
url: "https://platform-outputs.agnes-ai.space/video-25.mp4",
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
}) as typeof fetch;
try {
const result = await handleVideoGeneration({
body: {
model: "agnes/agnes-video-2.5-flash",
prompt: "a red ball rolling on a white floor",
seconds: "4",
mode: "text",
size: "720P",
aspect_ratio: "16:9",
},
credentials: { apiKey: "agnes-key" },
log: null,
});
assert.equal(result.success, true);
assert.equal(result.data.data[0].url, "https://platform-outputs.agnes-ai.space/video-25.mp4");
assert.equal(calls.length, 2);
assert.equal(calls[0].url, "https://apihub.agnes-ai.com/v1/videos");
assert.equal(calls[0].method, "POST");
assert.equal(calls[0].body?.model, "agnes-video-2.5-flash");
assert.equal(calls[0].body?.seconds, "4");
assert.equal(calls[0].body?.mode, "text");
assert.equal(
calls[1].url,
"https://apihub.agnes-ai.com/v1/videos/task_nEV6cJjyzWnix1g1O9QHjnHzTstegDGM"
);
assert.equal(calls[1].method, "GET");
} finally {
globalThis.fetch = originalFetch;
globalThis.setTimeout = originalSetTimeout;
}
});