Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8956ffef73 | |||
| 4383e7d807 | |||
| 863055768e | |||
| 2c1da9e146 | |||
| 845787ab7f | |||
| 1db948e9bb | |||
| f0d00bcee5 |
@@ -11,6 +11,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [2.3.4] — 2026-03-12
|
||||
|
||||
> ### UI Polish — Endpoint Music Section, Always-Visible Buttons & Provider Logos
|
||||
|
||||
### ✨ Improvements
|
||||
|
||||
- **Endpoint page: Music Generation section** — `/v1/music/generations` now appears in the "Media & Multi-Modal" category alongside Image, Audio Transcription, and Text-to-Speech. Music models (ComfyUI Stable Audio, MusicGen) show up in the endpoint's model count.
|
||||
- **Provider connections: action buttons always visible** — Edit, Delete, Proxy Config, and Reauthenticate buttons on the provider connection rows are no longer hidden until hover. Improves discoverability on touch and keyboard-only navigation.
|
||||
- **Combos: action buttons always visible** — Test, Duplicate, Proxy Config, Edit, and Delete buttons on combo cards are no longer hidden until hover on desktop screens.
|
||||
- **Provider logos: SVG fallback chain** — `ProviderCard` and `ApiKeyProviderCard` now try `.png` → `.svg` → text initials, enabling proper logo rendering for providers without a PNG.
|
||||
|
||||
### 🎨 New Provider Logos
|
||||
|
||||
| Provider | Logo | Brand Colors |
|
||||
| ------------ | ------------------ | -------------------------------------- |
|
||||
| ElevenLabs | `elevenlabs.svg` | `#6C47FF` purple, double-bar "11" mark |
|
||||
| Hyperbolic | `hyperbolic.svg` | dark + cyan→purple gradient "H" |
|
||||
| AssemblyAI | `assemblyai.svg` | `#0062FF` blue waveform |
|
||||
| PlayHT | `playht.svg` | dark + gradient play triangle |
|
||||
| Inworld | `inworld.svg` | dark + `#5B4FFF`→cyan "i" |
|
||||
| NanoBanana | `nanobanana.svg` | dark + yellow banana icon |
|
||||
| Ollama Cloud | `ollama-cloud.png` | Official Ollama logo |
|
||||
|
||||
### 🔧 CI Fixes (included in commit `8630557`)
|
||||
|
||||
- **docs-sync** — Updated `docs/openapi.yaml` version to `2.3.3`
|
||||
- **Unit tests** — Added missing `import { test } from 'node:test'` in `model-parse.test.mjs`
|
||||
- **Electron `.deb`** — `electron/package.json` `author` changed from string to object with `email` field (required by `fpm`)
|
||||
- **Docker build** — Added `COPY scripts/native-binary-compat.mjs` to `Dockerfile` (fixes `ERR_MODULE_NOT_FOUND` during `npm ci`)
|
||||
|
||||
---
|
||||
|
||||
## [2.3.3] — 2026-03-12
|
||||
|
||||
> ### Providers Test All Fix
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Providers page crash when clicking 'Test All'** — Clicking 'Testar Todos' could trigger the error boundary ('Failed to load providers') when the batch test timed out or returned a non-JSON response. Fixed with:
|
||||
- `AbortController` (90s timeout) on the `handleBatchTest` fetch
|
||||
- Inner `try/catch` for `res.json()` — truncated/non-JSON responses no longer crash the component
|
||||
- Null/type guard in `ProviderTestResultsView` — malformed results can no longer trigger a render exception
|
||||
- New i18n key `providerTestTimeout` for friendly timeout message (PR #330)
|
||||
|
||||
---
|
||||
|
||||
## [2.3.2] — 2026-03-12
|
||||
|
||||
> ### Claude 1M Context, Postinstall Fix, New Models & OAuth Remote Docs
|
||||
|
||||
@@ -3,6 +3,7 @@ WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
COPY scripts/postinstall.mjs ./scripts/postinstall.mjs
|
||||
COPY scripts/native-binary-compat.mjs ./scripts/native-binary-compat.mjs
|
||||
RUN if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; else npm install --no-audit --no-fund; fi
|
||||
|
||||
COPY . ./
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 2.3.1
|
||||
version: 2.3.4
|
||||
description: |
|
||||
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
|
||||
endpoint that routes requests to multiple AI providers with load balancing,
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
"version": "2.0.13",
|
||||
"description": "OmniRoute Desktop Application",
|
||||
"main": "main.js",
|
||||
"author": "OmniRoute Team",
|
||||
"author": {
|
||||
"name": "OmniRoute Team",
|
||||
"email": "support@omniroute.online"
|
||||
},
|
||||
"license": "MIT",
|
||||
"homepage": "https://omniroute.online",
|
||||
"scripts": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.3.2",
|
||||
"version": "2.3.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "2.3.2",
|
||||
"version": "2.3.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.3.2",
|
||||
"version": "2.3.4",
|
||||
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="18" fill="#0062FF"/>
|
||||
<!-- AssemblyAI — waveform/microphone mark -->
|
||||
<rect x="47" y="18" width="6" height="30" rx="3" fill="white"/>
|
||||
<rect x="35" y="26" width="6" height="22" rx="3" fill="white" opacity="0.8"/>
|
||||
<rect x="59" y="26" width="6" height="22" rx="3" fill="white" opacity="0.8"/>
|
||||
<rect x="23" y="34" width="6" height="14" rx="3" fill="white" opacity="0.5"/>
|
||||
<rect x="71" y="34" width="6" height="14" rx="3" fill="white" opacity="0.5"/>
|
||||
<!-- Bottom line -->
|
||||
<rect x="30" y="62" width="40" height="4" rx="2" fill="white" opacity="0.7"/>
|
||||
<rect x="45" y="66" width="10" height="14" rx="2" fill="white" opacity="0.7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 749 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="18" fill="#6C47FF"/>
|
||||
<!-- ElevenLabs "11" logo mark — two vertical bars -->
|
||||
<rect x="24" y="20" width="20" height="60" rx="4" fill="white"/>
|
||||
<rect x="56" y="20" width="20" height="60" rx="4" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 321 B |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="18" fill="#141414"/>
|
||||
<!-- Hyperbolic — stylized "H" with gradient accent -->
|
||||
<defs>
|
||||
<linearGradient id="hg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00D4FF"/>
|
||||
<stop offset="100%" stop-color="#7B2FFF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="22" y="20" width="14" height="60" rx="3" fill="url(#hg)"/>
|
||||
<rect x="22" y="41" width="56" height="14" rx="3" fill="url(#hg)"/>
|
||||
<rect x="64" y="20" width="14" height="60" rx="3" fill="url(#hg)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 600 B |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="18" fill="#0A0A1A"/>
|
||||
<defs>
|
||||
<linearGradient id="ig" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#5B4FFF"/>
|
||||
<stop offset="100%" stop-color="#00E5FF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Inworld "i" with dot - futuristic -->
|
||||
<circle cx="50" cy="28" r="8" fill="url(#ig)"/>
|
||||
<rect x="42" y="42" width="16" height="38" rx="5" fill="url(#ig)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 495 B |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="18" fill="#1C1A00"/>
|
||||
<!-- NanoBanana - banana icon stylized -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#FFE000"/>
|
||||
<stop offset="100%" stop-color="#FF9500"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M 35 75 Q 20 40 40 20 Q 55 10 70 18 Q 60 22 52 30 Q 38 45 42 65 Z" fill="url(#bg)"/>
|
||||
<path d="M 42 65 Q 38 45 52 30 Q 60 22 70 18 Q 75 28 72 38 Q 68 55 55 65 Z" fill="#FFD700"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 566 B |
@@ -0,0 +1,375 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<!doctype html>
|
||||
<html class="h-full overflow-y-scroll">
|
||||
<head>
|
||||
<title>Ollama</title>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta name="description" content="Get up and running with large language models."/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta property="og:title" content="Ollama" />
|
||||
<meta property="og:description" content="Get up and running with large language models." />
|
||||
<meta property="og:url" content="https://ollama.com" />
|
||||
<meta property="og:image" content="https://ollama.com/public/og.png" />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="628" />
|
||||
<meta property="og:type" content="website" />
|
||||
|
||||
<meta name="robots" content="index, follow" />
|
||||
|
||||
<meta property="twitter:card" content="summary" />
|
||||
<meta property="twitter:title" content="Ollama" />
|
||||
<meta property="twitter:description" content="Get up and running with large language models." />
|
||||
<meta property="twitter:site" content="ollama" />
|
||||
|
||||
<meta property="twitter:image:src" content="https://ollama.com/public/og-twitter.png" />
|
||||
<meta property="twitter:image:width" content="1200" />
|
||||
<meta property="twitter:image:height" content="628" />
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/public/icon-16x16.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/public/icon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/public/icon-48x48.png" />
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="/public/icon-64x64.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/public/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/public/android-chrome-icon-192x192.png" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/public/android-chrome-icon-512x512.png" />
|
||||
|
||||
|
||||
|
||||
|
||||
<link href="/public/tailwind.css?v=9f0babb28a8cef23daf033b8840da7f9" rel="stylesheet" />
|
||||
<link href="/public/vendor/prism/prism.css?v=9f0babb28a8cef23daf033b8840da7f9" rel="stylesheet" />
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "Ollama",
|
||||
"url": "https://ollama.com"
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="text/javascript">
|
||||
function copyToClipboard(element) {
|
||||
let commandElement = null;
|
||||
const preElement = element.closest('pre');
|
||||
const languageNoneElement = element.closest('.language-none');
|
||||
|
||||
if (preElement) {
|
||||
commandElement = preElement.querySelector('code');
|
||||
} else if (languageNoneElement) {
|
||||
commandElement = languageNoneElement.querySelector('.command');
|
||||
} else {
|
||||
const parent = element.parentElement;
|
||||
if (parent) {
|
||||
commandElement = parent.querySelector('.command');
|
||||
}
|
||||
}
|
||||
|
||||
if (!commandElement) {
|
||||
console.error('No code or command element found');
|
||||
return;
|
||||
}
|
||||
|
||||
const code = commandElement.textContent ? commandElement.textContent.trim() : commandElement.value;
|
||||
|
||||
navigator.clipboard
|
||||
.writeText(code)
|
||||
.then(() => {
|
||||
const copyIcon = element.querySelector('.copy-icon')
|
||||
const checkIcon = element.querySelector('.check-icon')
|
||||
|
||||
copyIcon.classList.add('hidden')
|
||||
checkIcon.classList.remove('hidden')
|
||||
|
||||
setTimeout(() => {
|
||||
copyIcon.classList.remove('hidden')
|
||||
checkIcon.classList.add('hidden')
|
||||
}, 2000)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
|
||||
function getIcon(url) {
|
||||
url = url.toLowerCase();
|
||||
if (url.includes('x.com') || url.includes('twitter.com')) return 'x';
|
||||
if (url.includes('github.com')) return 'github';
|
||||
if (url.includes('linkedin.com')) return 'linkedin';
|
||||
if (url.includes('youtube.com')) return 'youtube';
|
||||
if (url.includes('hf.co') || url.includes('huggingface.co') || url.includes('huggingface.com')) return 'hugging-face';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function setInputIcon(input) {
|
||||
const icon = getIcon(input.value);
|
||||
const img = input.previousElementSibling.querySelector('img');
|
||||
img.src = `/public/social/${icon}.svg`;
|
||||
img.alt = `${icon} icon`;
|
||||
}
|
||||
|
||||
function setDisplayIcon(imgElement, url) {
|
||||
const icon = getIcon(url);
|
||||
imgElement.src = `/public/social/${icon}.svg`;
|
||||
imgElement.alt = `${icon} icon`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script src="/public/vendor/htmx/bundle.js"></script>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="
|
||||
antialiased
|
||||
min-h-screen
|
||||
w-full
|
||||
m-0
|
||||
flex
|
||||
flex-col
|
||||
"
|
||||
hx-on:keydown="
|
||||
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
|
||||
// Ignore key events in input fields.
|
||||
return;
|
||||
}
|
||||
if ((event.metaKey && event.key === 'k') || event.key === '/') {
|
||||
event.preventDefault();
|
||||
const sp = htmx.find('#search') || htmx.find('#navbar-input');
|
||||
sp.focus();
|
||||
}
|
||||
"
|
||||
>
|
||||
|
||||
<header class="sticky top-0 z-40 bg-white underline-offset-4 lg:static">
|
||||
<nav class="flex w-full items-center justify-between px-6 py-[9px]">
|
||||
<a href="/" class="z-50">
|
||||
<img src="/public/ollama.png" class="w-8" alt="Ollama" />
|
||||
</a>
|
||||
|
||||
|
||||
<div class="hidden lg:flex xl:flex-1 items-center space-x-6 ml-6 mr-6 xl:mr-0 text-lg">
|
||||
<a class="hover:underline focus:underline focus:outline-none focus:ring-0" href="/search">Models</a>
|
||||
<a class="hover:underline focus:underline focus:outline-none focus:ring-0" href="/docs">Docs</a>
|
||||
<a class="hover:underline focus:underline focus:outline-none focus:ring-0" href="/pricing">Pricing</a>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex-grow justify-center items-center hidden lg:flex">
|
||||
<div class="relative w-full xl:max-w-[28rem]">
|
||||
|
||||
<form action="/search" autocomplete="off">
|
||||
<div
|
||||
class="relative flex w-full appearance-none bg-black/5 border border-neutral-100 items-center rounded-full"
|
||||
hx-on:focusout="
|
||||
if (!this.contains(event.relatedTarget)) {
|
||||
const searchPreview = document.querySelector('#searchpreview');
|
||||
if (searchPreview) {
|
||||
htmx.addClass('#searchpreview', 'hidden');
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<span id="searchIcon" class="pl-2 text-2xl text-neutral-500">
|
||||
<svg class="mt-0.25 ml-1.5 h-5 w-5 fill-current" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m8.5 3c3.0375661 0 5.5 2.46243388 5.5 5.5 0 1.24832096-.4158777 2.3995085-1.1166416 3.3225711l4.1469717 4.1470988c.2928932.2928932.2928932.767767 0 1.0606602-.2662666.2662665-.6829303.2904726-.9765418.0726181l-.0841184-.0726181-4.1470988-4.1469717c-.9230626.7007639-2.07425014 1.1166416-3.3225711 1.1166416-3.03756612 0-5.5-2.4624339-5.5-5.5 0-3.03756612 2.46243388-5.5 5.5-5.5zm0 1.5c-2.209139 0-4 1.790861-4 4s1.790861 4 4 4 4-1.790861 4-4-1.790861-4-4-4z" />
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
id="search"
|
||||
hx-get="/search"
|
||||
hx-trigger="keyup changed delay:100ms, focus"
|
||||
hx-target="#searchpreview"
|
||||
hx-swap="innerHTML"
|
||||
name="q"
|
||||
class="resize-none rounded-full border-0 py-2.5 bg-transparent text-sm w-full placeholder:text-neutral-500 focus:outline-none focus:ring-0"
|
||||
placeholder="Search models"
|
||||
autocomplete="off"
|
||||
hx-on:keydown="
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
window.location.href = '/search?q=' + encodeURIComponent(this.value);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.value = '';
|
||||
this.blur();
|
||||
htmx.addClass('#searchpreview', 'hidden');
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Tab') {
|
||||
htmx.addClass('#searchpreview', 'hidden');
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
let first = document.querySelector('#search-preview-list a:first-of-type');
|
||||
first?.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
let last = document.querySelector('#view-all-link');
|
||||
last?.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
htmx.removeClass('#searchpreview', 'hidden');
|
||||
"
|
||||
hx-on:focus="
|
||||
htmx.removeClass('#searchpreview', 'hidden')
|
||||
"
|
||||
/>
|
||||
</form>
|
||||
<div id="searchpreview" class="hidden absolute left-0 right-0 top-12 z-50" style="width: calc(100% + 2px); margin-left: -1px;"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="hidden lg:flex xl:flex-1 items-center space-x-2 justify-end ml-6 xl:ml-0">
|
||||
|
||||
<a class="flex cursor-pointer items-center rounded-full bg-black/5 hover:bg-black/10 text-lg px-4 py-1.5 text-black whitespace-nowrap" href="/signin">Sign in</a>
|
||||
<a class="flex cursor-pointer items-center rounded-full bg-neutral-800 text-lg px-4 py-1.5 text-white hover:bg-black whitespace-nowrap focus:bg-black" href="/download">Download</a>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="lg:hidden flex items-center">
|
||||
<input type="checkbox" id="menu" class="peer hidden" />
|
||||
<label for="menu" class="z-50 cursor-pointer peer-checked:hidden block">
|
||||
<svg
|
||||
class="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
</svg>
|
||||
</label>
|
||||
<label for="menu" class="z-50 cursor-pointer hidden peer-checked:block fixed top-4 right-6">
|
||||
<svg
|
||||
class="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</label>
|
||||
|
||||
<div class="fixed inset-0 bg-white z-40 hidden peer-checked:block overflow-y-auto">
|
||||
<div class="flex flex-col space-y-5 pt-[5.5rem] text-3xl">
|
||||
|
||||
|
||||
|
||||
<a class="px-6" href="/search">Models</a>
|
||||
<a class="px-6" href="/download">Download</a>
|
||||
<a class="px-6" href="/docs">Docs</a>
|
||||
<a class="px-6" href="/pricing">Pricing</a>
|
||||
|
||||
|
||||
<a href="/signin" class="block px-6">Sign in</a>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
||||
<main class="mx-auto flex max-w-4xl flex-1 flex-col-reverse items-center justify-center p-32 md:flex-row md:items-start md:justify-between">
|
||||
<div class="space-y-2 text-center md:pt-6 md:text-left">
|
||||
<h2 class="text-3xl font-normal tracking-tight md:text-4xl">
|
||||
404.
|
||||
<span class="text-neutral-400"> That's an error. </span>
|
||||
</h2>
|
||||
<p class="text-center text-lg md:text-left md:text-xl">
|
||||
The page was not found.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pb-4 md:pb-0">
|
||||
<img src="/public/400s.svg" class="w-40 md:w-48" alt="400s ollama" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
<footer class="mt-auto">
|
||||
|
||||
<div class="underline-offset-4 hidden md:block">
|
||||
<div class="flex items-center justify-between px-6 py-3.5">
|
||||
<div class="text-xs text-neutral-500">© 2026 Ollama</div>
|
||||
<div class="flex space-x-6 text-xs text-neutral-500">
|
||||
<a href="/download" class="hover:underline">Download</a>
|
||||
<a href="/blog" class="hover:underline">Blog</a>
|
||||
<a href="https://docs.ollama.com" class="hover:underline">Docs</a>
|
||||
<a href="https://github.com/ollama/ollama" class="hover:underline">GitHub</a>
|
||||
<a href="https://discord.com/invite/ollama" class="hover:underline">Discord</a>
|
||||
<a href="https://twitter.com/ollama" class="hover:underline">X (Twitter)</a>
|
||||
<a href="mailto:hello@ollama.com" class="hover:underline">Contact</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-4 md:hidden">
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<ul class="flex flex-wrap items-center justify-center text-sm text-neutral-500">
|
||||
<li class="mx-2 my-1">
|
||||
<a href="/blog" class="hover:underline">Blog</a>
|
||||
</li>
|
||||
<li class="mx-2 my-1">
|
||||
<a href="/download" class="hover:underline">Download</a>
|
||||
</li>
|
||||
<li class="mx-2 my-1">
|
||||
<a href="https://docs.ollama.com" class="hover:underline">Docs</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="flex flex-wrap items-center justify-center text-sm text-neutral-500">
|
||||
<li class="mx-2 my-1">
|
||||
<a href="https://github.com/ollama/ollama" class="hover:underline">GitHub</a>
|
||||
</li>
|
||||
<li class="mx-2 my-1">
|
||||
<a href="https://discord.com/invite/ollama" class="hover:underline">Discord</a>
|
||||
</li>
|
||||
<li class="mx-2 my-1">
|
||||
<a href="https://twitter.com/ollama" class="hover:underline">X (Twitter)</a>
|
||||
</li>
|
||||
<li class="mx-2 my-1">
|
||||
<a href="https://lu.ma/ollama" class="hover:underline">Meetups</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-2 flex items-center justify-center text-sm text-neutral-500">
|
||||
© 2026 Ollama Inc.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
|
||||
|
||||
|
||||
<span class="hidden" id="end_of_template"></span>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="18" fill="#1A1A2E"/>
|
||||
<defs>
|
||||
<linearGradient id="pg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#7B2FFF"/>
|
||||
<stop offset="100%" stop-color="#FF6B6B"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Play triangle -->
|
||||
<polygon points="28,22 28,78 78,50" fill="url(#pg)" rx="4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 418 B |
@@ -976,7 +976,7 @@ function ComboCard({
|
||||
onChange={onToggle}
|
||||
title={isDisabled ? t("enableCombo") : t("disableCombo")}
|
||||
/>
|
||||
<div className="flex items-center gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center gap-1 transition-opacity">
|
||||
<button
|
||||
onClick={onTest}
|
||||
disabled={testing}
|
||||
|
||||
@@ -86,7 +86,8 @@ export default function APIPageClient({ machineId }) {
|
||||
(m) => m.type === "audio" && m.subtype === "speech" && !m.parent
|
||||
);
|
||||
const moderation = allModels.filter((m) => m.type === "moderation" && !m.parent);
|
||||
return { chat, embeddings, images, rerank, audioTranscription, audioSpeech, moderation };
|
||||
const music = allModels.filter((m) => m.type === "music" && !m.parent);
|
||||
return { chat, embeddings, images, rerank, audioTranscription, audioSpeech, moderation, music };
|
||||
}, [allModels]);
|
||||
|
||||
const postCloudAction = async (action, timeoutMs = CLOUD_ACTION_TIMEOUT_MS) => {
|
||||
@@ -392,6 +393,7 @@ export default function APIPageClient({ machineId }) {
|
||||
endpointData.audioTranscription,
|
||||
endpointData.audioSpeech,
|
||||
endpointData.moderation,
|
||||
endpointData.music,
|
||||
].filter((a) => a.length > 0).length + 2,
|
||||
})}
|
||||
</p>
|
||||
@@ -530,6 +532,25 @@ export default function APIPageClient({ machineId }) {
|
||||
copied={copied}
|
||||
baseUrl={currentEndpoint}
|
||||
/>
|
||||
|
||||
{/* Music Generation */}
|
||||
<EndpointSection
|
||||
icon="music_note"
|
||||
iconColor="text-fuchsia-500"
|
||||
iconBg="bg-fuchsia-500/10"
|
||||
title={t("musicGeneration") || "Music Generation"}
|
||||
path="/v1/music/generations"
|
||||
description={
|
||||
t("musicDesc") ||
|
||||
"Generate music and audio tracks via ComfyUI (Stable Audio, MusicGen)"
|
||||
}
|
||||
models={endpointData.music}
|
||||
expanded={expandedEndpoint === "music"}
|
||||
onToggle={() => setExpandedEndpoint(expandedEndpoint === "music" ? null : "music")}
|
||||
copy={copy}
|
||||
copied={copied}
|
||||
baseUrl={currentEndpoint}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1631,16 +1631,23 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-[240px]">
|
||||
<span className="text-xs text-text-muted mb-1 block">Supported Endpoints</span>
|
||||
<span className="text-xs text-text-muted mb-1 block">
|
||||
Supported Endpoints
|
||||
</span>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{["chat", "embeddings", "images", "audio"].map((ep) => (
|
||||
<label key={ep} className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer">
|
||||
<label
|
||||
key={ep}
|
||||
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editingEndpoints.includes(ep)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setEditingEndpoints((prev) => (prev.includes(ep) ? prev : [...prev, ep]));
|
||||
setEditingEndpoints((prev) =>
|
||||
prev.includes(ep) ? prev : [...prev, ep]
|
||||
);
|
||||
} else {
|
||||
setEditingEndpoints((prev) => prev.filter((x) => x !== ep));
|
||||
}
|
||||
@@ -2312,7 +2319,7 @@ function ConnectionRow({
|
||||
onChange={onToggleActive}
|
||||
title={(connection.isActive ?? true) ? t("disableConnection") : t("enableConnection")}
|
||||
/>
|
||||
<div className="flex gap-1 ml-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex gap-1 ml-1 transition-opacity">
|
||||
{onReauth && (
|
||||
<button
|
||||
onClick={onReauth}
|
||||
|
||||
@@ -189,23 +189,35 @@ export default function ProvidersPage() {
|
||||
if (testingMode) return;
|
||||
setTestingMode(mode === "provider" ? providerId : mode);
|
||||
setTestResults(null);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 90_000); // 90s max
|
||||
try {
|
||||
const res = await fetch("/api/providers/test-batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode, providerId }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const data = await res.json();
|
||||
let data: any;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
// Response body is not valid JSON (e.g. truncated due to timeout)
|
||||
data = { error: t("providerTestFailed"), results: [], summary: null };
|
||||
}
|
||||
setTestResults(data);
|
||||
if (data.summary) {
|
||||
if (data?.summary) {
|
||||
const { passed, failed, total } = data.summary;
|
||||
if (failed === 0) notify.success(t("allTestsPassed", { total }));
|
||||
else notify.warning(t("testSummary", { passed, failed, total }));
|
||||
}
|
||||
} catch (error) {
|
||||
setTestResults({ error: t("providerTestFailed") });
|
||||
notify.error(t("providerTestFailed"));
|
||||
} catch (error: any) {
|
||||
const isAbort = error?.name === "AbortError";
|
||||
const msg = isAbort ? t("providerTestTimeout") : t("providerTestFailed");
|
||||
setTestResults({ error: msg, results: [], summary: null });
|
||||
notify.error(msg);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
setTestingMode(null);
|
||||
}
|
||||
};
|
||||
@@ -470,8 +482,17 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
||||
const t = useTranslations("providers");
|
||||
const tc = useTranslations("common");
|
||||
const { connected, error, errorCode, errorTime, allDisabled } = stats;
|
||||
const [imgSrc, setImgSrc] = useState(`/providers/${provider.id}.png`);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const handleImgError = () => {
|
||||
if (imgSrc.endsWith(".png")) {
|
||||
setImgSrc(`/providers/${provider.id}.svg`);
|
||||
} else {
|
||||
setImgError(true);
|
||||
}
|
||||
};
|
||||
|
||||
const dotColors = {
|
||||
free: "bg-green-500",
|
||||
oauth: "bg-blue-500",
|
||||
@@ -503,13 +524,13 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
||||
</span>
|
||||
) : (
|
||||
<Image
|
||||
src={`/providers/${provider.id}.png`}
|
||||
src={imgSrc}
|
||||
alt={provider.name}
|
||||
width={30}
|
||||
height={30}
|
||||
className="object-contain rounded-lg max-w-[32px] max-h-[32px]"
|
||||
sizes="32px"
|
||||
onError={() => setImgError(true)}
|
||||
onError={handleImgError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -590,7 +611,6 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
|
||||
const { connected, error, errorCode, errorTime, allDisabled } = stats;
|
||||
const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
|
||||
const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const dotColors = {
|
||||
free: "bg-green-500",
|
||||
@@ -616,6 +636,18 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
|
||||
return `/providers/${provider.id}.png`;
|
||||
};
|
||||
|
||||
const [imgSrc, setImgSrc] = useState<string>(() => getIconPath());
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const handleImgError = () => {
|
||||
const basePath = getIconPath();
|
||||
if (imgSrc.endsWith(".png") && !isCompatible && !isAnthropicCompatible) {
|
||||
setImgSrc(`/providers/${provider.id}.svg`);
|
||||
} else {
|
||||
setImgError(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/providers/${providerId}`} className="group">
|
||||
<Card
|
||||
@@ -634,13 +666,13 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
|
||||
</span>
|
||||
) : (
|
||||
<Image
|
||||
src={getIconPath()}
|
||||
src={imgSrc || getIconPath()}
|
||||
alt={provider.name}
|
||||
width={30}
|
||||
height={30}
|
||||
className="object-contain rounded-lg max-w-[30px] max-h-[30px]"
|
||||
sizes="30px"
|
||||
onError={() => setImgError(true)}
|
||||
onError={handleImgError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1041,17 +1073,23 @@ function ProviderTestResultsView({ results }) {
|
||||
const t = useTranslations("providers");
|
||||
const tc = useTranslations("common");
|
||||
|
||||
if (results.error && !results.results) {
|
||||
// Guard: never crash on malformed/null results (would trigger error boundary)
|
||||
if (!results || typeof results !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (results.error && (!results.results || results.results.length === 0)) {
|
||||
return (
|
||||
<div className="text-center py-6">
|
||||
<span className="material-symbols-outlined text-red-500 text-[32px] mb-2 block">error</span>
|
||||
<p className="text-sm text-red-400">{results.error}</p>
|
||||
<p className="text-sm text-red-400">{String(results.error)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary, mode } = results;
|
||||
const items = results.results || [];
|
||||
const summary = results.summary ?? null;
|
||||
const mode = results.mode ?? "";
|
||||
const items = Array.isArray(results.results) ? results.results : [];
|
||||
|
||||
const modeLabel =
|
||||
{
|
||||
|
||||
@@ -1184,6 +1184,7 @@
|
||||
"clearing": "Clearing...",
|
||||
"until": "Until {time}",
|
||||
"providerTestFailed": "Provider test failed",
|
||||
"providerTestTimeout": "Provider test timed out — too many connections to test at once",
|
||||
"modeTest": "{mode} Test",
|
||||
"passedCount": "{count} passed",
|
||||
"failedCount": "{count} failed",
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseModel } from "../../open-sse/services/model.ts";
|
||||
|
||||
// [1m] extended context suffix — PR #311 (DavyMassoneto)
|
||||
test("[1m] suffix: strips suffix and sets extendedContext=true", () => {
|
||||
const result = parseModel("claude-sonnet-4-6[1m]");
|
||||
|
||||
Reference in New Issue
Block a user