Compare commits

...

1 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
b56641bea6 feat(ollama): native Claude-format transport for Claude clients
Ollama Cloud exposes a native Anthropic-compatible /v1/messages endpoint.
A Claude-format client (sourceFormat="claude") is now routed straight to it
instead of through the lossy claude->openai->ollama bridge, preserving thinking
blocks, tool-use ids, and image fidelity.

Narrow, single-provider port — NOT a generic transport-selection framework:

- registry: new optional `claudeBaseUrl` on ollama-cloud (mirrors the existing
  `responsesBaseUrl` precedent) → https://ollama.com/v1/messages.
- resolveChatCoreTargetFormat: a Claude-format client on the allowlisted
  ollama-cloud provider resolves targetFormat="claude" (skips the openai bridge).
- resolveExecutionCredentials: stamps a marker read by the executor.
- DefaultExecutor.buildUrl/buildHeaders: route to claudeBaseUrl and add the
  Anthropic-Version header on that path; auth stays Authorization: Bearer.
- hasValidContent: recognize image/document-only Claude messages so an
  attachment-only turn is no longer dropped as empty on the passthrough.

TDD regression guards: tests/unit/chatcore-target-format.test.ts,
tests/unit/chatcore-execution-credentials.test.ts,
tests/unit/executor-ollama-cloud-claude-passthrough.test.ts,
tests/unit/translator-helper-branches.test.ts.

Co-authored-by: thienpv <pvtcwd@gmail.com>
Inspired-by: https://github.com/decolua/9router/pull/2475
2026-07-09 04:44:32 -03:00
14 changed files with 262 additions and 9 deletions

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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",

View File

@@ -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;

View File

@@ -117,6 +117,7 @@ export type ProviderConfig = {
baseUrl?: string;
baseUrls?: string[];
responsesBaseUrl?: string;
claudeBaseUrl?: string;
chatPath?: string;
clientVersion?: string;
clientId?: string;

View File

@@ -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).

View File

@@ -700,6 +700,7 @@ export async function handleChatCore({
apiFormat,
customModelTargetFormat,
providerSpecificData: credentials?.providerSpecificData,
sourceFormat,
});
const initialProviderRequest =

View File

@@ -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,

View File

@@ -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 };
}

View File

@@ -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;

View File

@@ -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);
});

View File

@@ -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);
});

View 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");
});

View File

@@ -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" },