From b56641bea60437566e9cf199d8a77d9313a24f06 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 9 Jul 2026 04:44:32 -0300 Subject: [PATCH] feat(ollama): native Claude-format transport for Claude clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Inspired-by: https://github.com/decolua/9router/pull/2475 --- CHANGELOG.md | 1 + open-sse/config/providerRegistry.ts | 3 + .../providers/registry/ollama-cloud/index.ts | 7 +++ open-sse/config/providers/shared.ts | 10 +++ open-sse/executors/base.ts | 1 + open-sse/executors/default.ts | 25 ++++++++ open-sse/handlers/chatCore.ts | 1 + .../handlers/chatCore/executionCredentials.ts | 16 ++++- open-sse/handlers/chatCore/targetFormat.ts | 36 +++++++++-- open-sse/translator/helpers/claudeHelper.ts | 8 ++- .../chatcore-execution-credentials.test.ts | 33 ++++++++++ tests/unit/chatcore-target-format.test.ts | 57 +++++++++++++++++ ...or-ollama-cloud-claude-passthrough.test.ts | 61 +++++++++++++++++++ tests/unit/translator-helper-branches.test.ts | 12 ++++ 14 files changed, 262 insertions(+), 9 deletions(-) create mode 100644 tests/unit/executor-ollama-cloud-claude-passthrough.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 11bef1abed..4a36fe47af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 85403fdec9..19148b75e1 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -40,6 +40,9 @@ export function generateLegacyProviders(): Record { if (entry.responsesBaseUrl) { p.responsesBaseUrl = entry.responsesBaseUrl; } + if (entry.claudeBaseUrl) { + p.claudeBaseUrl = entry.claudeBaseUrl; + } if (entry.requestDefaults) { p.requestDefaults = entry.requestDefaults; } diff --git a/open-sse/config/providers/registry/ollama-cloud/index.ts b/open-sse/config/providers/registry/ollama-cloud/index.ts index bd74e0d218..ddd758e37c 100644 --- a/open-sse/config/providers/registry/ollama-cloud/index.ts +++ b/open-sse/config/providers/registry/ollama-cloud/index.ts @@ -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", diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index 4059673697..775390c74d 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -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; requestDefaults?: ProviderRequestDefaults; clientId?: string; diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 0058affe32..8b70dba8fc 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -117,6 +117,7 @@ export type ProviderConfig = { baseUrl?: string; baseUrls?: string[]; responsesBaseUrl?: string; + claudeBaseUrl?: string; chatPath?: string; clientVersion?: string; clientId?: string; diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index b2697a349e..a7cd969c84 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -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). diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 676292a4c5..a9e4589e37 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -700,6 +700,7 @@ export async function handleChatCore({ apiFormat, customModelTargetFormat, providerSpecificData: credentials?.providerSpecificData, + sourceFormat, }); const initialProviderRequest = diff --git a/open-sse/handlers/chatCore/executionCredentials.ts b/open-sse/handlers/chatCore/executionCredentials.ts index 3d0d7dcf93..4b22ff228d 100644 --- a/open-sse/handlers/chatCore/executionCredentials.ts +++ b/open-sse/handlers/chatCore/executionCredentials.ts @@ -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, diff --git a/open-sse/handlers/chatCore/targetFormat.ts b/open-sse/handlers/chatCore/targetFormat.ts index 8b1ed014c6..07e642e597 100644 --- a/open-sse/handlers/chatCore/targetFormat.ts +++ b/open-sse/handlers/chatCore/targetFormat.ts @@ -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 }; } diff --git a/open-sse/translator/helpers/claudeHelper.ts b/open-sse/translator/helpers/claudeHelper.ts index 6d8c8edb81..37457684ef 100644 --- a/open-sse/translator/helpers/claudeHelper.ts +++ b/open-sse/translator/helpers/claudeHelper.ts @@ -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; diff --git a/tests/unit/chatcore-execution-credentials.test.ts b/tests/unit/chatcore-execution-credentials.test.ts index 8eec6ee344..ddc1678f1a 100644 --- a/tests/unit/chatcore-execution-credentials.test.ts +++ b/tests/unit/chatcore-execution-credentials.test.ts @@ -96,3 +96,36 @@ test("missing providerSpecificData defaults to an empty object", () => { }) as Record; 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; + const psd = out.providerSpecificData as Record; + 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; + const psd = out.providerSpecificData as Record; + 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; + const psd = out.providerSpecificData as Record; + assert.equal(psd._omnirouteOllamaClaudeUpstream, undefined); +}); diff --git a/tests/unit/chatcore-target-format.test.ts b/tests/unit/chatcore-target-format.test.ts index affa94a113..86909eb313 100644 --- a/tests/unit/chatcore-target-format.test.ts +++ b/tests/unit/chatcore-target-format.test.ts @@ -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); +}); diff --git a/tests/unit/executor-ollama-cloud-claude-passthrough.test.ts b/tests/unit/executor-ollama-cloud-claude-passthrough.test.ts new file mode 100644 index 0000000000..70bb55a99f --- /dev/null +++ b/tests/unit/executor-ollama-cloud-claude-passthrough.test.ts @@ -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"); +}); diff --git a/tests/unit/translator-helper-branches.test.ts b/tests/unit/translator-helper-branches.test.ts index ef4afd8d1f..dbb0979444 100644 --- a/tests/unit/translator-helper-branches.test.ts +++ b/tests/unit/translator-helper-branches.test.ts @@ -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" },