This commit is contained in:
Timothy Jaeryang Baek
2026-07-23 04:16:14 -04:00
parent cc9a44569e
commit bb12b1a18b
4 changed files with 434 additions and 211 deletions

View File

@@ -1795,8 +1795,23 @@ async def generate_messages(
# Counting must not turn a compatible generation request into an outage.
log.warning('Unable to count Anthropic input tokens for model %s', requested_model, exc_info=True)
model_id = requested_model
model_info = await Models.get_model_by_id(model_id)
if model_info and model_info.base_model_id:
model_id = model_info.base_model_id
passthrough_params = []
models = request.app.state.OPENAI_MODELS
if not models or model_id not in models:
await openai.get_all_models(request, user=user)
models = request.app.state.OPENAI_MODELS
model = models.get(model_id)
if model:
_, _, api_config = await openai.get_openai_connection(model['urlIdx'])
passthrough_params = api_config.get('passthrough_params') or []
# Convert Anthropic payload to OpenAI format
openai_payload = convert_anthropic_to_openai_payload(form_data)
openai_payload = convert_anthropic_to_openai_payload(form_data, passthrough_params)
# Route through the existing chat_completion handler
response = await chat_completion(request, openai_payload, user)

View File

@@ -48,6 +48,7 @@ API_CONFIG_FIELDS = (
'azure',
'api_version',
'extra_params',
'passthrough_params',
)

View File

@@ -12,6 +12,23 @@ from open_webui.utils.headers import include_user_info_headers
log = logging.getLogger(__name__)
ANTHROPIC_CONVERTED_REQUEST_PARAMS = {
'model',
'messages',
'system',
'max_tokens',
'temperature',
'top_p',
'top_k',
'stop_sequences',
'stream',
'metadata',
'service_tier',
'tools',
'tool_choice',
'reasoning_effort',
}
def is_anthropic_url(url: str) -> bool:
"""Check if the URL is an Anthropic API endpoint."""
@@ -109,7 +126,9 @@ def _finalize_openai_content(blocks: list) -> str | list:
return blocks
def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
def convert_anthropic_to_openai_payload(
anthropic_payload: dict, passthrough_params: list[str] | str | None = None
) -> dict:
"""
Convert an Anthropic Messages API request to OpenAI Chat Completions format.
@@ -350,6 +369,48 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
if 'max_tokens' in anthropic_payload:
openai_payload['max_tokens'] = anthropic_payload['max_tokens']
captured_passthrough_params = {
param: value for param, value in anthropic_payload.items() if param not in ANTHROPIC_CONVERTED_REQUEST_PARAMS
}
if isinstance(passthrough_params, str):
passthrough_params = passthrough_params.split(',')
elif not isinstance(passthrough_params, (list, tuple, set)):
passthrough_params = []
passthrough_param_names = {str(item).strip() for item in passthrough_params if str(item).strip()}
if '*' in passthrough_param_names:
openai_payload.update(captured_passthrough_params)
else:
for param in passthrough_param_names:
if param in captured_passthrough_params:
openai_payload[param] = captured_passthrough_params[param]
output_config = anthropic_payload.get('output_config')
if isinstance(output_config, dict):
if 'effort' in output_config and 'reasoning_effort' not in anthropic_payload:
openai_payload['reasoning_effort'] = output_config['effort']
format_config = output_config.get('format')
if isinstance(format_config, dict):
format_type = format_config.get('type')
if format_type == 'json_schema':
json_schema = {
'name': format_config.get('name', 'response_schema'),
'schema': format_config.get('schema', {}),
}
if 'description' in format_config:
json_schema['description'] = format_config['description']
if 'strict' in format_config:
json_schema['strict'] = format_config['strict']
openai_payload['response_format'] = {
'type': 'json_schema',
'json_schema': json_schema,
}
elif format_type == 'json_object':
openai_payload['response_format'] = {'type': format_type}
if 'reasoning_effort' in anthropic_payload:
openai_payload['reasoning_effort'] = anthropic_payload['reasoning_effort']
# Common parameters
for param in ('temperature', 'top_p', 'top_k', 'stop_sequences', 'stream', 'metadata', 'service_tier'):
if param in anthropic_payload:
@@ -421,6 +482,32 @@ def convert_openai_to_anthropic_response(
# Build content blocks
content = []
message_thinking = message.get('thinking')
thinking_blocks = message.get('thinking_blocks') or []
if not thinking_blocks and isinstance(message_thinking, dict):
thinking_blocks = message_thinking.get('blocks') or []
has_thinking = False
for block in thinking_blocks:
if not isinstance(block, dict):
continue
thinking = block.get('thinking') or block.get('content') or block.get('text')
if not thinking:
continue
thinking_block = {'type': 'thinking', 'thinking': thinking}
if block.get('signature'):
thinking_block['signature'] = block['signature']
content.append(thinking_block)
has_thinking = True
reasoning_content = message.get('reasoning_content') or message.get('reasoning')
if not reasoning_content and isinstance(message_thinking, str):
reasoning_content = message_thinking
if reasoning_content and not has_thinking:
content.append({'type': 'thinking', 'thinking': reasoning_content})
message_content = message.get('content')
if message_content:
content.append({'type': 'text', 'text': message_content})
@@ -445,9 +532,11 @@ def convert_openai_to_anthropic_response(
# Usage
openai_usage = openai_response.get('usage', {})
usage = {
'input_tokens': input_tokens
if input_tokens is not None
else openai_usage.get('input_tokens', openai_usage.get('prompt_tokens', 0)),
'input_tokens': (
input_tokens
if input_tokens is not None
else openai_usage.get('input_tokens', openai_usage.get('prompt_tokens', 0))
),
'output_tokens': openai_usage.get('output_tokens', openai_usage.get('completion_tokens', 0)),
}
if 'cache_creation_input_tokens' in openai_usage:
@@ -490,6 +579,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
# Track content blocks with a running index.
# Each text block or tool_use block gets its own index.
current_block_index = 0
thinking_block_open = False
text_block_open = False
# Accumulated state for each tool call, keyed by tool call id.
@@ -563,11 +653,52 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
'output_tokens', data['usage'].get('completion_tokens', output_tokens)
)
reasoning_content = (
delta.get('reasoning_content')
or delta.get('reasoning')
or delta.get('thinking')
or message.get('reasoning_content')
or message.get('reasoning')
)
if not reasoning_content:
thinking_blocks = delta.get('thinking_blocks') or message.get('thinking_blocks') or []
for block in thinking_blocks:
if isinstance(block, dict):
reasoning_content = block.get('thinking') or block.get('content') or block.get('text')
if reasoning_content:
break
if reasoning_content and not text_block_open and not has_tool_calls:
if not thinking_block_open:
block_start = {
'type': 'content_block_start',
'index': current_block_index,
'content_block': {'type': 'thinking', 'thinking': ''},
}
yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode()
thinking_block_open = True
block_delta = {
'type': 'content_block_delta',
'index': current_block_index,
'delta': {'type': 'thinking_delta', 'thinking': reasoning_content},
}
yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode()
# --- Handle text content ---
# Anthropic expects text blocks before tool blocks, so skip
# text deltas once any tool call has started.
content = delta.get('content')
if content and not has_tool_calls:
if thinking_block_open:
block_stop = {
'type': 'content_block_stop',
'index': current_block_index,
}
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
thinking_block_open = False
current_block_index += 1
if not text_block_open:
block_start = {
'type': 'content_block_start',
@@ -593,6 +724,15 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
if tool_calls:
# Close text block if one is open (text comes before tools)
if thinking_block_open:
block_stop = {
'type': 'content_block_stop',
'index': current_block_index,
}
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
thinking_block_open = False
current_block_index += 1
if text_block_open:
block_stop = {
'type': 'content_block_stop',
@@ -704,6 +844,12 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
except Exception as e:
log.error(f'Error in Anthropic stream conversion: {e}')
# Close any open thinking block
if thinking_block_open:
block_stop = {'type': 'content_block_stop', 'index': current_block_index}
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
current_block_index += 1
# Flush any tools that buffered arguments but never emitted a block
for tool in tracked_tool_calls.values():
if not tool['started'] and tool['name']:

View File

@@ -49,6 +49,7 @@
let apiType = ''; // '' = chat completions (default), 'responses' = Responses API
let headers = '';
let passthroughParams = '';
let tags = [];
@@ -57,12 +58,19 @@
let loading = false;
let showDeleteConfirmDialog = false;
let showAdvanced = false;
const inputClass =
'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
const selectClass =
'dark:bg-gray-900 bg-transparent pr-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
const parsePassthroughParams = (value: string) =>
value
.split(',')
.map((param) => param.trim())
.filter(Boolean);
const verifyOllamaHandler = async () => {
// remove trailing slash from url
url = url.replace(/\/$/, '');
@@ -109,6 +117,7 @@
...(provider ? { provider } : {}),
...(azure ? { azure: true } : {}),
api_version: apiVersion,
passthrough_params: parsePassthroughParams(passthroughParams),
...(_headers ? { headers: _headers } : {})
}
},
@@ -149,6 +158,7 @@
if (azure) {
if (!apiVersion) {
loading = false;
showAdvanced = true;
toast.error($i18n.t('API Version is required'));
return;
@@ -163,6 +173,7 @@
if (modelIds.length === 0) {
loading = false;
showAdvanced = true;
toast.error($i18n.t('Deployment names are required for Azure OpenAI'));
return;
}
@@ -195,6 +206,7 @@
connection_type: connectionType,
auth_type,
headers: headers ? JSON.parse(headers) : undefined,
passthrough_params: parsePassthroughParams(passthroughParams),
...(provider ? { provider } : {}),
...(!ollama && azure ? { azure: true } : {}),
...(azure ? { api_version: apiVersion } : {}),
@@ -211,6 +223,8 @@
key = '';
auth_type = 'bearer';
prefixId = '';
passthroughParams = '';
showAdvanced = false;
tags = [];
modelIds = [];
};
@@ -228,6 +242,9 @@
enable = connection.config?.enable ?? true;
tags = connection.config?.tags ?? [];
prefixId = connection.config?.prefix_id ?? '';
passthroughParams = Array.isArray(connection.config?.passthrough_params)
? connection.config.passthrough_params.join(', ')
: (connection.config?.passthrough_params ?? '');
modelIds = connection.config?.model_ids ?? [];
if (ollama) {
@@ -429,107 +446,6 @@
</div>
</div>
{#if !direct}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="headers-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Headers')}</label
>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Enter additional headers in JSON format (e.g. {"X-Custom-Header": "value"}'
)}
>
<Textarea
className="w-full text-sm outline-hidden"
bind:value={headers}
placeholder={$i18n.t('Enter additional headers in JSON format')}
required={false}
minSize={30}
/>
</Tooltip>
</div>
</div>
</div>
{/if}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="prefix-id-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Prefix ID')}</label
>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Prefix ID is used to avoid conflicts with other connections by adding a prefix to the model IDs - leave empty to disable'
)}
>
<input
class={`w-full text-sm ${inputClass}`}
type="text"
id="prefix-id-input"
bind:value={prefixId}
placeholder={$i18n.t('Prefix ID')}
autocomplete="off"
/>
</Tooltip>
</div>
</div>
</div>
{#if !ollama && !direct}
<div class="flex flex-row justify-between items-center w-full mt-2">
<label
for="provider-select"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Provider')}</label
>
<div>
<select
id="provider-select"
bind:value={provider}
class="text-xs text-gray-700 dark:text-gray-300 bg-transparent outline-hidden"
>
<option value="">{$i18n.t('Default')}</option>
<option value="azure">{$i18n.t('Azure OpenAI')}</option>
<option value="llama.cpp">{$i18n.t('llama.cpp')}</option>
</select>
</div>
</div>
{/if}
{#if azure}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="api-version-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('API Version')}</label
>
<div class="flex-1">
<input
id="api-version-input"
class={`w-full text-sm ${inputClass}`}
type="text"
bind:value={apiVersion}
placeholder={$i18n.t('API Version')}
autocomplete="off"
required
/>
</div>
</div>
</div>
{/if}
{#if !ollama && !direct}
<div class="flex flex-row justify-between items-center w-full mt-1">
<label
@@ -548,19 +464,7 @@
class=" text-xs text-gray-700 dark:text-gray-300"
>
{#if apiType === 'responses'}
<Tooltip
className="flex items-center gap-1"
content={$i18n.t(
'This feature is currently experimental and may not work as expected.'
)}
>
<span
class="inline-flex items-center text-[0.625rem] font-normal uppercase leading-none text-gray-400 dark:text-gray-600"
>{$i18n.t('Experimental')}</span
>
{$i18n.t('Responses')}
</Tooltip>
{$i18n.t('Responses')}
{:else}
{$i18n.t('Chat Completions')}
{/if}
@@ -569,111 +473,268 @@
</div>
{/if}
<div class="flex flex-col w-full mt-2">
<div class="mb-1 flex justify-between">
<div
class={`mb-0.5 text-xs text-gray-500
`}
<div class="flex items-center justify-between">
<button
type="button"
class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition mt-2"
on:click={() => (showAdvanced = !showAdvanced)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3 h-3 transition-transform {showAdvanced ? 'rotate-90' : ''}"
>
{$i18n.t('Model IDs')}
</div>
</div>
<path
fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
clip-rule="evenodd"
/>
</svg>
{$i18n.t('Advanced')}
</button>
</div>
{#if modelIds.length > 0}
<ul class="flex flex-col">
{#each modelIds as modelId, modelIdx}
<li class=" flex gap-2 w-full justify-between items-center">
<div class=" text-sm flex-1 py-1 rounded-lg">
{modelId}
</div>
<div class="shrink-0">
<button
aria-label={$i18n.t(`Remove {{MODELID}} from list.`, {
MODELID: modelId
})}
type="button"
on:click={() => {
modelIds = modelIds.filter((_, idx) => idx !== modelIdx);
}}
>
<Minus strokeWidth="2" className="size-3.5" />
</button>
</div>
</li>
{/each}
</ul>
{:else}
<div
class={`text-gray-500 text-xs text-center py-2 px-10
`}
>
{#if ollama}
{$i18n.t('Leave empty to include all models from "{{url}}/api/tags" endpoint', {
url: url
})}
{:else if azure}
{$i18n.t('Deployment names are required for Azure OpenAI')}
<!-- {$i18n.t('Leave empty to include all models from "{{url}}" endpoint', {
url: `${url}/openai/deployments`
})} -->
{:else}
{$i18n.t('Leave empty to include all models from "{{url}}/models" endpoint', {
url: url
})}
{/if}
{#if showAdvanced}
{#if !direct}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="headers-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Headers')}</label
>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Enter additional headers in JSON format (e.g. {"X-Custom-Header": "value"}'
)}
>
<Textarea
className="w-full text-sm outline-hidden"
bind:value={headers}
placeholder={$i18n.t('Enter additional headers in JSON format')}
required={false}
minSize={30}
/>
</Tooltip>
</div>
</div>
</div>
{/if}
</div>
<div class="flex items-center">
<label class="sr-only" for="add-model-id-input">{$i18n.t('Add a model ID')}</label>
<input
class={`w-full text-sm ${inputClass} ${modelId ? '' : 'text-gray-500'}`}
bind:value={modelId}
id="add-model-id-input"
placeholder={$i18n.t('Add a model ID')}
/>
{#if !ollama && !direct}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="allowed-passthrough-params-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Passthrough params')}</label
>
<div>
<button
type="button"
aria-label={$i18n.t('Add')}
on:click={() => {
addModelHandler();
}}
>
<Plus className="size-3.5" strokeWidth="2" />
</button>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Comma-separated top-level request parameters this upstream may receive without translation. Use * to allow all captured passthrough params.'
)}
>
<input
class={`w-full text-sm ${inputClass}`}
type="text"
id="allowed-passthrough-params-input"
bind:value={passthroughParams}
placeholder={$i18n.t('thinking, output_config')}
autocomplete="off"
/>
</Tooltip>
</div>
</div>
</div>
{/if}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="prefix-id-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Prefix ID')}</label
>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Prefix ID is used to avoid conflicts with other connections by adding a prefix to the model IDs - leave empty to disable'
)}
>
<input
class={`w-full text-sm ${inputClass}`}
type="text"
id="prefix-id-input"
bind:value={prefixId}
placeholder={$i18n.t('Prefix ID')}
autocomplete="off"
/>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div
class={`mb-0.5 text-xs text-gray-500
{#if !ollama && !direct}
<div class="flex flex-row justify-between items-center w-full mt-2">
<label
for="provider-select"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('Provider')}</label
>
<div>
<select
id="provider-select"
bind:value={provider}
class="text-xs text-gray-700 dark:text-gray-300 bg-transparent outline-hidden"
>
<option value="">{$i18n.t('Default')}</option>
<option value="azure">{$i18n.t('Azure OpenAI')}</option>
<option value="llama.cpp">{$i18n.t('llama.cpp')}</option>
</select>
</div>
</div>
{/if}
{#if azure}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="api-version-input"
class={`mb-0.5 text-xs text-gray-500
`}>{$i18n.t('API Version')}</label
>
<div class="flex-1">
<input
id="api-version-input"
class={`w-full text-sm ${inputClass}`}
type="text"
bind:value={apiVersion}
placeholder={$i18n.t('API Version')}
autocomplete="off"
required
/>
</div>
</div>
</div>
{/if}
<div class="flex flex-col w-full mt-2">
<div class="mb-1 flex justify-between">
<div
class={`mb-0.5 text-xs text-gray-500
`}
>
{$i18n.t('Tags')}
>
{$i18n.t('Model IDs')}
</div>
</div>
{#if modelIds.length > 0}
<ul class="flex flex-col">
{#each modelIds as modelId, modelIdx}
<li class=" flex gap-2 w-full justify-between items-center">
<div class=" text-sm flex-1 py-1 rounded-lg">
{modelId}
</div>
<div class="shrink-0">
<button
aria-label={$i18n.t(`Remove {{MODELID}} from list.`, {
MODELID: modelId
})}
type="button"
on:click={() => {
modelIds = modelIds.filter((_, idx) => idx !== modelIdx);
}}
>
<Minus strokeWidth="2" className="size-3.5" />
</button>
</div>
</li>
{/each}
</ul>
{:else}
<div
class={`text-gray-500 text-xs text-center py-2 px-10
`}
>
{#if ollama}
{$i18n.t(
'Leave empty to include all models from "{{url}}/api/tags" endpoint',
{
url: url
}
)}
{:else if azure}
{$i18n.t('Deployment names are required for Azure OpenAI')}
<!-- {$i18n.t('Leave empty to include all models from "{{url}}" endpoint', {
url: `${url}/openai/deployments`
})} -->
{:else}
{$i18n.t('Leave empty to include all models from "{{url}}/models" endpoint', {
url: url
})}
{/if}
</div>
{/if}
</div>
<div class="flex-1 mt-0.5">
<Tags
bind:tags
on:add={(e) => {
tags = [
...tags,
{
name: e.detail
}
];
}}
on:delete={(e) => {
tags = tags.filter((tag) => tag.name !== e.detail);
}}
<div class="flex items-center">
<label class="sr-only" for="add-model-id-input">{$i18n.t('Add a model ID')}</label>
<input
class={`w-full text-sm ${inputClass} ${modelId ? '' : 'text-gray-500'}`}
bind:value={modelId}
id="add-model-id-input"
placeholder={$i18n.t('Add a model ID')}
/>
<div>
<button
type="button"
aria-label={$i18n.t('Add')}
on:click={() => {
addModelHandler();
}}
>
<Plus className="size-3.5" strokeWidth="2" />
</button>
</div>
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div
class={`mb-0.5 text-xs text-gray-500
`}
>
{$i18n.t('Tags')}
</div>
<div class="flex-1 mt-0.5">
<Tags
bind:tags
on:add={(e) => {
tags = [
...tags,
{
name: e.detail
}
];
}}
on:delete={(e) => {
tags = tags.filter((tag) => tag.name !== e.detail);
}}
/>
</div>
</div>
</div>
{/if}
</div>
<div class="flex justify-between items-center pt-3 text-sm font-medium">