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
3 changes: 2 additions & 1 deletion src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ResolvedModel } from "../config/types";
import type { CommandType } from "../core/types";
import { isAiRemoverModel } from "../utils/model-routing.ts";
import { type ApiEnvelope, endpoints, type TaskData } from "./types.ts";

async function httpJson(
Expand Down Expand Up @@ -121,7 +122,7 @@ export function buildSubmitTarget(
const modelRef = model.modelName ?? model.id;

const suffix =
model.submitMode === "canonical"
model.submitMode === "canonical" || (command === "edit" && isAiRemoverModel(model))
? ""
: {
generate: "",
Expand Down
116 changes: 106 additions & 10 deletions src/config/models.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ModelCache } from "../cache";
import { inferApiModelType } from "../utils/model-routing.ts";
import { ConfigError } from "./load";
import { getRegistryModel } from "./registry";
import type { ResolvedModel, ResolvedModelSummary, WavespeedConfig } from "./types";
Expand All @@ -8,7 +9,7 @@ import type { ResolvedModel, ResolvedModelSummary, WavespeedConfig } from "./typ
* Used to validate model IDs against the API without tight coupling
*/
export interface ApiModelCache {
models: Array<{ model_id: string }>;
models: Array<{ model_id: string; type?: string }>;
}

const BUILTIN_MODEL_ID = "seedream-v4";
Expand Down Expand Up @@ -46,13 +47,75 @@ export type ModelCommandName = "generate" | "edit" | "generate-sequential" | "ed
* Minimal cache interface shared by CLI and MCP model resolution.
*/
export interface ApiModelCacheProvider {
getCachedModels(): Promise<Array<{ model_id: string }>>;
getCachedModels(): Promise<Array<{ model_id: string; type?: string }>>;
}

function toApiModelCache(models: Array<{ model_id: string }>): ApiModelCache | undefined {
function toApiModelCache(
models: Array<{ model_id: string; type?: string }>,
): ApiModelCache | undefined {
return models.length > 0 ? { models } : undefined;
}

function stripCanonicalModelSuffix(modelRef?: string): string | undefined {
if (!modelRef) {
return undefined;
}

for (const suffix of CANONICAL_MODEL_SUFFIXES) {
if (modelRef.endsWith(suffix)) {
return modelRef.slice(0, -suffix.length);
}
}

return modelRef;
}

function normalizeModelRefForType(
modelRef: string | undefined,
apiModelType?: string,
): string | undefined {
return apiModelType === "ai-remover" ? stripCanonicalModelSuffix(modelRef) : modelRef;
}

function findCachedModelType(
apiCache: ApiModelCache | undefined,
...refs: Array<string | undefined>
): string | undefined {
for (const ref of refs) {
const candidates = [ref, stripCanonicalModelSuffix(ref)].filter(
(candidate, index, values): candidate is string =>
Boolean(candidate) && values.indexOf(candidate) === index,
);

if (candidates.length === 0) {
continue;
}

for (const candidate of candidates) {
const match = apiCache?.models.find((model) => model.model_id === candidate);
if (match?.type) {
return match.type;
}
}
}

return undefined;
}

function resolveApiModelType(
apiCache: ApiModelCache | undefined,
id: string,
modelName?: string,
configuredType?: string,
): string | undefined {
return (
configuredType ??
findCachedModelType(apiCache, modelName, id) ??
inferApiModelType(modelName ?? id) ??
inferApiModelType(stripCanonicalModelSuffix(modelName ?? id))
);
}
Comment thread
aditzel marked this conversation as resolved.

/**
* Resolve the requested model using cached API metadata when available, without
* forcing a network fetch during normal CLI execution.
Expand Down Expand Up @@ -142,43 +205,73 @@ function resolveModelId(
): ResolvedModel {
const modelConfig = config?.models?.[modelId];
if (modelConfig) {
return resolveFromConfigModel(modelId, modelConfig);
const apiModelType = resolveApiModelType(
apiCache,
modelId,
modelConfig.modelName,
modelConfig.apiModelType,
);
const normalizedModelRef = normalizeModelRefForType(
modelConfig.modelName ?? modelId,
apiModelType,
);

return resolveFromConfigModel(
modelId,
{
...modelConfig,
modelName: apiModelType === "ai-remover" ? normalizedModelRef : modelConfig.modelName,
},
inferSubmitMode(normalizedModelRef),
apiModelType,
);
}

const registryModel = getRegistryModel(modelId);
if (registryModel) {
const apiModelType = resolveApiModelType(apiCache, modelId, registryModel.modelName);
const normalizedModelRef = normalizeModelRefForType(registryModel.modelName, apiModelType);

return resolveFromConfigModel(
modelId,
{
provider: registryModel.provider,
apiBaseUrl: registryModel.apiBaseUrl,
modelName: registryModel.modelName,
modelName: normalizedModelRef,
apiKeyEnv: "WAVESPEED_API_KEY",
},
"base",
inferSubmitMode(normalizedModelRef),
apiModelType,
);
}

const cachedApiModel = apiCache?.models.find((model) => model.model_id === modelId);
if (cachedApiModel) {
const normalizedModelRef = normalizeModelRefForType(modelId, cachedApiModel.type);

return resolveFromConfigModel(
modelId,
{
provider: "wavespeed",
modelName: modelId,
modelName: normalizedModelRef,
},
"canonical",
inferSubmitMode(normalizedModelRef),
cachedApiModel.type,
);
}

if (modelId.includes("/")) {
const apiModelType = resolveApiModelType(apiCache, modelId, modelId);
const normalizedModelRef = normalizeModelRefForType(modelId, apiModelType);

return resolveFromConfigModel(
modelId,
{
provider: "wavespeed",
modelName: modelId,
modelName: normalizedModelRef,
},
"canonical",
inferSubmitMode(normalizedModelRef),
apiModelType,
);
}

Expand Down Expand Up @@ -212,10 +305,12 @@ function resolveFromConfigModel(
apiBaseUrl?: string;
apiKeyEnv?: string;
modelName?: string;
apiModelType?: string;
type?: "image" | "chat" | "completion";
requestDefaults?: ResolvedModel["requestDefaults"];
},
submitMode: ResolvedModel["submitMode"] = inferSubmitMode(model.modelName ?? id),
apiModelType: string | undefined = model.apiModelType,
): ResolvedModel {
const provider = model.provider;

Expand Down Expand Up @@ -254,6 +349,7 @@ function resolveFromConfigModel(
apiKey,
apiKeyEnv,
modelName: model.modelName,
apiModelType,
type,
requestDefaults,
isFromConfig: true,
Expand Down
2 changes: 2 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface ModelConfig {
apiBaseUrl?: string;
apiKeyEnv?: string;
modelName?: string;
apiModelType?: string;
type?: "image" | "chat" | "completion";
requestDefaults?: RequestDefaults;
}
Expand Down Expand Up @@ -41,6 +42,7 @@ export interface ResolvedModel {
apiKey: string;
apiKeyEnv: string;
modelName?: string;
apiModelType?: string;
type: "image" | "chat" | "completion";
requestDefaults: RequestDefaults;
isFromConfig: boolean;
Expand Down
21 changes: 4 additions & 17 deletions src/core/operations.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { submitTask } from "../api/client";
import { buildEditPayload } from "../utils/model-routing.ts";
import { pollUntilDone } from "../utils/polling";
import type {
EditParams,
Expand Down Expand Up @@ -71,26 +72,12 @@ export async function generateImage(params: GenerateParams): Promise<OperationRe
* Core edit operation - image to image
*/
export async function editImage(params: EditParams): Promise<OperationResult> {
const {
prompt,
images,
size = "2048*2048",
base64Output = false,
syncMode = true,
model,
} = params;

const payload: Record<string, unknown> = {
prompt,
images,
size,
enable_base64_output: base64Output,
enable_sync_mode: syncMode,
};
const { images, model } = params;

try {
const payload = buildEditPayload(params);
console.error(
`[DEBUG] editImage: Submitting task with model=${model.id}, size=${size}, images=${images.length}, syncMode=${syncMode}`,
`[DEBUG] editImage: Submitting task with model=${model.id}, images=${images.length}, aiRemover=${"image" in payload}, payloadKeys=${Object.keys(payload).join(",")}`,
);
const created = await submitTask(model, "edit", payload);
console.error(`[DEBUG] editImage: Task submitted, id=${created.id}, status=${created.status}`);
Expand Down
72 changes: 72 additions & 0 deletions src/utils/model-routing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { ResolvedModel } from "../config/types.ts";
import type { EditParams } from "../core/types.ts";

const AI_REMOVER_MODEL_HINTS = [
"remove-background",
"background-remover",
"image-eraser",
"image-text-remover",
"image-watermark-remover",
] as const;

function getModelRef(model: ResolvedModel): string {
return model.modelName ?? model.id;
}

export function inferApiModelType(modelRef?: string): string | undefined {
if (!modelRef) {
return undefined;
}

const lower = modelRef.toLowerCase();
return AI_REMOVER_MODEL_HINTS.some((hint) => lower.includes(hint)) ? "ai-remover" : undefined;
}

export function isAiRemoverModel(model: ResolvedModel): boolean {
if (model.apiModelType) {
return model.apiModelType === "ai-remover";
}

return inferApiModelType(getModelRef(model)) === "ai-remover";
}

function supportsPromptDrivenAiRemoval(model: ResolvedModel): boolean {
return getModelRef(model).toLowerCase().includes("image-eraser");
}

export function buildEditPayload(params: EditParams): Record<string, unknown> {
const {
prompt,
images,
size = "2048*2048",
base64Output = false,
syncMode = true,
model,
} = params;

if (!isAiRemoverModel(model)) {
return {
prompt,
images,
size,
enable_base64_output: base64Output,
enable_sync_mode: syncMode,
};
}

if (images.length !== 1) {
throw new Error(`Model '${model.id}' accepts exactly 1 input image`);
}

const payload: Record<string, unknown> = {
image: images[0],
enable_base64_output: base64Output,
enable_sync_mode: syncMode,
};

if (supportsPromptDrivenAiRemoval(model) && prompt.trim()) {
payload.prompt = prompt;
}

return payload;
}
42 changes: 42 additions & 0 deletions tests/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,34 @@ const configuredCanonicalAlias: ResolvedModel = {
submitMode: "canonical",
};

const aiRemoverAliasModel: ResolvedModel = {
id: "background-remover",
provider: "wavespeed",
apiBaseUrl: "https://api.wavespeed.ai",
apiKeyEnv: "WAVESPEED_API_KEY",
apiKey: "test-api-key",
modelName: "wavespeed-ai/image-background-remover",
apiModelType: "ai-remover",
type: "image",
requestDefaults: {},
isFromConfig: true,
submitMode: "base",
};

const explicitNonRemoverModel: ResolvedModel = {
id: "background-remover-edit",
provider: "wavespeed",
apiBaseUrl: "https://api.wavespeed.ai",
apiKeyEnv: "WAVESPEED_API_KEY",
apiKey: "test-api-key",
modelName: "vendor/background-remover",
apiModelType: "image-to-image",
type: "image",
requestDefaults: {},
isFromConfig: true,
submitMode: "base",
};

describe("API Client", () => {
const originalEnv = process.env.WAVESPEED_API_KEY;
const originalFetch = globalThis.fetch;
Expand Down Expand Up @@ -274,6 +302,20 @@ describe("API Client", () => {
path: "/api/v3/google/nano-banana-2/edit",
});
});

it("routes ai-remover aliases through the base model path", () => {
expect(buildSubmitTarget(aiRemoverAliasModel, "edit")).toEqual({
model: "wavespeed-ai/image-background-remover",
path: "/api/v3/wavespeed-ai/image-background-remover",
});
});

it("honors explicit non-remover model types before heuristic matches", () => {
expect(buildSubmitTarget(explicitNonRemoverModel, "edit")).toEqual({
model: "vendor/background-remover/edit",
path: "/api/v3/vendor/background-remover/edit",
});
});
});

describe("Static endpoints", () => {
Expand Down
Loading
Loading