fix(models): honor per-model targetFormat for custom models (#2905)

Custom models (added via the UI) on opencode-go / openai-compatible nodes always
routed as OpenAI-compatible because there was no per-model targetFormat: addCustomModel
didn't accept it, the API schema stripped it, and getModelTargetFormat (static-registry
only) never saw it — so a custom model needing the Anthropic Messages shape fell back to
the provider default 'openai'.

Thread an optional targetFormat through addCustomModel / replaceCustomModels /
updateCustomModel + the providerModelMutationSchema + the POST/PUT route, surface it from
getModelInfo (one combined custom-model metadata lookup alongside apiFormat), and use it
in chatCore's targetFormat resolution before the provider default.

Closes #2905
This commit is contained in:
diegosouzapw
2026-05-31 11:16:48 -03:00
parent 687c28474d
commit d4045e74b5
7 changed files with 149 additions and 16 deletions

View File

@@ -59,6 +59,12 @@
### Fixed
- **models/custom:** custom models can now carry a per-model `targetFormat`
override (e.g. an opencode-go custom model that must use the Anthropic Messages
shape). Previously custom models always routed as OpenAI-compatible because
`targetFormat` was neither persisted nor consulted at routing time. Threaded
through `addCustomModel`/`replaceCustomModels`/`updateCustomModel`, the API
schema/route, `getModelInfo`, and chatCore's targetFormat resolution. (#2905)
- **providers/pollinations:** route to `gen.pollinations.ai/v1` instead of the
retired `text.pollinations.ai` host, which now returns `404 "legacy API"` for
all models. The gen gateway is the current OpenAI-compatible endpoint. (#2987)

View File

@@ -1495,6 +1495,15 @@ export async function handleChatCore({
? ((modelInfo as { apiFormat?: string }).apiFormat as string)
: undefined
: undefined;
// #2905: per-model wire-format override for custom models, injected by
// getModelInfo. Custom models are not in the static registry, so
// getModelTargetFormat() can't see this — use it before the provider default.
const customModelTargetFormat: string | undefined =
modelInfo && typeof modelInfo === "object" && "targetFormat" in modelInfo
? typeof (modelInfo as { targetFormat?: unknown }).targetFormat === "string"
? ((modelInfo as { targetFormat?: string }).targetFormat as string)
: undefined
: undefined;
const requestedModel =
typeof body?.model === "string" && body.model.trim().length > 0 ? body.model : model;
const isModelScope = () => isModelScopeProvider(provider, credentials?.providerSpecificData);
@@ -1876,7 +1885,9 @@ export async function handleChatCore({
const targetFormat =
apiFormat === "responses"
? FORMATS.OPENAI_RESPONSES
: modelTargetFormat || getTargetFormat(provider, credentials?.providerSpecificData);
: modelTargetFormat ||
customModelTargetFormat ||
getTargetFormat(provider, credentials?.providerSpecificData);
const initialProviderRequest =
body && typeof body === "object" && !Array.isArray(body)

View File

@@ -96,7 +96,8 @@ export async function POST(request) {
if (isValidationFailure(validation)) {
return Response.json({ error: validation.error }, { status: 400 });
}
const { provider, modelId, modelName, source, apiFormat, supportedEndpoints } = validation.data;
const { provider, modelId, modelName, source, apiFormat, supportedEndpoints, targetFormat } =
validation.data;
const model = await addCustomModel(
provider,
@@ -104,7 +105,8 @@ export async function POST(request) {
modelName,
source || "manual",
apiFormat,
supportedEndpoints
supportedEndpoints,
targetFormat
);
return Response.json({ model });
} catch (error) {
@@ -150,6 +152,7 @@ export async function PUT(request) {
modelName,
apiFormat,
supportedEndpoints,
targetFormat,
normalizeToolCallId,
preserveOpenAIDeveloperRole,
upstreamHeaders,
@@ -161,6 +164,7 @@ export async function PUT(request) {
if ("modelName" in raw) updates.modelName = modelName;
if ("apiFormat" in raw) updates.apiFormat = apiFormat;
if ("supportedEndpoints" in raw) updates.supportedEndpoints = supportedEndpoints;
if ("targetFormat" in raw) updates.targetFormat = targetFormat;
if ("normalizeToolCallId" in raw) updates.normalizeToolCallId = normalizeToolCallId;
if ("preserveOpenAIDeveloperRole" in raw)
updates.preserveOpenAIDeveloperRole = preserveOpenAIDeveloperRole;

View File

@@ -355,7 +355,11 @@ export async function addCustomModel(
| "audio-transcriptions"
| "audio-speech"
| "images-generations" = "chat-completions",
supportedEndpoints: string[] = ["chat"]
supportedEndpoints: string[] = ["chat"],
// #2905: optional per-model wire format override (e.g. "claude" for an
// opencode-go custom model). When unset, routing falls back to the provider
// default format.
targetFormat?: string
) {
const db = getDbInstance();
const row = db
@@ -373,6 +377,7 @@ export async function addCustomModel(
source,
apiFormat,
supportedEndpoints,
...(targetFormat ? { targetFormat } : {}),
};
models.push(model);
db.prepare(
@@ -398,6 +403,7 @@ export async function replaceCustomModels(
outputTokenLimit?: number;
description?: string;
supportsThinking?: boolean;
targetFormat?: string;
}>,
{ allowEmpty = false }: { allowEmpty?: boolean } = {}
) {
@@ -427,6 +433,12 @@ export async function replaceCustomModels(
source: m.source || "auto-sync",
apiFormat: m.apiFormat || (prev as any)?.apiFormat || "chat-completions",
supportedEndpoints: m.supportedEndpoints || (prev as any)?.supportedEndpoints || ["chat"],
// #2905: preserve a per-model targetFormat override (new value wins, else prev).
...(m.targetFormat
? { targetFormat: m.targetFormat }
: (prev as any)?.targetFormat
? { targetFormat: (prev as any).targetFormat }
: {}),
// Preserve metadata from provider API (or previous sync)
...(m.inputTokenLimit != null
? { inputTokenLimit: m.inputTokenLimit }
@@ -815,6 +827,7 @@ export async function updateCustomModel(
...current,
...(updates.modelName !== undefined ? { name: updates.modelName || current.name } : {}),
...(updates.apiFormat !== undefined ? { apiFormat: updates.apiFormat } : {}),
...(updates.targetFormat !== undefined ? { targetFormat: updates.targetFormat } : {}),
...(updates.supportedEndpoints !== undefined
? { supportedEndpoints: updates.supportedEndpoints }
: {}),

View File

@@ -1048,6 +1048,11 @@ export const providerModelMutationSchema = z.object({
])
)
.default(["chat"]),
// #2905: optional per-model wire format override for custom models (e.g. a
// custom opencode-go model that must use the Anthropic Messages shape).
targetFormat: z
.enum(["openai", "openai-responses", "claude", "gemini", "gemini-cli", "antigravity"])
.optional(),
normalizeToolCallId: z.boolean().optional(),
preserveOpenAIDeveloperRole: z.boolean().nullable().optional(),
upstreamHeaders: upstreamHeadersRecordSchema.nullable().optional(),

View File

@@ -48,20 +48,25 @@ export async function resolveModelAlias(alias) {
}
/**
* Look up the apiFormat for a custom model from the DB.
* Returns "responses" if the model is configured for the Responses API, otherwise undefined.
* Look up custom-model metadata from the DB in a single read:
* - apiFormat: "responses" when the model is configured for the Responses API.
* - targetFormat: the optional per-model wire format override (#2905).
*/
async function lookupCustomModelApiFormat(
async function lookupCustomModelMeta(
providerId: string,
modelId: string
): Promise<string | undefined> {
): Promise<{ apiFormat?: string; targetFormat?: string }> {
try {
const models = await getCustomModels(providerId);
if (!Array.isArray(models)) return undefined;
if (!Array.isArray(models)) return {};
const match = models.find((m: any) => m.id === modelId);
return match?.apiFormat === "responses" ? "responses" : undefined;
if (!match) return {};
return {
apiFormat: match.apiFormat === "responses" ? "responses" : undefined,
targetFormat: typeof match.targetFormat === "string" ? match.targetFormat : undefined,
};
} catch {
return undefined;
return {};
}
}
@@ -74,11 +79,15 @@ export async function getModelInfo(modelStr) {
const attachCustomApiFormat = async (info: any) => {
if (!info?.provider || !info?.model) return info;
const apiFormat = await lookupCustomModelApiFormat(String(info.provider), String(info.model));
if (apiFormat) {
const { apiFormat, targetFormat } = await lookupCustomModelMeta(
String(info.provider),
String(info.model)
);
if (apiFormat || targetFormat) {
return {
...info,
apiFormat,
...(apiFormat && { apiFormat }),
...(targetFormat && { targetFormat }),
};
}
return info;
@@ -98,7 +107,7 @@ export async function getModelInfo(modelStr) {
(node) => node.prefix === prefixToCheck || node.id === prefixToCheck
);
if (matchedOpenAI) {
const apiFormat = await lookupCustomModelApiFormat(
const { apiFormat, targetFormat } = await lookupCustomModelMeta(
matchedOpenAI.id as string,
parsed.model as string
);
@@ -107,6 +116,7 @@ export async function getModelInfo(modelStr) {
model: parsed.model,
extendedContext,
...(apiFormat && { apiFormat }),
...(targetFormat && { targetFormat }),
};
}
@@ -116,7 +126,7 @@ export async function getModelInfo(modelStr) {
(node) => node.prefix === prefixToCheck || node.id === prefixToCheck
);
if (matchedAnthropic) {
const apiFormat = await lookupCustomModelApiFormat(
const { apiFormat, targetFormat } = await lookupCustomModelMeta(
matchedAnthropic.id as string,
parsed.model as string
);
@@ -125,6 +135,7 @@ export async function getModelInfo(modelStr) {
model: parsed.model,
extendedContext,
...(apiFormat && { apiFormat }),
...(targetFormat && { targetFormat }),
};
}

View File

@@ -0,0 +1,83 @@
/**
* Issue #2905 — custom models (added via the UI) on opencode-go / openai-compatible
* nodes always routed as OpenAI-compatible because there was no way to set a
* per-model `targetFormat`. `addCustomModel` didn't accept it, the API schema
* stripped it, and routing (`getModelTargetFormat`, static-registry-only) never
* saw it — so a custom model that needs the Anthropic Messages shape fell back
* to the provider default ("openai").
*
* This test verifies the persistence + getModelInfo-injection chain: a custom
* model saved with targetFormat: "claude" is surfaced on the resolved modelInfo
* (which chatCore then uses before the provider default).
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-custom-target-format-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const modelsDb = await import("../../src/lib/db/models.ts");
const { getModelInfo } = await import("../../src/sse/services/model.ts");
test.before(async () => {
await providersDb.createProviderNode({
id: "openai-compatible-2905",
type: "openai-compatible",
name: "Gateway 2905",
prefix: "g29",
baseUrl: "https://proxy.example.com",
chatPath: "/v1/chat/completions",
modelsPath: "/v1/models",
});
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("#2905 addCustomModel persists targetFormat", async () => {
await modelsDb.addCustomModel(
"openai-compatible-2905",
"my-claude-model",
"My Claude Model",
"manual",
"chat-completions",
["chat"],
"claude"
);
const models = (await modelsDb.getCustomModels("openai-compatible-2905")) as Array<{
id: string;
targetFormat?: string;
}>;
const saved = models.find((m) => m.id === "my-claude-model");
assert.ok(saved, "custom model must be saved");
assert.equal(saved.targetFormat, "claude", "targetFormat must be persisted");
});
test("#2905 getModelInfo surfaces the custom model targetFormat", async () => {
const info = (await getModelInfo("g29/my-claude-model")) as {
provider?: string;
targetFormat?: string;
};
assert.equal(info.provider, "openai-compatible-2905", "must resolve to the custom node");
assert.equal(info.targetFormat, "claude", "getModelInfo must inject the custom targetFormat");
});
test("#2905 a custom model without targetFormat surfaces none (provider default applies)", async () => {
await modelsDb.addCustomModel(
"openai-compatible-2905",
"plain-model",
"Plain Model",
"manual",
"chat-completions",
["chat"]
);
const info = (await getModelInfo("g29/plain-model")) as { targetFormat?: string };
assert.equal(info.targetFormat, undefined, "no targetFormat → falls back to provider default");
});