mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Compare commits
1 Commits
4053e2314a
...
feat/port-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b56641bea6 |
@@ -10,6 +10,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(ollama):** add a native Claude-format (`/v1/messages`) transport so Claude clients skip the lossy openai bridge. (thanks @thienpvt)
|
||||
- **Provider/model param filters**: config-driven parameter denylist/allowlist per provider/model with auto-learn from upstream 400s (#6649 — thanks @ThongAccount, closes #6625)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
@@ -40,6 +40,9 @@ export function generateLegacyProviders(): Record<string, LegacyProvider> {
|
||||
if (entry.responsesBaseUrl) {
|
||||
p.responsesBaseUrl = entry.responsesBaseUrl;
|
||||
}
|
||||
if (entry.claudeBaseUrl) {
|
||||
p.claudeBaseUrl = entry.claudeBaseUrl;
|
||||
}
|
||||
if (entry.requestDefaults) {
|
||||
p.requestDefaults = entry.requestDefaults;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,13 @@ export const ollama_cloudProvider: RegistryEntry = {
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://ollama.com/v1/chat/completions",
|
||||
// Ollama Cloud also exposes a native Anthropic-compatible /v1/messages
|
||||
// endpoint. A Claude-format client (sourceFormat="claude") is routed here
|
||||
// instead of through the lossy claude->openai->ollama bridge, preserving
|
||||
// thinking blocks/tool ids/image fidelity (port of decolua/9router#2475).
|
||||
// Auth stays Authorization: Bearer (same as the openai-format endpoint,
|
||||
// per the ollama.com auth domain) — no separate auth scheme needed.
|
||||
claudeBaseUrl: "https://ollama.com/v1/messages",
|
||||
modelsUrl: "https://ollama.com/api/tags",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
|
||||
@@ -106,6 +106,15 @@ export interface RegistryEntry {
|
||||
/** Override base URL used only for API key validation (e.g., opencode-go validates on zen/v1) */
|
||||
testKeyBaseUrl?: string;
|
||||
responsesBaseUrl?: string;
|
||||
/**
|
||||
* Alternate base URL that speaks the provider's native Claude-format
|
||||
* (`/v1/messages`) upstream API. When set, a Claude-format client
|
||||
* (sourceFormat="claude") is routed here instead of through the lossy
|
||||
* claude→openai bridge, mirroring the `responsesBaseUrl` precedent above
|
||||
* (see DefaultExecutor.buildUrl / resolveChatCoreTargetFormat). Port of
|
||||
* decolua/9router#2475 (ollama-cloud only — no generic transport framework).
|
||||
*/
|
||||
claudeBaseUrl?: string;
|
||||
urlSuffix?: string;
|
||||
urlBuilder?: (base: string, model: string, stream: boolean) => string;
|
||||
authType: string;
|
||||
@@ -173,6 +182,7 @@ export interface LegacyProvider {
|
||||
baseUrl?: string;
|
||||
baseUrls?: string[];
|
||||
responsesBaseUrl?: string;
|
||||
claudeBaseUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
requestDefaults?: ProviderRequestDefaults;
|
||||
clientId?: string;
|
||||
|
||||
@@ -117,6 +117,7 @@ export type ProviderConfig = {
|
||||
baseUrl?: string;
|
||||
baseUrls?: string[];
|
||||
responsesBaseUrl?: string;
|
||||
claudeBaseUrl?: string;
|
||||
chatPath?: string;
|
||||
clientVersion?: string;
|
||||
clientId?: string;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getGigachatAccessToken } from "../services/gigachatAuth.ts";
|
||||
import { getRegistryEntry } from "../config/providerRegistry.ts";
|
||||
import { getModelTargetFormat } from "../config/providerModels.ts";
|
||||
import {
|
||||
ANTHROPIC_VERSION_HEADER,
|
||||
mergeClientAnthropicBeta,
|
||||
normalizeAnthropicHeaderVariants,
|
||||
} from "../config/anthropicHeaders.ts";
|
||||
@@ -110,6 +111,18 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
void model;
|
||||
void stream;
|
||||
void urlIndex;
|
||||
// Ollama Cloud native Claude passthrough (port of decolua/9router#2475): a Claude-format
|
||||
// client resolved targetFormat="claude" (see resolveChatCoreTargetFormat +
|
||||
// resolveExecutionCredentials, which stamp the marker below) routes straight to the
|
||||
// registry's `claudeBaseUrl` instead of the default openai-format bridge endpoint. Narrow,
|
||||
// single-provider check — not a generic transport-selection framework.
|
||||
if (
|
||||
this.provider === "ollama-cloud" &&
|
||||
credentials?.providerSpecificData?._omnirouteOllamaClaudeUpstream === true &&
|
||||
this.config.claudeBaseUrl
|
||||
) {
|
||||
return this.config.claudeBaseUrl;
|
||||
}
|
||||
if (this.provider?.startsWith?.("openai-compatible-")) {
|
||||
const psd = credentials?.providerSpecificData;
|
||||
const baseUrl = psd?.baseUrl || "https://api.openai.com/v1";
|
||||
@@ -438,6 +451,18 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
// Ollama Cloud native Claude passthrough (port of decolua/9router#2475): the switch above
|
||||
// already set the correct Authorization: Bearer header for ollama-cloud (falls to the
|
||||
// `default:` branch, registry authHeader="bearer" — identical scheme on both the
|
||||
// openai-format and native-claude endpoints). Only the Claude-format-specific
|
||||
// Anthropic-Version header needs adding when routed to the native /v1/messages transport.
|
||||
if (
|
||||
this.provider === "ollama-cloud" &&
|
||||
credentials?.providerSpecificData?._omnirouteOllamaClaudeUpstream === true
|
||||
) {
|
||||
headers["Anthropic-Version"] = ANTHROPIC_VERSION_HEADER;
|
||||
}
|
||||
|
||||
headers["Accept"] = stream ? "text/event-stream" : "application/json";
|
||||
|
||||
// Qwen header cleanup: Remove X-Dashscope-* headers if using an API key (DashScope compatible mode).
|
||||
|
||||
@@ -700,6 +700,7 @@ export async function handleChatCore({
|
||||
apiFormat,
|
||||
customModelTargetFormat,
|
||||
providerSpecificData: credentials?.providerSpecificData,
|
||||
sourceFormat,
|
||||
});
|
||||
|
||||
const initialProviderRequest =
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
* Pure builder extracted from handleChatCore: derives the per-execution credentials object from the
|
||||
* resolved request context. Applies the native-Codex passthrough endpoint override, forces
|
||||
* apiType=responses (and the responses-upstream marker) for Azure AI Foundry / OCI when the model
|
||||
* routes to the OpenAI Responses format, and threads the Claude Code session id when present.
|
||||
* Side-effect-free; behaviour is byte-identical to the previous inline closure.
|
||||
* routes to the OpenAI Responses format, stamps the ollama-cloud native-Claude-upstream marker when
|
||||
* targetFormat="claude" (read by DefaultExecutor.buildUrl/buildHeaders to route to the registry's
|
||||
* `claudeBaseUrl` instead of the openai bridge — port of decolua/9router#2475, scoped to this one
|
||||
* provider), and threads the Claude Code session id when present. Side-effect-free; behaviour is
|
||||
* byte-identical to the previous inline closure for every provider other than ollama-cloud.
|
||||
*/
|
||||
|
||||
import { FORMATS } from "../../translator/formats.ts";
|
||||
@@ -55,6 +58,15 @@ export function resolveExecutionCredentials(opts: {
|
||||
providerSpecificData._omnirouteForceResponsesUpstream = true;
|
||||
}
|
||||
|
||||
// Ollama Cloud native Claude passthrough (port of decolua/9router#2475): when
|
||||
// resolveChatCoreTargetFormat resolved targetFormat="claude" for ollama-cloud (a
|
||||
// Claude-format client), stamp a marker so DefaultExecutor.buildUrl/buildHeaders route to
|
||||
// the registry's `claudeBaseUrl` (https://ollama.com/v1/messages) instead of the default
|
||||
// openai-format bridge endpoint.
|
||||
if (targetFormat === FORMATS.CLAUDE && provider === "ollama-cloud") {
|
||||
providerSpecificData._omnirouteOllamaClaudeUpstream = true;
|
||||
}
|
||||
|
||||
const withApiType = {
|
||||
...nextCredentials,
|
||||
providerSpecificData,
|
||||
|
||||
@@ -4,30 +4,54 @@
|
||||
*
|
||||
* Pure resolution of the provider alias + the upstream target format used to translate the request:
|
||||
* apiFormat==="responses" forces OpenAI Responses; otherwise the model's registry target format, then
|
||||
* the per-model custom override (#2905), then the provider default. Returns both `alias` (reused by
|
||||
* the handler when stripping the `alias/` prefix off the upstream model id) and `targetFormat`.
|
||||
* Side-effect-free; byte-identical to the previous inline block. Sits alongside the other
|
||||
* request-setup resolvers (resolveChatCoreRequestSetup / resolveChatCoreRequestFormat).
|
||||
* a narrow ollama-cloud Claude-native override (a Claude-format client talking to ollama-cloud skips
|
||||
* the lossy claude->openai bridge and resolves straight to targetFormat="claude" — port of
|
||||
* decolua/9router#2475, scoped to this one provider; see DefaultExecutor.buildUrl for the matching
|
||||
* `claudeBaseUrl` routing), then the per-model custom override (#2905), then the provider default.
|
||||
* Returns both `alias` (reused by the handler when stripping the `alias/` prefix off the upstream
|
||||
* model id) and `targetFormat`. Side-effect-free; byte-identical to the previous inline block for
|
||||
* every provider other than ollama-cloud. Sits alongside the other request-setup resolvers
|
||||
* (resolveChatCoreRequestSetup / resolveChatCoreRequestFormat).
|
||||
*/
|
||||
|
||||
import { PROVIDER_ID_TO_ALIAS, getModelTargetFormat } from "../../config/providerModels.ts";
|
||||
import { getTargetFormat } from "../../services/provider.ts";
|
||||
import { FORMATS } from "../../translator/formats.ts";
|
||||
|
||||
// Providers with a native Claude-format (`/v1/messages`) upstream (registry `claudeBaseUrl`) that a
|
||||
// Claude-format client should be routed to directly instead of through the openai bridge. Kept as an
|
||||
// explicit allowlist (not a generic transport framework) — port of decolua/9router#2475.
|
||||
const CLAUDE_NATIVE_PASSTHROUGH_PROVIDERS = new Set(["ollama-cloud"]);
|
||||
|
||||
export function resolveChatCoreTargetFormat(opts: {
|
||||
provider: string;
|
||||
resolvedModel: string;
|
||||
apiFormat: string | undefined;
|
||||
customModelTargetFormat: string | undefined;
|
||||
providerSpecificData: unknown;
|
||||
sourceFormat?: string;
|
||||
}) {
|
||||
const { provider, resolvedModel, apiFormat, customModelTargetFormat, providerSpecificData } = opts;
|
||||
const {
|
||||
provider,
|
||||
resolvedModel,
|
||||
apiFormat,
|
||||
customModelTargetFormat,
|
||||
providerSpecificData,
|
||||
sourceFormat,
|
||||
} = opts;
|
||||
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
||||
const modelTargetFormat = getModelTargetFormat(alias, resolvedModel);
|
||||
const claudeNativeOverride =
|
||||
sourceFormat === FORMATS.CLAUDE && CLAUDE_NATIVE_PASSTHROUGH_PROVIDERS.has(provider)
|
||||
? FORMATS.CLAUDE
|
||||
: undefined;
|
||||
const targetFormat =
|
||||
apiFormat === "responses"
|
||||
? FORMATS.OPENAI_RESPONSES
|
||||
: modelTargetFormat || customModelTargetFormat || getTargetFormat(provider, providerSpecificData);
|
||||
: modelTargetFormat ||
|
||||
claudeNativeOverride ||
|
||||
customModelTargetFormat ||
|
||||
getTargetFormat(provider, providerSpecificData);
|
||||
return { alias, targetFormat };
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,13 @@ export function hasValidContent(msg: ClaudeMessage): boolean {
|
||||
(block) =>
|
||||
(block.type === "text" && block.text?.trim()) ||
|
||||
block.type === "tool_use" ||
|
||||
block.type === "tool_result"
|
||||
block.type === "tool_result" ||
|
||||
// #2475 (native Claude passthrough): image/document-only user messages are
|
||||
// valid content and must not be dropped by prepareClaudeRequest's empty-message
|
||||
// filter — otherwise an attachment-only turn is silently lost on the claude->claude
|
||||
// path (e.g. the ollama-cloud /v1/messages transport). Port of decolua/9router#2475.
|
||||
block.type === "image" ||
|
||||
block.type === "document"
|
||||
);
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -96,3 +96,36 @@ test("missing providerSpecificData defaults to an empty object", () => {
|
||||
}) as Record<string, unknown>;
|
||||
assert.deepEqual(out.providerSpecificData, {});
|
||||
});
|
||||
|
||||
// Port of decolua/9router#2475: ollama-cloud + targetFormat="claude" stamps the marker read by
|
||||
// DefaultExecutor.buildUrl/buildHeaders to route to the registry's native claudeBaseUrl instead of
|
||||
// the openai bridge.
|
||||
test("ollama-cloud + claude target stamps the native-claude-upstream marker", () => {
|
||||
const out = resolveExecutionCredentials({
|
||||
...base,
|
||||
provider: "ollama-cloud",
|
||||
targetFormat: "claude",
|
||||
}) as Record<string, unknown>;
|
||||
const psd = out.providerSpecificData as Record<string, unknown>;
|
||||
assert.equal(psd._omnirouteOllamaClaudeUpstream, true);
|
||||
});
|
||||
|
||||
test("ollama-cloud + openai target never gets the native-claude-upstream marker", () => {
|
||||
const out = resolveExecutionCredentials({
|
||||
...base,
|
||||
provider: "ollama-cloud",
|
||||
targetFormat: "openai",
|
||||
}) as Record<string, unknown>;
|
||||
const psd = out.providerSpecificData as Record<string, unknown>;
|
||||
assert.equal(psd._omnirouteOllamaClaudeUpstream, undefined);
|
||||
});
|
||||
|
||||
test("a non-allowlisted provider + claude target never gets the ollama marker", () => {
|
||||
const out = resolveExecutionCredentials({
|
||||
...base,
|
||||
provider: "claude",
|
||||
targetFormat: "claude",
|
||||
}) as Record<string, unknown>;
|
||||
const psd = out.providerSpecificData as Record<string, unknown>;
|
||||
assert.equal(psd._omnirouteOllamaClaudeUpstream, undefined);
|
||||
});
|
||||
|
||||
@@ -87,3 +87,60 @@ test("unmapped provider → alias falls back to the provider id", () => {
|
||||
});
|
||||
assert.equal(r.alias, "some-unmapped-provider");
|
||||
});
|
||||
|
||||
// Port of decolua/9router#2475: ollama-cloud exposes a native Claude-format (`/v1/messages`)
|
||||
// upstream (registry `claudeBaseUrl`). A Claude-format client should resolve targetFormat="claude"
|
||||
// directly instead of the provider's default openai bridge, so the actual request skips the lossy
|
||||
// claude->openai->ollama translation. Narrow, single-provider override — see
|
||||
// CLAUDE_NATIVE_PASSTHROUGH_PROVIDERS in targetFormat.ts.
|
||||
test("ollama-cloud + sourceFormat=claude resolves targetFormat=claude (native passthrough)", () => {
|
||||
// precondition: no static per-model targetFormat override exists for this model, so the
|
||||
// sourceFormat-driven override is the thing actually deciding targetFormat here.
|
||||
assert.ok(!getModelTargetFormat(PROVIDER_ID_TO_ALIAS["ollama-cloud"] || "ollama-cloud", "deepseek-v4-pro"));
|
||||
|
||||
const r = resolveChatCoreTargetFormat({
|
||||
provider: "ollama-cloud",
|
||||
resolvedModel: "deepseek-v4-pro",
|
||||
apiFormat: undefined,
|
||||
customModelTargetFormat: undefined,
|
||||
providerSpecificData: undefined,
|
||||
sourceFormat: FORMATS.CLAUDE,
|
||||
});
|
||||
assert.equal(r.targetFormat, FORMATS.CLAUDE);
|
||||
});
|
||||
|
||||
test("ollama-cloud + sourceFormat=openai keeps the default openai bridge target", () => {
|
||||
const r = resolveChatCoreTargetFormat({
|
||||
provider: "ollama-cloud",
|
||||
resolvedModel: "deepseek-v4-pro",
|
||||
apiFormat: undefined,
|
||||
customModelTargetFormat: undefined,
|
||||
providerSpecificData: undefined,
|
||||
sourceFormat: FORMATS.OPENAI,
|
||||
});
|
||||
assert.equal(r.targetFormat, getTargetFormat("ollama-cloud", undefined));
|
||||
assert.equal(r.targetFormat, FORMATS.OPENAI);
|
||||
});
|
||||
|
||||
test("ollama-cloud + no sourceFormat (omitted) is byte-identical to the pre-port behavior", () => {
|
||||
const r = resolveChatCoreTargetFormat({
|
||||
provider: "ollama-cloud",
|
||||
resolvedModel: "deepseek-v4-pro",
|
||||
apiFormat: undefined,
|
||||
customModelTargetFormat: undefined,
|
||||
providerSpecificData: undefined,
|
||||
});
|
||||
assert.equal(r.targetFormat, FORMATS.OPENAI);
|
||||
});
|
||||
|
||||
test("a non-allowlisted provider is NOT affected by sourceFormat=claude (no generic transport framework)", () => {
|
||||
const r = resolveChatCoreTargetFormat({
|
||||
provider: "openai",
|
||||
resolvedModel: "gpt-4o",
|
||||
apiFormat: undefined,
|
||||
customModelTargetFormat: undefined,
|
||||
providerSpecificData: undefined,
|
||||
sourceFormat: FORMATS.CLAUDE,
|
||||
});
|
||||
assert.equal(r.targetFormat, FORMATS.OPENAI);
|
||||
});
|
||||
|
||||
61
tests/unit/executor-ollama-cloud-claude-passthrough.test.ts
Normal file
61
tests/unit/executor-ollama-cloud-claude-passthrough.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Tests for Ollama Cloud's native Claude-format (`/v1/messages`) passthrough transport
|
||||
* (port of decolua/9router#2475).
|
||||
*
|
||||
* Covers:
|
||||
* 1. buildUrl routes to the registry's claudeBaseUrl when
|
||||
* providerSpecificData._omnirouteOllamaClaudeUpstream === true.
|
||||
* 2. The default openai-format chat/completions path is preserved otherwise.
|
||||
* 3. buildHeaders adds Anthropic-Version only on the native-claude path; the
|
||||
* Authorization: Bearer scheme (registry authHeader) is unchanged on both paths.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { DefaultExecutor } from "@omniroute/open-sse/executors/default.ts";
|
||||
|
||||
test("DefaultExecutor.buildUrl routes ollama-cloud to claudeBaseUrl when the native-claude marker is set", () => {
|
||||
const executor = new DefaultExecutor("ollama-cloud");
|
||||
|
||||
const url = executor.buildUrl("deepseek-v4-pro", true, 0, {
|
||||
apiKey: "test-key",
|
||||
providerSpecificData: { _omnirouteOllamaClaudeUpstream: true },
|
||||
});
|
||||
|
||||
assert.equal(url, "https://ollama.com/v1/messages");
|
||||
});
|
||||
|
||||
test("DefaultExecutor.buildUrl keeps the default openai bridge path when the marker is absent", () => {
|
||||
const executor = new DefaultExecutor("ollama-cloud");
|
||||
|
||||
const url = executor.buildUrl("deepseek-v4-pro", true, 0, {
|
||||
apiKey: "test-key",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
|
||||
assert.equal(url, "https://ollama.com/v1/chat/completions");
|
||||
});
|
||||
|
||||
test("DefaultExecutor.buildUrl keeps the default openai bridge path with no credentials at all", () => {
|
||||
const executor = new DefaultExecutor("ollama-cloud");
|
||||
const url = executor.buildUrl("deepseek-v4-pro", true, 0, null);
|
||||
assert.equal(url, "https://ollama.com/v1/chat/completions");
|
||||
});
|
||||
|
||||
test("DefaultExecutor.buildHeaders adds Anthropic-Version only on the native-claude path", () => {
|
||||
const executor = new DefaultExecutor("ollama-cloud");
|
||||
|
||||
const claudeHeaders = executor.buildHeaders(
|
||||
{ apiKey: "test-key", providerSpecificData: { _omnirouteOllamaClaudeUpstream: true } },
|
||||
true
|
||||
);
|
||||
assert.equal(claudeHeaders["Anthropic-Version"], "2023-06-01");
|
||||
assert.equal(claudeHeaders["Authorization"], "Bearer test-key");
|
||||
|
||||
const openaiHeaders = executor.buildHeaders(
|
||||
{ apiKey: "test-key", providerSpecificData: {} },
|
||||
true
|
||||
);
|
||||
assert.equal("Anthropic-Version" in openaiHeaders, false);
|
||||
assert.equal(openaiHeaders["Authorization"], "Bearer test-key");
|
||||
});
|
||||
@@ -260,6 +260,18 @@ test("claudeHelper validates content, ordering and request preparation branches"
|
||||
assert.equal(claudeHelper.hasValidContent({ content: " hello " }), true);
|
||||
assert.equal(claudeHelper.hasValidContent({ content: [{ type: "tool_use", id: "call" }] }), true);
|
||||
assert.equal(claudeHelper.hasValidContent({ content: [{ type: "text", text: " " }] }), false);
|
||||
// #2475: image/document-only messages are valid content (native Claude passthrough) and must
|
||||
// not be dropped as empty by prepareClaudeRequest's filter.
|
||||
assert.equal(
|
||||
claudeHelper.hasValidContent({
|
||||
content: [{ type: "image", source: { type: "base64", media_type: "image/png", data: "x" } }],
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
claudeHelper.hasValidContent({ content: [{ type: "document", source: { type: "url", url: "u" } }] }),
|
||||
true
|
||||
);
|
||||
|
||||
assert.deepEqual(claudeHelper.fixToolUseOrdering([{ role: "user", content: "single" }]), [
|
||||
{ role: "user", content: "single" },
|
||||
|
||||
Reference in New Issue
Block a user