From 9b4210a53fed1bcfe9f2595b9a17f01e95e8f845 Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Fri, 11 Sep 2026 18:28:10 -0400 Subject: [PATCH] feat(providers): Agnes 3.0 Flash catalog, CN host, live /v1/models (#13120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../features/agnes-30-flash-catalog.md | 1 + open-sse/config/freeModelCatalog.data.ts | 6 +- open-sse/config/imageRegistry.ts | 12 ++ .../config/providers/registry/agnes/index.ts | 21 ++- open-sse/config/videoRegistry.ts | 12 ++ open-sse/handlers/videoGeneration.ts | 7 +- open-sse/handlers/videoGeneration/job.ts | 30 ++++ open-sse/services/modelDeprecation.ts | 6 +- .../providers/[id]/providerPageHelpers.ts | 7 + .../[id]/models/discovery/providerSets.ts | 5 + src/shared/validation/schemas/provider.ts | 10 +- tests/unit/agnes-provider.test.ts | 162 +++++++++++++++--- 12 files changed, 243 insertions(+), 36 deletions(-) create mode 100644 changelog.d/features/agnes-30-flash-catalog.md diff --git a/changelog.d/features/agnes-30-flash-catalog.md b/changelog.d/features/agnes-30-flash-catalog.md new file mode 100644 index 0000000000..0e5de18d1a --- /dev/null +++ b/changelog.d/features/agnes-30-flash-catalog.md @@ -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`. diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index c6b8b133a3..2debf50d67 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -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" }, diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 27bb0d41b9..fe455fc895 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -168,12 +168,24 @@ export const IMAGE_PROVIDERS: Record = { 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"], }, diff --git a/open-sse/config/providers/registry/agnes/index.ts b/open-sse/config/providers/registry/agnes/index.ts index 2843328f00..8e3a5cbdf5 100644 --- a/open-sse/config/providers/registry/agnes/index.ts +++ b/open-sse/config/providers/registry/agnes/index.ts @@ -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", + }, ], }; diff --git a/open-sse/config/videoRegistry.ts b/open-sse/config/videoRegistry.ts index aa5922d65b..c4b1278f43 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -16,6 +16,8 @@ interface VideoModel { isMarket?: boolean; supportedSizes?: string[]; mediaCapabilities?: Record; + /** Override the provider-level job preset for this model. */ + jobPreset?: string; } interface VideoProvider { @@ -48,6 +50,16 @@ export const VIDEO_PROVIDERS: Record = { 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", + }, ], }, diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index bfbf9267f4..4ca4a7a9a2 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -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, diff --git a/open-sse/handlers/videoGeneration/job.ts b/open-sse/handlers/videoGeneration/job.ts index 17308e77b8..d71e3178db 100644 --- a/open-sse/handlers/videoGeneration/job.ts +++ b/open-sse/handlers/videoGeneration/job.ts @@ -129,6 +129,36 @@ const VIDEO_JOB_PRESETS: Record = { 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", diff --git a/open-sse/services/modelDeprecation.ts b/open-sse/services/modelDeprecation.ts index 961b968e7e..eefb2fce9c 100644 --- a/open-sse/services/modelDeprecation.ts +++ b/open-sse/services/modelDeprecation.ts @@ -65,9 +65,13 @@ const BUILT_IN_ALIASES: Record = { // 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) ───────────────────────────── diff --git a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts index f5c0e22a4c..4843bafc6b 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts @@ -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 = { @@ -261,6 +265,7 @@ export const DEFAULT_PROVIDER_BASE_URLS: Record = { // 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 ""; } diff --git a/src/app/api/providers/[id]/models/discovery/providerSets.ts b/src/app/api/providers/[id]/models/discovery/providerSets.ts index 2b35e54b01..27bf1415cd 100644 --- a/src/app/api/providers/[id]/models/discovery/providerSets.ts +++ b/src/app/api/providers/[id]/models/discovery/providerSets.ts @@ -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 { diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index e6f324891b..743a673f75 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -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(), }); diff --git a/tests/unit/agnes-provider.test.ts b/tests/unit/agnes-provider.test.ts index 2b0930eaa6..f1353c6e1a 100644 --- a/tests/unit/agnes-provider.test.ts +++ b/tests/unit/agnes-provider.test.ts @@ -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; + body?: Record; + }> = []; + + 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, + ...(init?.body ? { body: JSON.parse(String(init.body)) as Record } : {}), + }; + 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; + } +});