mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
83
tests/unit/custom-model-target-format.test.ts
Normal file
83
tests/unit/custom-model-target-format.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user