Compare commits

..

1 Commits

Author SHA1 Message Date
Eva Ho
0cd8a0a442 launch: add codex model metadata catalog 2026-04-23 17:09:41 -04:00
306 changed files with 4784 additions and 41592 deletions

View File

@@ -141,14 +141,6 @@ jobs:
env:
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
steps:
# Increase pagefile to handle momentary spikes in RAM from NVCC compiles
- if: startsWith(matrix.preset, 'MLX ')
name: Increase pagefile to 200 GB
uses: al-cheb/configure-pagefile-action@v1.5
with:
minimum-size: 16GB
maximum-size: 200GB
disk-root: "D:"
- name: Install system dependencies
run: |
choco install -y --no-progress ccache ninja
@@ -245,9 +237,8 @@ jobs:
Import-Module 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'
Enter-VsDevShell -VsInstallPath 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise' -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -no_logo'
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }} --install-prefix "$((pwd).Path)\dist\${{ matrix.os }}-${{ matrix.arch }}"
cmake --build --preset "${{ matrix.preset }}" -- -l $([Environment]::ProcessorCount)
cmake --build --parallel ([Environment]::ProcessorCount) --preset "${{ matrix.preset }}"
cmake --install build --component "${{ startsWith(matrix.preset, 'MLX ') && 'MLX' || startsWith(matrix.preset, 'CUDA ') && 'CUDA' || startsWith(matrix.preset, 'ROCm ') && 'HIP' || startsWith(matrix.preset, 'Vulkan') && 'Vulkan' || 'CPU' }}" --strip
if ('${{ matrix.preset }}'.StartsWith('MLX ')) { cmake --install build --component MLX_VENDOR }
Remove-Item -Path dist\lib\ollama\rocm\rocblas\library\*gfx906* -ErrorAction SilentlyContinue
env:
CMAKE_GENERATOR: Ninja
@@ -389,36 +380,20 @@ jobs:
dist/*.ps1
dist/OllamaSetup.exe
# Pre-build each Dockerfile stage on its own runner in parallel and push the
# resulting layers to a per-stage registry cache. The downstream
# docker-build-push job then assembles cache-hit-only.
linux-depends:
linux-build:
strategy:
matrix:
include:
- arch: amd64
target: cpu
- arch: amd64
target: cuda-12
- arch: amd64
target: cuda-13
- arch: amd64
target: mlx
- arch: amd64
target: rocm-7
- arch: amd64
target: vulkan
- arch: arm64
target: cpu
- arch: arm64
target: cuda-12
- arch: arm64
target: cuda-13
- arch: arm64
target: jetpack-5
- arch: arm64
target: jetpack-6
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
- os: linux
arch: amd64
target: archive
- os: linux
arch: amd64
target: rocm
- os: linux
arch: arm64
target: archive
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
environment: release
needs: setup-environment
env:
@@ -426,53 +401,53 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKER_USER }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
# Increase swap to handle momentary spikes in RAM from NVCC compiles
- if: matrix.target == 'mlx'
name: Increase Linux swap to 200 GB
shell: bash
run: |
set -e
SWAP_PATH=/swapfile-mlx
SWAP_SIZE_GB=200
if [ -f "$SWAP_PATH" ]; then
sudo swapoff "$SWAP_PATH" 2>/dev/null || true
sudo rm -f "$SWAP_PATH"
fi
if ! sudo fallocate -l ${SWAP_SIZE_GB}G "$SWAP_PATH" 2>/dev/null; then
echo "fallocate unsupported, falling back to dd"
sudo dd if=/dev/zero of="$SWAP_PATH" bs=1M count=$((SWAP_SIZE_GB * 1024))
fi
sudo chmod 600 "$SWAP_PATH"
sudo mkswap "$SWAP_PATH"
sudo swapon "$SWAP_PATH"
swapon --show
free -h
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/${{ matrix.arch }}
platforms: ${{ matrix.os }}/${{ matrix.arch }}
target: ${{ matrix.target }}
provenance: false
sbom: false
build-args: |
GOFLAGS=${{ env.GOFLAGS }}
CGO_CFLAGS=${{ env.CGO_CFLAGS }}
CGO_CXXFLAGS=${{ env.CGO_CXXFLAGS }}
GOFLAGS=${{ env.GOFLAGS }}
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
OLLAMA_MLX_BUILD_JOBS=16
OLLAMA_MLX_NVCC_THREADS=6
cache-from: |
type=registry,ref=ollama/release:cache-${{ matrix.arch }}-${{ matrix.target }}
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
cache-to: type=registry,ref=ollama/release:cache-${{ matrix.arch }}-${{ matrix.target }},mode=max
outputs: type=local,dest=dist/${{ matrix.os }}-${{ matrix.arch }}
cache-from: type=registry,ref=${{ vars.DOCKER_REPO }}:latest
cache-to: type=inline
- name: Deduplicate CUDA libraries
run: |
./scripts/deduplicate_cuda_libs.sh dist/${{ matrix.os }}-${{ matrix.arch }}
- run: |
for COMPONENT in bin/* lib/ollama/*; do
case "$COMPONENT" in
bin/ollama*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/*.so*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/cuda_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/include*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
lib/ollama/rocm) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
esac
done
working-directory: dist/${{ matrix.os }}-${{ matrix.arch }}
- run: |
echo "Manifests"
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in ; do
echo $ARCHIVE
cat $ARCHIVE
done
- run: |
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in; do
tar c -C dist/${{ matrix.os }}-${{ matrix.arch }} -T $ARCHIVE --owner 0 --group 0 | zstd --ultra -22 -T0 >$(basename ${ARCHIVE//.*/}.tar.zst);
done
- uses: actions/upload-artifact@v4
with:
name: bundles-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.target }}
path: |
*.tar.zst
# Build each Docker variant (OS, arch, and flavor) separately. Using QEMU is unreliable and slower.
# Heavy stages were pre-built by linux-depends; this job is cache-hit-only for those layers
# and just assembles, runs the Go build, and pushes the final image.
docker-build-push:
strategy:
matrix:
@@ -483,32 +458,12 @@ jobs:
CGO_CFLAGS
CGO_CXXFLAGS
GOFLAGS
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
OLLAMA_MLX_BUILD_JOBS=16
OLLAMA_MLX_NVCC_THREADS=6
cache-from: |
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
type=registry,ref=ollama/release:cache-arm64-cpu
type=registry,ref=ollama/release:cache-arm64-cuda-12
type=registry,ref=ollama/release:cache-arm64-cuda-13
type=registry,ref=ollama/release:cache-arm64-jetpack-5
type=registry,ref=ollama/release:cache-arm64-jetpack-6
- os: linux
arch: amd64
build-args: |
CGO_CFLAGS
CGO_CXXFLAGS
GOFLAGS
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
OLLAMA_MLX_BUILD_JOBS=16
OLLAMA_MLX_NVCC_THREADS=6
cache-from: |
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
type=registry,ref=ollama/release:cache-amd64-cpu
type=registry,ref=ollama/release:cache-amd64-cuda-12
type=registry,ref=ollama/release:cache-amd64-cuda-13
type=registry,ref=ollama/release:cache-amd64-mlx
type=registry,ref=ollama/release:cache-amd64-vulkan
- os: linux
arch: amd64
suffix: '-rocm'
@@ -517,16 +472,9 @@ jobs:
CGO_CXXFLAGS
GOFLAGS
FLAVOR=rocm
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
OLLAMA_MLX_BUILD_JOBS=16
OLLAMA_MLX_NVCC_THREADS=6
cache-from: |
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
type=registry,ref=ollama/release:cache-amd64-cpu
type=registry,ref=ollama/release:cache-amd64-rocm-7
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
environment: release
needs: [setup-environment, linux-depends]
needs: setup-environment
env:
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
steps:
@@ -541,11 +489,9 @@ jobs:
with:
context: .
platforms: ${{ matrix.os }}/${{ matrix.arch }}
provenance: false
sbom: false
build-args: ${{ matrix.build-args }}
outputs: type=image,name=${{ vars.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
cache-from: ${{ matrix.cache-from }}
cache-from: type=registry,ref=${{ vars.DOCKER_REPO }}:latest
cache-to: type=inline
- run: |
mkdir -p ${{ matrix.os }}-${{ matrix.arch }}
@@ -556,58 +502,6 @@ jobs:
name: digest-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.suffix }}
path: |
${{ runner.temp }}/${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.suffix }}.txt
# Re-run buildx with --target archive against buildkit's local cache to
# extract the release directory layout. All upstream stages were just
# built above, so this is a cache-hit-only pass that just writes files.
- uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.os }}/${{ matrix.arch }}
target: archive
provenance: false
sbom: false
build-args: ${{ matrix.build-args }}
outputs: type=local,dest=dist/${{ matrix.os }}-${{ matrix.arch }}
cache-from: ${{ matrix.cache-from }}
- name: Deduplicate CUDA libraries
run: |
./scripts/deduplicate_cuda_libs.sh dist/${{ matrix.os }}-${{ matrix.arch }}
- run: |
for COMPONENT in bin/* lib/ollama/*; do
case "$COMPONENT" in
bin/ollama*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/*.so*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/cuda_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
lib/ollama/include*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
lib/ollama/rocm) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
esac
done
working-directory: dist/${{ matrix.os }}-${{ matrix.arch }}
# rocm builds cpu + rocm libs for the container image, which
# creates a CPU-only amd64 tarball that would collide with the full
# bundle when the release job merges artifacts.
- if: matrix.suffix == '-rocm'
run: rm -f dist/${{ matrix.os }}-${{ matrix.arch }}/ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in
- run: |
echo "Manifests"
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in ; do
echo $ARCHIVE
cat $ARCHIVE
done
- run: |
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in; do
tar c -C dist/${{ matrix.os }}-${{ matrix.arch }} -T $ARCHIVE --owner 0 --group 0 | zstd -19 -T0 >$(basename ${ARCHIVE//.*/}.tar.zst) &
done
wait
- uses: actions/upload-artifact@v4
with:
name: bundles-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.suffix }}
path: |
*.tar.zst
# Merge Docker images for the same flavor into a single multi-arch manifest
docker-merge-push:
@@ -647,7 +541,7 @@ jobs:
release:
runs-on: ubuntu-latest
environment: release
needs: [darwin-build, windows-app, docker-build-push]
needs: [darwin-build, windows-app, linux-build]
permissions:
contents: write
env:

View File

@@ -22,7 +22,6 @@ jobs:
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.changes.outputs.changed }}
app_changed: ${{ steps.changes.outputs.app_changed }}
vendorsha: ${{ steps.changes.outputs.vendorsha }}
steps:
- uses: actions/checkout@v4
@@ -39,7 +38,6 @@ jobs:
}
echo changed=$(changed 'llama/llama.cpp/**/*' 'ml/backend/ggml/ggml/**/*' '.github/**/*') | tee -a $GITHUB_OUTPUT
echo app_changed=$(changed 'app/**' 'app/**/*') | tee -a $GITHUB_OUTPUT
echo vendorsha=$(make -f Makefile.sync print-base) | tee -a $GITHUB_OUTPUT
linux:
@@ -65,7 +63,7 @@ jobs:
- preset: 'MLX CUDA 13'
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
extra-packages: libcudnn9-dev-cuda-13 libopenblas-dev liblapack-dev liblapacke-dev git curl
flags: '-DCMAKE_CUDA_ARCHITECTURES=87 -DMLX_CUDA_ARCHITECTURES=80-virtual -DBLAS_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu -DLAPACK_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu'
flags: '-DCMAKE_CUDA_ARCHITECTURES=87 -DBLAS_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu -DLAPACK_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu'
install-go: true
runs-on: linux
container: ${{ matrix.container }}
@@ -105,7 +103,7 @@ jobs:
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.vendorsha }}
- run: |
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }}
cmake --build --preset "${{ matrix.preset }}" -- -l $(nproc)
cmake --build --preset "${{ matrix.preset }}" --parallel
windows:
needs: [changes]
@@ -134,7 +132,7 @@ jobs:
- preset: 'MLX CUDA 13'
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
cudnn-install: https://developer.download.nvidia.com/compute/cudnn/redist/cudnn/windows-x86_64/cudnn-windows-x86_64-9.18.1.3_cuda13-archive.zip
flags: '-DCMAKE_CUDA_ARCHITECTURES=80 -DMLX_CUDA_ARCHITECTURES=80-virtual'
flags: '-DCMAKE_CUDA_ARCHITECTURES=80'
cuda-components:
- '"cudart"'
- '"nvcc"'
@@ -240,7 +238,7 @@ jobs:
Import-Module 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'
Enter-VsDevShell -VsInstallPath 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise' -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -no_logo'
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }}
cmake --build --preset "${{ matrix.preset }}" -- -l $([Environment]::ProcessorCount)
cmake --build --parallel --preset "${{ matrix.preset }}"
env:
CMAKE_GENERATOR: Ninja
@@ -252,7 +250,6 @@ jobs:
run: go mod tidy --diff || (echo "Please run 'go mod tidy'." && exit 1)
test:
needs: [changes]
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
@@ -287,10 +284,6 @@ jobs:
if: always()
run: go test -count=1 -benchtime=1x ./...
- name: go test app with live updater tag
if: ${{ needs.changes.outputs.app_changed == 'True' && contains(fromJSON('["macos-latest","windows-latest"]'), matrix.os) }}
run: go test -count=1 -tags updater_live ./app/...
- uses: golangci/golangci-lint-action@v9
with:
only-new-issues: true

View File

@@ -228,28 +228,15 @@ if(MLX_ENGINE)
list(APPEND MLX_INCLUDE_REGEXES "^dl\\.dll$")
endif()
# Split mlx/mlxc libraries from runtime deps to avoid stripping deps
install(TARGETS mlx mlxc
RUNTIME_DEPENDENCY_SET mlx_runtime_deps
RUNTIME_DEPENDENCIES
DIRECTORIES ${MLX_RUNTIME_DIRS}
PRE_INCLUDE_REGEXES ${MLX_INCLUDE_REGEXES}
PRE_EXCLUDE_REGEXES ".*"
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
)
install(RUNTIME_DEPENDENCY_SET mlx_runtime_deps
DIRECTORIES ${MLX_RUNTIME_DIRS}
PRE_INCLUDE_REGEXES ${MLX_INCLUDE_REGEXES}
PRE_EXCLUDE_REGEXES ".*"
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX_VENDOR
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX_VENDOR
)
if(TARGET jaccl)
install(TARGETS jaccl
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
)
endif()
# Install the Metal library for macOS arm64 (must be colocated with the binary)
# Metal backend is only built for arm64, not x86_64
@@ -371,7 +358,7 @@ if(MLX_ENGINE)
if(MLX_CUDA_LIBS)
install(FILES ${MLX_CUDA_LIBS}
DESTINATION ${OLLAMA_INSTALL_DIR}
COMPONENT MLX_VENDOR)
COMPONENT MLX)
endif()
endif()
endif()

View File

@@ -41,7 +41,7 @@
"inherits": [ "CUDA" ],
"cacheVariables": {
"CMAKE_CUDA_ARCHITECTURES": "75-virtual;80-virtual;86-virtual;87-virtual;89-virtual;90-virtual;90a-virtual;100-virtual;103-virtual;110-virtual;120-virtual;121-virtual",
"CMAKE_CUDA_FLAGS": "-t 2",
"CMAKE_CUDA_FLAGS": "-t 4",
"OLLAMA_RUNNER_DIR": "cuda_v13"
}
},
@@ -112,7 +112,7 @@
"name": "MLX CUDA 13",
"inherits": [ "MLX", "CUDA 13" ],
"cacheVariables": {
"MLX_CUDA_ARCHITECTURES": "75-virtual;80-virtual;86-virtual;89-virtual;90-virtual;90a-virtual;100-virtual;103-virtual;110-virtual;120-virtual;121-virtual",
"MLX_CUDA_ARCHITECTURES": "86;89;90;90a;100;103;75-virtual;80-virtual;110-virtual;120-virtual;121-virtual",
"OLLAMA_RUNNER_DIR": "mlx_cuda_v13"
}
}

View File

@@ -144,9 +144,6 @@ RUN --mount=type=cache,target=/root/.ccache \
FROM base AS mlx
ARG CUDA13VERSION=13.0
# OLLAMA_MLX_BUILD_JOBS empty -> ninja gates by load average (-l $(nproc))
ARG OLLAMA_MLX_BUILD_JOBS=
ARG OLLAMA_MLX_NVCC_THREADS=2
RUN dnf install -y cuda-toolkit-${CUDA13VERSION//./-} \
&& dnf install -y openblas-devel lapack-devel \
&& dnf install -y libcudnn9-cuda-13 libcudnn9-devel-cuda-13 \
@@ -173,10 +170,9 @@ RUN --mount=type=cache,target=/root/.ccache \
&& if [ -f /tmp/local-mlx-c/CMakeLists.txt ]; then \
export OLLAMA_MLX_C_SOURCE=/tmp/local-mlx-c; \
fi \
&& cmake --preset 'MLX CUDA 13' -DBLAS_INCLUDE_DIRS=/usr/include/openblas -DLAPACK_INCLUDE_DIRS=/usr/include/openblas -DCMAKE_CUDA_FLAGS="-t ${OLLAMA_MLX_NVCC_THREADS}" \
&& cmake --build --preset 'MLX CUDA 13' -- -l $(nproc) ${OLLAMA_MLX_BUILD_JOBS:+-j ${OLLAMA_MLX_BUILD_JOBS}} \
&& cmake --install build --component MLX --strip \
&& cmake --install build --component MLX_VENDOR
&& cmake --preset 'MLX CUDA 13' -DBLAS_INCLUDE_DIRS=/usr/include/openblas -DLAPACK_INCLUDE_DIRS=/usr/include/openblas \
&& cmake --build --preset 'MLX CUDA 13' -- -l $(nproc) \
&& cmake --install build --component MLX --strip
FROM base AS build
WORKDIR /go/src/github.com/ollama/ollama
@@ -216,11 +212,8 @@ COPY --from=cpu dist/lib/ollama /lib/ollama
COPY --from=build /bin/ollama /bin/ollama
FROM ubuntu:24.04
ARG APT_MIRROR=http://archive.ubuntu.com/ubuntu
RUN sed -i "s|http://archive.ubuntu.com/ubuntu|$APT_MIRROR|g" /etc/apt/sources.list.d/ubuntu.sources \
&& apt-get update \
RUN apt-get update \
&& apt-get install -y ca-certificates libvulkan1 libopenblas0 \
&& sed -i "s|$APT_MIRROR|http://archive.ubuntu.com/ubuntu|g" /etc/apt/sources.list.d/ubuntu.sources \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY --from=archive /bin /usr/bin

View File

@@ -1 +1 @@
fba4470b89073180056c9ea46c443051375f7399
0726ca922fc902c4c61ef9c27d94132be418e945

View File

@@ -1 +1 @@
e8ebdebeeb655feaa85a51f6b24ece5b6d5518d1
38ad257088fb2193ad47e527cf6534a689f30943

View File

@@ -78,11 +78,6 @@ type MessagesRequest struct {
ToolChoice *ToolChoice `json:"tool_choice,omitempty"`
Thinking *ThinkingConfig `json:"thinking,omitempty"`
Metadata *Metadata `json:"metadata,omitempty"`
OutputConfig *OutputConfig `json:"output_config,omitempty"`
}
type OutputConfig struct {
Effort string `json:"effort,omitempty"`
}
// MessageParam represents a message in the request
@@ -166,7 +161,7 @@ type WebSearchToolResultError struct {
// ImageSource represents the source of an image
type ImageSource struct {
Type string `json:"type"` // "base64"
Type string `json:"type"` // "base64" or "url"
MediaType string `json:"media_type,omitempty"`
Data string `json:"data,omitempty"`
URL string `json:"url,omitempty"`
@@ -378,26 +373,9 @@ func FromMessagesRequest(r MessagesRequest) (*api.ChatRequest, error) {
}
var think *api.ThinkValue
normalizedEffort := ""
if r.OutputConfig != nil {
normalizedEffort = strings.ToLower(strings.TrimSpace(r.OutputConfig.Effort))
if normalizedEffort == "xhigh" {
normalizedEffort = "high"
}
}
if r.Thinking != nil && r.Thinking.Type == "enabled" {
think = &api.ThinkValue{Value: true}
}
if r.Thinking != nil && r.Thinking.Type == "disabled" {
think = &api.ThinkValue{Value: false}
}
if think == nil && r.OutputConfig != nil {
switch normalizedEffort {
case "high", "medium", "low", "max":
think = &api.ThinkValue{Value: normalizedEffort}
}
}
stream := r.Stream
convertedRequest := &api.ChatRequest{
@@ -447,12 +425,17 @@ func convertMessage(msg MessageParam) ([]api.Message, error) {
return nil, errors.New("invalid image source")
}
decoded, err := resolveImageSource(block.Source)
if err != nil {
logutil.Trace("anthropic: unsupported image source", "role", role, "source_type", block.Source.Type, "error", err)
return nil, err
if block.Source.Type == "base64" {
decoded, err := base64.StdEncoding.DecodeString(block.Source.Data)
if err != nil {
logutil.Trace("anthropic: invalid base64 image data", "role", role, "error", err)
return nil, fmt.Errorf("invalid base64 image data: %w", err)
}
images = append(images, decoded)
} else {
logutil.Trace("anthropic: unsupported image source type", "role", role, "source_type", block.Source.Type)
return nil, fmt.Errorf("invalid image source type: %s. Only base64 images are supported.", block.Source.Type)
}
images = append(images, decoded)
case "tool_use":
toolUseBlocks++
@@ -474,16 +457,26 @@ func convertMessage(msg MessageParam) ([]api.Message, error) {
case "tool_result":
toolResultBlocks++
resultContent, resultImages, err := convertToolResultContent(block.Content)
if err != nil {
logutil.Trace("anthropic: invalid tool_result content", "role", role, "error", err)
return nil, err
var resultContent string
switch c := block.Content.(type) {
case string:
resultContent = c
case []any:
for _, cb := range c {
if cbMap, ok := cb.(map[string]any); ok {
if cbMap["type"] == "text" {
if text, ok := cbMap["text"].(string); ok {
resultContent += text
}
}
}
}
}
toolResults = append(toolResults, api.Message{
Role: "tool",
Content: resultContent,
Images: resultImages,
ToolCallID: block.ToolUseID,
})
@@ -515,10 +508,6 @@ func convertMessage(msg MessageParam) ([]api.Message, error) {
}
}
if role == "user" && len(toolResults) > 0 {
messages = append(messages, toolResults...)
}
if textContent.Len() > 0 || len(images) > 0 || len(toolCalls) > 0 || thinking != "" {
m := api.Message{
Role: role,
@@ -530,10 +519,8 @@ func convertMessage(msg MessageParam) ([]api.Message, error) {
messages = append(messages, m)
}
// Add tool results as separate messages.
if role != "user" || len(toolResults) == 0 {
messages = append(messages, toolResults...)
}
// Add tool results as separate messages
messages = append(messages, toolResults...)
logutil.Trace("anthropic: converted block message",
"role", role,
"blocks", len(msg.Content),
@@ -982,71 +969,6 @@ func GenerateMessageID() string {
return generateID("msg")
}
func resolveImageSource(source *ImageSource) (api.ImageData, error) {
if source.Type != "base64" {
return nil, fmt.Errorf("invalid image source type: %s. Only base64 images are supported.", source.Type)
}
decoded, err := base64.StdEncoding.DecodeString(source.Data)
if err != nil {
return nil, fmt.Errorf("invalid base64 image data: %w", err)
}
return decoded, nil
}
func convertToolResultContent(content any) (string, []api.ImageData, error) {
switch c := content.(type) {
case nil:
return "", nil, nil
case string:
return c, nil, nil
case []any:
var text strings.Builder
var images []api.ImageData
for _, cb := range c {
cbMap, ok := cb.(map[string]any)
if !ok {
continue
}
switch cbMap["type"] {
case "text":
if t, ok := cbMap["text"].(string); ok {
text.WriteString(t)
}
case "image":
rawSource, ok := cbMap["source"].(map[string]any)
if !ok {
return "", nil, errors.New("invalid tool_result image source")
}
var source ImageSource
if rawType, ok := rawSource["type"].(string); ok {
source.Type = rawType
}
if rawMediaType, ok := rawSource["media_type"].(string); ok {
source.MediaType = rawMediaType
}
if rawData, ok := rawSource["data"].(string); ok {
source.Data = rawData
}
img, err := resolveImageSource(&source)
if err != nil {
return "", nil, err
}
images = append(images, img)
}
}
return text.String(), images, nil
default:
return "", nil, nil
}
}
// ptr returns a pointer to the given string value
func ptr(s string) *string {
return &s

View File

@@ -271,241 +271,6 @@ func TestFromMessagesRequest_WithToolResult(t *testing.T) {
}
}
func TestFromMessagesRequest_WithToolResultImage(t *testing.T) {
imgData, _ := base64.StdEncoding.DecodeString(testImage)
req := MessagesRequest{
Model: "test-model",
MaxTokens: 1024,
Messages: []MessageParam{
{
Role: "user",
Content: []ContentBlock{
{
Type: "tool_result",
ToolUseID: "call_img",
Content: []any{
map[string]any{"type": "text", "text": "Attached image"},
map[string]any{
"type": "image",
"source": map[string]any{
"type": "base64",
"media_type": "image/png",
"data": testImage,
},
},
},
},
},
},
},
}
result, err := FromMessagesRequest(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result.Messages) != 1 {
t.Fatalf("expected 1 message, got %d", len(result.Messages))
}
msg := result.Messages[0]
if msg.Role != "tool" {
t.Errorf("expected role 'tool', got %q", msg.Role)
}
if msg.ToolCallID != "call_img" {
t.Errorf("expected tool_call_id 'call_img', got %q", msg.ToolCallID)
}
if msg.Content != "Attached image" {
t.Errorf("unexpected content: %q", msg.Content)
}
if len(msg.Images) != 1 {
t.Fatalf("expected 1 image, got %d", len(msg.Images))
}
if string(msg.Images[0]) != string(imgData) {
t.Error("image data mismatch")
}
}
func TestFromMessagesRequest_WithToolResultFollowedByUserText(t *testing.T) {
req := MessagesRequest{
Model: "test-model",
MaxTokens: 1024,
Messages: []MessageParam{
{
Role: "assistant",
Content: []ContentBlock{
{
Type: "tool_use",
ID: "call_read",
Name: "Read",
Input: makeArgs("file_path", "/Users/hoyyeva/Desktop/aaa.png"),
},
},
},
{
Role: "user",
Content: []ContentBlock{
{
Type: "tool_result",
ToolUseID: "call_read",
Content: "Read image (311.5KB)",
},
{
Type: "text",
Text: ptr("Please describe it."),
},
},
},
},
}
result, err := FromMessagesRequest(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result.Messages) != 3 {
t.Fatalf("expected 3 messages, got %d", len(result.Messages))
}
if result.Messages[1].Role != "tool" {
t.Fatalf("expected second message to be tool, got %q", result.Messages[1].Role)
}
if result.Messages[1].ToolCallID != "call_read" {
t.Fatalf("expected tool_call_id 'call_read', got %q", result.Messages[1].ToolCallID)
}
if result.Messages[2].Role != "user" {
t.Fatalf("expected third message to be user, got %q", result.Messages[2].Role)
}
if result.Messages[2].Content != "Please describe it." {
t.Fatalf("unexpected user content: %q", result.Messages[2].Content)
}
}
func TestFromMessagesRequest_WithOutputConfigEffort(t *testing.T) {
req := MessagesRequest{
Model: "gemma4",
MaxTokens: 32000,
Messages: []MessageParam{
{
Role: "user",
Content: textContent("Describe the image."),
},
},
OutputConfig: &OutputConfig{
Effort: "high",
},
}
result, err := FromMessagesRequest(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Think == nil {
t.Fatal("expected think to be set from output_config.effort")
}
if got := result.Think.String(); got != "high" {
t.Fatalf("expected think level 'high', got %q", got)
}
}
func TestFromMessagesRequest_WithOutputConfigEffortXHighMapsToHigh(t *testing.T) {
req := MessagesRequest{
Model: "gemma4",
MaxTokens: 32000,
Messages: []MessageParam{
{
Role: "user",
Content: textContent("Describe the image."),
},
},
OutputConfig: &OutputConfig{
Effort: "xhigh",
},
}
result, err := FromMessagesRequest(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Think == nil {
t.Fatal("expected think to be set from output_config.effort")
}
if got := result.Think.String(); got != "high" {
t.Fatalf("expected think level 'high' for xhigh effort, got %q", got)
}
}
func TestFromMessagesRequest_ThinkingDisabledOverridesOutputConfigEffort(t *testing.T) {
req := MessagesRequest{
Model: "gemma4",
MaxTokens: 32000,
Messages: []MessageParam{
{
Role: "user",
Content: textContent("Describe the image."),
},
},
Thinking: &ThinkingConfig{
Type: "disabled",
},
OutputConfig: &OutputConfig{
Effort: "high",
},
}
result, err := FromMessagesRequest(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Think == nil {
t.Fatal("expected think to be set")
}
if got := result.Think.Value; got != false {
t.Fatalf("expected think=false when thinking is disabled, got %v", got)
}
}
func TestFromMessagesRequest_ThinkingAdaptiveUsesOutputConfigEffort(t *testing.T) {
req := MessagesRequest{
Model: "gemma4",
MaxTokens: 32000,
Messages: []MessageParam{
{
Role: "user",
Content: textContent("Describe the image."),
},
},
Thinking: &ThinkingConfig{
Type: "adaptive",
},
OutputConfig: &OutputConfig{
Effort: "high",
},
}
result, err := FromMessagesRequest(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Think == nil {
t.Fatal("expected think to be set from output_config.effort")
}
if got := result.Think.String(); got != "high" {
t.Fatalf("expected think level 'high' for adaptive thinking, got %q", got)
}
}
func TestFromMessagesRequest_WithTools(t *testing.T) {
req := MessagesRequest{
Model: "test-model",

View File

@@ -368,16 +368,6 @@ func (c *Client) List(ctx context.Context) (*ListResponse, error) {
return &lr, nil
}
// ModelRecommendationsExperimental lists model recommendations from the local
// server's experimental recommendations endpoint.
func (c *Client) ModelRecommendationsExperimental(ctx context.Context) (*ModelRecommendationsResponse, error) {
var resp ModelRecommendationsResponse
if err := c.do(ctx, http.MethodGet, "/api/experimental/model-recommendations", nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// ListRunning lists running models.
func (c *Client) ListRunning(ctx context.Context) (*ProcessResponse, error) {
var lr ProcessResponse

View File

@@ -802,21 +802,6 @@ type ListResponse struct {
Models []ListModelResponse `json:"models"`
}
// ModelRecommendationsResponse is the response from [Client.ModelRecommendationsExperimental].
type ModelRecommendationsResponse struct {
Recommendations []ModelRecommendation `json:"recommendations"`
}
// ModelRecommendation is a single recommendation entry in [ModelRecommendationsResponse].
type ModelRecommendation struct {
Model string `json:"model"`
Description string `json:"description"`
ContextLength int `json:"context_length,omitempty"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
VRAMBytes int64 `json:"vram_bytes,omitempty"`
RequiredPlan string `json:"required_plan,omitempty"`
}
// ProcessResponse is the response from [Client.Process].
type ProcessResponse struct {
Models []ProcessModelResponse `json:"models"`
@@ -824,15 +809,14 @@ type ProcessResponse struct {
// ListModelResponse is a single model description in [ListResponse].
type ListModelResponse struct {
Name string `json:"name"`
Model string `json:"model"`
RemoteModel string `json:"remote_model,omitempty"`
RemoteHost string `json:"remote_host,omitempty"`
ModifiedAt time.Time `json:"modified_at"`
Size int64 `json:"size"`
Digest string `json:"digest"`
Details ModelDetails `json:"details,omitempty"`
Capabilities []model.Capability `json:"capabilities,omitempty"`
Name string `json:"name"`
Model string `json:"model"`
RemoteModel string `json:"remote_model,omitempty"`
RemoteHost string `json:"remote_host,omitempty"`
ModifiedAt time.Time `json:"modified_at"`
Size int64 `json:"size"`
Digest string `json:"digest"`
Details ModelDetails `json:"details,omitempty"`
}
// ProcessModelResponse is a single model description in [ProcessResponse].
@@ -925,8 +909,6 @@ type ModelDetails struct {
Families []string `json:"families"`
ParameterSize string `json:"parameter_size"`
QuantizationLevel string `json:"quantization_level"`
ContextLength int `json:"context_length,omitempty"`
EmbeddingLength int `json:"embedding_length,omitempty"`
}
// UserResponse provides information about a user.
@@ -1098,7 +1080,7 @@ func DefaultOptions() Options {
}
}
// ThinkValue represents a value that can be a boolean or a string ("high", "medium", "low", "max")
// ThinkValue represents a value that can be a boolean or a string ("high", "medium", "low")
type ThinkValue struct {
// Value can be a bool or string
Value interface{}
@@ -1114,7 +1096,7 @@ func (t *ThinkValue) IsValid() bool {
case bool:
return true
case string:
return v == "high" || v == "medium" || v == "low" || v == "max"
return v == "high" || v == "medium" || v == "low"
default:
return false
}
@@ -1148,8 +1130,8 @@ func (t *ThinkValue) Bool() bool {
case bool:
return v
case string:
// Any string value ("high", "medium", "low", "max") means thinking is enabled
return v == "high" || v == "medium" || v == "low" || v == "max"
// Any string value ("high", "medium", "low") means thinking is enabled
return v == "high" || v == "medium" || v == "low"
default:
return false
}
@@ -1187,14 +1169,14 @@ func (t *ThinkValue) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err == nil {
// Validate string values
if s != "high" && s != "medium" && s != "low" && s != "max" {
return fmt.Errorf("invalid think value: %q (must be \"high\", \"medium\", \"low\", \"max\", true, or false)", s)
if s != "high" && s != "medium" && s != "low" {
return fmt.Errorf("invalid think value: %q (must be \"high\", \"medium\", \"low\", true, or false)", s)
}
t.Value = s
return nil
}
return fmt.Errorf("think must be a boolean or string (\"high\", \"medium\", \"low\", \"max\", true, or false)")
return fmt.Errorf("think must be a boolean or string (\"high\", \"medium\", \"low\", true, or false)")
}
// MarshalJSON implements json.Marshaler

View File

@@ -495,11 +495,6 @@ func TestThinking_UnmarshalJSON(t *testing.T) {
input: `{ "think": "low" }`,
expectedThinking: &ThinkValue{Value: "low"},
},
{
name: "string_max",
input: `{ "think": "max" }`,
expectedThinking: &ThinkValue{Value: "max"},
},
{
name: "invalid_string",
input: `{ "think": "invalid" }`,

View File

@@ -157,6 +157,10 @@ func main() {
}
}
if u := os.Getenv("OLLAMA_UPDATE_URL"); u != "" {
updater.UpdateCheckURLBase = u
}
// Detect if this is a first start after an upgrade, in
// which case we need to do some cleanup
var skipMove bool

View File

@@ -83,29 +83,6 @@ func resolvePath(name string) string {
return name
}
func ollamaServeArgs(args []string) bool {
if len(args) < 2 {
return false
}
switch strings.Trim(filepath.Base(args[0]), `"`) {
case "ollama", "ollama.exe":
default:
return false
}
for _, rawArg := range args[1:] {
arg := strings.Trim(rawArg, `"`)
if strings.HasPrefix(arg, "-") {
continue
}
return arg == "serve" || arg == "start"
}
return false
}
// cleanup checks the pid file for a running ollama process
// and shuts it down gracefully if it is running
func cleanup() error {

View File

@@ -205,63 +205,6 @@ func TestServerCmdCloudSettingEnv(t *testing.T) {
}
}
func TestOllamaServeArgs(t *testing.T) {
tests := []struct {
name string
args []string
want bool
}{
{
name: "system ollama serve",
args: []string{"ollama", "serve"},
want: true,
},
{
name: "relative path ollama serve",
args: []string{"./ollama", "serve"},
want: true,
},
{
name: "serve after other flags",
args: []string{"./ollama", "--verbose", "serve"},
want: true,
},
{
name: "start alias",
args: []string{"ollama", "start"},
want: true,
},
{
name: "launch command",
args: []string{"ollama", "launch", "opencode"},
want: false,
},
{
name: "run command with model named serve",
args: []string{"ollama", "run", "serve"},
want: false,
},
{
name: "launch command with serve in passthrough args",
args: []string{"ollama", "launch", "codex", "--", "-p", "serve"},
want: false,
},
{
name: "different executable",
args: []string{"go", "run", "serve"},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ollamaServeArgs(tt.args); got != tt.want {
t.Fatalf("ollamaServeArgs(%v) = %v, want %v", tt.args, got, tt.want)
}
})
}
}
func TestGetInferenceInfo(t *testing.T) {
tests := []struct {
name string

View File

@@ -46,17 +46,7 @@ func terminated(pid int) (bool, error) {
return false, nil
}
func ollamaServeProcess(pid int) bool {
output, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "args=").Output()
if err != nil {
slog.Debug("failed to inspect ollama process", "pid", pid, "err", err)
return false
}
return ollamaServeArgs(strings.Fields(strings.TrimSpace(string(output))))
}
// reapServers kills external ollama serve processes except our own.
// reapServers kills all ollama processes except our own
func reapServers() error {
// Get our own PID to avoid killing ourselves
currentPID := os.Getpid()
@@ -92,9 +82,6 @@ func reapServers() error {
if pid == currentPID {
continue
}
if !ollamaServeProcess(pid) {
continue
}
proc, err := os.FindProcess(pid)
if err != nil {

View File

@@ -101,29 +101,7 @@ func terminated(pid int) (bool, error) {
return true, nil
}
func ollamaServeProcess(pid int) bool {
cmd := exec.Command("wmic", "process", "where", fmt.Sprintf("ProcessId=%d", pid), "get", "CommandLine", "/value")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
output, err := cmd.Output()
if err != nil {
slog.Debug("failed to inspect ollama process", "pid", pid, "err", err)
return false
}
for _, line := range strings.Split(string(output), "\n") {
line = strings.TrimSpace(line)
commandLine, ok := strings.CutPrefix(line, "CommandLine=")
if !ok {
continue
}
return ollamaServeArgs(strings.Fields(strings.ToLower(commandLine)))
}
return false
}
// reapServers kills external ollama serve processes except our own.
// reapServers kills all ollama processes except our own
func reapServers() error {
// Get current process ID to avoid killing ourselves
currentPID := os.Getpid()
@@ -160,9 +138,6 @@ func reapServers() error {
if pid == currentPID {
continue
}
if !ollamaServeProcess(pid) {
continue
}
cmd := exec.Command("taskkill", "/F", "/PID", pidStr)
if err := cmd.Run(); err != nil {

View File

@@ -1201,16 +1201,13 @@ func (db *database) getSettings() (Settings, error) {
func (db *database) setSettings(s Settings) error {
lastHomeView := strings.ToLower(strings.TrimSpace(s.LastHomeView))
validLaunchView := map[string]struct{}{
"launch": {},
"openclaw": {},
"claude": {},
"hermes": {},
"codex": {},
"codex-app": {},
"copilot": {},
"opencode": {},
"droid": {},
"pi": {},
"launch": {},
"openclaw": {},
"claude": {},
"codex": {},
"opencode": {},
"droid": {},
"pi": {},
}
if lastHomeView != "chat" {
if _, ok := validLaunchView[lastHomeView]; !ok {

View File

@@ -107,36 +107,6 @@ func TestStore(t *testing.T) {
}
})
t.Run("settings disabled home view falls back to launch", func(t *testing.T) {
if err := s.SetSettings(Settings{LastHomeView: "claude-desktop"}); err != nil {
t.Fatal(err)
}
loaded, err := s.Settings()
if err != nil {
t.Fatal(err)
}
if loaded.LastHomeView != "launch" {
t.Fatalf("expected disabled LastHomeView to fall back to launch, got %q", loaded.LastHomeView)
}
})
t.Run("settings codex app home view is accepted", func(t *testing.T) {
if err := s.SetSettings(Settings{LastHomeView: "codex-app"}); err != nil {
t.Fatal(err)
}
loaded, err := s.Settings()
if err != nil {
t.Fatal(err)
}
if loaded.LastHomeView != "codex-app" {
t.Fatalf("expected codex-app LastHomeView to be preserved, got %q", loaded.LastHomeView)
}
})
t.Run("window size", func(t *testing.T) {
if err := s.SetWindowSize(1024, 768); err != nil {
t.Fatal(err)

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude Code</title><path clip-rule="evenodd" d="M20.998 10.949H24v3.102h-3v3.028h-1.487V20H18v-2.921h-1.487V20H15v-2.921H9V20H7.488v-2.921H6V20H4.487v-2.921H3V14.05H0V10.95h3V5h17.998v5.949zM6 10.949h1.488V8.102H6v2.847zm10.51 0H18V8.102h-1.49v2.847z" fill="#D97757" fill-rule="evenodd"></path></svg>

Before

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" fill-rule="evenodd" style="flex:none;line-height:1" viewBox="0 2.5 24 19"><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z" fill="white"/></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" fill-rule="evenodd" style="flex:none;line-height:1" viewBox="0 2.5 24 19"><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"/></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,181 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000" viewBox="0 0 1000 1000"><circle cx="500.0" cy="500.0" r="500.0" fill="white"/><g transform="translate(100.0 100.0) scale(0.8333333333333334)"><g transform="translate(0.000000,960.000000) scale(0.100000,-0.100000)"
fill="black" stroke="none">
<path d="M4485 9589 c-248 -27 -432 -60 -730 -130 -458 -108 -798 -230 -1207
-435 -533 -267 -1072 -675 -1358 -1030 -205 -255 -442 -748 -535 -1114 -108
-426 -97 -870 29 -1160 73 -169 236 -369 381 -467 139 -94 425 -206 425 -167
0 3 -26 32 -58 63 -74 71 -147 182 -184 278 -16 41 -27 77 -25 79 2 3 31 -28
63 -68 91 -114 153 -177 231 -235 40 -29 77 -62 83 -73 7 -13 4 -85 -10 -242
-10 -123 -24 -281 -30 -353 -6 -71 -29 -296 -51 -500 -135 -1262 -202 -1568
-378 -1733 -69 -64 -105 -77 -216 -77 -132 0 -188 20 -271 97 -110 103 -165
248 -167 438 -1 123 12 191 60 318 20 51 33 95 29 99 -10 10 -92 -74 -136
-137 -153 -223 -204 -504 -146 -790 19 -91 97 -252 161 -333 112 -141 316
-237 504 -237 180 0 419 118 591 290 81 81 107 115 196 255 50 78 54 56 13
-71 -107 -337 -343 -577 -613 -625 -213 -39 -544 99 -707 295 -93 111 -133
199 -173 381 -34 153 -34 154 -46 135 -15 -23 -12 -194 5 -305 42 -280 149
-488 327 -636 133 -111 287 -195 465 -254 62 -20 115 -40 117 -44 3 -4 12 -43
21 -87 44 -222 199 -385 416 -438 96 -24 265 -21 359 5 138 38 281 148 388
297 39 55 48 63 50 45 4 -27 -38 -185 -78 -294 -80 -217 -176 -374 -312 -515
-54 -55 -98 -104 -98 -107 0 -4 245 -7 545 -7 l544 0 126 66 c69 36 130 64
135 62 6 -2 -10 -28 -34 -59 -46 -59 -48 -69 -10 -69 17 0 32 15 58 59 36 59
51 71 87 71 23 0 25 -27 4 -77 -8 -19 -15 -39 -15 -44 0 -12 447 -11 470 1 10
5 85 87 168 182 257 297 400 415 317 263 -16 -30 -26 -57 -23 -60 11 -12 210
100 383 215 115 76 226 143 375 225 58 32 178 100 266 151 150 87 244 132 244
117 0 -4 -78 -86 -174 -182 -207 -208 -246 -254 -334 -386 -48 -71 -122 -157
-259 -298 -117 -120 -193 -206 -193 -217 0 -19 8 -20 115 -20 101 0 116 2 122
18 19 54 112 249 156 327 124 221 283 436 369 502 87 67 225 152 337 209 103
51 297 117 384 130 38 6 29 -2 -92 -75 -74 -45 -170 -107 -214 -139 -106 -76
-247 -225 -332 -351 -67 -99 -70 -102 -79 -77 -6 14 -13 26 -18 26 -11 0 -57
-75 -94 -155 -63 -136 -124 -376 -103 -401 15 -18 152 -19 166 -2 6 7 18 36
27 63 23 72 66 131 217 301 302 339 430 464 590 577 148 104 233 128 520 148
205 14 255 9 447 -38 118 -29 183 -65 298 -163 229 -195 538 -577 579 -715 24
-83 71 -117 109 -79 9 8 16 27 16 42 0 62 -40 212 -73 277 -158 313 -551 635
-922 755 -127 41 -142 55 -52 46 117 -11 231 -35 321 -65 158 -54 259 -123
405 -277 164 -174 283 -379 322 -556 11 -51 25 -113 32 -137 36 -128 148 -81
117 49 -14 60 -7 106 27 177 36 74 62 94 224 179 254 133 425 260 561 417 272
315 403 732 358 1138 -24 218 -84 401 -179 545 -65 98 -155 203 -166 192 -3
-3 7 -37 23 -75 136 -309 136 -725 2 -1063 -130 -330 -441 -760 -633 -879 -60
-37 -60 -20 2 54 478 577 578 1337 315 2390 -25 99 -86 326 -136 505 -169 611
-386 1539 -494 2109 -161 857 -200 998 -442 1606 -161 405 -321 692 -529 950
-93 114 -322 343 -433 431 -203 160 -497 332 -737 428 -293 118 -702 220 -978
246 -129 12 -406 11 -520 -1z m-267 -214 c301 -47 596 -191 852 -419 85 -76
266 -270 345 -371 294 -376 520 -873 640 -1409 45 -199 45 -222 3 -244 -18 -9
-45 -30 -61 -45 -25 -23 -28 -33 -23 -60 9 -44 21 -54 121 -98 91 -40 225
-124 225 -140 0 -9 -33 5 -211 89 -126 60 -159 94 -167 173 -5 50 14 89 43 89
26 0 65 49 65 81 0 52 -22 110 -48 127 -23 15 -37 14 -186 -7 -172 -25 -210
-36 -240 -69 -23 -27 -31 -99 -14 -131 16 -29 30 -37 85 -51 26 -7 44 -17 48
-29 10 -32 -15 -153 -41 -197 -27 -49 -131 -161 -139 -152 -9 9 36 77 85 126
23 24 48 60 56 79 13 31 13 38 -3 71 -10 20 -29 42 -43 48 -14 7 -37 18 -52
24 -33 15 -42 39 -63 155 -24 133 -90 393 -135 532 -119 367 -287 686 -483
919 -175 208 -434 427 -659 557 -229 131 -498 197 -804 197 -136 0 -185 -4
-313 -26 -87 -14 -31 14 137 70 186 61 289 89 437 117 120 23 376 20 543 -6z
m2913 -962 c50 -53 113 -138 198 -268 76 -116 60 -104 -41 32 -34 46 -63 81
-66 79 -2 -2 2 -30 9 -63 9 -44 9 -86 1 -171 -6 -63 -13 -116 -16 -119 -3 -4
-15 2 -26 12 -32 29 -34 12 -5 -57 50 -118 66 -160 62 -164 -2 -2 -29 34 -61
81 -31 47 -61 83 -66 80 -6 -4 -7 -24 -4 -47 7 -39 6 -40 -14 -27 -12 7 -25
10 -29 5 -17 -17 -4 -106 31 -210 20 -60 35 -111 33 -112 -2 -2 -30 43 -62 99
-60 102 -173 233 -182 210 -2 -7 26 -82 62 -168 37 -85 65 -160 63 -166 -2 -6
-43 66 -91 160 -63 121 -93 171 -106 171 -9 0 -37 -21 -61 -46 -55 -56 -56
-56 -248 20 -78 31 -156 59 -172 63 l-30 6 24 -54 c13 -30 51 -114 85 -188 34
-73 60 -135 58 -137 -2 -3 -21 24 -42 58 -56 91 -85 128 -102 128 -13 0 -14
-8 -9 -42 l7 -42 -83 80 c-109 106 -163 133 -260 126 -35 -3 -38 0 -53 34 -9
21 -13 44 -11 51 7 17 58 16 147 -2 101 -21 104 -17 92 106 -6 52 -7 98 -3
102 4 5 25 -20 47 -55 22 -35 47 -68 54 -75 18 -14 278 -83 316 -83 23 0 42
13 86 59 61 64 64 72 42 111 -19 33 -19 54 0 46 11 -4 23 6 37 30 l21 35 66
-3 66 -3 -2 29 c-1 16 -25 63 -52 105 -28 41 -49 77 -47 78 2 2 47 -42 101
-97 75 -76 105 -100 125 -100 14 0 35 -7 47 -15 20 -14 22 -14 27 2 3 10 6 72
7 138 2 102 -1 128 -19 175 -12 30 -22 56 -22 58 0 9 40 -22 71 -55z m-2642
-139 c29 -35 60 -64 67 -64 8 0 52 27 97 61 l82 60 47 -51 c59 -63 161 -218
218 -327 23 -46 48 -83 55 -83 8 0 26 5 41 11 49 18 73 4 104 -64 33 -70 48
-127 33 -127 -6 0 -32 9 -58 21 -28 12 -56 18 -71 15 -21 -6 -27 1 -55 56 -67
132 -208 340 -244 361 -10 5 -19 -1 -29 -22 -29 -55 -35 -106 -20 -183 8 -40
14 -74 14 -75 0 -1 -12 2 -26 8 l-27 10 7 -62 c6 -61 6 -62 -14 -44 -15 14
-31 17 -72 13 -58 -6 -88 -32 -88 -76 l0 -26 -29 34 c-29 35 -30 35 -122 38
-52 2 -99 8 -106 14 -15 12 -83 132 -83 146 0 6 23 39 50 73 56 70 68 99 52
134 -12 27 -15 25 81 46 65 14 68 21 42 105 -26 85 -18 85 54 -2z m-397 -294
c84 -126 196 -352 237 -475 33 -99 28 -115 -23 -66 -45 44 -55 29 -49 -77 5
-95 -4 -97 -33 -7 -22 67 -52 125 -64 125 -6 0 -10 -39 -11 -92 0 -51 -4 -101
-8 -111 -9 -23 -10 -22 -85 101 -32 50 -62 92 -68 92 -7 0 -9 -22 -4 -70 6
-74 -3 -90 -24 -40 -21 50 -33 54 -87 30 -26 -11 -57 -30 -69 -41 -20 -19 -21
-19 -75 10 -30 17 -91 41 -137 54 -46 13 -89 30 -97 38 -16 16 -45 142 -36
157 3 5 58 33 121 61 l114 51 21 -26 21 -26 49 39 c51 40 69 66 80 116 5 23
10 28 32 25 19 -2 28 -11 37 -38 37 -115 42 114 6 250 -45 171 -47 164 22 90
33 -36 92 -112 130 -170z m-1789 73 c-3 -10 -32 -77 -63 -148 -48 -109 -137
-340 -242 -628 -11 -32 -22 -56 -24 -54 -2 2 -9 32 -14 68 -24 141 -20 131
-47 124 -13 -3 -50 -18 -81 -33 -43 -20 -62 -37 -79 -67 -32 -59 -40 -65 -88
-65 -55 0 -56 6 -16 106 29 75 176 339 194 351 12 7 14 14 -47 -125 -25 -56
-46 -113 -46 -127 0 -25 0 -25 35 -11 109 46 179 120 301 316 135 217 241 360
217 293z m-869 -35 c-4 -7 -33 -49 -64 -93 -140 -200 -268 -431 -368 -665
-114 -268 -153 -314 -74 -86 41 115 44 129 29 137 -28 16 -39 80 -22 128 27
77 98 202 139 246 58 61 355 345 362 345 3 0 2 -6 -2 -12z m1415 -533 l1 -130
-23 39 c-26 47 -41 51 -45 14 -4 -36 -18 -35 -37 1 -8 17 -19 32 -25 36 -5 3
-60 -10 -122 -30 -76 -25 -130 -36 -165 -36 -29 1 -82 -5 -118 -14 -99 -23
-109 -21 -149 29 -20 23 -36 50 -36 58 0 12 55 182 75 231 11 27 38 21 147
-34 76 -38 108 -49 130 -45 21 4 28 2 28 -9 0 -10 11 -15 34 -15 45 0 153 34
177 56 10 9 35 69 55 133 56 180 58 180 65 1 4 -85 7 -213 8 -285z m-1394 272
c-31 -55 -32 -83 -2 -91 47 -12 65 -6 97 34 18 23 34 39 36 38 2 -2 -20 -48
-48 -102 l-50 -99 33 7 c84 17 149 19 149 5 0 -8 -37 -104 -82 -213 -74 -177
-83 -195 -86 -162 -4 50 -26 56 -66 17 -17 -17 -35 -31 -39 -31 -4 0 -7 18 -7
40 0 28 -6 43 -18 51 -15 10 -18 21 -14 65 8 93 -18 69 -89 -80 -35 -74 -65
-133 -67 -131 -3 2 5 35 17 74 11 38 21 74 21 80 0 6 -35 11 -87 13 l-88 3 3
30 c4 42 162 364 187 381 11 8 44 14 76 14 55 0 59 2 93 42 20 23 36 45 36 50
0 4 5 8 10 8 6 0 -1 -20 -15 -43z m1710 6 c120 -17 143 -19 164 -12 10 3 21
-17 37 -67 24 -77 75 -306 69 -312 -2 -2 -21 14 -43 37 l-39 41 -145 0 c-128
0 -148 2 -170 19 -16 13 -29 17 -39 10 -8 -5 -17 -9 -20 -9 -10 0 -66 178 -74
232 -4 25 -4 56 0 68 7 21 11 22 79 16 39 -4 121 -14 181 -23z m-79 -988 c74
-190 129 -303 247 -514 76 -136 79 -145 62 -157 -24 -17 -76 -18 -98 -1 -21
16 -126 237 -165 347 -30 87 -143 516 -141 541 1 19 17 -19 95 -216z m3644 61
c64 -163 185 -534 301 -926 66 -223 147 -490 179 -595 175 -566 250 -858 321
-1240 22 -121 43 -231 46 -245 4 -18 3 -22 -6 -15 -6 6 -22 71 -36 145 -58
313 -100 487 -200 820 -40 135 -94 317 -120 405 -161 550 -413 1387 -465 1540
-55 165 -67 205 -56 194 2 -2 18 -39 36 -83z m-4395 -812 c61 -60 129 -118
149 -128 46 -22 69 -19 238 25 70 18 130 30 133 27 3 -3 -19 -42 -49 -87 -53
-78 -75 -125 -63 -137 14 -14 111 32 205 96 57 38 136 85 176 104 84 40 299
116 327 116 12 0 37 -24 66 -65 25 -36 54 -67 62 -69 9 -2 178 -1 376 2 l360
7 10 70 c10 68 10 69 22 40 7 -16 17 -49 23 -73 14 -53 18 -55 187 -82 178
-28 191 -33 200 -78 12 -57 8 -612 -6 -742 -17 -170 -53 -395 -141 -880 -206
-1142 -248 -1540 -194 -1863 8 -49 12 -97 9 -107 -8 -24 -195 -200 -213 -200
-7 0 -21 14 -30 31 -140 256 -353 528 -467 594 -66 39 -95 39 -361 1 -137 -19
-303 -40 -368 -47 -128 -12 -307 -7 -368 11 -54 16 -140 78 -185 132 -41 51
-348 610 -438 801 -105 220 -178 478 -191 667 -7 97 11 328 25 343 5 5 23 -17
41 -49 18 -32 72 -97 125 -148 54 -53 97 -104 101 -120 12 -50 -1 -119 -34
-170 -22 -33 -32 -62 -32 -88 0 -45 31 -126 70 -182 23 -35 28 -50 23 -82 -3
-24 4 -70 17 -119 18 -68 28 -87 68 -128 59 -60 118 -101 132 -92 6 4 10 18 8
32 -3 22 3 28 48 44 28 11 59 28 70 40 l18 20 -107 -7 c-118 -8 -146 0 -166
43 -17 37 -14 52 18 84 33 33 78 41 66 12 -14 -31 -16 -77 -5 -93 9 -13 14
-11 36 14 21 24 25 37 21 68 -5 37 -4 38 36 49 56 15 192 6 242 -15 l40 -17
-27 -20 c-66 -49 -1 -50 120 -2 80 31 92 40 92 64 0 33 -30 52 -72 45 -33 -5
-51 1 -140 49 -226 121 -267 139 -327 139 -31 1 -68 -3 -83 -8 -24 -7 -32 -3
-62 30 -62 67 -63 76 -30 145 53 111 38 151 -110 308 -88 93 -131 162 -142
230 -11 67 0 84 99 164 142 113 222 232 261 384 46 178 -18 329 -188 441 -74
48 -75 49 -51 60 35 16 31 28 -20 64 -31 22 -41 34 -32 40 21 14 168 59 252
77 67 15 80 21 83 39 2 12 -33 92 -82 187 -100 193 -117 263 -35 144 28 -41
102 -124 164 -185z m-430 -364 c-13 -21 19 -51 100 -97 78 -43 142 -93 119
-93 -5 0 -34 7 -64 15 -69 19 -148 19 -180 0 -24 -14 -24 -14 21 -15 63 0 238
-37 267 -56 24 -16 42 -52 42 -85 0 -17 -8 -14 -57 24 -92 71 -138 91 -213 90
-36 0 -85 -8 -109 -17 -55 -20 -64 -14 -103 71 -37 82 -36 104 4 127 60 36
190 63 173 36z m4328 -154 c76 -37 148 -103 127 -116 -27 -17 -107 -10 -174
15 -91 34 -212 35 -310 1 -55 -19 -76 -22 -100 -14 -17 5 -37 12 -45 14 -17 6
18 45 66 75 76 47 131 59 258 57 109 -3 125 -6 178 -32z m-4293 -282 c3 -28
11 -58 17 -66 7 -8 12 -30 12 -49 -1 -40 10 -72 46 -128 33 -53 32 -67 -5 -80
-78 -27 -118 37 -133 210 -10 105 -9 120 7 144 27 42 50 29 56 -31z m-53 -438
c-4 -9 -11 -16 -17 -16 -11 0 -14 33 -3 44 11 10 26 -11 20 -28z m449 -922
c60 -20 62 -23 44 -34 -23 -15 -104 -12 -126 5 -19 15 -19 15 0 30 24 18 22
19 82 -1z m5843 -211 c82 -179 180 -472 218 -653 34 -160 38 -387 10 -517 -37
-172 -107 -345 -191 -471 -81 -122 -215 -266 -232 -249 -2 2 10 37 27 78 188
447 261 1077 185 1584 -15 98 -31 196 -35 217 -10 44 0 50 18 11z m-2689 -313
c25 -333 24 -319 22 -342 -1 -10 -66 -56 -164 -115 -175 -106 -203 -121 -196
-101 3 7 26 81 53 163 39 124 214 633 241 699 15 37 22 -12 44 -304z m110
-832 c37 -172 37 -173 -56 -257 -80 -73 -143 -103 -230 -109 -105 -7 -132 9
-183 108 -53 101 -53 132 -3 177 21 19 126 88 233 153 182 111 194 117 201 97
3 -12 20 -88 38 -169z m-1046 -650 c67 -151 141 -236 331 -383 138 -106 239
-173 309 -204 42 -18 47 -23 36 -36 -7 -9 -110 -81 -229 -160 -178 -118 -251
-161 -416 -238 -110 -51 -250 -117 -312 -147 -62 -29 -118 -50 -126 -47 -7 3
-24 34 -36 69 -28 77 -74 158 -252 445 -75 122 -140 231 -144 242 -6 19 -2 21
42 21 63 0 171 24 230 50 35 15 99 72 238 209 182 180 271 261 285 261 4 0 24
-37 44 -82z m-2387 -88 c-10 -81 -64 -284 -102 -378 -55 -136 -119 -238 -204
-323 -108 -107 -162 -132 -306 -137 -109 -4 -111 -3 -175 30 -42 22 -76 48
-98 78 l-35 45 114 7 c338 22 478 105 645 383 29 50 70 131 89 180 49 124 67
165 73 165 3 0 2 -22 -1 -50z m1647 -1402 c-54 -78 -300 -358 -315 -358 -16 0
-10 15 29 76 113 173 323 408 330 370 2 -10 -18 -49 -44 -88z"/>
<path d="M3229 5845 c-108 -15 -150 -30 -198 -71 -49 -41 -111 -123 -111 -146
0 -18 5 -20 40 -15 22 3 40 3 40 1 0 -2 -9 -26 -20 -53 -12 -32 -16 -52 -9
-56 9 -6 7 -23 -8 -62 -3 -8 4 -13 20 -13 16 0 26 -7 30 -20 8 -30 33 -24 48
12 15 37 122 148 142 148 12 0 11 -10 0 -56 -17 -66 -10 -118 17 -137 15 -11
19 -21 14 -44 -5 -25 2 -39 44 -90 28 -33 66 -67 85 -76 39 -19 112 -22 152
-7 39 15 108 74 140 121 27 38 28 41 13 72 -15 32 -15 34 8 54 13 12 24 33 24
47 l0 25 38 -17 c58 -26 111 -62 122 -81 6 -13 3 -29 -11 -57 -98 -186 -404
-264 -685 -174 -101 32 -143 37 -162 18 -21 -21 -13 -28 31 -28 49 0 82 -19
91 -53 5 -20 11 -23 31 -19 14 2 31 0 38 -6 17 -14 145 -34 168 -27 11 4 26 1
34 -5 9 -7 23 -9 37 -4 13 5 41 9 63 11 22 1 51 3 65 4 14 1 37 -1 52 -5 21
-6 27 -4 33 13 5 18 14 21 55 21 43 0 49 3 52 23 3 19 10 22 51 25 49 3 53 7
37 32 -11 17 4 30 38 30 15 0 22 6 22 19 0 14 16 27 55 45 40 18 56 31 61 51
3 14 16 32 27 40 18 13 20 18 10 34 -11 17 -8 21 25 35 100 44 116 63 55 68
-33 3 -38 6 -36 26 3 22 0 22 -45 16 -44 -6 -49 -4 -83 29 -58 56 -354 199
-469 227 -116 28 -147 43 -70 36 30 -3 108 -23 173 -45 64 -22 117 -36 117
-31 0 13 -23 24 -160 75 -142 53 -197 60 -331 40z"/>
<path d="M3027 5303 c-3 -5 -2 -15 2 -22 7 -10 10 -10 16 -1 4 6 3 16 -3 22
-5 5 -12 6 -15 1z"/>
<path d="M2180 4462 c0 -11 136 -122 149 -122 19 0 12 46 -11 75 -12 15 -36
34 -54 41 -37 15 -84 19 -84 6z"/>
</g></g></svg>

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -406,31 +406,6 @@ export async function* pullModel(
}
}
export interface ModelRecommendation {
model: string;
description: string;
context_length?: number;
max_output_tokens?: number;
vram_bytes?: number;
}
export interface ModelRecommendationsResponse {
recommendations: ModelRecommendation[];
}
export async function getModelRecommendations(): Promise<ModelRecommendation[]> {
const response = await fetch(
`${API_BASE}/api/experimental/model-recommendations`,
);
if (!response.ok) {
throw new Error(
`Failed to fetch model recommendations: ${response.statusText}`,
);
}
const data: ModelRecommendationsResponse = await response.json();
return data.recommendations || [];
}
export async function getInferenceCompute(): Promise<InferenceComputeResponse> {
const response = await fetch(`${API_BASE}/api/v1/inference-compute`);
if (!response.ok) {

View File

@@ -13,30 +13,6 @@ interface LaunchCommand {
}
const LAUNCH_COMMANDS: LaunchCommand[] = [
{
id: "claude",
name: "Claude Code",
command: "ollama launch claude",
description: "Anthropic's coding tool with subagents",
icon: "/launch-icons/claude-code.svg",
iconClassName: "h-7 w-7",
},
{
id: "codex-app",
name: "Codex App",
command: "ollama launch codex-app",
description: "An AI agent you can delegate real work to, by OpenAI",
icon: "/launch-icons/codex-app.png",
iconClassName: "h-full w-full",
},
{
id: "hermes",
name: "Hermes Agent",
command: "ollama launch hermes",
description: "Self-improving AI agent built by Nous Research",
icon: "/launch-icons/hermes-agent.svg",
iconClassName: "h-7 w-7",
},
{
id: "openclaw",
name: "OpenClaw",
@@ -45,12 +21,12 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [
icon: "/launch-icons/openclaw.svg",
},
{
id: "opencode",
name: "OpenCode",
command: "ollama launch opencode",
description: "Anomaly's open-source coding agent",
icon: "/launch-icons/opencode.svg",
iconClassName: "h-7 w-7 rounded",
id: "claude",
name: "Claude",
command: "ollama launch claude",
description: "Anthropic's coding tool with subagents",
icon: "/launch-icons/claude.svg",
iconClassName: "h-7 w-7",
},
{
id: "codex",
@@ -62,13 +38,12 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [
iconClassName: "h-7 w-7",
},
{
id: "copilot",
name: "Copilot CLI",
command: "ollama launch copilot",
description: "GitHub's AI coding agent for the terminal",
icon: "/launch-icons/copilot.svg",
darkIcon: "/launch-icons/copilot-dark.svg",
iconClassName: "h-7 w-7",
id: "opencode",
name: "OpenCode",
command: "ollama launch opencode",
description: "Anomaly's open-source coding agent",
icon: "/launch-icons/opencode.svg",
iconClassName: "h-7 w-7 rounded",
},
{
id: "droid",

View File

@@ -1,13 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { getModelRecommendations } from "@/api";
import type { ModelRecommendation } from "@/api";
export function useFeaturedModels() {
return useQuery<ModelRecommendation[], Error>({
queryKey: ["modelRecommendations"],
queryFn: getModelRecommendations,
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
refetchOnWindowFocus: false,
});
}

View File

@@ -1,49 +1,51 @@
import { useQuery } from "@tanstack/react-query";
import { Model } from "@/gotypes";
import { getModels } from "@/api";
import { mergeModels } from "@/utils/mergeModels";
import { useMemo } from "react";
import { useCloudStatus } from "./useCloudStatus";
import { useFeaturedModels } from "./useFeaturedModels";
export function useModels(searchQuery = "") {
const { cloudDisabled } = useCloudStatus();
const { data: recommendations, isLoading: recommendationsLoading } =
useFeaturedModels();
const localQuery = useQuery<Model[], Error>({
queryKey: ["models", searchQuery],
queryFn: () => getModels(searchQuery),
gcTime: 10 * 60 * 1000,
gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
retry: 10,
// exponential backoff, starting at 100ms and capping at 5s
retryDelay: (attemptIndex) => Math.min(100 * 2 ** attemptIndex, 5000),
refetchOnWindowFocus: true,
refetchInterval: 30 * 1000,
refetchInterval: 30 * 1000, // Refetch every 30 seconds to keep models updated
refetchIntervalInBackground: true,
});
const allModels = useMemo(() => {
const local = localQuery.data || [];
const featured = (recommendations || []).map((r) => r.model);
const featuredSet = new Set(featured);
const models = mergeModels(localQuery.data || [], cloudDisabled);
// Recommended models first (using the local copy when downloaded),
// then everything else from /api/tags in tags order.
const recommended = featured.map(
(name) =>
local.find((m) => m.model === name) || new Model({ model: name }),
);
const rest = local.filter((m) => !featuredSet.has(m.model));
const merged = [...recommended, ...rest];
if (searchQuery && searchQuery.trim()) {
const query = searchQuery.toLowerCase().trim();
const filteredModels = models.filter((model) =>
model.model.toLowerCase().includes(query),
);
const visible = cloudDisabled
? merged.filter((m) => !m.isCloud())
: merged;
return filterBySearch(visible, searchQuery);
}, [localQuery.data, searchQuery, cloudDisabled, recommendations]);
const seen = new Set<string>();
return filteredModels.filter((model) => {
const currentModel = model.model.toLowerCase();
if (seen.has(currentModel)) {
return false;
}
seen.add(currentModel);
return true;
});
}
return models;
}, [localQuery.data, searchQuery, cloudDisabled]);
return {
...localQuery,
data: allModels,
isLoading: localQuery.isLoading || recommendationsLoading,
isLoading: localQuery.isLoading,
};
}
@@ -51,16 +53,3 @@ export function useRefetchModels() {
const { refetch } = useModels();
return refetch;
}
function filterBySearch(models: Model[], query: string): Model[] {
const q = query.trim().toLowerCase();
if (!q) return models;
const seen = new Set<string>();
return models.filter((m) => {
const name = m.model.toLowerCase();
if (!name.includes(q) || seen.has(name)) return false;
seen.add(name);
return true;
});
}

View File

@@ -4,6 +4,7 @@ import { useModels } from "./useModels";
import { useChat } from "./useChats";
import { useSettings } from "./useSettings.ts";
import { Model } from "@/gotypes";
import { FEATURED_MODELS } from "@/utils/mergeModels";
import { getTotalVRAM } from "@/utils/vram.ts";
import { getInferenceCompute } from "@/api";
import { useCloudStatus } from "./useCloudStatus";
@@ -91,7 +92,9 @@ export function useSelectedModel(currentChatId?: string, searchQuery?: string) {
(settings.selectedModel &&
new Model({
model: settings.selectedModel,
cloud: settings.selectedModel.endsWith("cloud"),
cloud: FEATURED_MODELS.some(
(f) => f.endsWith("cloud") && f === settings.selectedModel,
),
ollama_host: false,
})) ||
null

View File

@@ -0,0 +1,128 @@
import { describe, it, expect } from "vitest";
import { Model } from "@/gotypes";
import { mergeModels, FEATURED_MODELS } from "@/utils/mergeModels";
import "@/api";
describe("Model merging logic", () => {
it("should handle cloud models with -cloud suffix", () => {
const localModels: Model[] = [
new Model({ model: "gpt-oss:120b-cloud" }),
new Model({ model: "llama3:latest" }),
new Model({ model: "mistral:latest" }),
];
const merged = mergeModels(localModels);
// First verify cloud models are first and in FEATURED_MODELS order
const cloudModels = FEATURED_MODELS.filter((m: string) =>
m.endsWith("cloud"),
);
for (let i = 0; i < cloudModels.length; i++) {
expect(merged[i].model).toBe(cloudModels[i]);
expect(merged[i].isCloud()).toBe(true);
}
// Then verify non-cloud featured models are next and in FEATURED_MODELS order
const nonCloudFeatured = FEATURED_MODELS.filter(
(m: string) => !m.endsWith("cloud"),
);
for (let i = 0; i < nonCloudFeatured.length; i++) {
const model = merged[i + cloudModels.length];
expect(model.model).toBe(nonCloudFeatured[i]);
expect(model.isCloud()).toBe(false);
}
// Verify local models are preserved and come after featured models
const featuredCount = FEATURED_MODELS.length;
expect(merged[featuredCount].model).toBe("llama3:latest");
expect(merged[featuredCount + 1].model).toBe("mistral:latest");
// Length should be exactly featured models plus our local models
expect(merged.length).toBe(FEATURED_MODELS.length + 2);
});
it("should hide cloud models when cloud is disabled", () => {
const localModels: Model[] = [
new Model({ model: "gpt-oss:120b-cloud" }),
new Model({ model: "llama3:latest" }),
new Model({ model: "mistral:latest" }),
];
const merged = mergeModels(localModels, true); // cloud disabled = true
// No cloud models should be present
const cloudModels = merged.filter((m) => m.isCloud());
expect(cloudModels.length).toBe(0);
// Should have non-cloud featured models
const nonCloudFeatured = FEATURED_MODELS.filter(
(m) => !m.endsWith("cloud"),
);
for (let i = 0; i < nonCloudFeatured.length; i++) {
const model = merged[i];
expect(model.model).toBe(nonCloudFeatured[i]);
expect(model.isCloud()).toBe(false);
}
// Local models should be preserved
const featuredCount = nonCloudFeatured.length;
expect(merged[featuredCount].model).toBe("llama3:latest");
expect(merged[featuredCount + 1].model).toBe("mistral:latest");
});
it("should handle empty input", () => {
const merged = mergeModels([]);
// First verify cloud models are first and in FEATURED_MODELS order
const cloudModels = FEATURED_MODELS.filter((m) => m.endsWith("cloud"));
for (let i = 0; i < cloudModels.length; i++) {
expect(merged[i].model).toBe(cloudModels[i]);
expect(merged[i].isCloud()).toBe(true);
}
// Then verify non-cloud featured models are next and in FEATURED_MODELS order
const nonCloudFeatured = FEATURED_MODELS.filter(
(m) => !m.endsWith("cloud"),
);
for (let i = 0; i < nonCloudFeatured.length; i++) {
const model = merged[i + cloudModels.length];
expect(model.model).toBe(nonCloudFeatured[i]);
expect(model.isCloud()).toBe(false);
}
// Length should be exactly FEATURED_MODELS length
expect(merged.length).toBe(FEATURED_MODELS.length);
});
it("should sort models correctly", () => {
const localModels: Model[] = [
new Model({ model: "zephyr:latest" }),
new Model({ model: "alpha:latest" }),
new Model({ model: "gpt-oss:120b-cloud" }),
];
const merged = mergeModels(localModels);
// First verify cloud models are first and in FEATURED_MODELS order
const cloudModels = FEATURED_MODELS.filter((m) => m.endsWith("cloud"));
for (let i = 0; i < cloudModels.length; i++) {
expect(merged[i].model).toBe(cloudModels[i]);
expect(merged[i].isCloud()).toBe(true);
}
// Then verify non-cloud featured models are next and in FEATURED_MODELS order
const nonCloudFeatured = FEATURED_MODELS.filter(
(m) => !m.endsWith("cloud"),
);
for (let i = 0; i < nonCloudFeatured.length; i++) {
const model = merged[i + cloudModels.length];
expect(model.model).toBe(nonCloudFeatured[i]);
expect(model.isCloud()).toBe(false);
}
// Non-featured local models should be at the end in alphabetical order
const featuredCount = FEATURED_MODELS.length;
expect(merged[featuredCount].model).toBe("alpha:latest");
expect(merged[featuredCount + 1].model).toBe("zephyr:latest");
});
});

View File

@@ -0,0 +1,102 @@
import { Model } from "@/gotypes";
// Featured models list (in priority order)
export const FEATURED_MODELS = [
"kimi-k2.5:cloud",
"glm-5:cloud",
"minimax-m2.7:cloud",
"gemma4:31b-cloud",
"qwen3.5:397b-cloud",
"gpt-oss:120b-cloud",
"gpt-oss:20b-cloud",
"deepseek-v3.1:671b-cloud",
"gpt-oss:120b",
"gpt-oss:20b",
"gemma4:31b",
"gemma4:26b",
"gemma4:e4b",
"gemma4:e2b",
"deepseek-r1:8b",
"qwen3-coder:30b",
"qwen3-vl:30b",
"qwen3-vl:8b",
"qwen3-vl:4b",
"qwen3.5:27b",
"qwen3.5:9b",
"qwen3.5:4b",
];
function alphabeticalSort(a: Model, b: Model): number {
return a.model.toLowerCase().localeCompare(b.model.toLowerCase());
}
//Merges models, sorting cloud models first, then other models
export function mergeModels(
localModels: Model[],
hideCloudModels: boolean = false,
): Model[] {
const allModels = (localModels || []).map((model) => model);
// 1. Get cloud models from local models and featured list
const cloudModels = [...allModels.filter((m) => m.isCloud())];
// Add any cloud models from FEATURED_MODELS that aren't in local models
FEATURED_MODELS.filter((f) => f.endsWith("cloud")).forEach((cloudModel) => {
if (!cloudModels.some((m) => m.model === cloudModel)) {
cloudModels.push(new Model({ model: cloudModel }));
}
});
// 2. Get other featured models (non-cloud)
const featuredModels = FEATURED_MODELS.filter(
(f) => !f.endsWith("cloud"),
).map((model) => {
// Check if this model exists in local models
const localMatch = allModels.find(
(m) => m.model.toLowerCase() === model.toLowerCase(),
);
if (localMatch) return localMatch;
return new Model({
model,
});
});
// 3. Get remaining local models that aren't featured and aren't cloud models
const remainingModels = allModels.filter(
(model) =>
!model.isCloud() &&
!FEATURED_MODELS.some(
(f) => f.toLowerCase() === model.model.toLowerCase(),
),
);
cloudModels.sort((a, b) => {
const aIndex = FEATURED_MODELS.indexOf(a.model);
const bIndex = FEATURED_MODELS.indexOf(b.model);
// If both are featured, sort by their position in FEATURED_MODELS
if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex;
}
// If only one is featured, featured model comes first
if (aIndex !== -1 && bIndex === -1) return -1;
if (aIndex === -1 && bIndex !== -1) return 1;
// If neither is featured, sort alphabetically
return a.model.toLowerCase().localeCompare(b.model.toLowerCase());
});
featuredModels.sort(
(a, b) =>
FEATURED_MODELS.indexOf(a.model) - FEATURED_MODELS.indexOf(b.model),
);
remainingModels.sort(alphabeticalSort);
return hideCloudModels
? [...featuredModels, ...remainingModels]
: [...cloudModels, ...featuredModels, ...remainingModels];
}

View File

@@ -302,7 +302,6 @@ func (s *Server) Handler() http.Handler {
mux.Handle("HEAD /api/version", ollamaProxy)
mux.Handle("POST /api/me", ollamaProxy)
mux.Handle("POST /api/signout", ollamaProxy)
mux.Handle("GET /api/experimental/model-recommendations", ollamaProxy)
// React app - catch all non-API routes and serve the React app
mux.Handle("GET /", s.appHandler())

View File

@@ -5,8 +5,6 @@ package updater
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -171,20 +169,22 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
if err != nil {
return fmt.Errorf("error checking update: %w", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status attempting to download update %d", resp.StatusCode)
}
resp.Body.Close()
etag := strings.Trim(resp.Header.Get("etag"), "\"")
if etag == "" {
slog.Debug("no etag detected, falling back to filename based dedup")
etag = "_"
}
filename := Installer
_, params, err := mime.ParseMediaType(resp.Header.Get("content-disposition"))
if err == nil && params["filename"] != "" {
if err == nil {
filename = params["filename"]
}
stageFilename, err := updateStagePath(UpdateStageDir, resp.Header.Get("etag"), filename)
if err != nil {
return err
}
stageFilename := filepath.Join(UpdateStageDir, etag, filename)
// Check to see if we already have it downloaded
_, err = os.Stat(stageFilename)
@@ -202,14 +202,13 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
return fmt.Errorf("error checking update: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status attempting to download update %d", resp.StatusCode)
etag = strings.Trim(resp.Header.Get("etag"), "\"")
if etag == "" {
slog.Debug("no etag detected, falling back to filename based dedup") // TODO probably can get rid of this redundant log
etag = "_"
}
stageFilename, err = updateStagePath(UpdateStageDir, resp.Header.Get("etag"), filename)
if err != nil {
return err
}
stageFilename = filepath.Join(UpdateStageDir, etag, filename)
_, err = os.Stat(filepath.Dir(stageFilename))
if errors.Is(err, os.ErrNotExist) {
@@ -226,13 +225,10 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
if err != nil {
return fmt.Errorf("write payload %s: %w", stageFilename, err)
}
defer fp.Close()
if n, err := fp.Write(payload); err != nil || n != len(payload) {
_ = fp.Close()
return fmt.Errorf("write payload %s: %d vs %d -- %w", stageFilename, n, len(payload), err)
}
if err := fp.Close(); err != nil {
return fmt.Errorf("close payload %s: %w", stageFilename, err)
}
slog.Info("new update downloaded " + stageFilename)
if err := VerifyDownload(); err != nil {
@@ -243,61 +239,6 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
return nil
}
func updateStagePath(stageDir, etag, filename string) (string, error) {
filename, err := safeUpdateFilename(filename)
if err != nil {
return "", err
}
stageDir, err = filepath.Abs(stageDir)
if err != nil {
return "", fmt.Errorf("resolve update stage dir: %w", err)
}
stageFilename := filepath.Join(stageDir, updateStageETagDir(etag), filename)
if err := ensurePathInDir(stageDir, stageFilename); err != nil {
return "", err
}
return stageFilename, nil
}
func safeUpdateFilename(filename string) (string, error) {
filename = strings.TrimSpace(filename)
if filename == "" {
return "", errors.New("missing update filename")
}
if filename == "." || filename == ".." ||
filepath.IsAbs(filename) || path.IsAbs(filename) ||
strings.ContainsAny(filename, `/\:`) ||
filepath.Base(filename) != filename || path.Base(filename) != filename {
return "", fmt.Errorf("unsafe update filename %q", filename)
}
return filename, nil
}
func updateStageETagDir(etag string) string {
etag = strings.Trim(strings.TrimSpace(etag), "\"")
if etag == "" {
slog.Debug("no etag detected, falling back to filename based dedup")
return "_"
}
sum := sha256.Sum256([]byte(etag))
return hex.EncodeToString(sum[:])
}
func ensurePathInDir(dir, name string) error {
rel, err := filepath.Rel(dir, name)
if err != nil {
return fmt.Errorf("resolve update staging path: %w", err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
return fmt.Errorf("update staging path escapes stage dir: %s", name)
}
return nil
}
func cleanupOldDownloads(stageDir string) {
files, err := os.ReadDir(stageDir)
if err != nil && errors.Is(err, os.ErrNotExist) {

View File

@@ -22,15 +22,6 @@ import (
"golang.org/x/sys/unix"
)
const updateArchiveRoot = "Ollama.app"
type bundleEntryScope int
const (
bundleEntryRelative bundleEntryScope = iota
bundleEntryWithArchiveRoot
)
var (
appBackupDir string
SystemWidePath = "/Applications/Ollama.app"
@@ -176,12 +167,8 @@ func DoUpgrade(interactive bool) error {
}
name := s[1]
if strings.HasSuffix(name, "/") {
d, err := bundleEntryPath(BundlePath, name, bundleEntryRelative)
if err != nil {
anyFailures = true
return err
}
err = os.MkdirAll(d, 0o755)
d := filepath.Join(BundlePath, name)
err := os.MkdirAll(d, 0o755)
if err != nil {
anyFailures = true
return fmt.Errorf("failed to mkdir %s: %w", d, err)
@@ -194,14 +181,30 @@ func DoUpgrade(interactive bool) error {
continue
}
destName, err := bundleEntryPath(BundlePath, name, bundleEntryRelative)
src, err := f.Open()
if err != nil {
anyFailures = true
return err
return fmt.Errorf("failed to open bundle file %s: %w", name, err)
}
if err := extractBundleFile(f, destName, name); err != nil {
destName := filepath.Join(BundlePath, name)
// Verify directory first
d := filepath.Dir(destName)
if _, err := os.Stat(d); err != nil {
err := os.MkdirAll(d, 0o755)
if err != nil {
anyFailures = true
return fmt.Errorf("failed to mkdir %s: %w", d, err)
}
}
destFile, err := os.OpenFile(destName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
anyFailures = true
return err
return fmt.Errorf("failed to open output file %s: %w", destName, err)
}
defer destFile.Close()
if _, err := io.Copy(destFile, src); err != nil {
anyFailures = true
return fmt.Errorf("failed to open extract file %s: %w", destName, err)
}
}
for _, f := range links {
@@ -222,24 +225,16 @@ func DoUpgrade(interactive bool) error {
return err
}
link := string(buf)
if link == "" {
anyFailures = true
return fmt.Errorf("bundle contains empty symlink %s", f.Name)
}
if filepath.IsAbs(link) {
if link[0] == '/' {
anyFailures = true
return fmt.Errorf("bundle contains absolute symlink %s -> %s", f.Name, link)
}
if !validBundleLinkTarget(name, link, bundleEntryRelative) {
// Don't allow links outside of Ollama.app
if strings.HasPrefix(filepath.Join(filepath.Dir(name), link), "..") {
anyFailures = true
return fmt.Errorf("bundle contains invalid symlink %s -> %s", f.Name, link)
return fmt.Errorf("bundle contains link outside of contents %s -> %s", f.Name, link)
}
destName, err := bundleEntryPath(BundlePath, name, bundleEntryRelative)
if err != nil {
anyFailures = true
return err
}
if err = os.Symlink(link, destName); err != nil {
if err = os.Symlink(link, filepath.Join(BundlePath, name)); err != nil {
anyFailures = true
return err
}
@@ -287,11 +282,8 @@ func verifyDownload() error {
links := []*zip.File{}
for _, f := range r.File {
if strings.HasSuffix(f.Name, "/") {
d, err := bundleEntryPath(dir, f.Name, bundleEntryWithArchiveRoot)
if err != nil {
return err
}
err = os.MkdirAll(d, 0o755)
d := filepath.Join(dir, f.Name)
err := os.MkdirAll(d, 0o755)
if err != nil {
return fmt.Errorf("failed to mkdir %s: %w", d, err)
}
@@ -302,12 +294,26 @@ func verifyDownload() error {
links = append(links, f)
continue
}
destName, err := bundleEntryPath(dir, f.Name, bundleEntryWithArchiveRoot)
src, err := f.Open()
if err != nil {
return err
return fmt.Errorf("failed to open bundle file %s: %w", f.Name, err)
}
if err := extractBundleFile(f, destName, f.Name); err != nil {
return err
destName := filepath.Join(dir, f.Name)
// Verify directory first
d := filepath.Dir(destName)
if _, err := os.Stat(d); err != nil {
err := os.MkdirAll(d, 0o755)
if err != nil {
return fmt.Errorf("failed to mkdir %s: %w", d, err)
}
}
destFile, err := os.OpenFile(destName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
return fmt.Errorf("failed to open output file %s: %w", destName, err)
}
defer destFile.Close()
if _, err := io.Copy(destFile, src); err != nil {
return fmt.Errorf("failed to open extract file %s: %w", destName, err)
}
}
for _, f := range links {
@@ -320,20 +326,13 @@ func verifyDownload() error {
return err
}
link := string(buf)
if link == "" {
return fmt.Errorf("bundle contains empty symlink %s", f.Name)
}
if filepath.IsAbs(link) {
if link[0] == '/' {
return fmt.Errorf("bundle contains absolute symlink %s -> %s", f.Name, link)
}
if !validBundleLinkTarget(f.Name, link, bundleEntryWithArchiveRoot) {
return fmt.Errorf("bundle contains invalid symlink %s -> %s", f.Name, link)
if strings.HasPrefix(filepath.Join(filepath.Dir(f.Name), link), "..") {
return fmt.Errorf("bundle contains link outside of contents %s -> %s", f.Name, link)
}
destName, err := bundleEntryPath(dir, f.Name, bundleEntryWithArchiveRoot)
if err != nil {
return err
}
if err = os.Symlink(link, destName); err != nil {
if err = os.Symlink(link, filepath.Join(dir, f.Name)); err != nil {
return err
}
}
@@ -344,53 +343,6 @@ func verifyDownload() error {
return nil
}
func bundleEntryPath(root, name string, scope bundleEntryScope) (string, error) {
cleanName := filepath.Clean(filepath.FromSlash(name))
if !filepath.IsLocal(cleanName) {
return "", fmt.Errorf("bundle contains invalid path: %s", name)
}
if scope == bundleEntryWithArchiveRoot && cleanName != updateArchiveRoot &&
!strings.HasPrefix(cleanName, updateArchiveRoot+string(os.PathSeparator)) {
return "", fmt.Errorf("bundle contains invalid path: %s", name)
}
return filepath.Join(root, cleanName), nil
}
func extractBundleFile(f *zip.File, destName, name string) error {
src, err := f.Open()
if err != nil {
return fmt.Errorf("failed to open bundle file %s: %w", name, err)
}
defer src.Close()
d := filepath.Dir(destName)
if _, err := os.Stat(d); err != nil {
if err := os.MkdirAll(d, 0o755); err != nil {
return fmt.Errorf("failed to mkdir %s: %w", d, err)
}
}
destFile, err := os.OpenFile(destName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
return fmt.Errorf("failed to open output file %s: %w", destName, err)
}
defer destFile.Close()
if _, err := io.Copy(destFile, src); err != nil {
return fmt.Errorf("failed to open extract file %s: %w", destName, err)
}
return nil
}
func validBundleLinkTarget(name, link string, scope bundleEntryScope) bool {
cleanTarget := filepath.Clean(filepath.Join(filepath.Dir(filepath.FromSlash(name)), filepath.FromSlash(link)))
if !filepath.IsLocal(cleanTarget) {
return false
}
return scope == bundleEntryRelative || cleanTarget == updateArchiveRoot ||
strings.HasPrefix(cleanTarget, updateArchiveRoot+string(os.PathSeparator))
}
// If we detect an upgrade bundle, attempt to upgrade at startup
func DoUpgradeAtStartup() error {
bundle := getStagedUpdate()

View File

@@ -2,7 +2,6 @@ package updater
import (
"archive/zip"
"errors"
"io/fs"
"os"
"path/filepath"
@@ -147,46 +146,6 @@ func TestDoUpgrade(t *testing.T) {
}
}
func TestDoUpgradeRejectsInvalidBundlePath(t *testing.T) {
tmpDir := t.TempDir()
BundlePath = filepath.Join(tmpDir, "Ollama.app")
appBackupDir = filepath.Join(tmpDir, "backup")
UpdateStageDir = filepath.Join(tmpDir, "updates")
UpgradeMarkerFile = filepath.Join(tmpDir, "upgraded")
bundle := filepath.Join(UpdateStageDir, "foo", "ollama-darwin.zip")
invalidTarget := filepath.Join(tmpDir, "invalid-entry")
if err := os.MkdirAll(filepath.Join(BundlePath, "Contents", "MacOS"), 0o755); err != nil {
t.Fatal("failed to create empty dirs")
}
if err := os.WriteFile(filepath.Join(BundlePath, "Contents", "MacOS", "Ollama"), []byte("old app"), 0o755); err != nil {
t.Fatal("failed to create old app")
}
if err := os.MkdirAll(filepath.Dir(bundle), 0o755); err != nil {
t.Fatal("failed to create empty dirs")
}
if err := zipCreationHelper(bundle, []testPayload{{
Name: "Ollama.app/../invalid-entry",
Body: []byte("payload"),
}}); err != nil {
t.Fatal(err)
}
if err := DoUpgrade(false); err == nil {
t.Fatal("expected failure with invalid bundle path")
} else if !strings.Contains(err.Error(), "bundle contains invalid path") {
t.Fatalf("unexpected error with invalid bundle path: %s", err)
}
if _, err := os.Stat(invalidTarget); err == nil {
t.Fatalf("invalid bundle path wrote %s", invalidTarget)
} else if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("unexpected stat error for %s: %s", invalidTarget, err)
}
if _, err := os.Stat(filepath.Join(BundlePath, "Contents", "MacOS", "Ollama")); err != nil {
t.Fatalf("old app was not restored: %s", err)
}
}
func TestDoUpgradeAtStartup(t *testing.T) {
tmpDir := t.TempDir()
BundlePath = filepath.Join(tmpDir, "Ollama.app")
@@ -244,7 +203,7 @@ func TestVerifyDownloadFailures(t *testing.T) {
in []testPayload
expected string
}{
{"invalid symlink target", []testPayload{
{"breakout", []testPayload{
{
Name: "Ollama.app/",
Body: []byte{},
@@ -253,34 +212,15 @@ func TestVerifyDownloadFailures(t *testing.T) {
Body: []byte("cli payload here"),
}, {
Name: "Ollama.app/Contents/MacOS/Ollama",
Body: []byte("../../../../invalid-target"),
Body: []byte("../../../../breakout"),
Mode: os.ModeSymlink,
},
}, "bundle contains invalid symlink"},
{"invalid archive symlink target", []testPayload{
{
Name: "Ollama.app/Contents/MacOS/Ollama",
Body: []byte("../../../invalid-target"),
Mode: os.ModeSymlink,
},
}, "bundle contains invalid symlink"},
}, "bundle contains link outside"},
{"absolute", []testPayload{{
Name: "Ollama.app/Contents/MacOS/Ollama",
Body: []byte("/etc/foo"),
Mode: os.ModeSymlink,
}}, "bundle contains absolute"},
{"invalid relative file", []testPayload{{
Name: "Ollama.app/../invalid-entry",
Body: []byte("payload"),
}}, "bundle contains invalid path"},
{"invalid relative directory", []testPayload{{
Name: "Ollama.app/../invalid-entry/",
Body: []byte{},
}}, "bundle contains invalid path"},
{"absolute file", []testPayload{{
Name: filepath.Join(tmpDir, "invalid-entry"),
Body: []byte("payload"),
}}, "bundle contains invalid path"},
{"missing", []testPayload{{
Name: "Ollama.app/Contents/MacOS/Ollama",
Body: []byte("../nothere"),
@@ -302,11 +242,6 @@ func TestVerifyDownloadFailures(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), tt.expected) {
t.Fatalf("expected \"%s\" got %s", tt.expected, err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "invalid-entry")); err == nil {
t.Fatal("invalid bundle path wrote unexpected file")
} else if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("unexpected stat error for invalid file: %s", err)
}
})
}
}

View File

@@ -1,127 +0,0 @@
//go:build (windows || darwin) && updater_live
package updater
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/ollama/ollama/app/store"
"github.com/ollama/ollama/app/version"
)
// TestLiveAppUpdate exercises the production update endpoint and downloads the
// current OS update artifact. It is intentionally excluded from normal test
// runs because it depends on ollama.com and downloads a release artifact.
//
// Run with:
//
// go test -tags updater_live -run TestLiveAppUpdate ./app/updater
func TestLiveAppUpdate(t *testing.T) {
const spoofedVersion = "0.20.0"
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
oldUpdateStageDir := UpdateStageDir
oldUpdateDownloaded := UpdateDownloaded
oldVerifyDownload := VerifyDownload
oldVersion := version.Version
defer func() {
UpdateStageDir = oldUpdateStageDir
UpdateDownloaded = oldUpdateDownloaded
VerifyDownload = oldVerifyDownload
version.Version = oldVersion
}()
version.Version = spoofedVersion
expectedFilename := ""
switch runtime.GOOS {
case "windows":
t.Setenv("LOCALAPPDATA", t.TempDir())
expectedFilename = "OllamaSetup.exe"
case "darwin":
expectedFilename = "Ollama-darwin.zip"
default:
t.Fatalf("unsupported updater live test OS %q", runtime.GOOS)
}
UpdateStageDir = filepath.Join(t.TempDir(), "updates")
UpdateDownloaded = false
verifyCalled := false
VerifyDownload = func() error {
verifyCalled = true
return verifyDownload()
}
updater := &Updater{Store: &store.Store{DBPath: filepath.Join(t.TempDir(), "db.sqlite")}}
defer updater.Store.Close()
available, updateResp := updater.checkForUpdate(ctx)
if !available {
t.Fatalf("expected production update check to offer an update for spoofed version %s", spoofedVersion)
}
if updateResp.UpdateURL == "" {
t.Fatal("production update response did not include a download URL")
}
t.Logf("production update version=%q url=%q", updateResp.UpdateVersion, updateResp.UpdateURL)
if err := updater.DownloadNewRelease(ctx, updateResp); err != nil {
t.Fatalf("download production update: %v", err)
}
staged := getStagedUpdate()
if staged == "" {
t.Fatal("production update was not staged")
}
t.Logf("staged production update at %s", staged)
assertPathInsideDir(t, UpdateStageDir, staged)
if filepath.Base(staged) != expectedFilename {
t.Fatalf("expected staged %s update filename to be %q, got %q", runtime.GOOS, expectedFilename, filepath.Base(staged))
}
expectedExt := filepath.Ext(expectedFilename)
if filepath.Ext(staged) != expectedExt {
t.Fatalf("expected staged %s update to be a %s artifact, got %s", runtime.GOOS, expectedExt, staged)
}
info, err := os.Stat(staged)
if err != nil {
t.Fatalf("stat staged update: %v", err)
}
if info.Size() == 0 {
t.Fatal("staged production update is empty")
}
if !verifyCalled {
t.Fatal("DownloadNewRelease did not call VerifyDownload")
}
t.Logf("production updater download path verified staged %s update", runtime.GOOS)
}
func assertPathInsideDir(t *testing.T, dir, name string) {
t.Helper()
dir, err := filepath.Abs(dir)
if err != nil {
t.Fatal(err)
}
name, err = filepath.Abs(name)
if err != nil {
t.Fatal(err)
}
rel, err := filepath.Rel(dir, name)
if err != nil {
t.Fatal(err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
t.Fatalf("staged update escaped update stage dir: %s", name)
}
}

View File

@@ -11,9 +11,7 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
@@ -21,52 +19,6 @@ import (
"github.com/ollama/ollama/app/store"
)
func TestUpdateStagePathRejectsUnsafeFilename(t *testing.T) {
stageDir := t.TempDir()
for _, tt := range []struct {
name string
filename string
}{
{"empty", ""},
{"dot", "."},
{"dotdot", ".."},
{"posix_parent", "../OllamaSetup.exe"},
{"windows_parent", `..\OllamaSetup.exe`},
{"posix_absolute_tmp", "/tmp/OllamaSetup.exe"},
{"darwin_absolute_app", "/Applications/Ollama.app"},
{"darwin_bundle_path", "Ollama.app/Contents/MacOS/Ollama"},
{"darwin_user_download", "~/Downloads/Ollama-darwin.zip"},
{"windows_absolute", `C:\Users\Public\OllamaSetup.exe`},
{"colon", "Ollama:Setup.exe"},
} {
t.Run(tt.name, func(t *testing.T) {
if _, err := updateStagePath(stageDir, "etag", tt.filename); err == nil {
t.Fatal("expected unsafe filename to be rejected")
}
})
}
}
func TestUpdateStagePathHashesETag(t *testing.T) {
stageDir := t.TempDir()
stageFilename, err := updateStagePath(stageDir, `../escaped`, "OllamaSetup.exe")
if err != nil {
t.Fatal(err)
}
rel, err := filepath.Rel(stageDir, stageFilename)
if err != nil {
t.Fatal(err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
t.Fatalf("stage filename escaped stage dir: %s", stageFilename)
}
etagDir := filepath.Base(filepath.Dir(stageFilename))
if etagDir == ".." || etagDir == "escaped" || strings.ContainsAny(etagDir, `/\`) {
t.Fatalf("stage filename used raw etag path component: %s", stageFilename)
}
}
func TestIsNewReleaseAvailable(t *testing.T) {
slog.SetLogLoggerLevel(slog.LevelDebug)
var server *httptest.Server
@@ -95,223 +47,6 @@ func TestIsNewReleaseAvailable(t *testing.T) {
}
}
func TestDownloadNewReleaseRejectsUnsafeHeaderFilename(t *testing.T) {
UpdateStageDir = t.TempDir()
oldInstaller := Installer
oldVerifyDownload := VerifyDownload
oldUpdateDownloaded := UpdateDownloaded
defer func() {
Installer = oldInstaller
VerifyDownload = oldVerifyDownload
UpdateDownloaded = oldUpdateDownloaded
}()
Installer = "OllamaSetup.exe"
UpdateDownloaded = false
VerifyDownload = func() error {
t.Fatal("verification should not run for rejected downloads")
return nil
}
var getAttempted atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
w.Header().Set("ETag", `"safe"`)
w.Header().Set("Content-Disposition", `attachment; filename="../OllamaSetup.exe"`)
w.WriteHeader(http.StatusOK)
return
}
getAttempted.Store(true)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
updater := &Updater{}
err := updater.DownloadNewRelease(t.Context(), UpdateResponse{UpdateURL: server.URL + "/download"})
if err == nil || !strings.Contains(err.Error(), "unsafe update filename") {
t.Fatalf("expected unsafe filename error, got %v", err)
}
if getAttempted.Load() {
t.Fatal("download should not continue after unsafe filename")
}
if _, err := os.Stat(filepath.Join(filepath.Dir(UpdateStageDir), "OllamaSetup.exe")); err == nil {
t.Fatal("download escaped update stage dir")
}
}
func TestDownloadNewReleaseDoesNotUseRawETagAsPathComponent(t *testing.T) {
UpdateStageDir = t.TempDir()
oldInstaller := Installer
oldVerifyDownload := VerifyDownload
oldUpdateDownloaded := UpdateDownloaded
defer func() {
Installer = oldInstaller
VerifyDownload = oldVerifyDownload
UpdateDownloaded = oldUpdateDownloaded
}()
Installer = "OllamaSetup.exe"
UpdateDownloaded = false
VerifyDownload = func() error {
return nil
}
payload := []byte("payload")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("ETag", `"../escaped"`)
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodGet {
_, _ = w.Write(payload)
}
}))
defer server.Close()
updater := &Updater{}
if err := updater.DownloadNewRelease(t.Context(), UpdateResponse{UpdateURL: server.URL + "/download"}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(filepath.Dir(UpdateStageDir), "escaped", Installer)); err == nil {
t.Fatal("download escaped update stage dir via etag")
}
entries, err := os.ReadDir(UpdateStageDir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 {
t.Fatalf("expected one staged update dir, got %d", len(entries))
}
stageFilename := filepath.Join(UpdateStageDir, entries[0].Name(), Installer)
got, err := os.ReadFile(stageFilename)
if err != nil {
t.Fatal(err)
}
if string(got) != string(payload) {
t.Fatalf("unexpected staged payload %q", got)
}
}
func TestBackgroundCheckerSkipsAlreadyStagedETagDownload(t *testing.T) {
UpdateStageDir = t.TempDir()
oldInstaller := Installer
oldVerifyDownload := VerifyDownload
oldUpdateDownloaded := UpdateDownloaded
oldUpdateCheckInitialDelay := UpdateCheckInitialDelay
oldUpdateCheckInterval := UpdateCheckInterval
oldUpdateCheckURLBase := UpdateCheckURLBase
defer func() {
Installer = oldInstaller
VerifyDownload = oldVerifyDownload
UpdateDownloaded = oldUpdateDownloaded
UpdateCheckInitialDelay = oldUpdateCheckInitialDelay
UpdateCheckInterval = oldUpdateCheckInterval
UpdateCheckURLBase = oldUpdateCheckURLBase
}()
Installer = "OllamaSetup.exe"
UpdateDownloaded = false
UpdateCheckInitialDelay = time.Millisecond
UpdateCheckInterval = 5 * time.Millisecond
var verifyCount atomic.Int32
VerifyDownload = func() error {
verifyCount.Add(1)
return nil
}
headETag := `"old-update"`
getETag := `"download-response-etag"`
payload := []byte("payload")
var headCount atomic.Int32
var getCount atomic.Int32
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/update.json":
w.Write([]byte(
fmt.Sprintf(`{"version": "9.9.9", "url": "%s"}`,
server.URL+"/9.9.9/"+Installer)))
case "/9.9.9/" + Installer:
w.Header().Set("Content-Disposition", `attachment; filename="OllamaSetup.exe"`)
switch r.Method {
case http.MethodHead:
etag := headETag
if getCount.Load() > 0 {
etag = getETag
}
w.Header().Set("ETag", etag)
headCount.Add(1)
w.WriteHeader(http.StatusOK)
case http.MethodGet:
w.Header().Set("ETag", getETag)
getCount.Add(1)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(payload)
default:
t.Errorf("unexpected request method %s", r.Method)
w.WriteHeader(http.StatusMethodNotAllowed)
}
default:
t.Errorf("unexpected request path %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
UpdateCheckURLBase = server.URL + "/update.json"
updater := &Updater{Store: &store.Store{DBPath: filepath.Join(t.TempDir(), "test.db")}}
defer updater.Store.Close()
settings, err := updater.Store.Settings()
if err != nil {
t.Fatal(err)
}
settings.AutoUpdateEnabled = true
if err := updater.Store.SetSettings(settings); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
callbacks := make(chan string, 4)
updater.StartBackgroundUpdaterChecker(ctx, func(ver string) error {
callbacks <- ver
return nil
})
for range 2 {
select {
case <-callbacks:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for repeated update checks")
}
}
cancel()
stageFilename, err := updateStagePath(UpdateStageDir, getETag, Installer)
if err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(stageFilename)
if err != nil {
t.Fatal(err)
}
if string(got) != string(payload) {
t.Fatalf("unexpected staged payload %q", got)
}
if headCount.Load() < 2 {
t.Fatalf("HEAD count = %d, want at least 2", headCount.Load())
}
if getCount.Load() != 1 {
t.Fatalf("GET count = %d, want 1", getCount.Load())
}
if verifyCount.Load() != 1 {
t.Fatalf("verification count = %d, want 1", verifyCount.Load())
}
if !UpdateDownloaded {
t.Fatal("UpdateDownloaded should stay true for already staged update")
}
}
func TestBackgoundChecker(t *testing.T) {
UpdateStageDir = t.TempDir()
haveUpdate := false

View File

@@ -1,7 +1,6 @@
package updater
import (
"crypto/x509"
"errors"
"fmt"
"log/slog"
@@ -19,30 +18,6 @@ import (
var runningInstaller string
var (
crypt32 = windows.NewLazySystemDLL("crypt32.dll")
procCryptMsgGetParam = crypt32.NewProc("CryptMsgGetParam")
procCryptMsgClose = crypt32.NewProc("CryptMsgClose")
)
const cmsgSignerInfoParam = 6
type cmsgSignerInfo struct {
Version uint32
Issuer windows.CertNameBlob
SerialNumber windows.CryptIntegerBlob
HashAlgorithm windows.CryptAlgorithmIdentifier
HashEncryptionAlgorithm windows.CryptAlgorithmIdentifier
EncryptedHash windows.CryptDataBlob
AuthAttrs cryptAttributes
UnauthAttrs cryptAttributes
}
type cryptAttributes struct {
Count uint32
Attributes unsafe.Pointer
}
type OSVERSIONINFOEXW struct {
dwOSVersionInfoSize uint32
dwMajorVersion uint32
@@ -124,12 +99,6 @@ func DoUpgrade(interactive bool) error {
return fmt.Errorf("failed to lookup downloads")
}
if err := VerifyDownload(); err != nil {
_ = os.Remove(bundle)
slog.Warn("verification failure", "bundle", bundle, "error", err)
return fmt.Errorf("staged update verification failed: %w", err)
}
// We move the installer to ensure we don't race with multiple apps starting in quick succession
if err := os.Rename(bundle, runningInstaller); err != nil {
return fmt.Errorf("unable to rename %s -> %s : %w", bundle, runningInstaller, err)
@@ -215,150 +184,6 @@ func DoPostUpgradeCleanup() error {
}
func verifyDownload() error {
bundle := getStagedUpdate()
if bundle == "" {
return fmt.Errorf("failed to lookup downloads")
}
slog.Debug("verifying update", "bundle", bundle)
if err := verifyWindowsInstallerSignature(bundle); err != nil {
return fmt.Errorf("signature verification failed: %w", err)
}
return nil
}
func verifyWindowsInstallerSignature(filename string) error {
filename16, err := windows.UTF16PtrFromString(filename)
if err != nil {
return err
}
data := &windows.WinTrustData{
Size: uint32(unsafe.Sizeof(windows.WinTrustData{})),
UIChoice: windows.WTD_UI_NONE,
RevocationChecks: windows.WTD_REVOKE_WHOLECHAIN,
UnionChoice: windows.WTD_CHOICE_FILE,
StateAction: windows.WTD_STATEACTION_VERIFY,
UIContext: windows.WTD_UICONTEXT_INSTALL,
FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(&windows.WinTrustFileInfo{
Size: uint32(unsafe.Sizeof(windows.WinTrustFileInfo{})),
FilePath: filename16,
}),
}
verifyErr := windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data)
data.StateAction = windows.WTD_STATEACTION_CLOSE
closeErr := windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data)
if verifyErr != nil {
return verifyErr
}
if closeErr != nil {
return fmt.Errorf("close WinVerifyTrust state: %w", closeErr)
}
subject, err := windowsInstallerSignerSubject(filename)
if err != nil {
return err
}
slog.Debug("verified update signature", "subject", subject)
return nil
}
func windowsInstallerSignerSubject(filename string) (string, error) {
filename16, err := windows.UTF16PtrFromString(filename)
if err != nil {
return "", err
}
var certStore windows.Handle
var msg windows.Handle
if err := windows.CryptQueryObject(
windows.CERT_QUERY_OBJECT_FILE,
unsafe.Pointer(filename16),
windows.CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED,
windows.CERT_QUERY_FORMAT_FLAG_BINARY,
0,
nil,
nil,
nil,
&certStore,
&msg,
nil,
); err != nil {
return "", err
}
defer windows.CertCloseStore(certStore, 0) //nolint:errcheck
defer cryptMsgClose(msg) //nolint:errcheck
var signerInfoSize uint32
if err := cryptMsgGetParam(msg, cmsgSignerInfoParam, 0, nil, &signerInfoSize); err != nil {
return "", err
}
if signerInfoSize == 0 {
return "", fmt.Errorf("missing signer info")
}
signerInfoBuf := make([]byte, signerInfoSize)
if err := cryptMsgGetParam(msg, cmsgSignerInfoParam, 0, unsafe.Pointer(&signerInfoBuf[0]), &signerInfoSize); err != nil {
return "", err
}
signerInfo := (*cmsgSignerInfo)(unsafe.Pointer(&signerInfoBuf[0]))
certInfo := windows.CertInfo{
Issuer: signerInfo.Issuer,
SerialNumber: signerInfo.SerialNumber,
}
cert, err := windows.CertFindCertificateInStore(
certStore,
windows.X509_ASN_ENCODING|windows.PKCS_7_ASN_ENCODING,
0,
windows.CERT_FIND_SUBJECT_CERT,
unsafe.Pointer(&certInfo),
nil,
)
if err != nil {
return "", err
}
defer windows.CertFreeCertificateContext(cert) //nolint:errcheck
parsed, err := x509.ParseCertificate(unsafe.Slice(cert.EncodedCert, cert.Length))
if err != nil {
return "", err
}
for _, org := range parsed.Subject.Organization {
if org == "Ollama Inc." {
return parsed.Subject.String(), nil
}
}
return "", fmt.Errorf("unexpected signer: %s", parsed.Subject.String())
}
func cryptMsgGetParam(msg windows.Handle, paramType, index uint32, data unsafe.Pointer, size *uint32) error {
r1, _, e1 := procCryptMsgGetParam.Call(
uintptr(msg),
uintptr(paramType),
uintptr(index),
uintptr(data),
uintptr(unsafe.Pointer(size)),
)
if r1 == 0 {
if e1 != syscall.Errno(0) {
return e1
}
return syscall.EINVAL
}
return nil
}
func cryptMsgClose(msg windows.Handle) error {
r1, _, e1 := procCryptMsgClose.Call(uintptr(msg))
if r1 == 0 {
if e1 != syscall.Errno(0) {
return e1
}
return syscall.EINVAL
}
return nil
}

View File

@@ -1,85 +1,13 @@
//go:build windows
//go:build windows || darwin
package updater
import (
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
)
func TestVerifyDownloadRejectsUnsignedWindowsInstaller(t *testing.T) {
oldUpdateStageDir := UpdateStageDir
defer func() {
UpdateStageDir = oldUpdateStageDir
}()
t.Setenv("LOCALAPPDATA", t.TempDir())
UpdateStageDir = t.TempDir()
bundle := filepath.Join(UpdateStageDir, "etag", "OllamaSetup.exe")
if err := os.MkdirAll(filepath.Dir(bundle), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(bundle, []byte("not a signed installer"), 0o755); err != nil {
t.Fatal(err)
}
err := verifyDownload()
if err == nil || !strings.Contains(err.Error(), "signature verification failed") {
t.Fatalf("expected signature verification failure, got %v", err)
}
}
func TestDoUpgradeAtStartupRejectsUnsignedWindowsInstaller(t *testing.T) {
oldUpdateStageDir := UpdateStageDir
oldRunningInstaller := runningInstaller
oldUpgradeLogFile := UpgradeLogFile
oldUpgradeMarkerFile := UpgradeMarkerFile
oldVerifyDownload := VerifyDownload
defer func() {
UpdateStageDir = oldUpdateStageDir
runningInstaller = oldRunningInstaller
UpgradeLogFile = oldUpgradeLogFile
UpgradeMarkerFile = oldUpgradeMarkerFile
VerifyDownload = oldVerifyDownload
}()
t.Setenv("LOCALAPPDATA", t.TempDir())
UpdateStageDir = t.TempDir()
runDir := t.TempDir()
runningInstaller = filepath.Join(runDir, "OllamaSetup.exe")
UpgradeLogFile = filepath.Join(runDir, "upgrade.log")
UpgradeMarkerFile = filepath.Join(runDir, "upgraded")
VerifyDownload = verifyDownload
bundle := filepath.Join(UpdateStageDir, "etag", "OllamaSetup.exe")
if err := os.MkdirAll(filepath.Dir(bundle), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(bundle, []byte("not a signed installer"), 0o755); err != nil {
t.Fatal(err)
}
err := DoUpgradeAtStartup()
if err == nil || !strings.Contains(err.Error(), "signature verification failed") {
t.Fatalf("expected signature verification failure, got %v", err)
}
if _, err := os.Stat(runningInstaller); !os.IsNotExist(err) {
t.Fatalf("unsigned installer was moved before verification failed: %v", err)
}
if _, err := os.Stat(bundle); !os.IsNotExist(err) {
t.Fatalf("unsigned staged installer was not removed after verification failure: %v", err)
}
}
func TestIsInstallerRunning(t *testing.T) {
oldInstaller := Installer
defer func() {
Installer = oldInstaller
}()
slog.SetLogLoggerLevel(slog.LevelDebug)
Installer = "go.exe"
if !isInstallerRunning() {

View File

@@ -54,27 +54,34 @@ import (
"github.com/ollama/ollama/types/syncmap"
"github.com/ollama/ollama/version"
xcmd "github.com/ollama/ollama/x/cmd"
xcreate "github.com/ollama/ollama/x/create"
xcreateclient "github.com/ollama/ollama/x/create/client"
"github.com/ollama/ollama/x/imagegen"
)
func init() {
// Override default selectors to use Bubbletea TUI instead of raw terminal I/O.
launch.DefaultSingleSelector = func(title string, items []launch.SelectionItem, current string) (string, error) {
return runTUISingleSelector(title, items, current, nil)
launch.DefaultSingleSelector = func(title string, items []launch.ModelItem, current string) (string, error) {
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
return "", fmt.Errorf("model selection requires an interactive terminal; use --model to run in headless mode")
}
tuiItems := tui.ReorderItems(tui.ConvertItems(items))
result, err := tui.SelectSingle(title, tuiItems, current)
if errors.Is(err, tui.ErrCancelled) {
return "", launch.ErrCancelled
}
return result, err
}
launch.DefaultSingleSelectorWithUpdates = func(title string, items []launch.SelectionItem, current string, updates <-chan []launch.SelectionItem) (string, error) {
return runTUISingleSelector(title, items, current, updates)
}
launch.DefaultMultiSelector = func(title string, items []launch.SelectionItem, preChecked []string) ([]string, error) {
return runTUIMultiSelector(title, items, preChecked, nil)
}
launch.DefaultMultiSelectorWithUpdates = func(title string, items []launch.SelectionItem, preChecked []string, updates <-chan []launch.SelectionItem) ([]string, error) {
return runTUIMultiSelector(title, items, preChecked, updates)
launch.DefaultMultiSelector = func(title string, items []launch.ModelItem, preChecked []string) ([]string, error) {
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
return nil, fmt.Errorf("model selection requires an interactive terminal; use --model to run in headless mode")
}
tuiItems := tui.ReorderItems(tui.ConvertItems(items))
result, err := tui.SelectMultiple(title, tuiItems, preChecked)
if errors.Is(err, tui.ErrCancelled) {
return nil, launch.ErrCancelled
}
return result, err
}
launch.DefaultSignIn = func(modelName, signInURL string) (string, error) {
@@ -85,55 +92,9 @@ func init() {
return userName, err
}
launch.DefaultUpgrade = func(modelName, requiredPlan string) (string, error) {
plan, err := tui.RunUpgrade(modelName, requiredPlan)
if errors.Is(err, tui.ErrCancelled) {
return "", launch.ErrCancelled
}
return plan, err
}
launch.DefaultConfirmPrompt = tui.RunConfirmWithOptions
}
func runTUISingleSelector(title string, items []launch.SelectionItem, current string, updates <-chan []launch.SelectionItem) (string, error) {
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
return "", fmt.Errorf("model selection requires an interactive terminal; use --model to run in headless mode")
}
tuiItems := tui.ReorderItems(tui.ConvertItems(items))
result, err := tui.SelectSingleWithUpdates(title, tuiItems, current, convertSelectionItemUpdates(updates))
if errors.Is(err, tui.ErrCancelled) {
return "", launch.ErrCancelled
}
return result, err
}
func runTUIMultiSelector(title string, items []launch.SelectionItem, preChecked []string, updates <-chan []launch.SelectionItem) ([]string, error) {
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
return nil, fmt.Errorf("model selection requires an interactive terminal; use --model to run in headless mode")
}
tuiItems := tui.ReorderItems(tui.ConvertItems(items))
result, err := tui.SelectMultipleWithUpdates(title, tuiItems, preChecked, convertSelectionItemUpdates(updates))
if errors.Is(err, tui.ErrCancelled) {
return nil, launch.ErrCancelled
}
return result, err
}
func convertSelectionItemUpdates(updates <-chan []launch.SelectionItem) <-chan []tui.SelectItem {
if updates == nil {
return nil
}
out := make(chan []tui.SelectItem, 1)
go func() {
defer close(out)
for items := range updates {
out <- tui.ReorderItems(tui.ConvertItems(items))
}
}()
return out
}
const ConnectInstructions = "If your browser did not open, navigate to:\n %s\n\n"
// ensureThinkingSupport emits a warning if the model does not advertise thinking support
@@ -184,39 +145,6 @@ func isLocalhost() bool {
return ip != nil && (ip.IsLoopback() || ip.IsUnspecified())
}
func resolveExperimentalLocalModelDir(ref, filename string) string {
if ref == "" || filepath.IsAbs(ref) || filename == "" {
return ref
}
candidate := filepath.Join(filepath.Dir(filename), ref)
if xcreate.IsSafetensorsModelDir(candidate) || xcreate.IsTensorModelDir(candidate) {
return candidate
}
return ref
}
func resolveExperimentalDraftDir(ref, filename string) (string, error) {
if ref == "" {
return "", nil
}
if filepath.IsAbs(ref) {
if xcreate.IsSafetensorsModelDir(ref) {
return ref, nil
}
return "", fmt.Errorf("draft %s is not a supported safetensors model directory", ref)
}
if filename != "" {
candidate := filepath.Join(filepath.Dir(filename), ref)
if xcreate.IsSafetensorsModelDir(candidate) {
return candidate, nil
}
}
return "", fmt.Errorf("DRAFT model references are not supported with --experimental yet: %s", ref)
}
func CreateHandler(cmd *cobra.Command, args []string) error {
p := progress.NewProgress(os.Stderr)
defer p.Stop()
@@ -231,10 +159,6 @@ func CreateHandler(cmd *cobra.Command, args []string) error {
// Check for --experimental flag for safetensors model creation
// This gates both safetensors LLM and imagegen model creation
experimental, _ := cmd.Flags().GetBool("experimental")
draftQuantize, _ := cmd.Flags().GetString("draft-quantize")
if draftQuantize != "" && !experimental {
return errors.New("--draft-quantize requires --experimental")
}
if experimental {
if !isLocalhost() {
return errors.New("remote safetensor model creation not yet supported")
@@ -268,22 +192,17 @@ func CreateHandler(cmd *cobra.Command, args []string) error {
return err
}
modelDir = resolveExperimentalLocalModelDir(modelDir, filename)
if mfConfig.Draft != "" {
draftDir, err := resolveExperimentalDraftDir(mfConfig.Draft, filename)
if err != nil {
return err
}
mfConfig.Draft = draftDir
// Resolve relative paths based on Modelfile location
if !filepath.IsAbs(modelDir) && filename != "" {
modelDir = filepath.Join(filepath.Dir(filename), modelDir)
}
quantize, _ := cmd.Flags().GetString("quantize")
return xcreateclient.CreateModel(xcreateclient.CreateOptions{
ModelName: modelName,
ModelDir: modelDir,
Quantize: quantize,
DraftQuantize: draftQuantize,
Modelfile: mfConfig,
ModelName: modelName,
ModelDir: modelDir,
Quantize: quantize,
Modelfile: mfConfig,
}, p)
}
@@ -663,10 +582,10 @@ func RunHandler(cmd *cobra.Command, args []string) error {
opts.Think = &api.ThinkValue{Value: true}
case "false":
opts.Think = &api.ThinkValue{Value: false}
case "high", "medium", "low", "max":
case "high", "medium", "low":
opts.Think = &api.ThinkValue{Value: thinkStr}
default:
return fmt.Errorf("invalid value for --think: %q (must be true, false, high, medium, low, or max)", thinkStr)
return fmt.Errorf("invalid value for --think: %q (must be true, false, high, medium, or low)", thinkStr)
}
} else {
opts.Think = nil
@@ -2009,7 +1928,7 @@ func appendEnvDocs(cmd *cobra.Command, envs []envconfig.EnvVar) {
Environment Variables:
`
for _, e := range envs {
envUsage += fmt.Sprintf(" %-27s %s\n", e.Name, e.Description)
envUsage += fmt.Sprintf(" %-24s %s\n", e.Name, e.Description)
}
cmd.SetUsageTemplate(cmd.UsageTemplate() + envUsage)
@@ -2128,15 +2047,12 @@ func runInteractiveTUI(cmd *cobra.Command) {
return
}
accountPrefetch := launch.StartAccountStatePrefetch(cmd.Context())
deps := launcherDeps{
buildState: launch.BuildLauncherState,
runMenu: tui.RunMenu,
resolveRunModel: launch.ResolveRunModel,
launchIntegration: launch.LaunchIntegration,
runModel: launchInteractiveModel,
accountState: accountPrefetch.StateIfReady,
accountStateUpdates: accountPrefetch.StateUpdates,
buildState: launch.BuildLauncherState,
runMenu: tui.RunMenu,
resolveRunModel: launch.ResolveRunModel,
launchIntegration: launch.LaunchIntegration,
runModel: launchInteractiveModel,
}
for {
@@ -2151,13 +2067,11 @@ func runInteractiveTUI(cmd *cobra.Command) {
}
type launcherDeps struct {
buildState func(context.Context) (*launch.LauncherState, error)
runMenu func(*launch.LauncherState) (tui.TUIAction, error)
resolveRunModel func(context.Context, launch.RunModelRequest) (string, error)
launchIntegration func(context.Context, launch.IntegrationLaunchRequest) error
runModel func(*cobra.Command, string) error
accountState func() *launch.AccountState
accountStateUpdates func(context.Context) <-chan *launch.AccountState
buildState func(context.Context) (*launch.LauncherState, error)
runMenu func(*launch.LauncherState) (tui.TUIAction, error)
resolveRunModel func(context.Context, launch.RunModelRequest) (string, error)
launchIntegration func(context.Context, launch.IntegrationLaunchRequest) error
runModel func(*cobra.Command, string) error
}
func runInteractiveTUIStep(cmd *cobra.Command, deps launcherDeps) (bool, error) {
@@ -2165,9 +2079,6 @@ func runInteractiveTUIStep(cmd *cobra.Command, deps launcherDeps) (bool, error)
if err != nil {
return false, fmt.Errorf("build launcher state: %w", err)
}
if state != nil && deps.accountState != nil {
state.AccountState = deps.accountState()
}
action, err := deps.runMenu(state)
if err != nil {
@@ -2188,13 +2099,7 @@ func runLauncherAction(cmd *cobra.Command, action tui.TUIAction, deps launcherDe
return false, nil
case tui.TUIActionRunModel:
saveLauncherSelection(action)
req := action.RunModelRequest()
if deps.accountState != nil {
req.AccountState = deps.accountState()
req.AccountStateProvider = deps.accountState
}
req.AccountStateUpdates = deps.accountStateUpdates
modelName, err := deps.resolveRunModel(cmd.Context(), req)
modelName, err := deps.resolveRunModel(cmd.Context(), action.RunModelRequest())
if errors.Is(err, launch.ErrCancelled) {
return true, nil
}
@@ -2207,20 +2112,15 @@ func runLauncherAction(cmd *cobra.Command, action tui.TUIAction, deps launcherDe
return true, nil
case tui.TUIActionLaunchIntegration:
saveLauncherSelection(action)
req := action.IntegrationLaunchRequest()
if deps.accountState != nil {
req.AccountState = deps.accountState()
req.AccountStateProvider = deps.accountState
}
req.AccountStateUpdates = deps.accountStateUpdates
err := deps.launchIntegration(cmd.Context(), req)
err := deps.launchIntegration(cmd.Context(), action.IntegrationLaunchRequest())
if errors.Is(err, launch.ErrCancelled) {
return true, nil
}
if err != nil {
return true, fmt.Errorf("launching %s: %w", action.Integration, err)
}
if launcherActionExitsLoop(action.Integration) {
// VS Code is a GUI app — exit the TUI loop after launching
if action.Integration == "vscode" {
return false, nil
}
return true, nil
@@ -2229,15 +2129,6 @@ func runLauncherAction(cmd *cobra.Command, action tui.TUIAction, deps launcherDe
}
}
func launcherActionExitsLoop(integration string) bool {
switch integration {
case "codex-app", "vscode":
return true
default:
return false
}
}
func NewCLI() *cobra.Command {
log.SetFlags(log.LstdFlags | log.Lshortfile)
cobra.EnableCommandSorting = false
@@ -2277,9 +2168,6 @@ func NewCLI() *cobra.Command {
if experimental, _ := cmd.Flags().GetBool("experimental"); experimental {
return nil
}
if draftQuantize, _ := cmd.Flags().GetString("draft-quantize"); draftQuantize != "" {
return errors.New("--draft-quantize requires --experimental")
}
return checkServerHeartbeat(cmd, args)
},
RunE: CreateHandler,
@@ -2287,7 +2175,6 @@ func NewCLI() *cobra.Command {
createCmd.Flags().StringP("file", "f", "", "Name of the Modelfile (default \"Modelfile\")")
createCmd.Flags().StringP("quantize", "q", "", "Quantize model to this level (e.g. q4_K_M)")
createCmd.Flags().String("draft-quantize", "", "Quantize draft model to this level")
createCmd.Flags().Bool("experimental", false, "Enable experimental safetensors model creation")
showCmd := &cobra.Command{
@@ -2473,7 +2360,6 @@ func NewCLI() *cobra.Command {
envVars["OLLAMA_CONTEXT_LENGTH"],
envVars["OLLAMA_KEEP_ALIVE"],
envVars["OLLAMA_MAX_LOADED_MODELS"],
envVars["OLLAMA_MAX_TRANSFER_STREAMS"],
envVars["OLLAMA_MAX_QUEUE"],
envVars["OLLAMA_MODELS"],
envVars["OLLAMA_NUM_PARALLEL"],

View File

@@ -76,18 +76,11 @@ func TestRunInteractiveTUI_RunModelActionsUseResolveRunModel(t *testing.T) {
var gotReq launch.RunModelRequest
var launched string
prefetchedAccount := &launch.AccountState{}
accountUpdates := func(context.Context) <-chan *launch.AccountState { return nil }
deps := launcherDeps{
buildState: func(ctx context.Context) (*launch.LauncherState, error) {
return &launch.LauncherState{}, nil
},
runMenu: func(state *launch.LauncherState) (tui.TUIAction, error) {
if state.AccountState != prefetchedAccount {
t.Fatalf("prefetched account state was not piped to menu state")
}
return runMenu(state)
},
runMenu: runMenu,
resolveRunModel: func(ctx context.Context, req launch.RunModelRequest) (string, error) {
gotReq = req
return tt.wantModel, nil
@@ -97,10 +90,6 @@ func TestRunInteractiveTUI_RunModelActionsUseResolveRunModel(t *testing.T) {
launched = model
return nil
},
accountState: func() *launch.AccountState {
return prefetchedAccount
},
accountStateUpdates: accountUpdates,
}
cmd := &cobra.Command{}
@@ -118,12 +107,6 @@ func TestRunInteractiveTUI_RunModelActionsUseResolveRunModel(t *testing.T) {
if gotReq.ForcePicker != tt.wantForce {
t.Fatalf("expected ForcePicker=%v, got %v", tt.wantForce, gotReq.ForcePicker)
}
if gotReq.AccountState != prefetchedAccount {
t.Fatalf("expected prefetched account state to be passed to run model request")
}
if gotReq.AccountStateUpdates == nil {
t.Fatalf("expected account state updates to be passed to run model request")
}
if launched != tt.wantModel {
t.Fatalf("expected interactive launcher to run %q, got %q", tt.wantModel, launched)
}
@@ -165,28 +148,17 @@ func TestRunInteractiveTUI_IntegrationActionsUseLaunchIntegration(t *testing.T)
}
var gotReq launch.IntegrationLaunchRequest
prefetchedAccount := &launch.AccountState{}
accountUpdates := func(context.Context) <-chan *launch.AccountState { return nil }
deps := launcherDeps{
buildState: func(ctx context.Context) (*launch.LauncherState, error) {
return &launch.LauncherState{}, nil
},
runMenu: func(state *launch.LauncherState) (tui.TUIAction, error) {
if state.AccountState != prefetchedAccount {
t.Fatalf("prefetched account state was not piped to menu state")
}
return runMenu(state)
},
runMenu: runMenu,
resolveRunModel: unexpectedRunModelResolution(t),
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
gotReq = req
return nil
},
runModel: unexpectedModelLaunch(t),
accountState: func() *launch.AccountState {
return prefetchedAccount
},
accountStateUpdates: accountUpdates,
}
cmd := &cobra.Command{}
@@ -207,12 +179,6 @@ func TestRunInteractiveTUI_IntegrationActionsUseLaunchIntegration(t *testing.T)
if gotReq.ForceConfigure != tt.wantForce {
t.Fatalf("expected ForceConfigure=%v, got %v", tt.wantForce, gotReq.ForceConfigure)
}
if gotReq.AccountState != prefetchedAccount {
t.Fatalf("expected prefetched account state to be passed to integration request")
}
if gotReq.AccountStateUpdates == nil {
t.Fatalf("expected account state updates to be passed to integration request")
}
if got := config.LastSelection(); got != "claude" {
t.Fatalf("expected last selection to be claude, got %q", got)
}
@@ -243,30 +209,29 @@ func TestRunLauncherAction_RunModelContinuesAfterCancellation(t *testing.T) {
}
}
func TestRunLauncherAction_GUIAppsExitTUILoop(t *testing.T) {
func TestRunLauncherAction_VSCodeExitsTUILoop(t *testing.T) {
setCmdTestHome(t, t.TempDir())
cmd := &cobra.Command{}
cmd.SetContext(context.Background())
for _, integration := range []string{"codex-app", "vscode"} {
continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: integration}, launcherDeps{
resolveRunModel: unexpectedRunModelResolution(t),
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
return nil
},
runModel: unexpectedModelLaunch(t),
})
if err != nil {
t.Fatalf("expected nil error for %s, got %v", integration, err)
}
if continueLoop {
t.Fatalf("expected %s launch to exit the TUI loop (return false)", integration)
}
// VS Code should exit the TUI loop (return false) after a successful launch.
continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "vscode"}, launcherDeps{
resolveRunModel: unexpectedRunModelResolution(t),
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
return nil
},
runModel: unexpectedModelLaunch(t),
})
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if continueLoop {
t.Fatal("expected vscode launch to exit the TUI loop (return false)")
}
// Other integrations should continue the TUI loop (return true).
continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "claude"}, launcherDeps{
continueLoop, err = runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "claude"}, launcherDeps{
resolveRunModel: unexpectedRunModelResolution(t),
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
return nil

View File

@@ -9,7 +9,6 @@ import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
@@ -1525,87 +1524,6 @@ func TestCreateHandler(t *testing.T) {
}
}
func TestCreateHandlerDraftQuantizeRequiresExperimental(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().Bool("experimental", false, "")
cmd.Flags().String("draft-quantize", "mxfp8", "")
cmd.SetContext(t.Context())
err := CreateHandler(cmd, []string{"test-model"})
if err == nil || !strings.Contains(err.Error(), "--draft-quantize requires --experimental") {
t.Fatalf("error = %v, want draft-quantize requires experimental", err)
}
}
func TestCreateHandlerDraftRequiresExperimental(t *testing.T) {
dir := t.TempDir()
modelfile := filepath.Join(dir, "Modelfile")
if err := os.WriteFile(modelfile, []byte("FROM base\nDRAFT ./assistant\n"), 0o644); err != nil {
t.Fatal(err)
}
cmd := &cobra.Command{}
cmd.Flags().Bool("experimental", false, "")
cmd.Flags().String("draft-quantize", "", "")
cmd.Flags().String("file", modelfile, "")
cmd.SetContext(t.Context())
err := CreateHandler(cmd, []string{"test-model"})
if err == nil || !strings.Contains(err.Error(), "DRAFT requires --experimental") {
t.Fatalf("error = %v, want DRAFT requires --experimental", err)
}
}
func TestResolveExperimentalLocalModelDir(t *testing.T) {
dir := t.TempDir()
modelfile := filepath.Join(dir, "Modelfile")
modelDir := filepath.Join(dir, "model")
if err := os.Mkdir(modelDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(modelDir, "config.json"), []byte(`{}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(modelDir, "model.safetensors"), []byte("dummy"), 0o644); err != nil {
t.Fatal(err)
}
if got := resolveExperimentalLocalModelDir("gemma4", modelfile); got != "gemma4" {
t.Fatalf("resolveExperimentalLocalModelDir(model name) = %q, want gemma4", got)
}
if got := resolveExperimentalLocalModelDir("./model", modelfile); got != modelDir {
t.Fatalf("resolveExperimentalLocalModelDir(local dir) = %q, want %q", got, modelDir)
}
}
func TestResolveExperimentalDraftDir(t *testing.T) {
dir := t.TempDir()
modelfile := filepath.Join(dir, "Modelfile")
draftDir := filepath.Join(dir, "assistant")
if err := os.Mkdir(draftDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(draftDir, "config.json"), []byte(`{}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(draftDir, "model.safetensors"), []byte("dummy"), 0o644); err != nil {
t.Fatal(err)
}
got, err := resolveExperimentalDraftDir("./assistant", modelfile)
if err != nil {
t.Fatal(err)
}
if got != draftDir {
t.Fatalf("resolveExperimentalDraftDir(local dir) = %q, want %q", got, draftDir)
}
_, err = resolveExperimentalDraftDir("assistant-model", modelfile)
if err == nil || !strings.Contains(err.Error(), "DRAFT model references are not supported with --experimental yet") {
t.Fatalf("error = %v, want unsupported draft model reference", err)
}
}
func TestNewCreateRequest(t *testing.T) {
tests := []struct {
name string

View File

@@ -8,16 +8,9 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
// Keep a bounded number of backups per file so config backups do not grow
// without limit. We keep the 5 most recent backups and do not pin the oldest.
const maxBackupsPerFile = 5
// ReadJSON reads a JSON object file into a generic map.
func ReadJSON(path string) (map[string]any, error) {
data, err := os.ReadFile(path)
@@ -43,51 +36,34 @@ func copyFile(src, dst string) error {
return os.WriteFile(dst, data, info.Mode().Perm())
}
// BackupDir returns the shared backup root used before overwriting files.
// BackupDir returns the shared backup directory used before overwriting files.
func BackupDir() string {
if home, err := os.UserHomeDir(); err == nil && home != "" {
return filepath.Join(home, ".ollama", "backup")
}
return filepath.Join(os.TempDir(), "ollama-backup")
return filepath.Join(os.TempDir(), "ollama-backups")
}
func writeBackupCopy(srcPath string, integration string) (string, error) {
func backupToTmp(srcPath string) (string, error) {
dir := BackupDir()
name := filepath.Base(srcPath)
if integration != "" {
dir = filepath.Join(dir, integration)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
backupPath := filepath.Join(dir, fmt.Sprintf("%s.%d", name, time.Now().Unix()))
backupPath := filepath.Join(dir, fmt.Sprintf("%s.%d", filepath.Base(srcPath), time.Now().Unix()))
if err := copyFile(srcPath, backupPath); err != nil {
return "", err
}
pruneOldBackups(dir, name, maxBackupsPerFile)
return backupPath, nil
}
// WriteWithBackup writes data to path via temp file + rename, backing up any
// existing file first. Callers may optionally pass one integration name to
// store backups under BackupDir()/.../<integration>/.
func WriteWithBackup(path string, data []byte, integration ...string) error {
backupIntegration := ""
if len(integration) > 0 {
backupIntegration = integration[0]
}
// WriteWithBackup writes data to path via temp file + rename, backing up any existing file first.
func WriteWithBackup(path string, data []byte) error {
var backupPath string
// backup must be created before any writes to the target file
if existingContent, err := os.ReadFile(path); err == nil {
if bytes.Equal(existingContent, data) {
return nil
}
backupPath, err = writeBackupCopy(path, backupIntegration)
if err != nil {
return fmt.Errorf("backup failed: %w", err)
if !bytes.Equal(existingContent, data) {
backupPath, err = backupToTmp(path)
if err != nil {
return fmt.Errorf("backup failed: %w", err)
}
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("read existing file: %w", err)
@@ -125,52 +101,3 @@ func WriteWithBackup(path string, data []byte, integration ...string) error {
return nil
}
func pruneOldBackups(dir, name string, keep int) {
if keep < 1 {
return
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
type backupEntry struct {
name string
timestamp int64
}
prefix := name + "."
backups := make([]backupEntry, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) {
continue
}
timestamp, err := strconv.ParseInt(strings.TrimPrefix(entry.Name(), prefix), 10, 64)
if err != nil {
continue
}
backups = append(backups, backupEntry{
name: entry.Name(),
timestamp: timestamp,
})
}
if len(backups) <= keep {
return
}
sort.Slice(backups, func(i, j int) bool {
if backups[i].timestamp != backups[j].timestamp {
return backups[i].timestamp > backups[j].timestamp
}
return backups[i].name > backups[j].name
})
for _, backup := range backups[keep:] {
_ = os.Remove(filepath.Join(dir, backup.name))
}
}

View File

@@ -18,12 +18,6 @@ func TestMain(m *testing.M) {
if err := os.Setenv("TMPDIR", tmpRoot); err != nil {
panic(err)
}
if err := os.Setenv("HOME", tmpRoot); err != nil {
panic(err)
}
if err := os.Setenv("USERPROFILE", tmpRoot); err != nil {
panic(err)
}
code := m.Run()
_ = os.RemoveAll(tmpRoot)
@@ -47,17 +41,6 @@ func isolatedTempDir(t *testing.T) string {
func TestWriteWithBackup(t *testing.T) {
tmpDir := isolatedTempDir(t)
t.Run("uses ollama directory under home", func(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
want := filepath.Join(home, ".ollama", "backup")
if got := BackupDir(); got != want {
t.Fatalf("BackupDir() = %q, want %q", got, want)
}
})
t.Run("creates file", func(t *testing.T) {
path := filepath.Join(tmpDir, "new.json")
data := mustMarshal(t, map[string]string{"key": "value"})
@@ -80,7 +63,7 @@ func TestWriteWithBackup(t *testing.T) {
}
})
t.Run("creates backup in the shared backup directory", func(t *testing.T) {
t.Run("creates backup in the temp backup directory", func(t *testing.T) {
path := filepath.Join(tmpDir, "backup.json")
os.WriteFile(path, []byte(`{"original": true}`), 0o644)
@@ -127,35 +110,6 @@ func TestWriteWithBackup(t *testing.T) {
}
})
t.Run("stores hinted backups under a subdirectory", func(t *testing.T) {
path := filepath.Join(tmpDir, "hinted.json")
os.WriteFile(path, []byte(`{"original": true}`), 0o644)
data := mustMarshal(t, map[string]bool{"updated": true})
if err := WriteWithBackup(path, data, "openclaw"); err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir(filepath.Join(BackupDir(), "openclaw"))
if err != nil {
t.Fatal(err)
}
var found bool
for _, entry := range entries {
name := entry.Name()
if len(name) > len("hinted.json.") && name[:len("hinted.json.")] == "hinted.json." {
found = true
_ = os.Remove(filepath.Join(BackupDir(), "openclaw", name))
break
}
}
if !found {
t.Error("backup file was not created under hint directory")
}
})
t.Run("no backup for new file", func(t *testing.T) {
path := filepath.Join(tmpDir, "nobak.json")
@@ -235,35 +189,6 @@ func TestWriteWithBackup(t *testing.T) {
t.Error("backup file with timestamp not found")
}
})
t.Run("retains only the five newest backups per file", func(t *testing.T) {
path := filepath.Join(tmpDir, "pruned.json")
if err := os.WriteFile(path, []byte(`{"v": 0}`), 0o644); err != nil {
t.Fatal(err)
}
for i := 1; i <= maxBackupsPerFile; i++ {
backupPath := filepath.Join(BackupDir(), fmt.Sprintf("pruned.json.%d", i))
if err := os.WriteFile(backupPath, []byte(fmt.Sprintf(`{"v": %d}`, i)), 0o644); err != nil {
t.Fatal(err)
}
}
if err := WriteWithBackup(path, []byte(`{"v": 1}`)); err != nil {
t.Fatal(err)
}
backups, err := filepath.Glob(filepath.Join(BackupDir(), "pruned.json.*"))
if err != nil {
t.Fatal(err)
}
if len(backups) != maxBackupsPerFile {
t.Fatalf("expected %d backups after pruning, got %d", maxBackupsPerFile, len(backups))
}
if _, err := os.Stat(filepath.Join(BackupDir(), "pruned.json.1")); !os.IsNotExist(err) {
t.Fatalf("expected oldest backup to be pruned, stat err = %v", err)
}
})
}
// Edge case tests for files.go
@@ -326,36 +251,6 @@ func TestWriteWithBackup_PermissionDenied(t *testing.T) {
}
}
func TestWriteWithBackup_UnchangedContentIsNoOp(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("permission tests unreliable on Windows")
}
tmpDir := isolatedTempDir(t)
path := filepath.Join(tmpDir, "unchanged-noop.json")
data := []byte(`{"same":true}`)
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chmod(tmpDir, 0o555); err != nil {
t.Fatal(err)
}
defer os.Chmod(tmpDir, 0o755)
if err := WriteWithBackup(path, data); err != nil {
t.Fatalf("expected unchanged write to be a no-op, got %v", err)
}
backups, err := filepath.Glob(filepath.Join(BackupDir(), "unchanged-noop.json.*"))
if err != nil {
t.Fatal(err)
}
if len(backups) != 0 {
t.Fatalf("expected no backups for unchanged content, got %d", len(backups))
}
}
// TestWriteWithBackup_DirectoryDoesNotExist verifies behavior when target directory doesn't exist.
// writeWithBackup doesn't create directories - caller is responsible.
func TestWriteWithBackup_DirectoryDoesNotExist(t *testing.T) {
@@ -407,9 +302,9 @@ func TestBackupToTmp_SpecialCharsInFilename(t *testing.T) {
path := filepath.Join(tmpDir, "my config (backup).json")
os.WriteFile(path, []byte(`{"test": true}`), 0o644)
backupPath, err := writeBackupCopy(path, "")
backupPath, err := backupToTmp(path)
if err != nil {
t.Fatalf("writeBackupCopy with special chars failed: %v", err)
t.Fatalf("backupToTmp with special chars failed: %v", err)
}
// Verify backup exists and has correct content

View File

@@ -1,371 +0,0 @@
package launch
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/ollama/ollama/api"
)
const (
// DefaultUpgradeURL is the fixed destination for subscription upgrades.
DefaultUpgradeURL = "https://ollama.com/upgrade"
accountCheckTimeout = 3 * time.Second
)
var (
ErrPlanVerificationUnavailable = errors.New("Could not verify your plan. Try again in a moment.")
errUpgradeCancelled = errors.New("upgrade cancelled")
)
type accountStateStatus int
const (
accountStateUnknown accountStateStatus = iota
accountStateSignedOut
accountStateSignedIn
)
type AccountState struct {
Status accountStateStatus
Plan string
}
type AccountStatePrefetch struct {
done chan struct{}
state AccountState
}
func StartAccountStatePrefetch(ctx context.Context) *AccountStatePrefetch {
if ctx == nil {
ctx = context.Background()
}
p := &AccountStatePrefetch{done: make(chan struct{})}
go func() {
state := AccountState{Status: accountStateUnknown}
client, err := api.ClientFromEnvironment()
if err == nil {
prefetchCtx, cancel := context.WithTimeout(ctx, accountCheckTimeout)
defer cancel()
if disabled, known := cloudStatusDisabled(prefetchCtx, client); !known || !disabled {
state = launchAccountState(prefetchCtx, client)
}
}
p.state = state
close(p.done)
}()
return p
}
func (p *AccountStatePrefetch) StateIfReady() *AccountState {
if p == nil {
return nil
}
select {
case <-p.done:
state := p.state
return &state
default:
return nil
}
}
func (p *AccountStatePrefetch) StateUpdates(ctx context.Context) <-chan *AccountState {
if p == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
out := make(chan *AccountState, 1)
go func() {
defer close(out)
select {
case <-p.done:
if p.state.Status == accountStateUnknown {
return
}
state := p.state
select {
case out <- &state:
case <-ctx.Done():
}
case <-ctx.Done():
}
}()
return out
}
func launchAccountState(ctx context.Context, client *api.Client) AccountState {
if client == nil {
return AccountState{Status: accountStateUnknown}
}
user, err := whoamiWithTimeout(ctx, client)
if err != nil {
var authErr api.AuthorizationError
if errors.As(err, &authErr) && authErr.StatusCode == http.StatusUnauthorized {
return AccountState{Status: accountStateSignedOut}
}
return AccountState{Status: accountStateUnknown}
}
if user == nil || strings.TrimSpace(user.Name) == "" {
return AccountState{Status: accountStateSignedOut}
}
return AccountState{
Status: accountStateSignedIn,
Plan: strings.TrimSpace(user.Plan),
}
}
func whoamiWithTimeout(ctx context.Context, client *api.Client) (*api.UserResponse, error) {
if ctx == nil {
ctx = context.Background()
}
checkCtx, cancel := context.WithTimeout(ctx, accountCheckTimeout)
defer cancel()
return client.Whoami(checkCtx)
}
func ApplyAccountStateToSelectionItems(items []ModelItem, state AccountState) []SelectionItem {
out := make([]SelectionItem, len(items))
for i, item := range items {
out[i] = SelectionItem{
Name: item.Name,
Description: item.Description,
Recommended: item.Recommended,
AvailabilityBadge: availabilityBadge(item, state),
}
}
return out
}
func SelectionItemsWithAccountState(items []ModelItem, state *AccountState) []SelectionItem {
if state == nil || !selectionItemsNeedAccountState(items) {
return ApplyAccountStateToSelectionItems(items, AccountState{Status: accountStateUnknown})
}
return ApplyAccountStateToSelectionItems(items, *state)
}
func selectionItemsNeedAccountState(items []ModelItem) bool {
for _, item := range items {
if isCloudModelName(item.Name) && itemHasRecommendationMetadata(item) {
return true
}
}
return false
}
func (c *launcherClient) selectionItemUpdates(ctx context.Context, items []ModelItem, state *AccountState) <-chan []SelectionItem {
if !selectionItemsNeedAccountState(items) || state != nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
stateUpdates := c.accountStateUpdateSource(ctx)
if stateUpdates == nil {
return nil
}
out := make(chan []SelectionItem, 1)
go func() {
defer close(out)
select {
case state, ok := <-stateUpdates:
if !ok || state == nil {
return
}
select {
case out <- SelectionItemsWithAccountState(items, state):
case <-ctx.Done():
}
case <-ctx.Done():
}
}()
return out
}
func (c *launcherClient) accountStateUpdateSource(ctx context.Context) <-chan *AccountState {
if c.accountStateUpdates != nil {
return c.accountStateUpdates(ctx)
}
if c.apiClient == nil {
return nil
}
out := make(chan *AccountState, 1)
go func() {
defer close(out)
state := launchAccountState(ctx, c.apiClient)
if state.Status == accountStateUnknown {
return
}
select {
case out <- &state:
case <-ctx.Done():
}
}()
return out
}
func availabilityBadge(item ModelItem, state AccountState) string {
if !isCloudModelName(item.Name) {
return ""
}
switch state.Status {
case accountStateSignedOut:
if itemHasRecommendationMetadata(item) {
return "Sign in required"
}
case accountStateSignedIn:
if item.RequiredPlan != "" && !PlanSatisfies(state.Plan, item.RequiredPlan) {
return "Upgrade required"
}
}
return ""
}
func itemHasRecommendationMetadata(item ModelItem) bool {
return item.Recommended || strings.TrimSpace(item.RequiredPlan) != ""
}
func (c *launcherClient) ensureCloudModelAccess(ctx context.Context, model string) error {
item, ok := c.modelRecommendationItem(ctx, model)
if !ok || strings.TrimSpace(item.RequiredPlan) == "" {
return nil
}
state := launchAccountState(ctx, c.apiClient)
if state.Status != accountStateUnknown {
c.accountState = &state
}
if state.Status == accountStateUnknown {
return ErrPlanVerificationUnavailable
}
if state.Status == accountStateSignedOut {
if err := ensureCloudAuth(ctx, c.apiClient, model); err != nil {
return err
}
state = launchAccountState(ctx, c.apiClient)
if state.Status != accountStateUnknown {
c.accountState = &state
}
if state.Status == accountStateUnknown {
return ErrPlanVerificationUnavailable
}
}
if PlanSatisfies(state.Plan, item.RequiredPlan) {
return nil
}
if err := c.runUpgradeFlow(ctx, item); err != nil {
return err
}
state = launchAccountState(ctx, c.apiClient)
if state.Status == accountStateUnknown {
return ErrPlanVerificationUnavailable
}
if state.Status != accountStateSignedIn || !PlanSatisfies(state.Plan, item.RequiredPlan) {
return errUpgradeCancelled
}
return nil
}
func (c *launcherClient) modelRecommendationItem(ctx context.Context, model string) (ModelItem, bool) {
for _, item := range c.recommendations(ctx) {
if item.Name == model {
return item, true
}
}
return ModelItem{}, false
}
func (c *launcherClient) runUpgradeFlow(ctx context.Context, item ModelItem) error {
if DefaultUpgrade != nil {
if _, err := DefaultUpgrade(item.Name, item.RequiredPlan); err != nil {
if errors.Is(err, ErrCancelled) {
return errUpgradeCancelled
}
return err
}
return nil
}
yes, err := ConfirmPrompt(fmt.Sprintf("Upgrade to use %s?", item.Name))
if errors.Is(err, ErrCancelled) {
return errUpgradeCancelled
}
if err != nil {
return err
}
if !yes {
return errUpgradeCancelled
}
fmt.Fprintf(os.Stderr, "\nTo upgrade, navigate to:\n %s\n\n", DefaultUpgradeURL)
openNow, err := ConfirmPrompt("Open now?")
if errors.Is(err, ErrCancelled) {
return errUpgradeCancelled
}
if err != nil {
return err
}
if openNow {
OpenBrowser(DefaultUpgradeURL)
} else {
return errUpgradeCancelled
}
spinnerFrames := []string{"|", "/", "-", "\\"}
frame := 0
fmt.Fprintf(os.Stderr, "\033[90mwaiting for upgrade to complete... %s\033[0m", spinnerFrames[0])
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
fmt.Fprintf(os.Stderr, "\r\033[K")
return ctx.Err()
case <-ticker.C:
frame++
fmt.Fprintf(os.Stderr, "\r\033[90mwaiting for upgrade to complete... %s\033[0m", spinnerFrames[frame%len(spinnerFrames)])
if frame%10 != 0 {
continue
}
state := launchAccountState(ctx, c.apiClient)
if state.Status == accountStateUnknown {
fmt.Fprintf(os.Stderr, "\r\033[K")
return ErrPlanVerificationUnavailable
}
if state.Status == accountStateSignedIn && PlanSatisfies(state.Plan, item.RequiredPlan) {
fmt.Fprintf(os.Stderr, "\r\033[K\033[A\r\033[K\033[1mplan updated\033[0m\n")
return nil
}
}
}
}
// PlanSatisfies reports whether currentPlan can use a model that has a requiredPlan.
func PlanSatisfies(currentPlan, requiredPlan string) bool {
required := normalizePlan(requiredPlan)
if required == "" || required == "free" {
return true
}
current := normalizePlan(currentPlan)
return current != "" && current != "free"
}
func normalizePlan(plan string) string {
return strings.ToLower(strings.TrimSpace(plan))
}

View File

@@ -44,7 +44,7 @@ func (c *Claude) findPath() (string, error) {
return fallback, nil
}
func (c *Claude) Run(model string, _ []LaunchModel, args []string) error {
func (c *Claude) Run(model string, args []string) error {
claudePath, err := c.findPath()
if err != nil {
return fmt.Errorf("claude is not installed, install from https://code.claude.com/docs/en/quickstart")

View File

@@ -1,888 +0,0 @@
package launch
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/ollama/ollama/cmd/config"
"github.com/ollama/ollama/cmd/internal/fileutil"
"golang.org/x/term"
)
const (
claudeDesktopIntegrationName = "claude-desktop"
claudeDesktopProfileName = "Ollama"
claudeDesktopProfileID = "00000000-0000-4000-8000-000000000114"
claudeDesktopGatewayBaseURL = "https://ollama.com"
claudeDesktopAPIKeyURL = "https://ollama.com/settings/keys"
claudeDesktopModelLabel = "Ollama Cloud"
claudeDesktopUnsupported = "Claude Desktop is no longer supported. Existing installations can be restored with 'ollama launch claude-desktop --restore'."
claudeDesktopSuccessMessage = "Claude Desktop profile changed to Ollama Cloud."
claudeDesktopRestoreMessage = "To restore the usual Claude profile, run: ollama launch claude-desktop --restore"
claudeDesktopRestoredMessage = "Claude Desktop restored to the usual Claude profile."
)
var (
claudeDesktopGOOS = runtime.GOOS
claudeDesktopUserHome = os.UserHomeDir
claudeDesktopStat = os.Stat
claudeDesktopOpenApp = defaultClaudeDesktopOpenApp
claudeDesktopOpenAppPath = defaultClaudeDesktopOpenAppPath
claudeDesktopQuitApp = defaultClaudeDesktopQuitApp
claudeDesktopIsRunning = defaultClaudeDesktopIsRunning
claudeDesktopRunningAppPath = defaultClaudeDesktopRunningAppPath
claudeDesktopGlob = filepath.Glob
claudeDesktopSleep = time.Sleep
claudeDesktopHTTPClient = http.DefaultClient
claudeDesktopPromptAPIKey = promptClaudeDesktopAPIKey
claudeDesktopValidateAPIKey = validateClaudeDesktopAPIKey
)
// ClaudeDesktop configures and launches Claude Desktop in third-party
// inference mode using Ollama Cloud as the gateway.
type ClaudeDesktop struct{}
func (c *ClaudeDesktop) String() string { return "Claude Desktop" }
func (c *ClaudeDesktop) Supported() error { return claudeDesktopSupported() }
func (c *ClaudeDesktop) Paths() []string {
return nil
}
func (c *ClaudeDesktop) AutodiscoveredModel() string {
return claudeDesktopModelLabel
}
func (c *ClaudeDesktop) ConfigureAutodiscovery() error {
if err := claudeDesktopSupported(); err != nil {
return err
}
targets, err := claudeDesktopTargetPaths()
if err != nil {
return err
}
key, err := claudeDesktopValidatedAPIKey(context.Background(), claudeDesktopTargetProfilePaths(targets))
if err != nil {
return err
}
for _, path := range targets.normalConfigs {
if err := writeClaudeDesktopDeploymentMode(path, "3p"); err != nil {
return err
}
}
for _, target := range targets.thirdPartyProfiles {
if err := writeClaudeDesktopDeploymentMode(target.desktopConfig, "3p"); err != nil {
return err
}
if err := writeClaudeDesktopMeta(target.meta, claudeDesktopProfileID, claudeDesktopProfileName); err != nil {
return err
}
if err := writeClaudeDesktopGatewayProfile(target.profile, key, true); err != nil {
return err
}
}
return nil
}
func (c *ClaudeDesktop) RestoreHint() string {
return claudeDesktopRestoreMessage
}
func (c *ClaudeDesktop) ConfigurationSuccessMessage() string {
return claudeDesktopSuccessMessage + "\n" + claudeDesktopRestoreMessage
}
func (c *ClaudeDesktop) RestoreSuccessMessage() string {
return claudeDesktopRestoredMessage
}
func (c *ClaudeDesktop) AutodiscoveryConfigured() bool {
targets, err := claudeDesktopTargetPaths()
if err != nil {
return false
}
return claudeDesktopTargetsConfigured(targets)
}
func (c *ClaudeDesktop) Onboard() error {
return config.MarkIntegrationOnboarded(claudeDesktopIntegrationName)
}
func (c *ClaudeDesktop) RequiresInteractiveOnboarding() bool {
return false
}
func (c *ClaudeDesktop) SkipModelReadiness() bool {
return true
}
func (c *ClaudeDesktop) Run(_ string, _ []LaunchModel, _ []string) error {
return errClaudeDesktopUnsupported()
}
func (c *ClaudeDesktop) Restore() error {
if err := claudeDesktopSupported(); err != nil {
return err
}
targets, err := claudeDesktopTargetPaths()
if err != nil {
return err
}
for _, path := range targets.normalConfigs {
if err := writeClaudeDesktopDeploymentMode(path, "1p"); err != nil {
return err
}
}
for _, target := range targets.thirdPartyProfiles {
if err := writeClaudeDesktopDeploymentMode(target.desktopConfig, "1p"); err != nil {
return err
}
if err := restoreClaudeDesktopMeta(target.meta); err != nil {
return err
}
if err := restoreClaudeDesktopOllamaProfile(target.profile); err != nil {
return err
}
}
return claudeDesktopLaunchOrRestart("Restart Claude Desktop to use the usual Claude profile?")
}
func errClaudeDesktopUnsupported() error {
return errors.New(claudeDesktopUnsupported)
}
func claudeDesktopSupported() error {
switch claudeDesktopGOOS {
case "darwin", "windows":
return nil
default:
return fmt.Errorf("Claude Desktop launch is only supported on macOS and Windows")
}
}
func claudeDesktopInstalled() bool {
if claudeDesktopAppPath() != "" {
return true
}
if claudeDesktopGOOS == "windows" && claudeDesktopIsRunning() {
return true
}
for _, dir := range claudeDesktopProfileDirCandidates(false) {
if _, err := claudeDesktopStat(dir); err == nil {
return true
}
}
return false
}
func claudeDesktopAppPath() string {
if claudeDesktopGOOS != "darwin" && claudeDesktopGOOS != "windows" {
return ""
}
for _, path := range claudeDesktopAppCandidates() {
if _, err := claudeDesktopStat(path); err == nil {
return path
}
}
return ""
}
func claudeDesktopAppCandidates() []string {
switch claudeDesktopGOOS {
case "darwin":
return claudeDesktopDarwinAppCandidates()
case "windows":
return claudeDesktopWindowsAppCandidates()
default:
return nil
}
}
func claudeDesktopDarwinAppCandidates() []string {
candidates := []string{"/Applications/Claude.app"}
if home, err := claudeDesktopUserHome(); err == nil {
candidates = append(candidates, filepath.Join(home, "Applications", "Claude.app"))
}
return candidates
}
func claudeDesktopWindowsAppCandidates() []string {
local, err := claudeDesktopLocalAppData()
if err != nil {
return nil
}
candidates := []string{
filepath.Join(local, "Programs", "Claude", "Claude.exe"),
filepath.Join(local, "Programs", "Claude Desktop", "Claude.exe"),
filepath.Join(local, "Claude", "Claude.exe"),
filepath.Join(local, "Claude Nest", "Claude.exe"),
filepath.Join(local, "Claude Desktop", "Claude.exe"),
filepath.Join(local, "AnthropicClaude", "Claude.exe"),
}
for _, pattern := range []string{
filepath.Join(local, "AnthropicClaude", "app-*", "Claude.exe"),
filepath.Join(local, "Programs", "Claude", "app-*", "Claude.exe"),
filepath.Join(local, "Programs", "Claude Desktop", "app-*", "Claude.exe"),
} {
matches, _ := claudeDesktopGlob(pattern)
candidates = append(candidates, matches...)
}
return claudeDesktopDedupePaths(candidates)
}
func claudeDesktopDedupePaths(paths []string) []string {
out := make([]string, 0, len(paths))
seen := make(map[string]bool, len(paths))
for _, path := range paths {
if strings.TrimSpace(path) == "" {
continue
}
key := strings.ToLower(path)
if seen[key] {
continue
}
seen[key] = true
out = append(out, path)
}
return out
}
type claudeDesktopPaths struct {
normalConfig string
desktopConfig string
meta string
profile string
}
type claudeDesktopThirdPartyPaths struct {
desktopConfig string
meta string
profile string
}
type claudeDesktopTargets struct {
normalConfigs []string
thirdPartyProfiles []claudeDesktopThirdPartyPaths
}
func claudeDesktopConfigPaths() (claudeDesktopPaths, error) {
switch claudeDesktopGOOS {
case "darwin":
return claudeDesktopDarwinConfigPaths()
case "windows":
return claudeDesktopWindowsConfigPaths()
default:
return claudeDesktopPaths{}, claudeDesktopSupported()
}
}
func claudeDesktopDarwinConfigPaths() (claudeDesktopPaths, error) {
normalRoots, thirdPartyRoots, err := claudeDesktopDarwinProfileRoots()
if err != nil {
return claudeDesktopPaths{}, err
}
normalBase := normalRoots[0]
thirdPartyBase := thirdPartyRoots[0]
return claudeDesktopPaths{
normalConfig: filepath.Join(normalBase, "claude_desktop_config.json"),
desktopConfig: filepath.Join(thirdPartyBase, "claude_desktop_config.json"),
meta: filepath.Join(thirdPartyBase, "configLibrary", "_meta.json"),
profile: filepath.Join(thirdPartyBase, "configLibrary", claudeDesktopProfileID+".json"),
}, nil
}
func claudeDesktopWindowsConfigPaths() (claudeDesktopPaths, error) {
normalBase, err := claudeDesktopProfileDir(true)
if err != nil {
return claudeDesktopPaths{}, err
}
thirdPartyBase, err := claudeDesktopProfileDir(false)
if err != nil {
return claudeDesktopPaths{}, err
}
return claudeDesktopPaths{
normalConfig: filepath.Join(normalBase, "claude_desktop_config.json"),
desktopConfig: filepath.Join(thirdPartyBase, "claude_desktop_config.json"),
meta: filepath.Join(thirdPartyBase, "configLibrary", "_meta.json"),
profile: filepath.Join(thirdPartyBase, "configLibrary", claudeDesktopProfileID+".json"),
}, nil
}
func claudeDesktopProfileDir(normal bool) (string, error) {
candidates := claudeDesktopProfileDirCandidates(normal)
if len(candidates) == 0 {
return "", fmt.Errorf("Claude Desktop profile directory could not be resolved")
}
for _, candidate := range candidates {
if _, err := claudeDesktopStat(candidate); err == nil {
return candidate, nil
}
}
return candidates[0], nil
}
func claudeDesktopProfileDirCandidates(normal bool) []string {
if claudeDesktopGOOS != "windows" {
return nil
}
normalRoots, thirdPartyRoots, err := claudeDesktopWindowsProfileRoots()
if err != nil {
return nil
}
if normal {
return normalRoots
}
return thirdPartyRoots
}
func claudeDesktopDarwinProfileRoots() ([]string, []string, error) {
home, err := claudeDesktopUserHome()
if err != nil {
return nil, nil, err
}
base := filepath.Join(home, "Library", "Application Support")
return []string{filepath.Join(base, "Claude")}, []string{filepath.Join(base, "Claude-3p")}, nil
}
func claudeDesktopWindowsProfileRoots() ([]string, []string, error) {
local, err := claudeDesktopLocalAppData()
if err != nil {
return nil, nil, err
}
normalRoots := []string{
filepath.Join(local, "Claude"),
filepath.Join(local, "Claude Nest"),
}
thirdPartyRoots := []string{
filepath.Join(local, "Claude-3p"),
filepath.Join(local, "Claude Nest-3p"),
}
return normalRoots, thirdPartyRoots, nil
}
func claudeDesktopTargetPaths() (claudeDesktopTargets, error) {
var (
normalRoots []string
thirdPartyRoots []string
err error
)
switch claudeDesktopGOOS {
case "darwin":
normalRoots, thirdPartyRoots, err = claudeDesktopDarwinProfileRoots()
case "windows":
normalRoots, thirdPartyRoots, err = claudeDesktopWindowsProfileRoots()
default:
err = claudeDesktopSupported()
}
if err != nil {
return claudeDesktopTargets{}, err
}
return newClaudeDesktopTargets(normalRoots, thirdPartyRoots), nil
}
func newClaudeDesktopTargets(normalRoots, thirdPartyRoots []string) claudeDesktopTargets {
targets := claudeDesktopTargets{}
for _, root := range claudeDesktopDedupePaths(normalRoots) {
targets.normalConfigs = append(targets.normalConfigs, filepath.Join(root, "claude_desktop_config.json"))
}
for _, root := range claudeDesktopDedupePaths(thirdPartyRoots) {
targets.thirdPartyProfiles = append(targets.thirdPartyProfiles, claudeDesktopThirdPartyPaths{
desktopConfig: filepath.Join(root, "claude_desktop_config.json"),
meta: filepath.Join(root, "configLibrary", "_meta.json"),
profile: filepath.Join(root, "configLibrary", claudeDesktopProfileID+".json"),
})
}
return targets
}
func claudeDesktopTargetProfilePaths(targets claudeDesktopTargets) []string {
paths := make([]string, 0, len(targets.thirdPartyProfiles))
for _, target := range targets.thirdPartyProfiles {
paths = append(paths, target.profile)
}
return paths
}
func claudeDesktopLocalAppData() (string, error) {
if local := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); local != "" {
return local, nil
}
if home := strings.TrimSpace(os.Getenv("USERPROFILE")); home != "" {
return filepath.Join(home, "AppData", "Local"), nil
}
home, err := claudeDesktopUserHome()
if err != nil {
return "", err
}
return filepath.Join(home, "AppData", "Local"), nil
}
type claudeDesktopAPIKeySource int
const (
claudeDesktopAPIKeySourceNone claudeDesktopAPIKeySource = iota
claudeDesktopAPIKeySourceEnv
claudeDesktopAPIKeySourceProfile
)
func claudeDesktopValidatedAPIKey(ctx context.Context, profilePaths []string) (string, error) {
key, source, err := claudeDesktopAPIKey(profilePaths)
if err != nil {
return "", err
}
if err := claudeDesktopValidateAPIKey(ctx, key); err == nil {
return key, nil
} else if source != claudeDesktopAPIKeySourceProfile || !canPromptClaudeDesktopAPIKey() {
return "", err
}
return promptValidClaudeDesktopAPIKey(ctx)
}
func claudeDesktopAPIKey(profilePaths []string) (string, claudeDesktopAPIKeySource, error) {
if key := strings.TrimSpace(os.Getenv("OLLAMA_API_KEY")); key != "" {
return key, claudeDesktopAPIKeySourceEnv, nil
}
for _, profilePath := range profilePaths {
if key := readClaudeDesktopGatewayAPIKey(profilePath); key != "" {
return key, claudeDesktopAPIKeySourceProfile, nil
}
}
key, err := promptClaudeDesktopAPIKeyValue()
return key, claudeDesktopAPIKeySourceNone, err
}
func canPromptClaudeDesktopAPIKey() bool {
return isInteractiveSession() && !currentLaunchConfirmPolicy.requireYesMessage
}
func promptValidClaudeDesktopAPIKey(ctx context.Context) (string, error) {
key, err := promptClaudeDesktopAPIKeyValue()
if err != nil {
return "", err
}
if err := claudeDesktopValidateAPIKey(ctx, key); err != nil {
return "", err
}
return key, nil
}
func promptClaudeDesktopAPIKeyValue() (string, error) {
if !canPromptClaudeDesktopAPIKey() {
return "", missingClaudeDesktopAPIKeyError()
}
key, err := claudeDesktopPromptAPIKey()
if err != nil {
return "", err
}
key = strings.TrimSpace(key)
if key == "" {
return "", missingClaudeDesktopAPIKeyError()
}
return key, nil
}
func missingClaudeDesktopAPIKeyError() error {
return fmt.Errorf("OLLAMA_API_KEY is required for Claude Desktop. Create an API key at %s, then re-run with OLLAMA_API_KEY set", claudeDesktopAPIKeyURL)
}
func promptClaudeDesktopAPIKey() (string, error) {
fmt.Fprint(os.Stderr, claudeDesktopAPIKeyPrompt())
key, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return "", err
}
return string(key), nil
}
func claudeDesktopAPIKeyPrompt() string {
return fmt.Sprintf("Create an Ollama API key at %s\nEnter Ollama API key (input hidden): ", claudeDesktopAPIKeyURL)
}
func readClaudeDesktopGatewayAPIKey(path string) string {
cfg, err := readClaudeDesktopJSON(path)
if err != nil {
return ""
}
key, _ := cfg["inferenceGatewayApiKey"].(string)
return strings.TrimSpace(key)
}
func validateClaudeDesktopAPIKey(ctx context.Context, key string) error {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
if claudeDesktopAPIKeyHasInvalidHeaderChars(key) {
return claudeDesktopAPIKeyVerificationError()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, claudeDesktopGatewayBaseURL+"/v1/models", nil)
if err != nil {
return claudeDesktopAPIKeyVerificationError()
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Accept", "application/json")
resp, err := claudeDesktopHTTPClient.Do(req)
if err != nil {
return claudeDesktopAPIKeyVerificationError()
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
switch {
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
return fmt.Errorf("Ollama API key was rejected; create a valid key at %s", claudeDesktopAPIKeyURL)
case resp.StatusCode >= 200 && resp.StatusCode < 300:
return nil
default:
return fmt.Errorf("could not verify Ollama API key; ollama.com returned status %d, try again later", resp.StatusCode)
}
}
func claudeDesktopAPIKeyHasInvalidHeaderChars(key string) bool {
return strings.ContainsFunc(key, func(r rune) bool {
return r < ' ' || r == 0x7f
})
}
func claudeDesktopAPIKeyVerificationError() error {
return fmt.Errorf("could not verify Ollama API key; copy a key from %s and try again", claudeDesktopAPIKeyURL)
}
func writeClaudeDesktopDeploymentMode(path, mode string) error {
cfg, err := readClaudeDesktopJSONAllowMissing(path)
if err != nil {
return fmt.Errorf("parse Claude Desktop config: %w", err)
}
cfg["deploymentMode"] = mode
return writeClaudeDesktopJSON(path, cfg)
}
func writeClaudeDesktopMeta(path, id, name string) error {
meta, err := readClaudeDesktopJSONAllowMissing(path)
if err != nil {
return fmt.Errorf("parse Claude Desktop config metadata: %w", err)
}
meta["appliedId"] = id
entries := make([]any, 0)
for _, entry := range claudeDesktopAnySlice(meta["entries"]) {
entryMap, _ := entry.(map[string]any)
if entryMap == nil {
entries = append(entries, entry)
continue
}
if entryID, _ := entryMap["id"].(string); entryID == id {
continue
}
entries = append(entries, entryMap)
}
entries = append(entries, map[string]any{
"id": id,
"name": name,
})
meta["entries"] = entries
return writeClaudeDesktopJSON(path, meta)
}
func writeClaudeDesktopGatewayProfile(path string, apiKey string, forceChooser bool) error {
cfg, err := readClaudeDesktopJSONAllowMissing(path)
if err != nil {
return fmt.Errorf("parse Claude Desktop Ollama profile: %w", err)
}
cfg["inferenceProvider"] = "gateway"
cfg["inferenceGatewayBaseUrl"] = claudeDesktopGatewayBaseURL
cfg["inferenceGatewayApiKey"] = apiKey
cfg["inferenceGatewayAuthScheme"] = "bearer"
delete(cfg, "inferenceModels")
cfg["disableDeploymentModeChooser"] = forceChooser
return writeClaudeDesktopJSON(path, cfg)
}
func restoreClaudeDesktopMeta(path string) error {
meta, err := readClaudeDesktopJSONAllowMissing(path)
if err != nil {
return fmt.Errorf("parse Claude Desktop config metadata: %w", err)
}
if len(meta) == 0 {
return nil
}
changed := false
if appliedID, _ := meta["appliedId"].(string); appliedID == claudeDesktopProfileID {
delete(meta, "appliedId")
changed = true
}
entries := claudeDesktopAnySlice(meta["entries"])
if entries != nil {
filtered := make([]any, 0, len(entries))
for _, entry := range entries {
entryMap, _ := entry.(map[string]any)
if entryID, _ := entryMap["id"].(string); entryID == claudeDesktopProfileID {
changed = true
continue
}
filtered = append(filtered, entry)
}
meta["entries"] = filtered
}
if !changed {
return nil
}
return writeClaudeDesktopJSON(path, meta)
}
func restoreClaudeDesktopOllamaProfile(path string) error {
cfg, err := readClaudeDesktopJSONAllowMissing(path)
if err != nil {
return fmt.Errorf("parse Claude Desktop Ollama profile: %w", err)
}
if len(cfg) == 0 {
return nil
}
cfg["disableDeploymentModeChooser"] = false
delete(cfg, "inferenceProvider")
delete(cfg, "inferenceGatewayBaseUrl")
delete(cfg, "inferenceGatewayAuthScheme")
delete(cfg, "inferenceModels")
return writeClaudeDesktopJSON(path, cfg)
}
func readClaudeDesktopAppliedID(path string) string {
meta, err := readClaudeDesktopJSON(path)
if err != nil {
return ""
}
applied, _ := meta["appliedId"].(string)
return applied
}
func readClaudeDesktopDeploymentMode(path string) string {
cfg, err := readClaudeDesktopJSON(path)
if err != nil {
return ""
}
mode, _ := cfg["deploymentMode"].(string)
return mode
}
func claudeDesktopTargetsConfigured(targets claudeDesktopTargets) bool {
if len(targets.normalConfigs) == 0 || len(targets.thirdPartyProfiles) == 0 {
return false
}
for _, path := range targets.normalConfigs {
if readClaudeDesktopDeploymentMode(path) != "3p" {
return false
}
}
for _, target := range targets.thirdPartyProfiles {
if readClaudeDesktopDeploymentMode(target.desktopConfig) != "3p" {
return false
}
if !claudeDesktopThirdPartyProfileConfigured(target) {
return false
}
}
return true
}
func claudeDesktopThirdPartyProfileConfigured(target claudeDesktopThirdPartyPaths) bool {
if readClaudeDesktopAppliedID(target.meta) != claudeDesktopProfileID {
return false
}
cfg, err := readClaudeDesktopJSON(target.profile)
if err != nil {
return false
}
if s, _ := cfg["inferenceProvider"].(string); s != "gateway" {
return false
}
if s, _ := cfg["inferenceGatewayBaseUrl"].(string); strings.TrimRight(s, "/") != claudeDesktopGatewayBaseURL {
return false
}
if s, _ := cfg["inferenceGatewayApiKey"].(string); strings.TrimSpace(s) == "" {
return false
}
return true
}
func readClaudeDesktopJSONAllowMissing(path string) (map[string]any, error) {
cfg, err := readClaudeDesktopJSON(path)
if errors.Is(err, os.ErrNotExist) {
return map[string]any{}, nil
}
return cfg, err
}
func readClaudeDesktopJSON(path string) (map[string]any, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg map[string]any
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
if cfg == nil {
cfg = map[string]any{}
}
return cfg, nil
}
func writeClaudeDesktopJSON(path string, cfg any) error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return fileutil.WriteWithBackup(path, data)
}
func claudeDesktopAnySlice(value any) []any {
switch v := value.(type) {
case []any:
return v
case nil:
return nil
default:
return nil
}
}
func claudeDesktopLaunchOrRestart(prompt string) error {
if !claudeDesktopIsRunning() {
return claudeDesktopOpenApp()
}
restartAppPath := ""
if claudeDesktopGOOS == "windows" {
restartAppPath = claudeDesktopRunningAppPath()
}
restart, err := ConfirmPrompt(prompt)
if err != nil {
return err
}
if !restart {
fmt.Fprintln(os.Stderr, "\nQuit and reopen Claude Desktop when you're ready for the profile change to take effect.")
return nil
}
if err := claudeDesktopQuitApp(); err != nil {
return fmt.Errorf("quit Claude Desktop: %w", err)
}
if err := waitForClaudeDesktopExit(30 * time.Second); err != nil {
return err
}
if restartAppPath != "" {
return claudeDesktopOpenAppPath(restartAppPath)
}
return claudeDesktopOpenApp()
}
func waitForClaudeDesktopExit(timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if !claudeDesktopIsRunning() {
return nil
}
claudeDesktopSleep(200 * time.Millisecond)
}
return fmt.Errorf("Claude Desktop did not quit; quit it manually and re-run the command")
}
func defaultClaudeDesktopIsRunning() bool {
switch claudeDesktopGOOS {
case "darwin":
out, err := exec.Command("pgrep", "-f", "Claude.app/Contents/MacOS/Claude").Output()
return err == nil && strings.TrimSpace(string(out)) != ""
case "windows":
out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", `(Get-Process claude -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1).Id`).Output()
return err == nil && strings.TrimSpace(string(out)) != ""
default:
return false
}
}
func defaultClaudeDesktopOpenApp() error {
switch claudeDesktopGOOS {
case "windows":
if path := claudeDesktopAppPath(); path != "" {
return claudeDesktopOpenAppPath(path)
}
if path := claudeDesktopRunningAppPath(); path != "" {
return claudeDesktopOpenAppPath(path)
}
return fmt.Errorf("Claude Desktop executable was not found; open Claude Desktop manually once and re-run 'ollama launch claude-desktop --restore'")
case "darwin":
return openClaudeDesktopDarwin()
default:
return claudeDesktopSupported()
}
}
func defaultClaudeDesktopOpenAppPath(path string) error {
switch claudeDesktopGOOS {
case "windows":
return exec.Command("powershell.exe", "-NoProfile", "-Command", "Start-Process -FilePath "+quotePowerShellString(path)).Run()
case "darwin":
return openClaudeDesktopDarwin()
default:
return claudeDesktopSupported()
}
}
func openClaudeDesktopDarwin() error {
cmd := exec.Command("open", "-a", "Claude")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func defaultClaudeDesktopRunningAppPath() string {
if claudeDesktopGOOS != "windows" {
return ""
}
script := `(Get-Process claude -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 -and $_.Path } | Select-Object -First 1 -ExpandProperty Path)`
out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func defaultClaudeDesktopQuitApp() error {
if claudeDesktopGOOS == "windows" {
script := `Get-Process claude -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | ForEach-Object { [void]$_.CloseMainWindow() }`
return exec.Command("powershell.exe", "-NoProfile", "-Command", script).Run()
}
return exec.Command("osascript", "-e", `tell application "Claude" to quit`).Run()
}
func quotePowerShellString(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}

View File

@@ -1,946 +0,0 @@
package launch
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func withClaudeDesktopPlatform(t *testing.T, goos string) {
t.Helper()
old := claudeDesktopGOOS
claudeDesktopGOOS = goos
t.Cleanup(func() {
claudeDesktopGOOS = old
})
}
func withClaudeDesktopValidation(t *testing.T, fn func(context.Context, string) error) {
t.Helper()
old := claudeDesktopValidateAPIKey
claudeDesktopValidateAPIKey = fn
t.Cleanup(func() {
claudeDesktopValidateAPIKey = old
})
}
func withClaudeDesktopPrompt(t *testing.T, fn func() (string, error)) {
t.Helper()
old := claudeDesktopPromptAPIKey
claudeDesktopPromptAPIKey = fn
t.Cleanup(func() {
claudeDesktopPromptAPIKey = old
})
}
func withClaudeDesktopProcessHooks(t *testing.T, running func() bool, quit func() error, open func() error) {
t.Helper()
oldRunning := claudeDesktopIsRunning
oldQuit := claudeDesktopQuitApp
oldOpen := claudeDesktopOpenApp
oldOpenPath := claudeDesktopOpenAppPath
oldRunningPath := claudeDesktopRunningAppPath
oldSleep := claudeDesktopSleep
claudeDesktopIsRunning = running
claudeDesktopQuitApp = quit
claudeDesktopOpenApp = open
claudeDesktopOpenAppPath = oldOpenPath
claudeDesktopRunningAppPath = oldRunningPath
claudeDesktopSleep = func(time.Duration) {}
t.Cleanup(func() {
claudeDesktopIsRunning = oldRunning
claudeDesktopQuitApp = oldQuit
claudeDesktopOpenApp = oldOpen
claudeDesktopOpenAppPath = oldOpenPath
claudeDesktopRunningAppPath = oldRunningPath
claudeDesktopSleep = oldSleep
})
}
func claudeDesktopReadJSON(t *testing.T, path string) map[string]any {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var cfg map[string]any
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
return cfg
}
func TestClaudeDesktopIntegration(t *testing.T) {
c := &ClaudeDesktop{}
t.Run("implements Runner", func(t *testing.T) {
var _ Runner = c
})
t.Run("implements managed autodiscovery integration", func(t *testing.T) {
var _ ManagedAutodiscoveryIntegration = c
})
t.Run("does not use local Ollama Cloud auth gate", func(t *testing.T) {
if _, ok := any(c).(ManagedAutodiscoveryCloudIntegration); ok {
t.Fatal("Claude Desktop should validate OLLAMA_API_KEY directly instead of requiring local Ollama Cloud sign-in")
}
})
t.Run("implements restore", func(t *testing.T) {
var _ RestorableIntegration = c
})
t.Run("has restore hint", func(t *testing.T) {
var _ RestoreHintIntegration = c
if !strings.Contains(c.RestoreHint(), "--restore") {
t.Fatalf("expected restore hint to mention --restore, got %q", c.RestoreHint())
}
if strings.Contains(c.RestoreHint(), "Tip:") {
t.Fatalf("restore hint should not use Tip wording, got %q", c.RestoreHint())
}
})
t.Run("has success messages", func(t *testing.T) {
var _ ConfigurationSuccessIntegration = c
var _ RestoreSuccessIntegration = c
if got := c.ConfigurationSuccessMessage(); got != "Claude Desktop profile changed to Ollama Cloud.\nTo restore the usual Claude profile, run: ollama launch claude-desktop --restore" {
t.Fatalf("configuration success message = %q", got)
}
if got := c.RestoreSuccessMessage(); got != "Claude Desktop restored to the usual Claude profile." {
t.Fatalf("restore success message = %q", got)
}
})
t.Run("skips local model readiness", func(t *testing.T) {
var _ ManagedModelReadinessSkipper = c
if !c.SkipModelReadiness() {
t.Fatal("expected Claude Desktop to skip local model readiness")
}
})
}
func TestLaunchIntegration_ClaudeDesktopLaunchReturnsUnsupported(t *testing.T) {
for _, name := range []string{"claude-desktop", "claude-app"} {
t.Run(name, func(t *testing.T) {
err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: name})
if err == nil {
t.Fatal("expected Claude Desktop launch to fail")
}
if !strings.Contains(err.Error(), "Claude Desktop is no longer supported") {
t.Fatalf("expected unsupported guidance, got %v", err)
}
if !strings.Contains(err.Error(), "ollama launch claude-desktop --restore") {
t.Fatalf("expected restore guidance, got %v", err)
}
})
}
}
func TestLaunchIntegration_ClaudeDesktopRestoreStillWorks(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withClaudeDesktopPlatform(t, "darwin")
withClaudeDesktopProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { return nil })
if err := os.MkdirAll(filepath.Join(tmpDir, "Applications", "Claude.app"), 0o755); err != nil {
t.Fatal(err)
}
paths, err := claudeDesktopConfigPaths()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(paths.profile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paths.meta, []byte(`{"appliedId":"`+claudeDesktopProfileID+`","entries":[{"id":"`+claudeDesktopProfileID+`","name":"Ollama"}]}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paths.profile, []byte(`{"disableDeploymentModeChooser":true,"inferenceGatewayApiKey":"keep","inferenceProvider":"gateway","inferenceGatewayBaseUrl":"https://ollama.com","inferenceGatewayAuthScheme":"bearer"}`), 0o644); err != nil {
t.Fatal(err)
}
stderr := captureStderr(t, func() {
err = LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "claude-desktop", Restore: true})
})
if err != nil {
t.Fatalf("LaunchIntegration restore returned error: %v", err)
}
if !strings.Contains(stderr, claudeDesktopRestoredMessage) {
t.Fatalf("expected restore success message, got stderr: %q", stderr)
}
desktopConfig := claudeDesktopReadJSON(t, paths.desktopConfig)
if desktopConfig["deploymentMode"] != "1p" {
t.Fatalf("deploymentMode = %v, want 1p", desktopConfig["deploymentMode"])
}
}
func TestClaudeDesktopConfigureWritesOllamaCloudProfile(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withClaudeDesktopPlatform(t, "darwin")
t.Setenv("OLLAMA_API_KEY", "test-api-key")
var validatedKey string
withClaudeDesktopValidation(t, func(_ context.Context, key string) error {
validatedKey = key
return nil
})
paths, err := claudeDesktopConfigPaths()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(paths.desktopConfig), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(paths.meta), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paths.desktopConfig, []byte(`{"existing":true}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paths.meta, []byte(`{"entries":[{"id":"custom","name":"Custom"}]}`), 0o644); err != nil {
t.Fatal(err)
}
if err := (&ClaudeDesktop{}).ConfigureAutodiscovery(); err != nil {
t.Fatalf("Configure returned error: %v", err)
}
if validatedKey != "test-api-key" {
t.Fatalf("validated key = %q, want test API key", validatedKey)
}
desktopConfig := claudeDesktopReadJSON(t, paths.desktopConfig)
if desktopConfig["existing"] != true {
t.Fatalf("existing desktop config key was not preserved: %v", desktopConfig)
}
if desktopConfig["deploymentMode"] != "3p" {
t.Fatalf("deploymentMode = %v, want 3p", desktopConfig["deploymentMode"])
}
normalConfig := claudeDesktopReadJSON(t, paths.normalConfig)
if normalConfig["deploymentMode"] != "3p" {
t.Fatalf("normal deploymentMode = %v, want 3p", normalConfig["deploymentMode"])
}
meta := claudeDesktopReadJSON(t, paths.meta)
if meta["appliedId"] != claudeDesktopProfileID {
t.Fatalf("appliedId = %v, want %s", meta["appliedId"], claudeDesktopProfileID)
}
entries, _ := meta["entries"].([]any)
if len(entries) != 2 {
t.Fatalf("entries len = %d, want 2: %v", len(entries), entries)
}
profile := claudeDesktopReadJSON(t, paths.profile)
if profile["inferenceProvider"] != "gateway" {
t.Fatalf("inferenceProvider = %v, want gateway", profile["inferenceProvider"])
}
if profile["inferenceGatewayBaseUrl"] != claudeDesktopGatewayBaseURL {
t.Fatalf("base URL = %v, want %s", profile["inferenceGatewayBaseUrl"], claudeDesktopGatewayBaseURL)
}
if profile["inferenceGatewayApiKey"] != "test-api-key" {
t.Fatal("expected configured API key to be written")
}
if profile["inferenceGatewayAuthScheme"] != "bearer" {
t.Fatalf("auth scheme = %v, want bearer", profile["inferenceGatewayAuthScheme"])
}
if profile["disableDeploymentModeChooser"] != true {
t.Fatalf("disableDeploymentModeChooser = %v, want true", profile["disableDeploymentModeChooser"])
}
if _, ok := profile["inferenceModels"]; ok {
t.Fatalf("inferenceModels should be omitted so Claude can discover models, got %v", profile["inferenceModels"])
}
}
func TestClaudeDesktopConfigureAutodiscoveryRemovesExistingModelCatalog(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withClaudeDesktopPlatform(t, "darwin")
t.Setenv("OLLAMA_API_KEY", "test-api-key")
withClaudeDesktopValidation(t, func(context.Context, string) error { return nil })
paths, err := claudeDesktopConfigPaths()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(paths.profile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paths.profile, []byte(`{"inferenceModels":["qwen3.5"],"inferenceGatewayApiKey":"old"}`), 0o644); err != nil {
t.Fatal(err)
}
if err := (&ClaudeDesktop{}).ConfigureAutodiscovery(); err != nil {
t.Fatalf("ConfigureAutodiscovery returned error: %v", err)
}
profile := claudeDesktopReadJSON(t, paths.profile)
if _, ok := profile["inferenceModels"]; ok {
t.Fatalf("inferenceModels should be removed, got %v", profile["inferenceModels"])
}
if profile["inferenceGatewayApiKey"] != "test-api-key" {
t.Fatal("expected env API key to replace the old key")
}
}
func TestClaudeDesktopWindowsConfigPathsUseLocalAppData(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withClaudeDesktopPlatform(t, "windows")
t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "LocalAppData"))
paths, err := claudeDesktopConfigPaths()
if err != nil {
t.Fatal(err)
}
if want := filepath.Join(tmpDir, "LocalAppData", "Claude-3p", "claude_desktop_config.json"); paths.desktopConfig != want {
t.Fatalf("desktop config = %q, want %q", paths.desktopConfig, want)
}
if want := filepath.Join(tmpDir, "LocalAppData", "Claude", "claude_desktop_config.json"); paths.normalConfig != want {
t.Fatalf("normal config = %q, want %q", paths.normalConfig, want)
}
}
func TestClaudeDesktopWindowsConfigPathsFallbackToNestProfile(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withClaudeDesktopPlatform(t, "windows")
local := filepath.Join(tmpDir, "LocalAppData")
t.Setenv("LOCALAPPDATA", local)
if err := os.MkdirAll(filepath.Join(local, "Claude Nest-3p"), 0o755); err != nil {
t.Fatal(err)
}
paths, err := claudeDesktopConfigPaths()
if err != nil {
t.Fatal(err)
}
if want := filepath.Join(local, "Claude Nest-3p", "claude_desktop_config.json"); paths.desktopConfig != want {
t.Fatalf("desktop config = %q, want %q", paths.desktopConfig, want)
}
}
func TestClaudeDesktopAutodiscoveryConfiguredOnWindows(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withClaudeDesktopPlatform(t, "windows")
t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "LocalAppData"))
t.Setenv("OLLAMA_API_KEY", "test-api-key")
withClaudeDesktopValidation(t, func(context.Context, string) error { return nil })
c := &ClaudeDesktop{}
if err := c.ConfigureAutodiscovery(); err != nil {
t.Fatalf("Configure returned error: %v", err)
}
if !c.AutodiscoveryConfigured() {
t.Fatal("expected Claude Desktop autodiscovery config to be detected on Windows")
}
}
func TestClaudeDesktopConfigureAutodiscoveryTouchesAllWindowsProfileCandidates(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withClaudeDesktopPlatform(t, "windows")
local := filepath.Join(tmpDir, "LocalAppData")
t.Setenv("LOCALAPPDATA", local)
t.Setenv("OLLAMA_API_KEY", "test-api-key")
withClaudeDesktopValidation(t, func(context.Context, string) error { return nil })
targets, err := claudeDesktopTargetPaths()
if err != nil {
t.Fatal(err)
}
if len(targets.normalConfigs) != 2 {
t.Fatalf("normal config target count = %d, want 2", len(targets.normalConfigs))
}
if len(targets.thirdPartyProfiles) != 2 {
t.Fatalf("third-party target count = %d, want 2", len(targets.thirdPartyProfiles))
}
c := &ClaudeDesktop{}
if err := c.ConfigureAutodiscovery(); err != nil {
t.Fatalf("ConfigureAutodiscovery returned error: %v", err)
}
for _, path := range targets.normalConfigs {
cfg := claudeDesktopReadJSON(t, path)
if cfg["deploymentMode"] != "3p" {
t.Fatalf("%s deploymentMode = %v, want 3p", path, cfg["deploymentMode"])
}
}
for _, target := range targets.thirdPartyProfiles {
cfg := claudeDesktopReadJSON(t, target.desktopConfig)
if cfg["deploymentMode"] != "3p" {
t.Fatalf("%s deploymentMode = %v, want 3p", target.desktopConfig, cfg["deploymentMode"])
}
meta := claudeDesktopReadJSON(t, target.meta)
if meta["appliedId"] != claudeDesktopProfileID {
t.Fatalf("%s appliedId = %v, want %s", target.meta, meta["appliedId"], claudeDesktopProfileID)
}
profile := claudeDesktopReadJSON(t, target.profile)
if profile["inferenceProvider"] != "gateway" {
t.Fatalf("%s inferenceProvider = %v, want gateway", target.profile, profile["inferenceProvider"])
}
if profile["inferenceGatewayBaseUrl"] != claudeDesktopGatewayBaseURL {
t.Fatalf("%s base URL = %v, want %s", target.profile, profile["inferenceGatewayBaseUrl"], claudeDesktopGatewayBaseURL)
}
if profile["inferenceGatewayApiKey"] != "test-api-key" {
t.Fatalf("%s should contain the configured API key", target.profile)
}
if _, ok := profile["inferenceModels"]; ok {
t.Fatalf("%s inferenceModels should be omitted, got %v", target.profile, profile["inferenceModels"])
}
}
if !c.AutodiscoveryConfigured() {
t.Fatal("expected all Windows profile candidates to be considered configured")
}
if err := writeClaudeDesktopDeploymentMode(targets.thirdPartyProfiles[1].desktopConfig, "1p"); err != nil {
t.Fatal(err)
}
if c.AutodiscoveryConfigured() {
t.Fatal("expected a stale Windows candidate to force reconfiguration")
}
}
func TestClaudeDesktopInstalledOnWindowsRecognizesLocalProfileDir(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withClaudeDesktopPlatform(t, "windows")
local := filepath.Join(tmpDir, "LocalAppData")
t.Setenv("LOCALAPPDATA", local)
withClaudeDesktopProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { return nil })
if err := os.MkdirAll(filepath.Join(local, "Claude-3p"), 0o755); err != nil {
t.Fatal(err)
}
if !claudeDesktopInstalled() {
t.Fatal("expected Claude Desktop to be installed when the Windows profile directory exists")
}
}
func TestClaudeDesktopWindowsAppPathFindsAnthropicClaudeInstall(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withClaudeDesktopPlatform(t, "windows")
local := filepath.Join(tmpDir, "LocalAppData")
t.Setenv("LOCALAPPDATA", local)
want := filepath.Join(local, "AnthropicClaude", "app-1.2.3", "Claude.exe")
if err := os.MkdirAll(filepath.Dir(want), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(want, []byte(""), 0o755); err != nil {
t.Fatal(err)
}
if got := claudeDesktopAppPath(); got != want {
t.Fatalf("claudeDesktopAppPath() = %q, want %q", got, want)
}
}
func TestWaitForClaudeDesktopExitUsesRunningHook(t *testing.T) {
withClaudeDesktopPlatform(t, "windows")
runningChecks := 0
withClaudeDesktopProcessHooks(t,
func() bool {
runningChecks++
return runningChecks == 1
},
func() error { return nil },
func() error { return nil },
)
if err := waitForClaudeDesktopExit(time.Second); err != nil {
t.Fatalf("waitForClaudeDesktopExit returned error: %v", err)
}
if runningChecks < 2 {
t.Fatalf("expected running hook to be checked until the visible window exits, got %d checks", runningChecks)
}
}
func TestClaudeDesktopWindowsRestoreRestartUsesCapturedDesktopPath(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withClaudeDesktopPlatform(t, "windows")
t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "LocalAppData"))
restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
defer restoreConfirm()
paths, err := claudeDesktopConfigPaths()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(paths.profile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paths.meta, []byte(`{"appliedId":"`+claudeDesktopProfileID+`","entries":[{"id":"`+claudeDesktopProfileID+`","name":"Ollama"}]}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paths.profile, []byte(`{"disableDeploymentModeChooser":true,"inferenceGatewayApiKey":"keep"}`), 0o644); err != nil {
t.Fatal(err)
}
desktopPath := `C:\Users\parth\AppData\Local\AnthropicClaude\app-1.2.3\Claude.exe`
running := true
var openedPath string
withClaudeDesktopProcessHooks(t,
func() bool { return running },
func() error {
running = false
return nil
},
func() error {
t.Fatal("expected restart to open the captured Desktop executable path, not the generic launcher")
return nil
},
)
claudeDesktopRunningAppPath = func() string { return desktopPath }
claudeDesktopOpenAppPath = func(path string) error {
openedPath = path
return nil
}
if err := (&ClaudeDesktop{}).Restore(); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
if openedPath != desktopPath {
t.Fatalf("opened path = %q, want %q", openedPath, desktopPath)
}
}
func TestClaudeDesktopWindowsOpenDoesNotFallBackToClaudeCommand(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withClaudeDesktopPlatform(t, "windows")
t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "LocalAppData"))
oldRunningPath := claudeDesktopRunningAppPath
claudeDesktopRunningAppPath = func() string { return "" }
t.Cleanup(func() { claudeDesktopRunningAppPath = oldRunningPath })
err := defaultClaudeDesktopOpenApp()
if err == nil || !strings.Contains(err.Error(), "Claude Desktop executable was not found") {
t.Fatalf("defaultClaudeDesktopOpenApp error = %v, want executable-not-found error", err)
}
}
func TestClaudeDesktopConfigureStopsBeforeWriteWhenKeyValidationFails(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withClaudeDesktopPlatform(t, "darwin")
t.Setenv("OLLAMA_API_KEY", "bad-key")
withClaudeDesktopValidation(t, func(context.Context, string) error {
return errors.New("invalid key")
})
err := (&ClaudeDesktop{}).ConfigureAutodiscovery()
if err == nil || !strings.Contains(err.Error(), "invalid key") {
t.Fatalf("Configure error = %v, want invalid key", err)
}
paths, err := claudeDesktopConfigPaths()
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(paths.desktopConfig); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("desktop config should not be written after validation failure, stat err = %v", err)
}
}
func TestValidateClaudeDesktopAPIKeyUsesClaudeModelsRoute(t *testing.T) {
oldClient := claudeDesktopHTTPClient
var gotPath, gotAuth string
claudeDesktopHTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
gotPath = req.URL.Path
gotAuth = req.Header.Get("Authorization")
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"data":[]}`)),
Header: make(http.Header),
}, nil
})}
t.Cleanup(func() {
claudeDesktopHTTPClient = oldClient
})
if err := validateClaudeDesktopAPIKey(context.Background(), "test-key"); err != nil {
t.Fatalf("validateClaudeDesktopAPIKey returned error: %v", err)
}
if gotPath != "/v1/models" {
t.Fatalf("validation path = %q, want /v1/models", gotPath)
}
if gotAuth != "Bearer test-key" {
t.Fatalf("Authorization header = %q, want bearer key", gotAuth)
}
}
func TestValidateClaudeDesktopAPIKeyHidesInvalidHeaderDetails(t *testing.T) {
err := validateClaudeDesktopAPIKey(context.Background(), "bad\nkey")
if err == nil {
t.Fatal("expected validation error for key with newline")
}
if !strings.Contains(err.Error(), "could not verify Ollama API key") {
t.Fatalf("validation error = %v, want friendly verification message", err)
}
if strings.Contains(err.Error(), "invalid header") || strings.Contains(err.Error(), "net/http") {
t.Fatalf("validation error should not expose transport internals: %v", err)
}
if !strings.Contains(err.Error(), "https://ollama.com/settings/keys") {
t.Fatalf("validation error should include settings link: %v", err)
}
}
func TestClaudeDesktopConfigureRequiresAPIKey(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withClaudeDesktopPlatform(t, "darwin")
t.Setenv("OLLAMA_API_KEY", "")
withClaudeDesktopValidation(t, func(context.Context, string) error {
t.Fatal("validation should not run without an API key")
return nil
})
err := (&ClaudeDesktop{}).ConfigureAutodiscovery()
if err == nil || !strings.Contains(err.Error(), "OLLAMA_API_KEY is required") {
t.Fatalf("Configure error = %v, want missing key guidance", err)
}
}
func TestClaudeDesktopAPIKeyPromptIncludesSettingsLink(t *testing.T) {
prompt := claudeDesktopAPIKeyPrompt()
if !strings.Contains(prompt, "Enter Ollama API key") {
t.Fatalf("prompt should ask for the API key, got %q", prompt)
}
if !strings.Contains(prompt, "https://ollama.com/settings/keys") {
t.Fatalf("prompt should include API key settings link, got %q", prompt)
}
}
func TestClaudeDesktopConfigureReusesExistingAPIKey(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withClaudeDesktopPlatform(t, "darwin")
t.Setenv("OLLAMA_API_KEY", "")
paths, err := claudeDesktopConfigPaths()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(paths.profile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paths.profile, []byte(`{"inferenceGatewayApiKey":"existing-key"}`), 0o644); err != nil {
t.Fatal(err)
}
var validatedKey string
withClaudeDesktopValidation(t, func(_ context.Context, key string) error {
validatedKey = key
return nil
})
if err := (&ClaudeDesktop{}).ConfigureAutodiscovery(); err != nil {
t.Fatalf("ConfigureAutodiscovery returned error: %v", err)
}
if validatedKey != "existing-key" {
t.Fatalf("validated key = %q, want existing-key", validatedKey)
}
}
func TestClaudeDesktopConfigureReplacesInvalidExistingAPIKey(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withClaudeDesktopPlatform(t, "darwin")
withInteractiveSession(t, true)
t.Setenv("OLLAMA_API_KEY", "")
paths, err := claudeDesktopConfigPaths()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(paths.profile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paths.profile, []byte(`{"inferenceGatewayApiKey":"stale-key"}`), 0o644); err != nil {
t.Fatal(err)
}
var validated []string
withClaudeDesktopValidation(t, func(_ context.Context, key string) error {
validated = append(validated, key)
if key == "stale-key" {
return errors.New("invalid key")
}
return nil
})
withClaudeDesktopPrompt(t, func() (string, error) {
return "replacement-key", nil
})
if err := (&ClaudeDesktop{}).ConfigureAutodiscovery(); err != nil {
t.Fatalf("ConfigureAutodiscovery returned error: %v", err)
}
if diff := compareStrings(validated, []string{"stale-key", "replacement-key"}); diff != "" {
t.Fatalf("validated keys mismatch: %s", diff)
}
profile := claudeDesktopReadJSON(t, paths.profile)
if profile["inferenceGatewayApiKey"] != "replacement-key" {
t.Fatalf("configured key = %v, want replacement-key", profile["inferenceGatewayApiKey"])
}
}
func TestClaudeDesktopConfigureReusesExistingAPIKeyFromAnyWindowsProfile(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withClaudeDesktopPlatform(t, "windows")
local := filepath.Join(tmpDir, "LocalAppData")
t.Setenv("LOCALAPPDATA", local)
t.Setenv("OLLAMA_API_KEY", "")
targets, err := claudeDesktopTargetPaths()
if err != nil {
t.Fatal(err)
}
fallbackProfile := targets.thirdPartyProfiles[1].profile
if err := os.MkdirAll(filepath.Dir(fallbackProfile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(fallbackProfile, []byte(`{"inferenceGatewayApiKey":"fallback-key"}`), 0o644); err != nil {
t.Fatal(err)
}
var validatedKey string
withClaudeDesktopValidation(t, func(_ context.Context, key string) error {
validatedKey = key
return nil
})
if err := (&ClaudeDesktop{}).ConfigureAutodiscovery(); err != nil {
t.Fatalf("ConfigureAutodiscovery returned error: %v", err)
}
if validatedKey != "fallback-key" {
t.Fatalf("validated key = %q, want fallback-key", validatedKey)
}
for _, target := range targets.thirdPartyProfiles {
profile := claudeDesktopReadJSON(t, target.profile)
if profile["inferenceGatewayApiKey"] != "fallback-key" {
t.Fatalf("%s should reuse fallback key, got %v", target.profile, profile["inferenceGatewayApiKey"])
}
}
}
func TestClaudeDesktopAutodiscoveryConfiguredRequiresAppliedOllamaProfile(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withClaudeDesktopPlatform(t, "darwin")
t.Setenv("OLLAMA_API_KEY", "test-api-key")
withClaudeDesktopValidation(t, func(context.Context, string) error { return nil })
c := &ClaudeDesktop{}
if err := c.ConfigureAutodiscovery(); err != nil {
t.Fatalf("Configure returned error: %v", err)
}
if !c.AutodiscoveryConfigured() {
t.Fatal("expected Claude Desktop autodiscovery config to be detected")
}
paths, err := claudeDesktopConfigPaths()
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paths.meta, []byte(`{"appliedId":"custom"}`), 0o644); err != nil {
t.Fatal(err)
}
if c.AutodiscoveryConfigured() {
t.Fatal("expected another applied profile to hide Claude Desktop autodiscovery config")
}
}
func TestClaudeDesktopAutodiscoveryConfiguredRequiresAPIKey(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withClaudeDesktopPlatform(t, "darwin")
t.Setenv("OLLAMA_API_KEY", "test-api-key")
withClaudeDesktopValidation(t, func(context.Context, string) error { return nil })
c := &ClaudeDesktop{}
if err := c.ConfigureAutodiscovery(); err != nil {
t.Fatalf("Configure returned error: %v", err)
}
paths, err := claudeDesktopConfigPaths()
if err != nil {
t.Fatal(err)
}
profile := claudeDesktopReadJSON(t, paths.profile)
delete(profile, "inferenceGatewayApiKey")
data, err := json.Marshal(profile)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paths.profile, data, 0o644); err != nil {
t.Fatal(err)
}
if c.AutodiscoveryConfigured() {
t.Fatal("expected missing gateway API key to force Claude Desktop reconfiguration")
}
}
func TestClaudeDesktopRestoreSwitchesBackToFirstPartyMode(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withClaudeDesktopPlatform(t, "darwin")
withClaudeDesktopProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { return nil })
paths, err := claudeDesktopConfigPaths()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(paths.profile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paths.meta, []byte(`{"appliedId":"`+claudeDesktopProfileID+`","entries":[{"id":"`+claudeDesktopProfileID+`","name":"Ollama"}]}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paths.profile, []byte(`{"disableDeploymentModeChooser":true,"inferenceGatewayApiKey":"keep","inferenceProvider":"gateway","inferenceGatewayBaseUrl":"https://ollama.com","inferenceGatewayAuthScheme":"bearer","inferenceModels":["legacy"]}`), 0o644); err != nil {
t.Fatal(err)
}
if err := (&ClaudeDesktop{}).Restore(); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
desktopConfig := claudeDesktopReadJSON(t, paths.desktopConfig)
if desktopConfig["deploymentMode"] != "1p" {
t.Fatalf("deploymentMode = %v, want 1p", desktopConfig["deploymentMode"])
}
normalConfig := claudeDesktopReadJSON(t, paths.normalConfig)
if normalConfig["deploymentMode"] != "1p" {
t.Fatalf("normal deploymentMode = %v, want 1p", normalConfig["deploymentMode"])
}
profile := claudeDesktopReadJSON(t, paths.profile)
if profile["disableDeploymentModeChooser"] != false {
t.Fatalf("disableDeploymentModeChooser = %v, want false", profile["disableDeploymentModeChooser"])
}
if profile["inferenceGatewayApiKey"] != "keep" {
t.Fatal("restore should leave existing Ollama profile credentials in place")
}
for _, key := range []string{"inferenceProvider", "inferenceGatewayBaseUrl", "inferenceGatewayAuthScheme", "inferenceModels"} {
if _, ok := profile[key]; ok {
t.Fatalf("restore should clear stale %s from the Ollama profile: %v", key, profile)
}
}
meta := claudeDesktopReadJSON(t, paths.meta)
if _, ok := meta["appliedId"]; ok {
t.Fatalf("restore should clear the applied Ollama third-party profile: %v", meta)
}
if (&ClaudeDesktop{}).AutodiscoveryConfigured() {
t.Fatal("restore should leave Claude Desktop autodiscovery unconfigured")
}
}
func TestClaudeDesktopRestoreTouchesAllWindowsProfileCandidates(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withClaudeDesktopPlatform(t, "windows")
local := filepath.Join(tmpDir, "LocalAppData")
t.Setenv("LOCALAPPDATA", local)
withClaudeDesktopProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { return nil })
targets, err := claudeDesktopTargetPaths()
if err != nil {
t.Fatal(err)
}
if len(targets.normalConfigs) != 2 {
t.Fatalf("normal config target count = %d, want 2", len(targets.normalConfigs))
}
if len(targets.thirdPartyProfiles) != 2 {
t.Fatalf("third-party target count = %d, want 2", len(targets.thirdPartyProfiles))
}
for _, target := range targets.thirdPartyProfiles {
if err := os.MkdirAll(filepath.Dir(target.profile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(target.meta, []byte(`{"appliedId":"`+claudeDesktopProfileID+`","entries":[{"id":"`+claudeDesktopProfileID+`","name":"Ollama"}]}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(target.profile, []byte(`{"disableDeploymentModeChooser":true,"inferenceGatewayApiKey":"keep","inferenceProvider":"gateway","inferenceGatewayBaseUrl":"https://ollama.com","inferenceGatewayAuthScheme":"bearer","inferenceModels":["legacy"]}`), 0o644); err != nil {
t.Fatal(err)
}
}
if err := (&ClaudeDesktop{}).Restore(); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
for _, path := range targets.normalConfigs {
cfg := claudeDesktopReadJSON(t, path)
if cfg["deploymentMode"] != "1p" {
t.Fatalf("%s deploymentMode = %v, want 1p", path, cfg["deploymentMode"])
}
}
for _, target := range targets.thirdPartyProfiles {
cfg := claudeDesktopReadJSON(t, target.desktopConfig)
if cfg["deploymentMode"] != "1p" {
t.Fatalf("%s deploymentMode = %v, want 1p", target.desktopConfig, cfg["deploymentMode"])
}
meta := claudeDesktopReadJSON(t, target.meta)
if _, ok := meta["appliedId"]; ok {
t.Fatalf("%s should not keep the Ollama applied profile: %v", target.meta, meta)
}
profile := claudeDesktopReadJSON(t, target.profile)
if profile["disableDeploymentModeChooser"] != false {
t.Fatalf("%s disableDeploymentModeChooser = %v, want false", target.profile, profile["disableDeploymentModeChooser"])
}
if profile["inferenceGatewayApiKey"] != "keep" {
t.Fatalf("%s should preserve gateway API key", target.profile)
}
for _, key := range []string{"inferenceProvider", "inferenceGatewayBaseUrl", "inferenceGatewayAuthScheme", "inferenceModels"} {
if _, ok := profile[key]; ok {
t.Fatalf("%s should clear stale %s: %v", target.profile, key, profile)
}
}
}
}
func TestClaudeDesktopRunReturnsUnsupported(t *testing.T) {
withClaudeDesktopPlatform(t, "darwin")
withClaudeDesktopProcessHooks(t,
func() bool {
t.Fatal("Run should not inspect Claude Desktop process state")
return false
},
func() error {
t.Fatal("Run should not quit Claude Desktop")
return nil
},
func() error {
t.Fatal("Run should not open Claude Desktop")
return nil
},
)
for _, args := range [][]string{nil, {"--foo"}} {
err := (&ClaudeDesktop{}).Run("qwen3.5", nil, args)
if err == nil {
t.Fatal("expected Run to fail")
}
if !strings.Contains(err.Error(), "Claude Desktop is no longer supported") {
t.Fatalf("expected unsupported guidance, got %v", err)
}
if !strings.Contains(err.Error(), "ollama launch claude-desktop --restore") {
t.Fatalf("expected restore guidance, got %v", err)
}
}
}

View File

@@ -16,7 +16,7 @@ type Cline struct{}
func (c *Cline) String() string { return "Cline" }
func (c *Cline) Run(model string, _ []LaunchModel, args []string) error {
func (c *Cline) Run(model string, args []string) error {
if _, err := exec.LookPath("cline"); err != nil {
return fmt.Errorf("cline is not installed, install with: npm install -g cline")
}
@@ -40,7 +40,7 @@ func (c *Cline) Paths() []string {
return nil
}
func (c *Cline) Edit(models []LaunchModel) error {
func (c *Cline) Edit(models []string) error {
if len(models) == 0 {
return nil
}
@@ -66,10 +66,10 @@ func (c *Cline) Edit(models []LaunchModel) error {
baseURL := envconfig.Host().String()
config["ollamaBaseUrl"] = baseURL
config["actModeApiProvider"] = "ollama"
config["actModeOllamaModelId"] = models[0].Name
config["actModeOllamaModelId"] = models[0]
config["actModeOllamaBaseUrl"] = baseURL
config["planModeApiProvider"] = "ollama"
config["planModeOllamaModelId"] = models[0].Name
config["planModeOllamaModelId"] = models[0]
config["planModeOllamaBaseUrl"] = baseURL
config["welcomeViewCompleted"] = true
@@ -78,7 +78,7 @@ func (c *Cline) Edit(models []LaunchModel) error {
if err != nil {
return err
}
return fileutil.WriteWithBackup(configPath, data, "cline")
return fileutil.WriteWithBackup(configPath, data)
}
func (c *Cline) Models() []string {

View File

@@ -43,7 +43,7 @@ func TestClineEdit(t *testing.T) {
t.Run("creates config from scratch", func(t *testing.T) {
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
if err := c.Edit(testLaunchModels("kimi-k2.5:cloud")); err != nil {
if err := c.Edit([]string{"kimi-k2.5:cloud"}); err != nil {
t.Fatal(err)
}
@@ -77,7 +77,7 @@ func TestClineEdit(t *testing.T) {
data, _ := json.Marshal(existing)
os.WriteFile(configPath, data, 0o644)
if err := c.Edit(testLaunchModels("glm-5:cloud")); err != nil {
if err := c.Edit([]string{"glm-5:cloud"}); err != nil {
t.Fatal(err)
}
@@ -93,10 +93,10 @@ func TestClineEdit(t *testing.T) {
t.Run("updates model on re-edit", func(t *testing.T) {
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
if err := c.Edit(testLaunchModels("kimi-k2.5:cloud")); err != nil {
if err := c.Edit([]string{"kimi-k2.5:cloud"}); err != nil {
t.Fatal(err)
}
if err := c.Edit(testLaunchModels("glm-5:cloud")); err != nil {
if err := c.Edit([]string{"glm-5:cloud"}); err != nil {
t.Fatal(err)
}
@@ -124,7 +124,7 @@ func TestClineEdit(t *testing.T) {
t.Run("uses first model as primary", func(t *testing.T) {
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
if err := c.Edit(testLaunchModels("kimi-k2.5:cloud", "glm-5:cloud")); err != nil {
if err := c.Edit([]string{"kimi-k2.5:cloud", "glm-5:cloud"}); err != nil {
t.Fatal(err)
}

View File

@@ -1,17 +1,20 @@
package launch
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/envconfig"
"github.com/ollama/ollama/types/model"
"github.com/pelletier/go-toml/v2"
"golang.org/x/mod/semver"
)
@@ -20,22 +23,10 @@ type Codex struct{}
func (c *Codex) String() string { return "Codex" }
const (
codexProfileName = "ollama-launch"
codexProviderName = "Ollama"
codexFallbackContextWindow = 128_000
const codexProfileName = "ollama-launch"
codexRootProfileKey = "profile"
codexRootModelKey = "model"
codexRootModelProviderKey = "model_provider"
codexRootModelCatalogJSONKey = "model_catalog_json"
)
func (c *Codex) args(model, modelCatalogPath string, extra []string) []string {
func (c *Codex) args(model string, extra []string) []string {
args := []string{"--profile", codexProfileName}
if modelCatalogPath != "" {
args = append(args, "-c", fmt.Sprintf("%s=%q", codexRootModelCatalogJSONKey, modelCatalogPath))
}
if model != "" {
args = append(args, "-m", model)
}
@@ -43,21 +34,16 @@ func (c *Codex) args(model, modelCatalogPath string, extra []string) []string {
return args
}
func (c *Codex) Run(model string, models []LaunchModel, args []string) error {
func (c *Codex) Run(model string, args []string) error {
if err := checkCodexVersion(); err != nil {
return err
}
if err := ensureCodexConfig(model, models); err != nil {
if err := ensureCodexConfig(model); err != nil {
return fmt.Errorf("failed to configure codex: %w", err)
}
catalogPath, err := codexModelCatalogPath()
if err != nil {
return fmt.Errorf("failed to configure codex: %w", err)
}
cmd := exec.Command("codex", c.args(model, catalogPath, args)...)
cmd := exec.Command("codex", c.args(model, args)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -69,518 +55,87 @@ func (c *Codex) Run(model string, models []LaunchModel, args []string) error {
// ensureCodexConfig writes a Codex profile and model catalog so Codex uses the
// local Ollama server and has model metadata available.
func ensureCodexConfig(modelName string, models []LaunchModel) error {
configPath, err := codexConfigPath()
func ensureCodexConfig(modelName string) error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
codexDir := filepath.Dir(configPath)
codexDir := filepath.Join(home, ".codex")
if err := os.MkdirAll(codexDir, 0o755); err != nil {
return err
}
catalogPath := codexModelCatalogPathForConfig(configPath)
if err := writeCodexModelCatalog(catalogPath, codexCatalogModel(modelName, models)); err != nil {
catalogPath := filepath.Join(codexDir, "model.json")
if err := writeCodexModelCatalog(catalogPath, modelName); err != nil {
return err
}
configPath := filepath.Join(codexDir, "config.toml")
return writeCodexProfile(configPath, catalogPath)
}
func codexConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".codex", "config.toml"), nil
}
func codexModelCatalogPath() (string, error) {
configPath, err := codexConfigPath()
if err != nil {
return "", err
}
return codexModelCatalogPathForConfig(configPath), nil
}
func codexModelCatalogPathForConfig(configPath string) string {
return filepath.Join(filepath.Dir(configPath), "model.json")
}
// writeCodexProfile ensures ~/.codex/config.toml has the ollama-launch profile
// and model provider sections with the correct base URL.
func writeCodexProfile(configPath string, modelCatalogPath ...string) error {
opts := codexLaunchProfileOptions{
forceAPIAuth: true,
}
if len(modelCatalogPath) > 0 {
opts.modelCatalogPath = modelCatalogPath[0]
}
return writeCodexLaunchProfile(configPath, opts)
}
type codexLaunchProfileOptions struct {
activate bool
profileName string
forceAPIAuth bool
setRootModelConfig bool
model string
modelCatalogPath string
backupIntegration string
}
func writeCodexLaunchProfile(configPath string, opts codexLaunchProfileOptions) error {
baseURL := codexBaseURL()
profileName := codexLaunchProfileName(opts)
profileHeader := codexProfileHeaderFor(profileName)
providerHeader := codexProviderHeaderFor(profileName)
content, readErr := os.ReadFile(configPath)
text := ""
if readErr == nil {
text = string(content)
} else if !os.IsNotExist(readErr) {
return readErr
}
parsed, err := codexParseConfig(text)
if err != nil {
return err
}
model := strings.TrimSpace(opts.model)
if model == "" {
model = parsed.ProfileString(profileName, codexRootModelKey)
}
modelCatalogPath := strings.TrimSpace(opts.modelCatalogPath)
if modelCatalogPath == "" {
modelCatalogPath = parsed.ProfileString(profileName, codexRootModelCatalogJSONKey)
}
profileLines := []string{}
if model != "" {
profileLines = append(profileLines, fmt.Sprintf("%s = %q", codexRootModelKey, model))
}
profileLines = append(profileLines,
fmt.Sprintf("openai_base_url = %q", baseURL),
fmt.Sprintf("%s = %q", codexRootModelProviderKey, profileName),
)
if opts.forceAPIAuth {
profileLines = append(profileLines, `forced_login_method = "api"`)
}
if modelCatalogPath != "" {
profileLines = append(profileLines, fmt.Sprintf("%s = %q", codexRootModelCatalogJSONKey, modelCatalogPath))
}
func writeCodexProfile(configPath, catalogPath string) error {
baseURL := envconfig.Host().String() + "/v1/"
sections := []struct {
header string
lines []string
}{
{
header: profileHeader,
lines: profileLines,
header: fmt.Sprintf("[profiles.%s]", codexProfileName),
lines: []string{
fmt.Sprintf("openai_base_url = %q", baseURL),
`forced_login_method = "api"`,
fmt.Sprintf("model_provider = %q", codexProfileName),
fmt.Sprintf("model_catalog_json = %q", catalogPath),
},
},
{
header: providerHeader,
header: fmt.Sprintf("[model_providers.%s]", codexProfileName),
lines: []string{
fmt.Sprintf("name = %q", codexProviderName),
`name = "Ollama"`,
fmt.Sprintf("base_url = %q", baseURL),
`wire_api = "responses"`,
},
},
}
if opts.activate {
text = codexSetRootStringValue(text, codexRootProfileKey, profileName)
}
if opts.setRootModelConfig {
if model != "" {
text = codexSetRootStringValue(text, codexRootModelKey, model)
}
text = codexSetRootStringValue(text, codexRootModelProviderKey, profileName)
if modelCatalogPath != "" {
text = codexSetRootStringValue(text, codexRootModelCatalogJSONKey, modelCatalogPath)
}
content, readErr := os.ReadFile(configPath)
text := ""
if readErr == nil {
text = string(content)
}
for _, s := range sections {
text = codexUpsertSection(text, s.header, s.lines)
}
parsed, err = codexParseConfig(text)
if err != nil {
return err
}
if err := codexValidateLaunchProfileText(parsed, profileName, opts, model, modelCatalogPath, baseURL); err != nil {
return err
}
block := strings.Join(append([]string{s.header}, s.lines...), "\n") + "\n"
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return err
}
return fileutil.WriteWithBackup(configPath, []byte(text), opts.backupIntegration)
}
func codexLaunchProfileName(opts codexLaunchProfileOptions) string {
if name := strings.TrimSpace(opts.profileName); name != "" {
return name
}
return codexProfileName
}
func codexBaseURL() string {
return strings.TrimRight(envconfig.ConnectableHost().String(), "/") + "/v1/"
}
func codexProfileHeader() string {
return codexProfileHeaderFor(codexProfileName)
}
func codexProviderHeader() string {
return codexProviderHeaderFor(codexProfileName)
}
func codexProfileHeaderFor(profileName string) string {
return fmt.Sprintf("[profiles.%s]", profileName)
}
func codexProviderHeaderFor(profileName string) string {
return fmt.Sprintf("[model_providers.%s]", profileName)
}
func codexValidateLaunchProfileText(config codexParsedConfig, profileName string, opts codexLaunchProfileOptions, model, modelCatalogPath, baseURL string) error {
for _, check := range []struct {
path []string
want string
}{
{[]string{"profiles", profileName, "openai_base_url"}, baseURL},
{[]string{"profiles", profileName, codexRootModelProviderKey}, profileName},
{[]string{"model_providers", profileName, "name"}, codexProviderName},
{[]string{"model_providers", profileName, "base_url"}, baseURL},
{[]string{"model_providers", profileName, "wire_api"}, "responses"},
} {
if got, ok := config.String(check.path...); !ok || got != check.want {
return fmt.Errorf("generated Codex config missing %s = %q", strings.Join(check.path, "."), check.want)
}
}
if opts.forceAPIAuth {
if got, ok := config.String("profiles", profileName, "forced_login_method"); !ok || got != "api" {
return fmt.Errorf("generated Codex config missing profiles.%s.forced_login_method = %q", profileName, "api")
}
}
if model != "" {
if got, ok := config.String("profiles", profileName, codexRootModelKey); !ok || got != model {
return fmt.Errorf("generated Codex config missing profiles.%s.model = %q", profileName, model)
}
}
if modelCatalogPath != "" {
if got, ok := config.String("profiles", profileName, codexRootModelCatalogJSONKey); !ok || got != modelCatalogPath {
return fmt.Errorf("generated Codex config missing profiles.%s.model_catalog_json = %q", profileName, modelCatalogPath)
}
}
if opts.activate {
if got := config.RootString(codexRootProfileKey); got != profileName {
return fmt.Errorf("generated Codex config missing profile = %q", profileName)
}
}
if opts.setRootModelConfig {
if model != "" {
if got := config.RootString(codexRootModelKey); got != model {
return fmt.Errorf("generated Codex config missing model = %q", model)
}
}
if got := config.RootString(codexRootModelProviderKey); got != profileName {
return fmt.Errorf("generated Codex config missing model_provider = %q", profileName)
}
if modelCatalogPath != "" {
if got := config.RootString(codexRootModelCatalogJSONKey); got != modelCatalogPath {
return fmt.Errorf("generated Codex config missing model_catalog_json = %q", modelCatalogPath)
}
}
}
return nil
}
func codexUpsertSection(text, header string, lines []string) string {
block := strings.Join(append([]string{header}, lines...), "\n") + "\n"
if targetPath, ok := codexTableHeaderPath(header); ok {
if start, end, found := codexSectionRange(text, targetPath); found {
return text[:start] + block + text[end:]
}
}
if text != "" && !strings.HasSuffix(text, "\n") {
text += "\n"
}
if text != "" {
text += "\n"
}
return text + block
}
func codexRemoveSection(text, header string) string {
targetPath, ok := codexTableHeaderPath(header)
if !ok {
return text
}
start, end, found := codexSectionRange(text, targetPath)
if !found {
return text
}
return text[:start] + text[end:]
}
type codexParsedConfig struct {
values map[string]any
}
func (c codexParsedConfig) String(path ...string) (string, bool) {
if len(path) == 0 {
return "", false
}
var current any = c.values
for _, part := range path {
table, ok := current.(map[string]any)
if !ok {
return "", false
}
current, ok = table[part]
if !ok {
return "", false
}
}
value, ok := current.(string)
if !ok {
return "", false
}
return value, true
}
func (c codexParsedConfig) RootString(key string) string {
value, _ := c.RootStringOK(key)
return value
}
func (c codexParsedConfig) RootStringOK(key string) (string, bool) {
return c.String(key)
}
func (c codexParsedConfig) ProfileString(profileName, key string) string {
value, _ := c.String("profiles", profileName, key)
return value
}
func (c codexParsedConfig) ProviderString(profileName, key string) string {
value, _ := c.String("model_providers", profileName, key)
return value
}
func codexRootStringValue(text, key string) string {
config, err := codexParseConfig(text)
if err != nil {
return ""
}
return config.RootString(key)
}
func codexRootStringValueOK(text, key string) (string, bool) {
config, err := codexParseConfig(text)
if err != nil {
return "", false
}
return config.RootStringOK(key)
}
func codexStringValue(text string, path ...string) (string, bool) {
config, err := codexParseConfig(text)
if err != nil {
return "", false
}
return config.String(path...)
}
func codexSectionStringValue(text, header, key string) string {
path, ok := codexTableHeaderPath(header)
if !ok {
return ""
}
value, _ := codexStringValue(text, append(path, key)...)
return value
}
func codexParseConfig(text string) (codexParsedConfig, error) {
values, err := codexParseConfigText(text)
if err != nil {
return codexParsedConfig{}, err
}
return codexParsedConfig{values: values}, nil
}
func codexParseConfigText(text string) (map[string]any, error) {
cfg := map[string]any{}
if strings.TrimSpace(text) == "" {
return cfg, nil
}
if err := toml.Unmarshal([]byte(text), &cfg); err != nil {
return nil, fmt.Errorf("invalid Codex config TOML: %w", err)
}
return cfg, nil
}
func codexValidateConfigText(text string) error {
_, err := codexParseConfig(text)
return err
}
func codexSectionRange(text string, targetPath []string) (int, int, bool) {
lines := strings.SplitAfter(text, "\n")
offset := 0
start := -1
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "#") {
offset += len(line)
continue
}
if start >= 0 {
return start, offset, true
}
if path, ok := codexTableHeaderPath(trimmed); ok && codexSamePath(path, targetPath) {
start = offset
}
offset += len(line)
}
if start >= 0 {
return start, len(text), true
}
return 0, 0, false
}
func codexTableHeaderPath(header string) ([]string, bool) {
trimmed := strings.TrimSpace(header)
if !strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "[[") {
return nil, false
}
const probeKey = "__ollama_launch_probe"
cfg := map[string]any{}
if err := toml.Unmarshal([]byte(trimmed+"\n"+probeKey+" = true\n"), &cfg); err != nil {
return nil, false
}
return codexFindProbePath(cfg, probeKey, nil)
}
func codexFindProbePath(value any, probeKey string, path []string) ([]string, bool) {
table, ok := value.(map[string]any)
if !ok {
return nil, false
}
if probe, ok := table[probeKey].(bool); ok && probe {
return path, true
}
for key, child := range table {
if key == probeKey {
continue
}
if childPath, ok := codexFindProbePath(child, probeKey, append(path, key)); ok {
return childPath, true
}
}
return nil, false
}
func codexSamePath(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func codexSetRootStringValue(text, key, value string) string {
lines := strings.SplitAfter(text, "\n")
rootEnd := len(lines)
for i, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "[") {
rootEnd = i
break
}
}
assignment := fmt.Sprintf("%s = %q", key, value)
for i := range rootEnd {
line := lines[i]
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if codexRootLineHasKey(trimmed, key) {
if strings.HasSuffix(line, "\n") {
lines[i] = assignment + "\n"
if idx := strings.Index(text, s.header); idx >= 0 {
// Replace the existing section up to the next section header.
rest := text[idx+len(s.header):]
if endIdx := strings.Index(rest, "\n["); endIdx >= 0 {
text = text[:idx] + block + rest[endIdx+1:]
} else {
lines[i] = assignment
text = text[:idx] + block
}
return strings.Join(lines, "")
}
}
insert := assignment + "\n"
root := strings.Join(lines[:rootEnd], "")
rest := strings.Join(lines[rootEnd:], "")
if root != "" && !strings.HasSuffix(root, "\n") {
root += "\n"
}
if rest != "" && !strings.HasSuffix(insert, "\n\n") {
insert += "\n"
}
return root + insert + rest
}
func codexRemoveRootValue(text, key string) string {
lines := strings.SplitAfter(text, "\n")
rootEnd := len(lines)
for i, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "[") {
rootEnd = i
break
}
}
out := make([]string, 0, len(lines))
for i, line := range lines {
if i < rootEnd {
trimmed := strings.TrimSpace(line)
if trimmed != "" && !strings.HasPrefix(trimmed, "#") && codexRootLineHasKey(trimmed, key) {
continue
} else {
// Append the section.
if text != "" && !strings.HasSuffix(text, "\n") {
text += "\n"
}
if text != "" {
text += "\n"
}
text += block
}
out = append(out, line)
}
return strings.Join(out, "")
return os.WriteFile(configPath, []byte(text), 0o644)
}
func codexRootLineHasKey(line, key string) bool {
cfg := map[string]any{}
if err := toml.Unmarshal([]byte(line+"\n"), &cfg); err != nil {
return false
}
_, ok := cfg[key]
return ok
}
func codexCatalogModel(modelName string, models []LaunchModel) LaunchModel {
if model, ok := findLaunchModel(models, modelName); ok {
return model.WithCloudLimits()
}
return fallbackLaunchModel(modelName)
}
func writeCodexModelCatalog(catalogPath string, model LaunchModel) error {
entry := buildCodexModelEntry(model)
func writeCodexModelCatalog(catalogPath, modelName string) error {
entry := buildCodexModelEntry(modelName)
catalog := map[string]any{
"models": []any{entry},
@@ -594,31 +149,56 @@ func writeCodexModelCatalog(catalogPath string, model LaunchModel) error {
return os.WriteFile(catalogPath, data, 0o644)
}
func buildCodexModelEntry(launchModel LaunchModel) map[string]any {
modelName := launchModel.Name
contextWindow := codexFallbackContextWindow
func buildCodexModelEntry(modelName string) map[string]any {
contextWindow := 0
hasVision := false
hasThinking := false
systemPrompt := ""
if launchModel.ContextLength > 0 {
contextWindow = launchModel.ContextLength
} else if launchModel.Details.ContextLength > 0 {
contextWindow = launchModel.Details.ContextLength
}
if l, ok := lookupCloudModelLimit(modelName); ok {
contextWindow = l.Context
}
if !isCloudModelName(modelName) && launchModel.Details.Format != "safetensors" {
if ctxLen := envconfig.ContextLength(); ctxLen > 0 {
contextWindow = int(ctxLen)
client := api.NewClient(envconfig.Host(), http.DefaultClient)
resp, err := client.Show(context.Background(), &api.ShowRequest{Model: modelName})
if err == nil {
systemPrompt = resp.System
if slices.Contains(resp.Capabilities, model.CapabilityVision) {
hasVision = true
}
if slices.Contains(resp.Capabilities, model.CapabilityThinking) {
hasThinking = true
}
if !isCloudModelName(modelName) {
if n, ok := modelInfoContextLength(resp.ModelInfo); ok {
contextWindow = n
}
if resp.Details.Format != "safetensors" {
if ctxLen := envconfig.ContextLength(); ctxLen > 0 {
contextWindow = int(ctxLen)
}
if numCtx := parseNumCtx(resp.Parameters); numCtx > 0 {
contextWindow = numCtx
}
}
}
}
modalities := []string{"text"}
if launchModel.HasCapability(model.CapabilityVision) {
if hasVision {
modalities = append(modalities, "image")
}
reasoningLevels := []any{}
if hasThinking {
reasoningLevels = []any{
map[string]any{"effort": "low", "description": "Fast responses with lighter reasoning"},
map[string]any{"effort": "medium", "description": "Balances speed and reasoning depth"},
map[string]any{"effort": "high", "description": "Greater reasoning depth for complex problems"},
}
}
truncationMode := "bytes"
if isCloudModelName(modelName) {
truncationMode = "tokens"
@@ -628,6 +208,7 @@ func buildCodexModelEntry(launchModel LaunchModel) map[string]any {
"slug": modelName,
"display_name": modelName,
"context_window": contextWindow,
"apply_patch_tool_type": "function",
"shell_type": "default",
"visibility": "list",
"supported_in_api": true,
@@ -638,12 +219,25 @@ func buildCodexModelEntry(launchModel LaunchModel) map[string]any {
"support_verbosity": true,
"default_verbosity": "low",
"supports_parallel_tool_calls": false,
"supports_reasoning_summaries": false,
"supported_reasoning_levels": []any{},
"supports_reasoning_summaries": hasThinking,
"supported_reasoning_levels": reasoningLevels,
"experimental_supported_tools": []any{},
}
}
func parseNumCtx(parameters string) int {
for _, line := range strings.Split(parameters, "\n") {
fields := strings.Fields(line)
if len(fields) == 2 && fields[0] == "num_ctx" {
if v, err := strconv.ParseFloat(fields[1], 64); err == nil {
return int(v)
}
}
}
return 0
}
func checkCodexVersion() error {
if _, err := exec.LookPath("codex"); err != nil {
return fmt.Errorf("codex is not installed, install with: npm install -g @openai/codex")

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,21 +3,17 @@ package launch
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/internal/fileutil"
modelpkg "github.com/ollama/ollama/types/model"
)
func TestCodexArgs(t *testing.T) {
c := &Codex{}
catalogPath := filepath.Join("tmp", "model.json")
catalogArg := fmt.Sprintf("%s=%q", codexRootModelCatalogJSONKey, catalogPath)
tests := []struct {
name string
@@ -25,15 +21,15 @@ func TestCodexArgs(t *testing.T) {
args []string
want []string
}{
{"with model", "llama3.2", nil, []string{"--profile", "ollama-launch", "-c", catalogArg, "-m", "llama3.2"}},
{"empty model", "", nil, []string{"--profile", "ollama-launch", "-c", catalogArg}},
{"with model and extra args", "qwen3.5", []string{"-p", "myprofile"}, []string{"--profile", "ollama-launch", "-c", catalogArg, "-m", "qwen3.5", "-p", "myprofile"}},
{"with sandbox flag", "llama3.2", []string{"--sandbox", "workspace-write"}, []string{"--profile", "ollama-launch", "-c", catalogArg, "-m", "llama3.2", "--sandbox", "workspace-write"}},
{"with model", "llama3.2", nil, []string{"--profile", "ollama-launch", "-m", "llama3.2"}},
{"empty model", "", nil, []string{"--profile", "ollama-launch"}},
{"with model and extra args", "qwen3.5", []string{"-p", "myprofile"}, []string{"--profile", "ollama-launch", "-m", "qwen3.5", "-p", "myprofile"}},
{"with sandbox flag", "llama3.2", []string{"--sandbox", "workspace-write"}, []string{"--profile", "ollama-launch", "-m", "llama3.2", "--sandbox", "workspace-write"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := c.args(tt.model, catalogPath, tt.args)
got := c.args(tt.model, tt.args)
if !slices.Equal(got, tt.want) {
t.Errorf("args(%q, %v) = %v, want %v", tt.model, tt.args, got, tt.want)
}
@@ -81,9 +77,6 @@ func TestWriteCodexProfile(t *testing.T) {
if !strings.Contains(content, `name = "Ollama"`) {
t.Error("missing model provider name")
}
if err := codexValidateConfigText(content); err != nil {
t.Fatalf("generated config should be valid TOML: %v\n%s", err, content)
}
})
t.Run("appends profile to existing file without profile", func(t *testing.T) {
@@ -131,150 +124,6 @@ func TestWriteCodexProfile(t *testing.T) {
if strings.Count(content, "[model_providers.ollama-launch]") != 1 {
t.Errorf("expected exactly one [model_providers.ollama-launch] section, got %d", strings.Count(content, "[model_providers.ollama-launch]"))
}
if err := codexValidateConfigText(content); err != nil {
t.Fatalf("generated config should be valid TOML: %v\n%s", err, content)
}
})
t.Run("replaces equivalent quoted profile table", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
existing := "" +
`profile = "default"` + "\n\n" +
`[profiles."ollama-launch"]` + "\n" +
`openai_base_url = "http://old:1234/v1/"` + "\n\n" +
`[model_providers."ollama-launch"]` + "\n" +
`name = "Old"` + "\n" +
`base_url = "http://old:1234/v1/"` + "\n\n" +
`[profiles.default]` + "\n" +
`model = "gpt-5.5"` + "\n"
os.WriteFile(configPath, []byte(existing), 0o644)
if err := writeCodexProfile(configPath); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
content := string(data)
if strings.Contains(content, `profiles."ollama-launch"`) {
t.Fatalf("quoted profile table should be replaced, got:\n%s", content)
}
if strings.Contains(content, "old:1234") {
t.Fatalf("old URL was not replaced, got:\n%s", content)
}
if got := codexSectionStringValue(content, codexProfileHeader(), "model_provider"); got != codexProfileName {
t.Fatalf("profile model_provider = %q, want %q", got, codexProfileName)
}
if got := codexSectionStringValue(content, codexProviderHeader(), "base_url"); !strings.Contains(got, "/v1/") {
t.Fatalf("provider base_url = %q, want /v1/ URL", got)
}
if err := codexValidateConfigText(content); err != nil {
t.Fatalf("generated config should be valid TOML: %v\n%s", err, content)
}
})
t.Run("rejects invalid existing toml without writing", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
existing := "profile = \n"
os.WriteFile(configPath, []byte(existing), 0o644)
err := writeCodexProfile(configPath)
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
t.Fatalf("writeCodexProfile error = %v, want invalid TOML", err)
}
data, _ := os.ReadFile(configPath)
if string(data) != existing {
t.Fatalf("invalid config should be left untouched, got:\n%s", data)
}
})
t.Run("rejects malformed existing toml variants without writing", func(t *testing.T) {
tests := map[string]string{
"duplicate root key": "profile = \"default\"\nprofile = \"other\"\n",
"unterminated string": "model = \"gpt-5.5\n",
"bad table": "[profiles.ollama-launch\nmodel = \"llama3.2\"\n",
"duplicate table key": "[profiles.ollama-launch]\nmodel = \"a\"\nmodel = \"b\"\n",
}
for name, existing := range tests {
t.Run(name, func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
err := writeCodexProfile(configPath)
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
t.Fatalf("writeCodexProfile error = %v, want invalid TOML", err)
}
data, _ := os.ReadFile(configPath)
if string(data) != existing {
t.Fatalf("invalid config should be left untouched, got:\n%s", data)
}
})
}
})
t.Run("backs up previous config before overwrite", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
existing := "# original-codex-backup-marker\n[profiles.default]\nmodel = \"gpt-5.5\"\n"
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
if err := writeCodexProfile(configPath); err != nil {
t.Fatal(err)
}
assertBackupContains(t, filepath.Join(fileutil.BackupDir(), "config.toml.*"), "original-codex-backup-marker")
})
t.Run("updates equivalent quoted root keys", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
existing := "" +
`"profile" = "default"` + "\n" +
`"model" = "gpt-5.5"` + "\n" +
`"model_provider" = "openai"` + "\n\n" +
`[profiles.default]` + "\n" +
`model = "gpt-5.5"` + "\n"
os.WriteFile(configPath, []byte(existing), 0o644)
err := writeCodexLaunchProfile(configPath, codexLaunchProfileOptions{
activate: true,
setRootModelConfig: true,
model: "llama3.2",
})
if err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
content := string(data)
for key, want := range map[string]string{
"profile": codexProfileName,
"model": "llama3.2",
"model_provider": codexProfileName,
} {
if got := codexRootStringValue(content, key); got != want {
t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, content)
}
}
if strings.Contains(content, `"profile"`) || strings.Contains(content, `"model_provider"`) {
t.Fatalf("quoted root keys should be rewritten once, got:\n%s", content)
}
if err := codexValidateConfigText(content); err != nil {
t.Fatalf("generated config should be valid TOML: %v\n%s", err, content)
}
})
t.Run("replaces profile while preserving following sections", func(t *testing.T) {
@@ -342,26 +191,6 @@ func TestWriteCodexProfile(t *testing.T) {
t.Errorf("expected custom host in URL, got:\n%s", content)
}
})
t.Run("uses connectable host for unspecified bind address", func(t *testing.T) {
t.Setenv("OLLAMA_HOST", "http://0.0.0.0:11434")
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
if err := writeCodexProfile(configPath); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
content := string(data)
if strings.Contains(content, "0.0.0.0") {
t.Fatalf("config should not write bind-only host, got:\n%s", content)
}
if !strings.Contains(content, "127.0.0.1:11434/v1/") {
t.Fatalf("expected connectable loopback URL, got:\n%s", content)
}
})
}
func TestEnsureCodexConfig(t *testing.T) {
@@ -369,7 +198,7 @@ func TestEnsureCodexConfig(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil {
if err := ensureCodexConfig("llama3.2"); err != nil {
t.Fatal(err)
}
@@ -401,10 +230,10 @@ func TestEnsureCodexConfig(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil {
if err := ensureCodexConfig("llama3.2"); err != nil {
t.Fatal(err)
}
if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil {
if err := ensureCodexConfig("llama3.2"); err != nil {
t.Fatal(err)
}
@@ -421,22 +250,27 @@ func TestEnsureCodexConfig(t *testing.T) {
})
}
func assertBackupContains(t *testing.T, pattern, marker string) {
t.Helper()
backups, err := filepath.Glob(pattern)
if err != nil {
t.Fatal(err)
func TestParseNumCtx(t *testing.T) {
tests := []struct {
name string
parameters string
want int
}{
{"num_ctx set", "num_ctx 8192", 8192},
{"num_ctx with other params", "temperature 0.7\nnum_ctx 4096\ntop_p 0.9", 4096},
{"no num_ctx", "temperature 0.7\ntop_p 0.9", 0},
{"empty string", "", 0},
{"malformed value", "num_ctx abc", 0},
{"float value", "num_ctx 8192.0", 8192},
}
for _, backupPath := range backups {
data, err := os.ReadFile(backupPath)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), marker) {
return
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parseNumCtx(tt.parameters); got != tt.want {
t.Errorf("parseNumCtx(%q) = %d, want %d", tt.parameters, got, tt.want)
}
})
}
t.Fatalf("backup matching %q with marker %q not found", pattern, marker)
}
func TestModelInfoContextLength(t *testing.T) {
@@ -465,108 +299,134 @@ func TestModelInfoContextLength(t *testing.T) {
func TestBuildCodexModelEntryContextWindow(t *testing.T) {
tests := []struct {
name string
model LaunchModel
modelName string
showResponse string
envContextLen string
wantContext int
}{
{
name: "inventory context length as fallback",
model: LaunchModel{
Name: "llama3.2",
ContextLength: 131072,
Details: api.ModelDetails{Format: "gguf"},
},
name: "architectural context length as fallback",
modelName: "llama3.2",
showResponse: `{
"model_info": {"llama.context_length": 131072},
"details": {"format": "gguf"}
}`,
wantContext: 131072,
},
{
name: "details context length is used when model context is empty",
model: LaunchModel{
Name: "llama3.2",
Details: api.ModelDetails{Format: "gguf", ContextLength: 131072},
},
wantContext: 131072,
},
{
name: "OLLAMA_CONTEXT_LENGTH overrides local gguf inventory context",
model: LaunchModel{
Name: "llama3.2",
ContextLength: 131072,
Details: api.ModelDetails{Format: "gguf"},
},
name: "OLLAMA_CONTEXT_LENGTH overrides architectural",
modelName: "llama3.2",
showResponse: `{
"model_info": {"llama.context_length": 131072},
"details": {"format": "gguf"}
}`,
envContextLen: "64000",
wantContext: 64000,
},
{
name: "safetensors uses inventory context only",
model: LaunchModel{
Name: "llama3.2",
ContextLength: 131072,
Details: api.ModelDetails{Format: "safetensors"},
},
name: "num_ctx overrides OLLAMA_CONTEXT_LENGTH",
modelName: "llama3.2",
showResponse: `{
"model_info": {"llama.context_length": 131072},
"parameters": "num_ctx 8192",
"details": {"format": "gguf"}
}`,
envContextLen: "64000",
wantContext: 8192,
},
{
name: "num_ctx overrides architectural",
modelName: "llama3.2",
showResponse: `{
"model_info": {"llama.context_length": 131072},
"parameters": "num_ctx 32768",
"details": {"format": "gguf"}
}`,
wantContext: 32768,
},
{
name: "safetensors uses architectural context only",
modelName: "llama3.2",
showResponse: `{
"model_info": {"llama.context_length": 131072},
"parameters": "num_ctx 8192",
"details": {"format": "safetensors"}
}`,
envContextLen: "64000",
wantContext: 131072,
},
{
name: "cloud model uses hardcoded limits",
model: LaunchModel{
Name: "qwen3.5:cloud",
ContextLength: 131072,
Details: api.ModelDetails{Format: "gguf"},
},
name: "cloud model uses hardcoded limits",
modelName: "qwen3.5:cloud",
showResponse: `{
"model_info": {"qwen3_5_moe.context_length": 131072},
"details": {"format": "gguf"}
}`,
envContextLen: "64000",
wantContext: 262144,
},
{
name: "unknown cloud model without metadata uses fallback context",
model: LaunchModel{
Name: "deepseek-v4-pro:cloud",
},
envContextLen: "64000",
wantContext: codexFallbackContextWindow,
},
{
name: "vision capability without reasoning advertisement",
model: LaunchModel{
Name: "llama3.2",
ContextLength: 131072,
Details: api.ModelDetails{Format: "gguf"},
Capabilities: []modelpkg.Capability{modelpkg.CapabilityVision, modelpkg.CapabilityThinking},
},
name: "vision and thinking capabilities",
modelName: "llama3.2",
showResponse: `{
"model_info": {"llama.context_length": 131072},
"details": {"format": "gguf"},
"capabilities": ["vision", "thinking"]
}`,
wantContext: 131072,
},
{
name: "missing metadata uses fallback context",
model: LaunchModel{Name: "llama3.2"},
wantContext: codexFallbackContextWindow,
name: "system prompt passed through",
modelName: "llama3.2",
showResponse: `{
"model_info": {"llama.context_length": 131072},
"details": {"format": "gguf"},
"system": "You are a helpful assistant."
}`,
wantContext: 131072,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/show":
fmt.Fprint(w, tt.showResponse)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
if tt.envContextLen != "" {
t.Setenv("OLLAMA_CONTEXT_LENGTH", tt.envContextLen)
} else {
t.Setenv("OLLAMA_CONTEXT_LENGTH", "")
}
entry := buildCodexModelEntry(tt.model)
entry := buildCodexModelEntry(tt.modelName)
gotContext, _ := entry["context_window"].(int)
if gotContext != tt.wantContext {
t.Errorf("context_window = %d, want %d", gotContext, tt.wantContext)
}
if tt.name == "vision capability without reasoning advertisement" {
if tt.name == "vision and thinking capabilities" {
modalities, _ := entry["input_modalities"].([]string)
if !slices.Contains(modalities, "image") {
t.Error("expected image in input_modalities")
}
levels, _ := entry["supported_reasoning_levels"].([]any)
if len(levels) != 0 {
t.Errorf("supported_reasoning_levels length = %d, want 0", len(levels))
if len(levels) == 0 {
t.Error("expected non-empty supported_reasoning_levels")
}
if got, _ := entry["supports_reasoning_summaries"].(bool); got {
t.Error("supports_reasoning_summaries = true, want false")
}
if tt.name == "system prompt passed through" {
if got, _ := entry["base_instructions"].(string); got != "You are a helpful assistant." {
t.Errorf("base_instructions = %q, want %q", got, "You are a helpful assistant.")
}
}
@@ -577,15 +437,12 @@ func TestBuildCodexModelEntryContextWindow(t *testing.T) {
}
}
requiredKeys := []string{"slug", "display_name", "shell_type"}
requiredKeys := []string{"slug", "display_name", "apply_patch_tool_type", "shell_type"}
for _, key := range requiredKeys {
if _, ok := entry[key]; !ok {
t.Errorf("missing required key %q", key)
}
}
if _, ok := entry["apply_patch_tool_type"]; ok {
t.Error("apply_patch_tool_type should be omitted so Codex CLI defaults can handle schema changes")
}
if _, err := json.Marshal(entry); err != nil {
t.Errorf("entry is not JSON serializable: %v", err)

View File

@@ -73,9 +73,6 @@ func TestLaunchCmd(t *testing.T) {
if cmd.Flags().Lookup("config") == nil {
t.Error("--config flag should exist")
}
if cmd.Flags().Lookup("restore") == nil {
t.Error("--restore flag should exist")
}
if cmd.Flags().Lookup("yes") == nil {
t.Error("--yes flag should exist")
}
@@ -210,52 +207,6 @@ func TestLaunchCmdTUICallback(t *testing.T) {
t.Error("TUI callback should NOT be called when flags or extra args are provided without an integration")
}
})
t.Run("--restore flag without integration returns error", func(t *testing.T) {
tuiCalled := false
mockTUI := func(cmd *cobra.Command) {
tuiCalled = true
}
cmd := LaunchCmd(mockCheck, mockTUI)
cmd.SetArgs([]string{"--restore"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected --restore without an integration to fail")
}
if !strings.Contains(err.Error(), "require an integration name") {
t.Fatalf("expected integration-name guidance, got %v", err)
}
if tuiCalled {
t.Error("TUI callback should NOT be called when --restore is provided without an integration")
}
})
}
func TestLaunchCmdClaudeDesktopLaunchReturnsUnsupported(t *testing.T) {
for _, name := range []string{"claude-desktop", "claude-app"} {
t.Run(name, func(t *testing.T) {
cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error {
t.Fatal("heartbeat check should not run before Claude Desktop unsupported error")
return nil
}, func(cmd *cobra.Command) {
t.Fatal("TUI callback should not run for direct integration launch")
})
cmd.SetArgs([]string{name})
err := cmd.Execute()
if err == nil {
t.Fatal("expected Claude Desktop launch command to fail")
}
if !strings.Contains(err.Error(), "Claude Desktop is no longer supported") {
t.Fatalf("expected unsupported guidance, got %v", err)
}
if !strings.Contains(err.Error(), "ollama launch claude-desktop --restore") {
t.Fatalf("expected restore guidance, got %v", err)
}
})
}
}
func TestLaunchCmdNilHeartbeat(t *testing.T) {
@@ -322,8 +273,6 @@ func TestLaunchCmdModelFlagClearsDisabledCloudOverride(t *testing.T) {
switch r.URL.Path {
case "/api/status":
fmt.Fprintf(w, `{"cloud":{"disabled":true,"source":"config"}}`)
case "/api/experimental/model-recommendations":
fmt.Fprint(w, `{"recommendations":[]}`)
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`)
case "/api/show":
@@ -344,7 +293,7 @@ func TestLaunchCmdModelFlagClearsDisabledCloudOverride(t *testing.T) {
var selectorCalls int
var gotCurrent string
DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) {
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
selectorCalls++
gotCurrent = current
return "llama3.2", nil
@@ -380,41 +329,6 @@ func TestLaunchCmdModelFlagClearsDisabledCloudOverride(t *testing.T) {
}
}
func TestLaunchCmdAutodiscoveryDefaultLaunchDoesNotForceConfigure(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withInteractiveSession(t, true)
withLauncherHooks(t)
runner := &launcherManagedAutodiscoveryRunner{
autodiscoveryConfigured: true,
}
restore := OverrideIntegration("stubauto", runner)
defer restore()
if err := config.SaveIntegration("stubauto", []string{"Ollama Cloud"}); err != nil {
t.Fatalf("failed to save managed integration config: %v", err)
}
if err := config.MarkIntegrationOnboarded("stubauto"); err != nil {
t.Fatalf("failed to mark integration onboarded: %v", err)
}
cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { return nil }, func(cmd *cobra.Command) {
t.Fatal("TUI callback should not run for direct integration launch")
})
cmd.SetArgs([]string{"stubauto"})
if err := cmd.Execute(); err != nil {
t.Fatalf("launch command failed: %v", err)
}
if runner.autodiscoveryConfigures != 0 {
t.Fatalf("expected default autodiscovery launch to reuse existing config, got %d configures", runner.autodiscoveryConfigures)
}
if runner.ranModel != "Ollama Cloud" {
t.Fatalf("expected launch to run autodiscovery label, got %q", runner.ranModel)
}
}
func TestLaunchCmdYes_AutoConfirmsLaunchPromptPath(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
@@ -504,7 +418,7 @@ func TestLaunchCmdHeadlessWithYes_AutoPullsMissingLocalModel(t *testing.T) {
}
}
func TestLaunchCmdHeadlessWithoutYes_AllowsConfiguredLaunch(t *testing.T) {
func TestLaunchCmdHeadlessWithoutYes_ReturnsActionableConfirmError(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
@@ -536,14 +450,17 @@ func TestLaunchCmdHeadlessWithoutYes_AllowsConfiguredLaunch(t *testing.T) {
cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { return nil }, func(cmd *cobra.Command) {})
cmd.SetArgs([]string{"stubeditor", "--model", "llama3.2"})
err := cmd.Execute()
if err != nil {
t.Fatalf("expected launch command to succeed without --yes when an explicit model is provided, got %v", err)
if err == nil {
t.Fatal("expected launch command to fail without --yes in headless mode")
}
if diff := compareStringSlices(stub.edited, [][]string{{"llama3.2"}}); diff != "" {
t.Fatalf("unexpected editor writes (-want +got):\n%s", diff)
if !strings.Contains(err.Error(), "re-run with --yes") {
t.Fatalf("expected actionable --yes guidance, got %v", err)
}
if stub.ranModel != "llama3.2" {
t.Fatalf("expected launch to run configured model, got %q", stub.ranModel)
if len(stub.edited) != 0 {
t.Fatalf("expected no editor writes when confirmation is blocked, got %v", stub.edited)
}
if stub.ranModel != "" {
t.Fatalf("expected launch to abort before run, got %q", stub.ranModel)
}
}
@@ -557,8 +474,6 @@ func TestLaunchCmdIntegrationArgPromptsForModelWithSavedSelection(t *testing.T)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/experimental/model-recommendations":
fmt.Fprint(w, `{"recommendations":[]}`)
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"llama3.2"},{"name":"qwen3:8b"}]}`)
case "/api/show":
@@ -578,7 +493,7 @@ func TestLaunchCmdIntegrationArgPromptsForModelWithSavedSelection(t *testing.T)
defer func() { DefaultSingleSelector = oldSelector }()
var gotCurrent string
DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) {
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
gotCurrent = current
return "qwen3:8b", nil
}
@@ -632,7 +547,7 @@ func TestLaunchCmdHeadlessYes_IntegrationRequiresModelEvenWhenSaved(t *testing.T
oldSelector := DefaultSingleSelector
defer func() { DefaultSingleSelector = oldSelector }()
DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) {
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
t.Fatal("selector should not be called for headless --yes saved-model launch")
return "", nil
}
@@ -669,7 +584,7 @@ func TestLaunchCmdHeadlessYes_IntegrationWithoutSavedModelReturnsError(t *testin
oldSelector := DefaultSingleSelector
defer func() { DefaultSingleSelector = oldSelector }()
DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) {
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
t.Fatal("selector should not be called for headless --yes without saved model")
return "", nil
}

View File

@@ -43,7 +43,7 @@ func (c *Copilot) findPath() (string, error) {
return fallback, nil
}
func (c *Copilot) Run(model string, _ []LaunchModel, args []string) error {
func (c *Copilot) Run(model string, args []string) error {
copilotPath, err := c.findPath()
if err != nil {
return fmt.Errorf("copilot is not installed, install from https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli")

View File

@@ -40,7 +40,7 @@ type modelEntry struct {
func (d *Droid) String() string { return "Droid" }
func (d *Droid) Run(model string, _ []LaunchModel, args []string) error {
func (d *Droid) Run(model string, args []string) error {
if _, err := exec.LookPath("droid"); err != nil {
return fmt.Errorf("droid is not installed, install from https://docs.factory.ai/cli/getting-started/quickstart")
}
@@ -64,7 +64,7 @@ func (d *Droid) Paths() []string {
return nil
}
func (d *Droid) Edit(models []LaunchModel) error {
func (d *Droid) Edit(models []string) error {
if len(models) == 0 {
return nil
}
@@ -96,10 +96,10 @@ func (d *Droid) Edit(models []LaunchModel) error {
if err != nil {
return err
}
return fileutil.WriteWithBackup(settingsPath, data, "droid")
return fileutil.WriteWithBackup(settingsPath, data)
}
func updateDroidSettings(settingsMap map[string]any, settings droidSettings, models []LaunchModel) map[string]any {
func updateDroidSettings(settingsMap map[string]any, settings droidSettings, models []string) map[string]any {
// Keep only non-Ollama models from the raw map (preserves extra fields)
// Rebuild Ollama models
var nonOllamaModels []any
@@ -119,18 +119,20 @@ func updateDroidSettings(settingsMap map[string]any, settings droidSettings, mod
var defaultModelID string
for i, model := range models {
maxOutput := 64000
if model.MaxOutputTokens > 0 {
maxOutput = model.MaxOutputTokens
if isCloudModelName(model) {
if l, ok := lookupCloudModelLimit(model); ok {
maxOutput = l.Output
}
}
modelID := fmt.Sprintf("custom:%s-%d", model.Name, i)
modelID := fmt.Sprintf("custom:%s-%d", model, i)
newModels = append(newModels, modelEntry{
Model: model.Name,
DisplayName: model.Name,
Model: model,
DisplayName: model,
BaseURL: envconfig.Host().String() + "/v1",
APIKey: "ollama",
Provider: "generic-chat-completion-api",
MaxOutputTokens: maxOutput,
SupportsImages: model.HasCapability("vision"),
SupportsImages: false,
ID: modelID,
Index: i,
})

View File

@@ -63,7 +63,7 @@ func TestDroidEdit(t *testing.T) {
t.Run("fresh install creates models with sequential indices", func(t *testing.T) {
cleanup()
if err := d.Edit(testLaunchModels("model-a", "model-b")); err != nil {
if err := d.Edit([]string{"model-a", "model-b"}); err != nil {
t.Fatal(err)
}
@@ -99,7 +99,7 @@ func TestDroidEdit(t *testing.T) {
t.Run("sets sessionDefaultSettings.model to first model ID", func(t *testing.T) {
cleanup()
if err := d.Edit(testLaunchModels("model-a", "model-b")); err != nil {
if err := d.Edit([]string{"model-a", "model-b"}); err != nil {
t.Fatal(err)
}
@@ -116,10 +116,10 @@ func TestDroidEdit(t *testing.T) {
t.Run("re-indexes when models removed", func(t *testing.T) {
cleanup()
// Add three models
d.Edit(testLaunchModels("model-a", "model-b", "model-c"))
d.Edit([]string{"model-a", "model-b", "model-c"})
// Remove middle model
d.Edit(testLaunchModels("model-a", "model-c"))
d.Edit([]string{"model-a", "model-c"})
settings := readSettings()
models := getCustomModels(settings)
@@ -155,7 +155,7 @@ func TestDroidEdit(t *testing.T) {
]
}`), 0o644)
d.Edit(testLaunchModels("model-a"))
d.Edit([]string{"model-a"})
settings := readSettings()
models := getCustomModels(settings)
@@ -184,7 +184,7 @@ func TestDroidEdit(t *testing.T) {
"sessionDefaultSettings": {"autonomyMode": "auto-high"}
}`), 0o644)
d.Edit(testLaunchModels("model-a"))
d.Edit([]string{"model-a"})
settings := readSettings()
@@ -203,7 +203,7 @@ func TestDroidEdit(t *testing.T) {
t.Run("required fields present", func(t *testing.T) {
cleanup()
d.Edit(testLaunchModels("test-model"))
d.Edit([]string{"test-model"})
settings := readSettings()
models := getCustomModels(settings)
@@ -239,7 +239,7 @@ func TestDroidEdit(t *testing.T) {
"sessionDefaultSettings": {"reasoningEffort": "off"}
}`), 0o644)
d.Edit(testLaunchModels("model-a"))
d.Edit([]string{"model-a"})
settings := readSettings()
session := settings["sessionDefaultSettings"].(map[string]any)
@@ -256,7 +256,7 @@ func TestDroidEdit(t *testing.T) {
"sessionDefaultSettings": {"reasoningEffort": "high"}
}`), 0o644)
d.Edit(testLaunchModels("model-a"))
d.Edit([]string{"model-a"})
settings := readSettings()
session := settings["sessionDefaultSettings"].(map[string]any)
@@ -281,7 +281,7 @@ func TestDroidEdit_CorruptedJSON(t *testing.T) {
os.WriteFile(settingsPath, []byte(`{corrupted json content`), 0o644)
// Corrupted JSON should return an error so user knows something is wrong
err := d.Edit(testLaunchModels("model-a"))
err := d.Edit([]string{"model-a"})
if err == nil {
t.Fatal("expected error for corrupted JSON, got nil")
}
@@ -306,7 +306,7 @@ func TestDroidEdit_WrongTypeCustomModels(t *testing.T) {
os.WriteFile(settingsPath, []byte(`{"customModels": "not an array"}`), 0o644)
// Should not panic - wrong type should be handled gracefully
err := d.Edit(testLaunchModels("model-a"))
err := d.Edit([]string{"model-a"})
if err != nil {
t.Fatalf("Edit failed with wrong type customModels: %v", err)
}
@@ -338,7 +338,7 @@ func TestDroidEdit_EmptyModels(t *testing.T) {
os.WriteFile(settingsPath, []byte(originalContent), 0o644)
// Empty models should be no-op
err := d.Edit(testLaunchModels())
err := d.Edit([]string{})
if err != nil {
t.Fatalf("Edit with empty models failed: %v", err)
}
@@ -359,7 +359,7 @@ func TestDroidEdit_DuplicateModels(t *testing.T) {
settingsPath := filepath.Join(settingsDir, "settings.json")
// Add same model twice
err := d.Edit(testLaunchModels("model-a", "model-a"))
err := d.Edit([]string{"model-a", "model-a"})
if err != nil {
t.Fatalf("Edit with duplicates failed: %v", err)
}
@@ -388,7 +388,7 @@ func TestDroidEdit_MalformedModelEntry(t *testing.T) {
// Model entry is a string instead of a map
os.WriteFile(settingsPath, []byte(`{"customModels": ["not a map", 123]}`), 0o644)
err := d.Edit(testLaunchModels("model-a"))
err := d.Edit([]string{"model-a"})
if err != nil {
t.Fatalf("Edit with malformed entries failed: %v", err)
}
@@ -415,7 +415,7 @@ func TestDroidEdit_WrongTypeSessionSettings(t *testing.T) {
// sessionDefaultSettings is a string instead of map
os.WriteFile(settingsPath, []byte(`{"sessionDefaultSettings": "not a map"}`), 0o644)
err := d.Edit(testLaunchModels("model-a"))
err := d.Edit([]string{"model-a"})
if err != nil {
t.Fatalf("Edit with wrong type sessionDefaultSettings failed: %v", err)
}
@@ -490,7 +490,7 @@ func TestDroidEdit_RoundTrip(t *testing.T) {
os.WriteFile(settingsPath, []byte(testDroidSettingsFixture), 0o644)
// Edit with new models
if err := d.Edit(testLaunchModels("llama3", "mistral")); err != nil {
if err := d.Edit([]string{"llama3", "mistral"}); err != nil {
t.Fatal(err)
}
@@ -615,7 +615,7 @@ func TestDroidEdit_PreservesUnknownFields(t *testing.T) {
}`
os.WriteFile(settingsPath, []byte(original), 0o644)
if err := d.Edit(testLaunchModels("model-a")); err != nil {
if err := d.Edit([]string{"model-a"}); err != nil {
t.Fatal(err)
}
@@ -660,7 +660,7 @@ func TestDroidEdit_PreservesUnknownFields(t *testing.T) {
}`
os.WriteFile(settingsPath, []byte(original), 0o644)
if err := d.Edit(testLaunchModels("llama3")); err != nil {
if err := d.Edit([]string{"llama3"}); err != nil {
t.Fatal(err)
}
@@ -715,10 +715,10 @@ func TestDroidEdit_Idempotent(t *testing.T) {
os.WriteFile(settingsPath, []byte(testDroidSettingsFixture), 0o644)
// Edit twice with same models
d.Edit(testLaunchModels("llama3", "mistral"))
d.Edit([]string{"llama3", "mistral"})
firstData, _ := os.ReadFile(settingsPath)
d.Edit(testLaunchModels("llama3", "mistral"))
d.Edit([]string{"llama3", "mistral"})
secondData, _ := os.ReadFile(settingsPath)
// Results should be identical
@@ -744,7 +744,7 @@ func TestDroidEdit_MultipleConsecutiveEdits(t *testing.T) {
if i%2 == 0 {
models = []string{"model-x", "model-y", "model-z"}
}
if err := d.Edit(launchModelsFromNames(models)); err != nil {
if err := d.Edit(models); err != nil {
t.Fatalf("edit %d failed: %v", i, err)
}
}
@@ -803,7 +803,7 @@ func TestDroidEdit_UnicodeAndSpecialCharacters(t *testing.T) {
}`
os.WriteFile(settingsPath, []byte(original), 0o644)
if err := d.Edit(testLaunchModels("model-a")); err != nil {
if err := d.Edit([]string{"model-a"}); err != nil {
t.Fatal(err)
}
@@ -845,7 +845,7 @@ func TestDroidEdit_LargeNumbers(t *testing.T) {
}`
os.WriteFile(settingsPath, []byte(original), 0o644)
if err := d.Edit(testLaunchModels("model-a")); err != nil {
if err := d.Edit([]string{"model-a"}); err != nil {
t.Fatal(err)
}
@@ -889,7 +889,7 @@ func TestDroidEdit_EmptyAndNullValues(t *testing.T) {
}`
os.WriteFile(settingsPath, []byte(original), 0o644)
if err := d.Edit(testLaunchModels("model-a")); err != nil {
if err := d.Edit([]string{"model-a"}); err != nil {
t.Fatal(err)
}
@@ -943,7 +943,7 @@ func TestDroidEdit_DeeplyNestedStructures(t *testing.T) {
}`
os.WriteFile(settingsPath, []byte(original), 0o644)
if err := d.Edit(testLaunchModels("model-a")); err != nil {
if err := d.Edit([]string{"model-a"}); err != nil {
t.Fatal(err)
}
@@ -988,7 +988,7 @@ func TestDroidEdit_ModelNamesWithSpecialCharacters(t *testing.T) {
"model_with_underscores",
}
if err := d.Edit(launchModelsFromNames(specialModels)); err != nil {
if err := d.Edit(specialModels); err != nil {
t.Fatal(err)
}
@@ -1025,7 +1025,7 @@ func TestDroidEdit_MissingCustomModelsKey(t *testing.T) {
t.Fatal(err)
}
settings = updateDroidSettings(settings, settingsStruct, testLaunchModels("model-a"))
settings = updateDroidSettings(settings, settingsStruct, []string{"model-a"})
// Original fields preserved
if settings["diffMode"] != "github" {
@@ -1062,7 +1062,7 @@ func TestDroidEdit_NullCustomModels(t *testing.T) {
}`
os.WriteFile(settingsPath, []byte(original), 0o644)
if err := d.Edit(testLaunchModels("model-a")); err != nil {
if err := d.Edit([]string{"model-a"}); err != nil {
t.Fatal(err)
}
@@ -1090,7 +1090,7 @@ func TestDroidEdit_MinifiedJSON(t *testing.T) {
original := `{"diffMode":"github","enableHooks":true,"hooks":{"imported":["cmd1","cmd2"]},"customModels":[],"sessionDefaultSettings":{}}`
os.WriteFile(settingsPath, []byte(original), 0o644)
if err := d.Edit(testLaunchModels("model-a")); err != nil {
if err := d.Edit([]string{"model-a"}); err != nil {
t.Fatal(err)
}
@@ -1120,7 +1120,7 @@ func TestDroidEdit_CreatesDirectoryIfMissing(t *testing.T) {
t.Fatal("directory should not exist before test")
}
if err := d.Edit(testLaunchModels("model-a")); err != nil {
if err := d.Edit([]string{"model-a"}); err != nil {
t.Fatal(err)
}
@@ -1157,7 +1157,7 @@ func TestDroidEdit_PreservesFileAfterError(t *testing.T) {
os.WriteFile(settingsPath, []byte(original), 0o644)
// Empty models list is a no-op, should not modify file
d.Edit(testLaunchModels())
d.Edit([]string{})
data, _ := os.ReadFile(settingsPath)
if string(data) != original {
@@ -1172,7 +1172,7 @@ func TestDroidEdit_BackupCreated(t *testing.T) {
settingsDir := filepath.Join(tmpDir, ".factory")
settingsPath := filepath.Join(settingsDir, "settings.json")
backupDir := fileutil.BackupDir()
backupDir := filepath.Join(os.TempDir(), "ollama-backups")
os.MkdirAll(settingsDir, 0o755)
@@ -1181,12 +1181,12 @@ func TestDroidEdit_BackupCreated(t *testing.T) {
original := fmt.Sprintf(`{"diffMode": "%s", "customModels": [], "sessionDefaultSettings": {}}`, uniqueMarker)
os.WriteFile(settingsPath, []byte(original), 0o644)
if err := d.Edit(testLaunchModels("model-a")); err != nil {
if err := d.Edit([]string{"model-a"}); err != nil {
t.Fatal(err)
}
// Find backup containing our unique marker
backups, _ := filepath.Glob(filepath.Join(backupDir, "droid", "settings.json.*"))
backups, _ := filepath.Glob(filepath.Join(backupDir, "settings.json.*"))
foundBackup := false
for _, backup := range backups {
data, err := os.ReadFile(backup)
@@ -1231,7 +1231,7 @@ func TestDroidEdit_LargeNumberOfModels(t *testing.T) {
models = append(models, fmt.Sprintf("model-%d", i))
}
if err := d.Edit(launchModelsFromNames(models)); err != nil {
if err := d.Edit(models); err != nil {
t.Fatal(err)
}
@@ -1261,7 +1261,7 @@ func TestDroidEdit_LocalModelDefaultMaxOutput(t *testing.T) {
settingsDir := filepath.Join(tmpDir, ".factory")
settingsPath := filepath.Join(settingsDir, "settings.json")
if err := d.Edit(testLaunchModels("llama3.2")); err != nil {
if err := d.Edit([]string{"llama3.2"}); err != nil {
t.Fatal(err)
}
@@ -1312,7 +1312,7 @@ func TestDroidEdit_ArraysWithMixedTypes(t *testing.T) {
}`
os.WriteFile(settingsPath, []byte(original), 0o644)
if err := d.Edit(testLaunchModels("model-a")); err != nil {
if err := d.Edit([]string{"model-a"}); err != nil {
t.Fatal(err)
}

View File

@@ -65,7 +65,7 @@ type Hermes struct{}
func (h *Hermes) String() string { return "Hermes Agent" }
func (h *Hermes) Run(_ string, _ []LaunchModel, args []string) error {
func (h *Hermes) Run(_ string, args []string) error {
// Hermes reads its primary model from config.yaml. launch configures that
// default model ahead of time so we can keep runtime invocation simple and
// still let Hermes discover additional models later via its own UX.
@@ -132,7 +132,7 @@ func (h *Hermes) Configure(model string) error {
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return err
}
return fileutil.WriteWithBackup(configPath, data, "hermes")
return fileutil.WriteWithBackup(configPath, data)
}
func (h *Hermes) CurrentModel() string {

View File

@@ -49,6 +49,15 @@ func withHermesUserHome(t *testing.T, dir string) {
})
}
func withHermesLookPath(t *testing.T, fn func(string) (string, error)) {
t.Helper()
old := hermesLookPath
hermesLookPath = fn
t.Cleanup(func() {
hermesLookPath = old
})
}
func clearHermesMessagingEnvVars(t *testing.T) {
t.Helper()
for _, group := range hermesMessagingEnvGroups {
@@ -103,8 +112,6 @@ func TestHermesConfigurePreservesExistingConfigAndEnablesWeb(t *testing.T) {
switch r.URL.Path {
case "/api/show":
fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`)
case "/api/experimental/model-recommendations":
fmt.Fprint(w, `{"recommendations":[]}`)
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"gemma4"},{"name":"qwen3.5"},{"name":"llama3.3"}]}`)
default:
@@ -217,8 +224,6 @@ func TestHermesConfigureUpdatesMatchingCustomProviderWithoutDroppingFields(t *te
switch r.URL.Path {
case "/api/show":
fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`)
case "/api/experimental/model-recommendations":
fmt.Fprint(w, `{"recommendations":[]}`)
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"gemma4"},{"name":"qwen3.5"},{"name":"llama3.3"}]}`)
default:
@@ -295,8 +300,6 @@ func TestHermesConfigureUsesLaunchResolvedHostForModelDiscovery(t *testing.T) {
switch r.URL.Path {
case "/api/show":
fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`)
case "/api/experimental/model-recommendations":
fmt.Fprint(w, `{"recommendations":[]}`)
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"gemma4"},{"name":"qwen3.5"},{"name":"llama3.3"}]}`)
default:
@@ -362,8 +365,6 @@ func TestHermesConfigureMigratesLegacyManagedAliases(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/experimental/model-recommendations":
fmt.Fprint(w, `{"recommendations":[]}`)
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"gemma4"},{"name":"qwen3.5"}]}`)
default:
@@ -552,7 +553,7 @@ func TestHermesRunPassthroughArgs(t *testing.T) {
}
h := &Hermes{}
if err := h.Run("", nil, []string{"--continue"}); err != nil {
if err := h.Run("", []string{"--continue"}); err != nil {
t.Fatalf("Run returned error: %v", err)
}
@@ -603,7 +604,7 @@ fi
}
h := &Hermes{}
if err := h.Run("", nil, nil); err != nil {
if err := h.Run("", nil); err != nil {
t.Fatalf("Run returned error: %v", err)
}
@@ -655,10 +656,10 @@ func TestHermesRun_SetUpLaterRepromptsOnLaterLaunches(t *testing.T) {
}
h := &Hermes{}
if err := h.Run("", nil, nil); err != nil {
if err := h.Run("", nil); err != nil {
t.Fatalf("first Run returned error: %v", err)
}
if err := h.Run("", nil, nil); err != nil {
if err := h.Run("", nil); err != nil {
t.Fatalf("second Run returned error: %v", err)
}
@@ -713,7 +714,7 @@ func TestHermesRun_SkipsMessagingPromptWhenConfigured(t *testing.T) {
}
h := &Hermes{}
if err := h.Run("", nil, nil); err != nil {
if err := h.Run("", nil); err != nil {
t.Fatalf("Run returned error: %v", err)
}
@@ -753,7 +754,7 @@ func TestHermesRun_SkipsMessagingPromptWithYesPolicy(t *testing.T) {
}
h := &Hermes{}
if err := h.Run("", nil, nil); err != nil {
if err := h.Run("", nil); err != nil {
t.Fatalf("Run returned error: %v", err)
}
@@ -798,7 +799,7 @@ fi
}
h := &Hermes{}
err := h.Run("", nil, nil)
err := h.Run("", nil)
if err == nil {
t.Fatal("expected messaging setup failure")
}

View File

@@ -10,9 +10,7 @@ import (
"net/url"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/ollama/ollama/api"
@@ -25,7 +23,7 @@ type stubEditorRunner struct {
editErr error
}
func (s *stubEditorRunner) Run(model string, _ []LaunchModel, args []string) error {
func (s *stubEditorRunner) Run(model string, args []string) error {
s.ranModel = model
return nil
}
@@ -34,11 +32,11 @@ func (s *stubEditorRunner) String() string { return "StubEditor" }
func (s *stubEditorRunner) Paths() []string { return nil }
func (s *stubEditorRunner) Edit(models []LaunchModel) error {
func (s *stubEditorRunner) Edit(models []string) error {
if s.editErr != nil {
return s.editErr
}
cloned := launchModelNames(models)
cloned := append([]string(nil), models...)
s.edited = append(s.edited, cloned)
return nil
}
@@ -55,16 +53,10 @@ func TestIntegrationLookup(t *testing.T) {
{"claude lowercase", "claude", true, "Claude Code"},
{"claude uppercase", "CLAUDE", true, "Claude Code"},
{"claude mixed case", "Claude", true, "Claude Code"},
{"claude desktop", "claude-desktop", true, "Claude Desktop"},
{"claude desktop alias", "claude-app", true, "Claude Desktop"},
{"codex", "codex", true, "Codex"},
{"codex app", "codex-app", true, "Codex App"},
{"codex app desktop alias", "codex-desktop", true, "Codex App"},
{"codex app gui alias", "codex-gui", true, "Codex App"},
{"kimi", "kimi", true, "Kimi Code CLI"},
{"droid", "droid", true, "Droid"},
{"opencode", "opencode", true, "OpenCode"},
{"pool", "pool", true, "Pool"},
{"unknown integration", "unknown", false, ""},
{"empty string", "", false, ""},
}
@@ -83,7 +75,8 @@ func TestIntegrationLookup(t *testing.T) {
}
func TestIntegrationRegistry(t *testing.T) {
expectedIntegrations := []string{"claude", "claude-desktop", "codex", "codex-app", "kimi", "droid", "opencode", "hermes", "pool"}
expectedIntegrations := []string{"claude", "codex", "kimi", "droid", "opencode", "hermes"}
for _, name := range expectedIntegrations {
t.Run(name, func(t *testing.T) {
r, ok := integrations[name]
@@ -143,23 +136,6 @@ func TestLookupIntegration_UnknownIntegration(t *testing.T) {
}
}
func TestLookupIntegration_ClaudeDesktopResolvesForRestore(t *testing.T) {
for _, name := range []string{"claude-desktop", "claude-app"} {
t.Run(name, func(t *testing.T) {
canonical, runner, err := LookupIntegration(name)
if err != nil {
t.Fatalf("expected Claude Desktop lookup to resolve, got: %v", err)
}
if canonical != "claude-desktop" {
t.Fatalf("canonical name = %q, want claude-desktop", canonical)
}
if runner.String() != "Claude Desktop" {
t.Fatalf("runner = %q, want Claude Desktop", runner.String())
}
})
}
}
func TestIsIntegrationInstalled_UnknownIntegrationReturnsFalse(t *testing.T) {
stderr := captureStderr(t, func() {
if IsIntegrationInstalled("unknown-integration") {
@@ -206,7 +182,7 @@ func TestAllIntegrations_HaveRequiredMethods(t *testing.T) {
if displayName == "" {
t.Error("String() should not return empty")
}
var _ func(string, []LaunchModel, []string) error = r.Run
var _ func(string, []string) error = r.Run
})
}
}
@@ -478,28 +454,6 @@ func TestBuildModelList_ExistingRecommendedMarked(t *testing.T) {
}
}
func TestBuildModelList_PreservesRecommendationRequiredPlanForExistingCloudModel(t *testing.T) {
recommendations := []ModelItem{
{
Name: "glm-5:cloud",
Description: "Reasoning and code generation",
Recommended: true,
RequiredPlan: "pro",
Details: api.ModelDetails{ContextLength: 202_752},
},
}
existing := []modelInfo{{Name: "glm-5:cloud", Remote: true}}
items, _, _, _ := buildModelListWithRecommendations(existing, recommendations, nil, "")
if len(items) != 1 {
t.Fatalf("expected one item, got %v", items)
}
item := items[0]
if item.RequiredPlan != "pro" {
t.Fatalf("RequiredPlan = %q, want pro", item.RequiredPlan)
}
}
func TestBuildModelList_ExistingCloudModelsNotPushedToBottom(t *testing.T) {
existing := []modelInfo{
{Name: "gemma4", Remote: false},
@@ -866,7 +820,7 @@ func TestPrepareEditorIntegration_SavesOnlyAfterSuccessfulEdit(t *testing.T) {
}
editor := &stubEditorRunner{editErr: errors.New("boom")}
err := prepareEditorIntegration("droid", editor, testLaunchModels("new-model"))
err := prepareEditorIntegration("droid", editor, editor, []string{"new-model"})
if err == nil || !strings.Contains(err.Error(), "setup failed") {
t.Fatalf("expected setup failure, got %v", err)
}
@@ -1410,211 +1364,6 @@ func TestEnsureAuth_SkipsWhenNoCloudSelected(t *testing.T) {
}
}
func TestEnsureAuth_EmptyWhoamiRequiresSignIn(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/status":
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"error":"not found"}`)
case "/api/me":
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{}`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
err := ensureAuth(context.Background(), client, map[string]bool{"cloud-model:cloud": true}, []string{"cloud-model:cloud"})
if err == nil || !strings.Contains(err.Error(), "cloud-model:cloud requires sign in") {
t.Fatalf("ensureAuth error = %v, want sign-in required", err)
}
}
func TestApplyAccountStateToSelectionItems_BadgesOnlyWhenActionRequired(t *testing.T) {
items := []ModelItem{
{Name: "qwen3.5:cloud", Recommended: true},
{Name: "kimi-k2.6:cloud", Recommended: true, RequiredPlan: "pro"},
{Name: "llama3.2", RequiredPlan: "pro"},
{Name: "glm-5:cloud"},
{Name: "nemotron-3-super:cloud", Recommended: true, RequiredPlan: "free"},
}
signedOut := ApplyAccountStateToSelectionItems(items, AccountState{Status: accountStateSignedOut})
if signedOut[0].AvailabilityBadge != "Sign in required" {
t.Fatalf("account cloud badge = %q", signedOut[0].AvailabilityBadge)
}
if signedOut[1].AvailabilityBadge != "Sign in required" {
t.Fatalf("subscription cloud signed-out badge = %q", signedOut[1].AvailabilityBadge)
}
if signedOut[4].AvailabilityBadge != "Sign in required" {
t.Fatalf("free-plan cloud signed-out badge = %q", signedOut[4].AvailabilityBadge)
}
if signedOut[2].AvailabilityBadge != "" || signedOut[3].AvailabilityBadge != "" {
t.Fatalf("unexpected badge for local or unmetadata item: %#v", signedOut)
}
freeUser := ApplyAccountStateToSelectionItems(items, AccountState{Status: accountStateSignedIn, Plan: "free"})
if freeUser[0].AvailabilityBadge != "" {
t.Fatalf("signed-in account model should not be badged, got %q", freeUser[0].AvailabilityBadge)
}
if freeUser[1].AvailabilityBadge != "Upgrade required" {
t.Fatalf("subscription cloud free-plan badge = %q", freeUser[1].AvailabilityBadge)
}
if freeUser[4].AvailabilityBadge != "" {
t.Fatalf("free required plan should be usable by free user, got %q", freeUser[4].AvailabilityBadge)
}
proUser := ApplyAccountStateToSelectionItems(items, AccountState{Status: accountStateSignedIn, Plan: "pro"})
if proUser[1].AvailabilityBadge != "" {
t.Fatalf("pro user should not see included badge, got %q", proUser[1].AvailabilityBadge)
}
maxUser := ApplyAccountStateToSelectionItems(items, AccountState{Status: accountStateSignedIn, Plan: "max"})
if maxUser[1].AvailabilityBadge != "" {
t.Fatalf("max user should not see upgrade badge, got %q", maxUser[1].AvailabilityBadge)
}
unknown := ApplyAccountStateToSelectionItems(items, AccountState{Status: accountStateUnknown})
for _, item := range unknown {
if item.AvailabilityBadge != "" {
t.Fatalf("unknown account state should not render badges: %#v", unknown)
}
}
}
func TestSelectionItemsWithAccountState_SkipsBadgesWithoutBadgeableCloudItems(t *testing.T) {
items := []ModelItem{
{Name: "llama3.2"},
{Name: "custom:cloud"},
}
state := &AccountState{Status: accountStateSignedOut}
got := SelectionItemsWithAccountState(items, state)
if len(got) != len(items) {
t.Fatalf("got %d selection items, want %d", len(got), len(items))
}
for _, item := range got {
if item.AvailabilityBadge != "" {
t.Fatalf("unexpected badge without account state: %#v", got)
}
}
}
func TestSelectionItemsWithAccountState_UsesPrefetchedStateForRecommendedCloudItems(t *testing.T) {
state := &AccountState{Status: accountStateSignedOut}
got := SelectionItemsWithAccountState([]ModelItem{{Name: "qwen3.5:cloud", Recommended: true}}, state)
if got[0].AvailabilityBadge != "Sign in required" {
t.Fatalf("badge = %q, want Sign in required", got[0].AvailabilityBadge)
}
}
func TestRecommendedModelsDoNotIncludeRequiredPlanStubs(t *testing.T) {
byName := make(map[string]ModelItem, len(recommendedModels))
for _, item := range recommendedModels {
byName[item.Name] = item
}
if item := byName["kimi-k2.6:cloud"]; item.RequiredPlan != "" {
t.Fatalf("kimi fallback required plan should not be stubbed: %#v", item)
}
if item := byName["minimax-m2.7:cloud"]; item.RequiredPlan != "" {
t.Fatalf("minimax fallback required plan should not be stubbed: %#v", item)
}
if item := byName["qwen3.5:cloud"]; item.RequiredPlan != "" {
t.Fatalf("qwen fallback required plan = %#v", item)
}
if item := byName["glm-5.1:cloud"]; item.RequiredPlan != "" {
t.Fatalf("glm fallback required plan = %#v", item)
}
}
func TestLaunchAccountState(t *testing.T) {
tests := []struct {
name string
statusCode int
body string
wantStatus accountStateStatus
wantPlan string
}{
{
name: "signed in",
statusCode: http.StatusOK,
body: `{"name":"parth","plan":"pro"}`,
wantStatus: accountStateSignedIn,
wantPlan: "pro",
},
{
name: "signed out",
statusCode: http.StatusUnauthorized,
body: `{"error":"unauthorized","signin_url":"https://example.com/signin"}`,
wantStatus: accountStateSignedOut,
},
{
name: "unreachable",
statusCode: http.StatusInternalServerError,
body: `{"error":"temporary failure"}`,
wantStatus: accountStateUnknown,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/me" {
http.NotFound(w, r)
return
}
w.WriteHeader(tt.statusCode)
fmt.Fprint(w, tt.body)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
got := launchAccountState(context.Background(), api.NewClient(u, srv.Client()))
if got.Status != tt.wantStatus {
t.Fatalf("Status = %v, want %v", got.Status, tt.wantStatus)
}
if got.Plan != tt.wantPlan {
t.Fatalf("Plan = %q, want %q", got.Plan, tt.wantPlan)
}
})
}
}
func TestStartAccountStatePrefetch_SkipsWhoamiWhenCloudDisabled(t *testing.T) {
var whoamiCalled atomic.Bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/status":
fmt.Fprint(w, `{"cloud":{"disabled":true,"source":"config"}}`)
case "/api/me":
whoamiCalled.Store(true)
http.NotFound(w, r)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
prefetch := StartAccountStatePrefetch(context.Background())
select {
case <-prefetch.done:
case <-time.After(time.Second):
t.Fatal("account prefetch did not finish")
}
if whoamiCalled.Load() {
t.Fatal("prefetch should not call whoami when cloud is disabled")
}
state := prefetch.StateIfReady()
if state == nil || state.Status != accountStateUnknown {
t.Fatalf("prefetch state = %#v, want unknown", state)
}
}
func TestEnsureAuth_PreservesCancelledSignInHook(t *testing.T) {
oldSignIn := DefaultSignIn
DefaultSignIn = func(modelName, signInURL string) (string, error) {
@@ -1740,21 +1489,11 @@ func TestIntegration_InstallHint(t *testing.T) {
input: "codex",
wantURL: "https://developers.openai.com/codex/cli/",
},
{
name: "codex app has hint",
input: "codex-app",
wantURL: "https://developers.openai.com/codex/quickstart",
},
{
name: "openclaw has hint",
input: "openclaw",
wantURL: "https://docs.openclaw.ai",
},
{
name: "pool has hint",
input: "pool",
wantURL: "https://github.com/poolsideai/pool",
},
{
name: "unknown has no hint",
input: "unknown",
@@ -1810,49 +1549,11 @@ func TestListIntegrationInfos(t *testing.T) {
for _, info := range infos {
got = append(got, info.Name)
}
want := append([]string(nil), integrationOrder...)
if poolsideGOOS == "windows" {
filtered := make([]string, 0, len(want))
for _, name := range want {
if name != "pool" {
filtered = append(filtered, name)
}
}
want = filtered
}
if codexAppSupported() != nil {
filtered := make([]string, 0, len(want))
for _, name := range want {
if name != "codex-app" {
filtered = append(filtered, name)
}
}
want = filtered
}
if diff := compareStrings(got, want); diff != "" {
if diff := compareStrings(got, integrationOrder); diff != "" {
t.Fatalf("launcher integration order mismatch: %s", diff)
}
})
t.Run("prioritizes primary launcher integrations", func(t *testing.T) {
got := make([]string, 0, len(infos))
for _, info := range infos {
got = append(got, info.Name)
}
wantPrefix := []string{"claude", "codex-app", "hermes", "openclaw"}
if codexAppSupported() != nil {
wantPrefix = []string{"claude", "hermes", "openclaw", "opencode"}
}
if len(got) < len(wantPrefix) {
t.Fatalf("expected at least %d integrations, got %v", len(wantPrefix), got)
}
if diff := compareStrings(got[:len(wantPrefix)], wantPrefix); diff != "" {
t.Fatalf("unexpected primary launcher order: %s", diff)
}
})
t.Run("all fields populated", func(t *testing.T) {
for _, info := range infos {
if info.Name == "" {
@@ -1866,12 +1567,6 @@ func TestListIntegrationInfos(t *testing.T) {
t.Run("includes known integrations", func(t *testing.T) {
known := map[string]bool{"claude": false, "codex": false, "opencode": false}
if codexAppSupported() == nil {
known["codex-app"] = false
}
if poolsideGOOS != "windows" {
known["pool"] = false
}
for _, info := range infos {
if _, ok := known[info.Name]; ok {
known[info.Name] = true
@@ -1907,26 +1602,6 @@ func TestListIntegrationInfos(t *testing.T) {
})
}
func TestListIntegrationInfos_HidesPoolsideOnWindows(t *testing.T) {
prev := poolsideGOOS
poolsideGOOS = "windows"
t.Cleanup(func() { poolsideGOOS = prev })
for _, info := range ListIntegrationInfos() {
if info.Name == "pool" {
t.Fatal("expected pool to be hidden on Windows")
}
}
}
func TestListIntegrationInfos_HidesClaudeDesktop(t *testing.T) {
for _, info := range ListIntegrationInfos() {
if info.Name == "claude-desktop" {
t.Fatal("expected hidden claude-desktop to be absent")
}
}
}
func TestBuildModelList_Descriptions(t *testing.T) {
t.Run("installed recommended has base description", func(t *testing.T) {
existing := []modelInfo{
@@ -1953,7 +1628,7 @@ func TestBuildModelList_Descriptions(t *testing.T) {
for _, item := range items {
if item.Name == "qwen3.5" {
if !strings.Contains(item.Description, "~14GB") {
if !strings.Contains(item.Description, "~11GB") {
t.Errorf("not-installed qwen3.5 should show VRAM hint, got %q", item.Description)
}
return
@@ -1970,7 +1645,7 @@ func TestBuildModelList_Descriptions(t *testing.T) {
for _, item := range items {
if item.Name == "qwen3.5" {
if strings.Contains(item.Description, "~14GB") {
if strings.Contains(item.Description, "~11GB") {
t.Errorf("installed qwen3.5 should not show VRAM hint, got %q", item.Description)
}
return
@@ -1989,7 +1664,6 @@ func TestIntegration_Editor(t *testing.T) {
{"opencode", true},
{"openclaw", true},
{"claude", false},
{"claude-desktop", false},
{"codex", false},
{"nonexistent", false},
}
@@ -2016,7 +1690,6 @@ func TestIntegration_AutoInstallable(t *testing.T) {
{"pi", true},
{"hermes", true},
{"claude", false},
{"claude-desktop", false},
{"codex", false},
{"opencode", false},
}
@@ -2034,20 +1707,6 @@ func TestIntegration_AutoInstallable(t *testing.T) {
}
}
func TestEnsureIntegrationInstalled_PoolsideUnsupportedOnWindows(t *testing.T) {
prev := poolsideGOOS
poolsideGOOS = "windows"
t.Cleanup(func() { poolsideGOOS = prev })
err := EnsureIntegrationInstalled("pool", &Poolside{})
if err == nil {
t.Fatal("expected Windows unsupported error")
}
if !strings.Contains(err.Error(), "not currently supported on Windows") {
t.Fatalf("expected Windows warning, got %v", err)
}
}
func TestIntegrationModels(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)

View File

@@ -36,7 +36,7 @@ func (k *Kimi) args(config string, extra []string) []string {
return args
}
func (k *Kimi) Run(model string, _ []LaunchModel, args []string) error {
func (k *Kimi) Run(model string, args []string) error {
if strings.TrimSpace(model) == "" {
return fmt.Errorf("model is required")
}

View File

@@ -307,7 +307,7 @@ func TestKimiRun_RejectsConflictingArgsBeforeInstall(t *testing.T) {
}
t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm })
err := k.Run("llama3.2", nil, []string{"--model", "other"})
err := k.Run("llama3.2", []string{"--model", "other"})
if err == nil || !strings.Contains(err.Error(), "--model") {
t.Fatalf("expected conflict error mentioning --model, got %v", err)
}
@@ -337,7 +337,7 @@ exit 0
t.Setenv("OLLAMA_HOST", srv.URL)
k := &Kimi{}
if err := k.Run("llama3.2", nil, []string{"--quiet", "--print"}); err != nil {
if err := k.Run("llama3.2", []string{"--quiet", "--print"}); err != nil {
t.Fatalf("Run() error = %v", err)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,201 +0,0 @@
package launch
import (
"context"
"slices"
"strings"
"sync"
"github.com/ollama/ollama/api"
modelpkg "github.com/ollama/ollama/types/model"
)
// LaunchModel is the model metadata Launch passes to integration config
// writers after resolving selected model names through the per-run inventory.
type LaunchModel struct {
Name string
Remote bool
ToolCapable bool
Capabilities []modelpkg.Capability
ContextLength int
MaxOutputTokens int
EmbeddingLength int
Size int64
Details api.ModelDetails
}
type modelInfo = LaunchModel
// ModelInfo re-exports launcher model inventory details for callers.
type ModelInfo = LaunchModel
func (m LaunchModel) HasCapability(capability modelpkg.Capability) bool {
return slices.Contains(m.Capabilities, capability)
}
func (m LaunchModel) WithCloudLimits() LaunchModel {
if limit, ok := lookupCloudModelLimit(m.Name); ok {
if m.ContextLength <= 0 {
m.ContextLength = limit.Context
}
if m.MaxOutputTokens <= 0 {
m.MaxOutputTokens = limit.Output
}
}
return m
}
type modelInventory struct {
client *api.Client
mu sync.Mutex
loaded bool
models []LaunchModel
err error
}
func newModelInventory(client *api.Client) *modelInventory {
return &modelInventory{client: client}
}
func (i *modelInventory) Load(ctx context.Context) ([]LaunchModel, error) {
return i.load(ctx, false)
}
func (i *modelInventory) Refresh(ctx context.Context) ([]LaunchModel, error) {
return i.load(ctx, true)
}
func (i *modelInventory) load(ctx context.Context, force bool) ([]LaunchModel, error) {
if i == nil || i.client == nil {
return nil, nil
}
i.mu.Lock()
defer i.mu.Unlock()
if i.loaded && !force {
return cloneLaunchModels(i.models), i.err
}
resp, err := i.client.List(ctx)
if err != nil {
i.models = nil
i.err = err
i.loaded = true
return nil, err
}
i.models = make([]LaunchModel, 0, len(resp.Models))
for _, model := range resp.Models {
i.models = append(i.models, launchModelFromListResponse(model))
}
i.err = nil
i.loaded = true
return cloneLaunchModels(i.models), i.err
}
func (i *modelInventory) Resolve(ctx context.Context, names []string) []LaunchModel {
names = dedupeModelList(names)
if len(names) == 0 {
return nil
}
models, err := i.Load(ctx)
if err != nil {
models = nil
}
resolved, localMiss := resolveLaunchModels(names, models)
if localMiss {
if refreshed, err := i.Refresh(ctx); err == nil {
resolved, _ = resolveLaunchModels(names, refreshed)
}
}
return resolved
}
func resolveLaunchModels(names []string, models []LaunchModel) ([]LaunchModel, bool) {
resolved := make([]LaunchModel, 0, len(names))
localMiss := false
for _, name := range names {
if model, ok := findLaunchModel(models, name); ok {
resolved = append(resolved, model.WithCloudLimits())
continue
}
if !isCloudModelName(name) {
localMiss = true
}
resolved = append(resolved, fallbackLaunchModel(name))
}
return resolved, localMiss
}
func launchModelFromListResponse(model api.ListModelResponse) LaunchModel {
return LaunchModel{
Name: model.Name,
Remote: model.RemoteModel != "",
ToolCapable: slices.Contains(model.Capabilities, modelpkg.CapabilityTools),
Capabilities: append([]modelpkg.Capability(nil), model.Capabilities...),
ContextLength: model.Details.ContextLength,
EmbeddingLength: model.Details.EmbeddingLength,
Size: model.Size,
Details: model.Details,
}.WithCloudLimits()
}
func fallbackLaunchModel(name string) LaunchModel {
return LaunchModel{Name: name, Remote: isCloudModelName(name)}.WithCloudLimits()
}
func findLaunchModel(models []LaunchModel, name string) (LaunchModel, bool) {
for _, model := range models {
if launchModelMatches(model.Name, name) {
return cloneLaunchModel(model), true
}
}
return LaunchModel{}, false
}
func launchModelMatches(candidate, name string) bool {
if candidate == name {
return true
}
return strings.TrimSuffix(candidate, ":latest") == name
}
func cloneLaunchModel(model LaunchModel) LaunchModel {
model.Capabilities = append([]modelpkg.Capability(nil), model.Capabilities...)
model.Details.Families = append([]string(nil), model.Details.Families...)
return model
}
func cloneLaunchModels(models []LaunchModel) []LaunchModel {
cloned := make([]LaunchModel, len(models))
for i, model := range models {
cloned[i] = cloneLaunchModel(model)
}
return cloned
}
func launchModelNames(models []LaunchModel) []string {
names := make([]string, 0, len(models))
for _, model := range models {
if model.Name != "" {
names = append(names, model.Name)
}
}
return names
}
func launchModelsFromNames(names []string) []LaunchModel {
models := make([]LaunchModel, 0, len(names))
for _, name := range names {
if name == "" {
continue
}
models = append(models, fallbackLaunchModel(name))
}
return models
}

View File

@@ -1,80 +0,0 @@
package launch
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/ollama/ollama/api"
modelpkg "github.com/ollama/ollama/types/model"
)
func TestModelInventoryResolveRefreshesLocalMiss(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/tags" {
http.NotFound(w, r)
return
}
calls++
if calls == 1 {
fmt.Fprint(w, `{"models":[]}`)
return
}
fmt.Fprint(w, `{"models":[{"name":"new-model","size":123,"details":{"context_length":65536,"embedding_length":1024},"capabilities":["vision","tools"]}]}`)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
inventory := newModelInventory(api.NewClient(u, srv.Client()))
got := inventory.Resolve(context.Background(), []string{"new-model"})
if calls != 2 {
t.Fatalf("List calls = %d, want 2", calls)
}
if len(got) != 1 {
t.Fatalf("Resolve returned %d models, want 1", len(got))
}
if got[0].Name != "new-model" {
t.Fatalf("Name = %q, want new-model", got[0].Name)
}
if got[0].ContextLength != 65_536 || got[0].EmbeddingLength != 1_024 {
t.Fatalf("metadata = context %d embedding %d, want refreshed metadata", got[0].ContextLength, got[0].EmbeddingLength)
}
if !got[0].HasCapability(modelpkg.CapabilityVision) || !got[0].ToolCapable {
t.Fatalf("capabilities = %v toolCapable=%v, want refreshed capabilities", got[0].Capabilities, got[0].ToolCapable)
}
}
func TestModelInventoryResolveDoesNotRefreshCloudMiss(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/tags" {
http.NotFound(w, r)
return
}
calls++
fmt.Fprint(w, `{"models":[]}`)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
inventory := newModelInventory(api.NewClient(u, srv.Client()))
got := inventory.Resolve(context.Background(), []string{"glm-5.1:cloud"})
if calls != 1 {
t.Fatalf("List calls = %d, want 1", calls)
}
if len(got) != 1 {
t.Fatalf("Resolve returned %d models, want 1", len(got))
}
if got[0].Name != "glm-5.1:cloud" || !got[0].Remote {
t.Fatalf("resolved model = %#v, want cloud fallback", got[0])
}
if got[0].ContextLength <= 0 || got[0].MaxOutputTokens <= 0 {
t.Fatalf("cloud limits not applied: %#v", got[0])
}
}

View File

@@ -4,42 +4,34 @@ import (
"context"
"errors"
"fmt"
"math"
"net/http"
"os"
"os/exec"
"runtime"
"slices"
"strings"
"sync"
"time"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/config"
"github.com/ollama/ollama/format"
"github.com/ollama/ollama/cmd/internal/fileutil"
internalcloud "github.com/ollama/ollama/internal/cloud"
"github.com/ollama/ollama/internal/modelref"
"github.com/ollama/ollama/progress"
)
var recommendedModels = []ModelItem{
{Name: "kimi-k2.6:cloud", Description: "State-of-the-art coding, long-horizon execution, and multimodal agent swarm capability", Recommended: true, Details: api.ModelDetails{ContextLength: 262_144}, MaxOutputTokens: 262_144},
{Name: "qwen3.5:cloud", Description: "Reasoning, coding, and agentic tool use with vision", Recommended: true, Details: api.ModelDetails{ContextLength: 262_144}, MaxOutputTokens: 32_768},
{Name: "glm-5.1:cloud", Description: "Reasoning and code generation", Recommended: true, Details: api.ModelDetails{ContextLength: 202_752}, MaxOutputTokens: 131_072},
{Name: "minimax-m2.7:cloud", Description: "Fast, efficient coding and real-world productivity", Recommended: true, Details: api.ModelDetails{ContextLength: 204_800}, MaxOutputTokens: 128_000},
{Name: "gemma4", Description: "Reasoning and code generation locally", Recommended: true, VRAMBytes: 12 * format.GigaByte},
{Name: "qwen3.5", Description: "Reasoning, coding, and visual understanding locally", Recommended: true, VRAMBytes: 14 * format.GigaByte},
{Name: "kimi-k2.6:cloud", Description: "State-of-the-art coding, long-horizon execution, and multimodal agent swarm capability", Recommended: true},
{Name: "qwen3.5:cloud", Description: "Reasoning, coding, and agentic tool use with vision", Recommended: true},
{Name: "glm-5.1:cloud", Description: "Reasoning and code generation", Recommended: true},
{Name: "minimax-m2.7:cloud", Description: "Fast, efficient coding and real-world productivity", Recommended: true},
{Name: "gemma4", Description: "Reasoning and code generation locally", Recommended: true},
{Name: "qwen3.5", Description: "Reasoning, coding, and visual understanding locally", Recommended: true},
}
func displayVRAM(vramBytes int64) string {
if vramBytes <= 0 {
return ""
}
gb := float64(vramBytes) / format.GigaByte
if gb == math.Trunc(gb) {
return fmt.Sprintf("~%.0fGB", gb)
}
return fmt.Sprintf("~%.1fGB", gb)
var recommendedVRAM = map[string]string{
"gemma4": "~16GB",
"qwen3.5": "~11GB",
}
// cloudModelLimit holds context and output token limits for a cloud model.
@@ -48,10 +40,10 @@ type cloudModelLimit struct {
Output int
}
// extraCloudModelLimits maps cloud model base names to token limits for models
// that are not already covered by recommendedModels fallback entries.
// cloudModelLimits maps cloud model base names to their token limits.
// TODO(parthsareen): grab context/output limits from model info instead of hardcoding
var extraCloudModelLimits = map[string]cloudModelLimit{
var cloudModelLimits = map[string]cloudModelLimit{
"minimax-m2.7": {Context: 204_800, Output: 128_000},
"cogito-2.1:671b": {Context: 163_840, Output: 65_536},
"deepseek-v3.1:671b": {Context: 163_840, Output: 163_840},
"deepseek-v3.2": {Context: 163_840, Output: 65_536},
@@ -73,24 +65,11 @@ var extraCloudModelLimits = map[string]cloudModelLimit{
"qwen3.5": {Context: 262_144, Output: 32_768},
}
var cloudModelLimits = mergeCloudModelLimits(cloudModelLimitsFromRecommendations(recommendedModels), extraCloudModelLimits)
var (
dynamicCloudModelLimitsMu sync.RWMutex
dynamicCloudModelLimits = map[string]cloudModelLimit{}
)
// lookupCloudModelLimit returns the token limits for a cloud model.
// It normalizes explicit cloud source suffixes before checking the shared limit map.
func lookupCloudModelLimit(name string) (cloudModelLimit, bool) {
base, stripped := modelref.StripCloudSourceTag(name)
if stripped {
dynamicCloudModelLimitsMu.RLock()
l, ok := dynamicCloudModelLimits[base]
dynamicCloudModelLimitsMu.RUnlock()
if ok {
return l, true
}
if l, ok := cloudModelLimits[base]; ok {
return l, true
}
@@ -98,49 +77,6 @@ func lookupCloudModelLimit(name string) (cloudModelLimit, bool) {
return cloudModelLimit{}, false
}
func setDynamicCloudModelLimits(limits map[string]cloudModelLimit) {
dynamicCloudModelLimitsMu.Lock()
defer dynamicCloudModelLimitsMu.Unlock()
if limits == nil {
dynamicCloudModelLimits = map[string]cloudModelLimit{}
return
}
cp := make(map[string]cloudModelLimit, len(limits))
for k, v := range limits {
cp[k] = v
}
dynamicCloudModelLimits = cp
}
func cloudModelLimitsFromRecommendations(recommendations []ModelItem) map[string]cloudModelLimit {
limits := make(map[string]cloudModelLimit, len(recommendations))
for _, rec := range recommendations {
if !isCloudModelName(rec.Name) || rec.Details.ContextLength <= 0 || rec.MaxOutputTokens <= 0 {
continue
}
base, stripped := modelref.StripCloudSourceTag(rec.Name)
if !stripped || base == "" {
continue
}
limits[base] = cloudModelLimit{
Context: rec.Details.ContextLength,
Output: rec.MaxOutputTokens,
}
}
return limits
}
func mergeCloudModelLimits(base map[string]cloudModelLimit, overlay map[string]cloudModelLimit) map[string]cloudModelLimit {
out := make(map[string]cloudModelLimit, len(base)+len(overlay))
for name, limit := range base {
out[name] = limit
}
for name, limit := range overlay {
out[name] = limit
}
return out
}
// missingModelPolicy controls how model-not-found errors should be handled.
type missingModelPolicy int
@@ -180,27 +116,22 @@ func ensureAuth(ctx context.Context, client *api.Client, cloudModels map[string]
if len(selectedCloudModels) == 0 {
return nil
}
return ensureCloudAuth(ctx, client, strings.Join(selectedCloudModels, ", "))
}
func ensureCloudAuth(ctx context.Context, client *api.Client, modelList string) error {
if disabled, known := cloudStatusDisabled(ctx, client); known && disabled {
return errors.New(internalcloud.DisabledError("remote inference is unavailable"))
}
user, err := whoamiWithTimeout(ctx, client)
user, err := client.Whoami(ctx)
if err == nil && user != nil && user.Name != "" {
return nil
}
var aErr api.AuthorizationError
if !errors.As(err, &aErr) || aErr.SigninURL == "" {
if err != nil {
return err
}
return fmt.Errorf("%s requires sign in", modelList)
return err
}
modelList := strings.Join(selectedCloudModels, ", ")
if DefaultSignIn != nil {
_, err := DefaultSignIn(modelList, aErr.SigninURL)
if errors.Is(err, ErrCancelled) {
@@ -243,7 +174,7 @@ func ensureCloudAuth(ctx context.Context, client *api.Client, modelList string)
fmt.Fprintf(os.Stderr, "\r\033[90mwaiting for sign in to complete... %s\033[0m", spinnerFrames[frame%len(spinnerFrames)])
if frame%10 == 0 {
u, err := whoamiWithTimeout(ctx, client)
u, err := client.Whoami(ctx)
if err == nil && u != nil && u.Name != "" {
fmt.Fprintf(os.Stderr, "\r\033[K\033[A\r\033[K\033[1msigned in:\033[0m %s\n", u.Name)
return nil
@@ -299,24 +230,28 @@ func pullMissingModel(ctx context.Context, client *api.Client, model string) err
}
// prepareEditorIntegration persists models and applies editor-managed config files.
func prepareEditorIntegration(name string, editor Editor, models []LaunchModel) error {
func prepareEditorIntegration(name string, runner Runner, editor Editor, models []string) error {
if ok, err := confirmConfigEdit(runner, editor.Paths()); err != nil {
return err
} else if !ok {
return errCancelled
}
if err := editor.Edit(models); err != nil {
return fmt.Errorf("setup failed: %w", err)
}
if err := config.SaveIntegration(name, launchModelNames(models)); err != nil {
if err := config.SaveIntegration(name, models); err != nil {
return fmt.Errorf("failed to save: %w", err)
}
return nil
}
func prepareManagedSingleIntegration(name string, managed ManagedSingleModel, model string, models []LaunchModel) error {
var err error
if withModels, ok := managed.(ManagedModelListConfigurer); ok {
err = withModels.ConfigureWithModels(model, models)
} else {
err = managed.Configure(model)
func prepareManagedSingleIntegration(name string, runner Runner, managed ManagedSingleModel, model string) error {
if ok, err := confirmConfigEdit(runner, managed.Paths()); err != nil {
return err
} else if !ok {
return errCancelled
}
if err != nil {
if err := managed.Configure(model); err != nil {
return fmt.Errorf("setup failed: %w", err)
}
if err := config.SaveIntegration(name, []string{model}); err != nil {
@@ -325,33 +260,31 @@ func prepareManagedSingleIntegration(name string, managed ManagedSingleModel, mo
return nil
}
func prepareManagedAutodiscoveryIntegration(name string, autodiscovery ManagedAutodiscoveryIntegration, model string) error {
if err := autodiscovery.ConfigureAutodiscovery(); err != nil {
return fmt.Errorf("setup failed: %w", err)
func confirmConfigEdit(runner Runner, paths []string) (bool, error) {
if len(paths) == 0 {
return true, nil
}
if err := config.SaveIntegration(name, []string{model}); err != nil {
return fmt.Errorf("failed to save: %w", err)
fmt.Fprintf(os.Stderr, "This will modify your %s configuration:\n", runner)
for _, path := range paths {
fmt.Fprintf(os.Stderr, " %s\n", path)
}
return nil
fmt.Fprintf(os.Stderr, "Backups will be saved to %s/\n\n", fileutil.BackupDir())
return ConfirmPrompt("Proceed?")
}
// buildModelList merges existing models with recommendations for selection UIs.
func buildModelList(existing []modelInfo, preChecked []string, current string) (items []ModelItem, orderedChecked []string, existingModels, cloudModels map[string]bool) {
return buildModelListWithRecommendations(existing, recommendedModels, preChecked, current)
}
func buildModelListWithRecommendations(existing []modelInfo, recommendations []ModelItem, preChecked []string, current string) (items []ModelItem, orderedChecked []string, existingModels, cloudModels map[string]bool) {
existingModels = make(map[string]bool)
cloudModels = make(map[string]bool)
recommended := make(map[string]bool)
var hasLocalModel, hasCloudModel bool
recDesc := make(map[string]string)
recByName := make(map[string]ModelItem)
for _, rec := range recommendations {
for _, rec := range recommendedModels {
recommended[rec.Name] = true
recDesc[rec.Name] = rec.Description
recByName[rec.Name] = rec
}
for _, m := range existing {
@@ -364,14 +297,11 @@ func buildModelListWithRecommendations(existing []modelInfo, recommendations []M
}
displayName := strings.TrimSuffix(m.Name, ":latest")
existingModels[displayName] = true
if rec, ok := recByName[displayName]; ok {
items = append(items, modelItemFromInventory(displayName, m, copyModelRecommendationFields(displayName, rec)))
} else {
items = append(items, modelItemFromInventory(displayName, m, ModelItem{Name: displayName, Recommended: recommended[displayName], Description: recDesc[displayName]}))
}
item := ModelItem{Name: displayName, Recommended: recommended[displayName], Description: recDesc[displayName]}
items = append(items, item)
}
for _, rec := range recommendations {
for _, rec := range recommendedModels {
if existingModels[rec.Name] || existingModels[rec.Name+":latest"] {
continue
}
@@ -417,7 +347,7 @@ func buildModelListWithRecommendations(existing []modelInfo, recommendations []M
if items[i].Description != "" {
parts = append(parts, items[i].Description)
}
if vram := displayVRAM(items[i].VRAMBytes); vram != "" {
if vram := recommendedVRAM[items[i].Name]; vram != "" {
parts = append(parts, vram)
}
parts = append(parts, "(not downloaded)")
@@ -426,12 +356,12 @@ func buildModelListWithRecommendations(existing []modelInfo, recommendations []M
}
recRank := make(map[string]int)
for i, rec := range recommendations {
for i, rec := range recommendedModels {
recRank[rec.Name] = i + 1
}
if hasLocalModel || hasCloudModel {
// Keep the Recommended section pinned to recommendation order. Checked
// Keep the Recommended section pinned to recommendedModels order. Checked
// and default-model priority only apply within the More section.
slices.SortStableFunc(items, func(a, b ModelItem) int {
ac, bc := checked[a.Name], checked[b.Name]
@@ -476,21 +406,6 @@ func buildModelListWithRecommendations(existing []modelInfo, recommendations []M
return items, preChecked, existingModels, cloudModels
}
func copyModelRecommendationFields(name string, rec ModelItem) ModelItem {
rec.Name = name
rec.Recommended = true
return rec
}
func modelItemFromInventory(name string, info modelInfo, item ModelItem) ModelItem {
item.Name = name
item.ToolCapable = info.ToolCapable
item.Capabilities = slices.Clone(info.Capabilities)
item.Size = info.Size
item.Details = info.Details
return item
}
// isCloudModelName reports whether the model name has an explicit cloud source.
func isCloudModelName(name string) bool {
return modelref.HasExplicitCloudSource(name)

View File

@@ -1,83 +0,0 @@
package launch
import (
"testing"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/format"
modelpkg "github.com/ollama/ollama/types/model"
)
func TestBuildModelList_UsesInventoryMetadataForInstalledModels(t *testing.T) {
existing := []modelInfo{
{
Name: "custom-tools:latest",
ToolCapable: true,
Capabilities: []modelpkg.Capability{modelpkg.CapabilityCompletion, modelpkg.CapabilityTools, modelpkg.CapabilityThinking},
Size: 7500 * format.MegaByte,
Details: api.ModelDetails{
ParameterSize: "8B",
QuantizationLevel: "Q4_K_M",
ContextLength: 131_072,
EmbeddingLength: 4096,
},
},
}
items, _, _, _ := buildModelList(existing, nil, "")
var got ModelItem
for _, item := range items {
if item.Name == "custom-tools" {
got = item
break
}
}
if got.Name == "" {
t.Fatal("custom-tools not found in items")
}
if !got.ToolCapable {
t.Fatal("expected installed model to preserve tool capability from tags metadata")
}
if got.Details.ContextLength != 131_072 {
t.Fatalf("Details.ContextLength = %d, want 131072", got.Details.ContextLength)
}
if got.Size != 7500*format.MegaByte {
t.Fatalf("Size = %d, want %d", got.Size, 7500*format.MegaByte)
}
if got.Description != "" {
t.Fatalf("Description = %q, want empty for installed model without recommendation copy", got.Description)
}
}
func TestBuildModelList_InstalledRecommendedPreservesRecommendationAndMetadata(t *testing.T) {
existing := []modelInfo{
{
Name: "qwen3.5",
ToolCapable: true,
Capabilities: []modelpkg.Capability{modelpkg.CapabilityCompletion, modelpkg.CapabilityTools, modelpkg.CapabilityVision},
Size: 14 * format.GigaByte,
Details: api.ModelDetails{ContextLength: 262_144},
},
}
items, _, _, _ := buildModelList(existing, nil, "")
var got ModelItem
for _, item := range items {
if item.Name == "qwen3.5" {
got = item
break
}
}
if got.Name == "" {
t.Fatal("qwen3.5 not found in items")
}
if !got.Recommended || !got.ToolCapable {
t.Fatalf("recommended/tool metadata = %v/%v, want true/true", got.Recommended, got.ToolCapable)
}
if got.Details.ContextLength != 262_144 {
t.Fatalf("Details.ContextLength = %d, want 262144", got.Details.ContextLength)
}
if got.Description != "Reasoning, coding, and visual understanding locally" {
t.Fatalf("Description = %q, want recommendation description", got.Description)
}
}

View File

@@ -1,6 +1,7 @@
package launch
import (
"context"
"encoding/json"
"fmt"
"net"
@@ -9,25 +10,29 @@ import (
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"time"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/envconfig"
"github.com/ollama/ollama/types/model"
)
const defaultGatewayPort = 18789
// Bound model capability probing so launch/config cannot hang on slow/unreachable API calls.
var openclawModelShowTimeout = 5 * time.Second
// openclawFreshInstall is set to true when ensureOpenclawInstalled performs an install
var openclawFreshInstall bool
var openclawCanInstallDaemon = canInstallDaemon
type Openclaw struct{}
func (c *Openclaw) String() string { return "OpenClaw" }
func (c *Openclaw) Run(model string, _ []LaunchModel, args []string) error {
func (c *Openclaw) Run(model string, args []string) error {
bin, err := ensureOpenclawInstalled()
if err != nil {
return err
@@ -53,7 +58,6 @@ func (c *Openclaw) Run(model string, _ []LaunchModel, args []string) error {
// the newest wizard flags (e.g. --auth-choice ollama).
if !openclawFreshInstall {
update := exec.Command(bin, "update")
update.Env = openclawInstallEnv()
update.Stdout = os.Stdout
update.Stderr = os.Stderr
_ = update.Run() // best-effort; continue even if update fails
@@ -69,18 +73,19 @@ func (c *Openclaw) Run(model string, _ []LaunchModel, args []string) error {
"--auth-choice", "ollama",
"--custom-base-url", envconfig.Host().String(),
"--custom-model-id", model,
// Launch owns the first real gateway startup immediately after onboarding,
// so don't let OpenClaw fail the whole first-run flow on a transient
// daemon health probe.
"--skip-health",
"--skip-channels",
"--skip-skills",
}
if openclawCanInstallDaemon() {
if canInstallDaemon() {
onboardArgs = append(onboardArgs, "--install-daemon")
} else {
// When we can't install a daemon (e.g. no systemd, sudo dropped
// XDG_RUNTIME_DIR, or container environment), skip the gateway
// health check so non-interactive onboarding completes. The
// gateway is started as a foreground child process after onboarding.
onboardArgs = append(onboardArgs, "--skip-health")
}
cmd := exec.Command(bin, onboardArgs...)
cmd.Env = openclawInstallEnv()
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -96,18 +101,6 @@ func (c *Openclaw) Run(model string, _ []LaunchModel, args []string) error {
// When extra args are passed through, run exactly what the user asked for
// after setup and skip the built-in gateway+TUI convenience flow.
if len(args) > 0 {
cleanup := func() {}
if shouldEnsureGatewayForArgs(args) {
cleanupFn, _, _, err := c.ensureGatewayReady(bin)
if err != nil {
return windowsHint(err)
}
if cleanupFn != nil {
cleanup = cleanupFn
}
}
defer cleanup()
cmd := exec.Command(bin, args...)
cmd.Env = openclawEnv()
cmd.Stdin = os.Stdin
@@ -128,11 +121,41 @@ func (c *Openclaw) Run(model string, _ []LaunchModel, args []string) error {
fmt.Fprintf(os.Stderr, "\n%sStarting your assistant — this may take a moment...%s\n\n", ansiGray, ansiReset)
cleanup, token, port, err := c.ensureGatewayReady(bin)
if err != nil {
return windowsHint(err)
token, port := c.gatewayInfo()
addr := fmt.Sprintf("localhost:%d", port)
// If the gateway is already running (e.g. via the daemon), restart it
// so it picks up any config changes (model, provider, etc.).
if portOpen(addr) {
restart := exec.Command(bin, "daemon", "restart")
restart.Env = openclawEnv()
if err := restart.Run(); err != nil {
fmt.Fprintf(os.Stderr, "%s Warning: daemon restart failed: %v%s\n", ansiYellow, err, ansiReset)
}
if !waitForPort(addr, 10*time.Second) {
fmt.Fprintf(os.Stderr, "%s Warning: gateway did not come back after restart%s\n", ansiYellow, ansiReset)
}
}
// If the gateway isn't running, start it as a background child process.
if !portOpen(addr) {
gw := exec.Command(bin, "gateway", "run", "--force")
gw.Env = openclawEnv()
if err := gw.Start(); err != nil {
return windowsHint(fmt.Errorf("failed to start gateway: %w", err))
}
defer func() {
if gw.Process != nil {
_ = gw.Process.Kill()
_ = gw.Wait()
}
}()
}
fmt.Fprintf(os.Stderr, "%sStarting gateway...%s\n", ansiGray, ansiReset)
if !waitForPort(addr, 30*time.Second) {
return windowsHint(fmt.Errorf("gateway did not start on %s", addr))
}
defer cleanup()
printOpenclawReady(bin, token, port, firstLaunch)
@@ -152,66 +175,6 @@ func (c *Openclaw) Run(model string, _ []LaunchModel, args []string) error {
return nil
}
func shouldEnsureGatewayForArgs(args []string) bool {
return len(args) > 0 && args[0] == "tui"
}
func (c *Openclaw) ensureGatewayReady(bin string) (func(), string, int, error) {
token, port := c.gatewayInfo()
addr := fmt.Sprintf("127.0.0.1:%d", port)
// If the gateway is already running (e.g. via the daemon), restart it
// so it picks up any config changes (model, provider, etc.).
if portOpen(addr) {
restart := exec.Command(bin, "daemon", "restart")
restart.Env = openclawEnv()
if err := restart.Run(); err != nil {
fmt.Fprintf(os.Stderr, "%s Warning: daemon restart failed: %v%s\n", ansiYellow, err, ansiReset)
}
if !waitForPort(addr, 10*time.Second) {
fmt.Fprintf(os.Stderr, "%s Warning: gateway did not come back after restart%s\n", ansiYellow, ansiReset)
}
}
// If the daemon is installed but not currently listening, try to bring it
// up before falling back to a foreground child process.
if openclawCanInstallDaemon() && !portOpen(addr) {
start := exec.Command(bin, "daemon", "start")
start.Env = openclawEnv()
if err := start.Run(); err != nil {
fmt.Fprintf(os.Stderr, "%s Warning: daemon start failed: %v%s\n", ansiYellow, err, ansiReset)
} else if waitForPort(addr, 10*time.Second) {
fmt.Fprintf(os.Stderr, "%sStarting gateway...%s\n", ansiGray, ansiReset)
return func() {}, token, port, nil
}
}
cleanup := func() {}
// If the gateway still isn't running, start it as a background child process.
if !portOpen(addr) {
gw := exec.Command(bin, "gateway", "run", "--force")
gw.Env = openclawEnv()
if err := gw.Start(); err != nil {
return nil, "", 0, fmt.Errorf("failed to start gateway: %w", err)
}
cleanup = func() {
if gw.Process != nil {
_ = gw.Process.Kill()
_ = gw.Wait()
}
}
}
fmt.Fprintf(os.Stderr, "%sStarting gateway...%s\n", ansiGray, ansiReset)
if !waitForPort(addr, 30*time.Second) {
cleanup()
return nil, "", 0, fmt.Errorf("gateway did not start on %s", addr)
}
return cleanup, token, port, nil
}
// runChannelSetupPreflight prompts users to connect a messaging channel before
// starting the built-in gateway+TUI flow. In interactive sessions, it loops
// until a channel is configured, unless the user chooses "Set up later".
@@ -334,7 +297,7 @@ func (c *Openclaw) gatewayInfo() (token string, port int) {
}
func printOpenclawReady(bin, token string, port int, firstLaunch bool) {
u := fmt.Sprintf("http://127.0.0.1:%d", port)
u := fmt.Sprintf("http://localhost:%d", port)
if token != "" {
u += "/#token=" + url.QueryEscape(token)
}
@@ -372,30 +335,9 @@ func openclawEnv() []string {
env = append(env, e)
}
}
if _, ok := os.LookupEnv("OPENCLAW_PLUGIN_STAGE_DIR"); !ok {
if dir := openclawPluginStageDir(); dir != "" {
env = append(env, "OPENCLAW_PLUGIN_STAGE_DIR="+dir)
}
}
return env
}
func openclawInstallEnv() []string {
env := openclawEnv()
if _, ok := os.LookupEnv("OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"); !ok {
env = append(env, "OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS=1")
}
return env
}
func openclawPluginStageDir() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".openclaw", "plugin-runtime-deps")
}
// portOpen checks if a TCP port is currently accepting connections.
func portOpen(addr string) bool {
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
@@ -619,7 +561,6 @@ func ensureOpenclawInstalled() (string, error) {
fmt.Fprintf(os.Stderr, "\nInstalling OpenClaw...\n")
cmd := exec.Command("npm", "install", "-g", "openclaw@latest")
cmd.Env = openclawInstallEnv()
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -649,7 +590,7 @@ func (c *Openclaw) Paths() []string {
return nil
}
func (c *Openclaw) Edit(models []LaunchModel) error {
func (c *Openclaw) Edit(models []string) error {
if len(models) == 0 {
return nil
}
@@ -703,11 +644,13 @@ func (c *Openclaw) Edit(models []LaunchModel) error {
}
}
client, _ := api.ClientFromEnvironment()
var newModels []any
for _, m := range models {
entry, _ := openclawModelConfig(m)
entry, _ := openclawModelConfig(context.Background(), client, m)
// Merge existing fields (user customizations)
if existing, ok := existingByID[m.Name]; ok {
if existing, ok := existingByID[m]; ok {
for k, v := range existing {
if _, isNew := entry[k]; !isNew {
entry[k] = v
@@ -735,7 +678,7 @@ func (c *Openclaw) Edit(models []LaunchModel) error {
if modelConfig == nil {
modelConfig = make(map[string]any)
}
modelConfig["primary"] = "ollama/" + models[0].Name
modelConfig["primary"] = "ollama/" + models[0]
defaults["model"] = modelConfig
agents["defaults"] = defaults
config["agents"] = agents
@@ -744,13 +687,13 @@ func (c *Openclaw) Edit(models []LaunchModel) error {
if err != nil {
return err
}
if err := fileutil.WriteWithBackup(configPath, data, "openclaw"); err != nil {
if err := fileutil.WriteWithBackup(configPath, data); err != nil {
return err
}
// Clear any per-session model overrides so the new primary takes effect
// immediately rather than being shadowed by a cached modelOverride.
clearSessionModelOverride(models[0].Name)
clearSessionModelOverride(models[0])
return nil
}
@@ -927,10 +870,10 @@ func configureOllamaWebSearch() {
// openclawModelConfig builds an OpenClaw model config entry with capability detection.
// The second return value indicates whether the model is a cloud (remote) model.
func openclawModelConfig(model LaunchModel) (map[string]any, bool) {
func openclawModelConfig(ctx context.Context, client *api.Client, modelID string) (map[string]any, bool) {
entry := map[string]any{
"id": model.Name,
"name": model.Name,
"id": modelID,
"name": modelID,
"input": []any{"text"},
"cost": map[string]any{
"input": 0,
@@ -940,24 +883,53 @@ func openclawModelConfig(model LaunchModel) (map[string]any, bool) {
},
}
if client == nil {
return entry, false
}
showCtx := ctx
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
var cancel context.CancelFunc
showCtx, cancel = context.WithTimeout(ctx, openclawModelShowTimeout)
defer cancel()
}
resp, err := client.Show(showCtx, &api.ShowRequest{Model: modelID})
if err != nil {
return entry, false
}
// Set input types based on vision capability
if model.HasCapability("vision") {
if slices.Contains(resp.Capabilities, model.CapabilityVision) {
entry["input"] = []any{"text", "image"}
}
// Set reasoning based on thinking capability
if model.HasCapability("thinking") {
if slices.Contains(resp.Capabilities, model.CapabilityThinking) {
entry["reasoning"] = true
}
if model.ContextLength > 0 {
entry["contextWindow"] = model.ContextLength
}
if model.MaxOutputTokens > 0 {
entry["maxTokens"] = model.MaxOutputTokens
// Cloud models: use hardcoded limits for context/output tokens.
// Capability detection above still applies (vision, thinking).
if resp.RemoteModel != "" {
if l, ok := lookupCloudModelLimit(modelID); ok {
entry["contextWindow"] = l.Context
entry["maxTokens"] = l.Output
}
return entry, true
}
return entry, model.Remote || isCloudModelName(model.Name)
// Extract context window from ModelInfo (local models only)
for key, val := range resp.ModelInfo {
if strings.HasSuffix(key, ".context_length") {
if ctxLen, ok := val.(float64); ok && ctxLen > 0 {
entry["contextWindow"] = int(ctxLen)
}
break
}
}
return entry, false
}
func (c *Openclaw) Models() []string {

View File

@@ -2,9 +2,12 @@ package launch
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
@@ -13,8 +16,7 @@ import (
"testing"
"time"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/types/model"
"github.com/ollama/ollama/api"
)
func TestOpenclawIntegration(t *testing.T) {
@@ -75,7 +77,7 @@ func TestOpenclawRunPassthroughArgs(t *testing.T) {
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
c := &Openclaw{}
if err := c.Run("llama3.2", nil, []string{"gateway", "--someflag"}); err != nil {
if err := c.Run("llama3.2", []string{"gateway", "--someflag"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
@@ -149,7 +151,7 @@ fi
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
c := &Openclaw{}
if err := c.Run("llama3.2", nil, nil); err != nil {
if err := c.Run("llama3.2", nil); err != nil {
t.Fatalf("Run() error = %v", err)
}
@@ -221,7 +223,7 @@ func TestOpenclawRun_SetupLaterContinuesToGatewayAndTUI(t *testing.T) {
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
c := &Openclaw{}
if err := c.Run("llama3.2", nil, nil); err != nil {
if err := c.Run("llama3.2", nil); err != nil {
t.Fatalf("Run() error = %v", err)
}
@@ -249,359 +251,6 @@ func TestOpenclawRun_SetupLaterContinuesToGatewayAndTUI(t *testing.T) {
}
}
func TestOpenclawRun_FirstLaunchOnboardUsesLaunchManagedHealthFlow(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell test binary")
}
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("PATH", tmpDir)
bin := filepath.Join(tmpDir, "openclaw")
script := fmt.Sprintf(`#!/bin/sh
printf '%%s\n' "$*" >> "$HOME/invocations.log"
if [ "$1" = "onboard" ]; then
/usr/bin/env | /usr/bin/sort > "$HOME/onboard-env.log"
/bin/mkdir -p "$HOME/.openclaw"
/bin/cat > "$HOME/.openclaw/openclaw.json" <<'EOF'
{"wizard":{"lastRunAt":"2026-01-01T00:00:00Z"},"gateway":{"port":18789,"mode":"local"}}
EOF
fi
exit 0
`)
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
oldConfirmPrompt := DefaultConfirmPrompt
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
if prompt != "I understand the risks. Continue?" {
t.Fatalf("unexpected prompt: %q", prompt)
}
return true, nil
}
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
c := &Openclaw{}
if err := c.Run("llama3.2", nil, []string{"status"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "invocations.log"))
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) < 2 {
t.Fatalf("expected onboard + passthrough invocations, got %v", lines)
}
onboardInvocation := ""
for _, line := range lines {
if strings.HasPrefix(line, "onboard ") {
onboardInvocation = line
break
}
}
if onboardInvocation == "" {
t.Fatalf("expected onboard invocation, got %v", lines)
}
if !strings.Contains(onboardInvocation, "--skip-health") {
t.Fatalf("expected onboard invocation to include --skip-health, got %q", onboardInvocation)
}
envData, err := os.ReadFile(filepath.Join(tmpDir, "onboard-env.log"))
if err != nil {
t.Fatal(err)
}
env := envSliceToMap(strings.Split(strings.TrimSpace(string(envData)), "\n"))
if env["OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"] != "1" {
t.Fatalf("OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS = %q, want %q", env["OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"], "1")
}
if env["OPENCLAW_PLUGIN_STAGE_DIR"] != filepath.Join(tmpDir, ".openclaw", "plugin-runtime-deps") {
t.Fatalf("OPENCLAW_PLUGIN_STAGE_DIR = %q, want %q", env["OPENCLAW_PLUGIN_STAGE_DIR"], filepath.Join(tmpDir, ".openclaw", "plugin-runtime-deps"))
}
}
func TestOpenclawRun_FirstLaunchTUIArgsEnsureGatewayBeforePassthrough(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell test binary")
}
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("PATH", tmpDir)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
port := ln.Addr().(*net.TCPAddr).Port
bin := filepath.Join(tmpDir, "openclaw")
script := fmt.Sprintf(`#!/bin/sh
printf '%%s\n' "$*" >> "$HOME/invocations.log"
if [ "$1" = "onboard" ]; then
/bin/mkdir -p "$HOME/.openclaw"
/bin/cat > "$HOME/.openclaw/openclaw.json" <<'EOF'
{"wizard":{"lastRunAt":"2026-01-01T00:00:00Z"},"gateway":{"port":%d,"mode":"local"}}
EOF
fi
exit 0
`, port)
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
oldConfirmPrompt := DefaultConfirmPrompt
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
if prompt != "I understand the risks. Continue?" {
t.Fatalf("unexpected prompt: %q", prompt)
}
return true, nil
}
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
c := &Openclaw{}
if err := c.Run("llama3.2", nil, []string{"tui"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "invocations.log"))
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) < 3 {
t.Fatalf("expected at least 3 invocations (update, onboard, daemon restart, tui), got %v", lines)
}
onboardIdx, daemonRestartIdx, tuiIdx := -1, -1, -1
for i, line := range lines {
if onboardIdx == -1 && strings.HasPrefix(line, "onboard ") {
onboardIdx = i
}
if daemonRestartIdx == -1 && line == "daemon restart" {
daemonRestartIdx = i
}
if tuiIdx == -1 && line == "tui" {
tuiIdx = i
}
}
if onboardIdx == -1 {
t.Fatalf("expected an onboarding invocation, got %v", lines)
}
if daemonRestartIdx == -1 {
t.Fatalf("expected a daemon restart before tui, got %v", lines)
}
if tuiIdx == -1 {
t.Fatalf("expected a tui invocation, got %v", lines)
}
if !(onboardIdx < daemonRestartIdx && daemonRestartIdx < tuiIdx) {
t.Fatalf("expected onboarding, then daemon restart, then tui; got %v", lines)
}
}
func TestOpenclawEnsureGatewayReady_UsesDaemonStartFallback(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell test binary")
}
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("PATH", tmpDir)
portProbe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := portProbe.Addr().(*net.TCPAddr).Port
_ = portProbe.Close()
configDir := filepath.Join(tmpDir, ".openclaw")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(fmt.Sprintf(`{
"wizard": {"lastRunAt": "2026-01-01T00:00:00Z"},
"gateway": {"port": %d, "mode": "local"}
}`, port)), 0o644); err != nil {
t.Fatal(err)
}
bin := filepath.Join(tmpDir, "openclaw")
if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/invocations.log\"\n"), 0o755); err != nil {
t.Fatal(err)
}
oldCanInstallDaemon := openclawCanInstallDaemon
openclawCanInstallDaemon = func() bool { return true }
defer func() { openclawCanInstallDaemon = oldCanInstallDaemon }()
triggeredBy := make(chan string, 1)
listenerReady := make(chan net.Listener, 1)
go func() {
invocationsPath := filepath.Join(tmpDir, "invocations.log")
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
data, err := os.ReadFile(invocationsPath)
if err == nil {
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
for _, line := range lines {
if line != "daemon start" && line != "gateway run --force" {
continue
}
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
return
}
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
_ = conn.Close()
}
}()
triggeredBy <- line
listenerReady <- ln
return
}
}
time.Sleep(10 * time.Millisecond)
}
}()
c := &Openclaw{}
cleanup, _, gotPort, err := c.ensureGatewayReady(bin)
if err != nil {
t.Fatalf("ensureGatewayReady() error = %v", err)
}
defer cleanup()
if gotPort != port {
t.Fatalf("ensureGatewayReady() port = %d, want %d", gotPort, port)
}
var ln net.Listener
select {
case which := <-triggeredBy:
if which != "daemon start" {
t.Fatalf("expected daemon start fallback, got %q", which)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for gateway startup trigger")
}
select {
case ln = <-listenerReady:
defer ln.Close()
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for test listener")
}
data, err := os.ReadFile(filepath.Join(tmpDir, "invocations.log"))
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) == 0 || lines[0] != "daemon start" {
t.Fatalf("expected daemon start invocation, got %v", lines)
}
for _, line := range lines {
if line == "gateway run --force" {
t.Fatalf("did not expect gateway run fallback when daemon start succeeds, got %v", lines)
}
}
}
func TestOpenclawEnv_StagesBundledPluginRuntimeDeps(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("OPENAI_API_KEY", "should-be-cleared")
env := envSliceToMap(openclawEnv())
if env["OPENCLAW_PLUGIN_STAGE_DIR"] != filepath.Join(tmpDir, ".openclaw", "plugin-runtime-deps") {
t.Fatalf("OPENCLAW_PLUGIN_STAGE_DIR = %q, want %q", env["OPENCLAW_PLUGIN_STAGE_DIR"], filepath.Join(tmpDir, ".openclaw", "plugin-runtime-deps"))
}
if _, ok := env["OPENAI_API_KEY"]; ok {
t.Fatal("expected OPENAI_API_KEY to be cleared from openclaw environment")
}
}
func TestOpenclawInstallEnv_PreservesExplicitStageDirAndAddsEagerDeps(t *testing.T) {
t.Setenv("OPENCLAW_PLUGIN_STAGE_DIR", "/tmp/custom-stage")
env := envSliceToMap(openclawInstallEnv())
if env["OPENCLAW_PLUGIN_STAGE_DIR"] != "/tmp/custom-stage" {
t.Fatalf("OPENCLAW_PLUGIN_STAGE_DIR = %q, want %q", env["OPENCLAW_PLUGIN_STAGE_DIR"], "/tmp/custom-stage")
}
if env["OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"] != "1" {
t.Fatalf("OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS = %q, want %q", env["OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"], "1")
}
}
func TestEnsureOpenclawInstalled_UsesBundledPluginInstallEnv(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell test binary")
}
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("PATH", tmpDir)
writeScript := func(path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
t.Fatal(err)
}
}
openclawPath := filepath.Join(tmpDir, "openclaw")
npmScript := fmt.Sprintf(`#!/bin/sh
/usr/bin/env | /usr/bin/sort > "$HOME/npm-env.log"
/bin/cat > %q <<'EOF'
#!/bin/sh
exit 0
EOF
/bin/chmod +x %q
exit 0
`, openclawPath, openclawPath)
writeScript(filepath.Join(tmpDir, "npm"), npmScript)
writeScript(filepath.Join(tmpDir, "git"), "#!/bin/sh\nexit 0\n")
oldConfirmPrompt := DefaultConfirmPrompt
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
if prompt != "OpenClaw is not installed. Install with npm?" {
t.Fatalf("unexpected prompt: %q", prompt)
}
return true, nil
}
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
openclawFreshInstall = false
bin, err := ensureOpenclawInstalled()
if err != nil {
t.Fatalf("ensureOpenclawInstalled() error = %v", err)
}
if bin != "openclaw" {
t.Fatalf("ensureOpenclawInstalled() bin = %q, want %q", bin, "openclaw")
}
envData, err := os.ReadFile(filepath.Join(tmpDir, "npm-env.log"))
if err != nil {
t.Fatal(err)
}
env := envSliceToMap(strings.Split(strings.TrimSpace(string(envData)), "\n"))
if env["OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"] != "1" {
t.Fatalf("OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS = %q, want %q", env["OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"], "1")
}
if env["OPENCLAW_PLUGIN_STAGE_DIR"] != filepath.Join(tmpDir, ".openclaw", "plugin-runtime-deps") {
t.Fatalf("OPENCLAW_PLUGIN_STAGE_DIR = %q, want %q", env["OPENCLAW_PLUGIN_STAGE_DIR"], filepath.Join(tmpDir, ".openclaw", "plugin-runtime-deps"))
}
}
func TestOpenclawEdit(t *testing.T) {
c := &Openclaw{}
tmpDir := t.TempDir()
@@ -614,7 +263,7 @@ func TestOpenclawEdit(t *testing.T) {
t.Run("fresh install", func(t *testing.T) {
cleanup()
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
if err := c.Edit([]string{"llama3.2"}); err != nil {
t.Fatal(err)
}
assertOpenclawModelExists(t, configPath, "llama3.2")
@@ -623,7 +272,7 @@ func TestOpenclawEdit(t *testing.T) {
t.Run("multiple models - first is primary", func(t *testing.T) {
cleanup()
if err := c.Edit(testLaunchModels("llama3.2", "mistral")); err != nil {
if err := c.Edit([]string{"llama3.2", "mistral"}); err != nil {
t.Fatal(err)
}
assertOpenclawModelExists(t, configPath, "llama3.2")
@@ -635,7 +284,7 @@ func TestOpenclawEdit(t *testing.T) {
cleanup()
os.MkdirAll(configDir, 0o755)
os.WriteFile(configPath, []byte(`{"models":{"providers":{"anthropic":{"apiKey":"xxx"}}}}`), 0o644)
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
if err := c.Edit([]string{"llama3.2"}); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
@@ -652,7 +301,7 @@ func TestOpenclawEdit(t *testing.T) {
cleanup()
os.MkdirAll(configDir, 0o755)
os.WriteFile(configPath, []byte(`{"theme":"dark","mcp":{"servers":{}}}`), 0o644)
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
if err := c.Edit([]string{"llama3.2"}); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
@@ -668,7 +317,7 @@ func TestOpenclawEdit(t *testing.T) {
t.Run("preserve user customizations on models", func(t *testing.T) {
cleanup()
c.Edit(testLaunchModels("llama3.2"))
c.Edit([]string{"llama3.2"})
// User adds custom field
data, _ := os.ReadFile(configPath)
@@ -684,7 +333,7 @@ func TestOpenclawEdit(t *testing.T) {
os.WriteFile(configPath, configData, 0o644)
// Re-run Edit
c.Edit(testLaunchModels("llama3.2"))
c.Edit([]string{"llama3.2"})
data, _ = os.ReadFile(configPath)
json.Unmarshal(data, &cfg)
@@ -700,8 +349,8 @@ func TestOpenclawEdit(t *testing.T) {
t.Run("edit replaces models list", func(t *testing.T) {
cleanup()
c.Edit(testLaunchModels("llama3.2", "mistral"))
c.Edit(testLaunchModels("llama3.2"))
c.Edit([]string{"llama3.2", "mistral"})
c.Edit([]string{"llama3.2"})
assertOpenclawModelExists(t, configPath, "llama3.2")
assertOpenclawModelNotExists(t, configPath, "mistral")
@@ -713,7 +362,7 @@ func TestOpenclawEdit(t *testing.T) {
original := `{"existing":"data"}`
os.WriteFile(configPath, []byte(original), 0o644)
c.Edit(testLaunchModels())
c.Edit([]string{})
data, _ := os.ReadFile(configPath)
if string(data) != original {
@@ -726,7 +375,7 @@ func TestOpenclawEdit(t *testing.T) {
os.MkdirAll(configDir, 0o755)
os.WriteFile(configPath, []byte(`{corrupted`), 0o644)
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
if err := c.Edit([]string{"llama3.2"}); err != nil {
t.Fatal(err)
}
@@ -742,7 +391,7 @@ func TestOpenclawEdit(t *testing.T) {
os.MkdirAll(configDir, 0o755)
os.WriteFile(configPath, []byte(`{"models":"not a map"}`), 0o644)
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
if err := c.Edit([]string{"llama3.2"}); err != nil {
t.Fatal(err)
}
assertOpenclawModelExists(t, configPath, "llama3.2")
@@ -922,7 +571,7 @@ func TestOpenclawEditSchemaFields(t *testing.T) {
setTestHome(t, tmpDir)
configPath := filepath.Join(tmpDir, ".openclaw", "openclaw.json")
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
if err := c.Edit([]string{"llama3.2"}); err != nil {
t.Fatal(err)
}
@@ -963,7 +612,7 @@ func TestOpenclawEditModelNames(t *testing.T) {
t.Run("model with colon tag", func(t *testing.T) {
cleanup()
if err := c.Edit(testLaunchModels("llama3.2:70b")); err != nil {
if err := c.Edit([]string{"llama3.2:70b"}); err != nil {
t.Fatal(err)
}
assertOpenclawModelExists(t, configPath, "llama3.2:70b")
@@ -972,7 +621,7 @@ func TestOpenclawEditModelNames(t *testing.T) {
t.Run("model with slash", func(t *testing.T) {
cleanup()
if err := c.Edit(testLaunchModels("library/model:tag")); err != nil {
if err := c.Edit([]string{"library/model:tag"}); err != nil {
t.Fatal(err)
}
assertOpenclawModelExists(t, configPath, "library/model:tag")
@@ -981,7 +630,7 @@ func TestOpenclawEditModelNames(t *testing.T) {
t.Run("model with hyphen", func(t *testing.T) {
cleanup()
if err := c.Edit(testLaunchModels("test-model")); err != nil {
if err := c.Edit([]string{"test-model"}); err != nil {
t.Fatal(err)
}
assertOpenclawModelExists(t, configPath, "test-model")
@@ -1001,7 +650,7 @@ func TestOpenclawEditAgentsPreservation(t *testing.T) {
os.MkdirAll(configDir, 0o755)
os.WriteFile(configPath, []byte(`{"agents":{"defaults":{"model":{"primary":"old"},"temperature":0.7}}}`), 0o644)
c.Edit(testLaunchModels("llama3.2"))
c.Edit([]string{"llama3.2"})
data, _ := os.ReadFile(configPath)
var cfg map[string]any
@@ -1018,7 +667,7 @@ func TestOpenclawEditAgentsPreservation(t *testing.T) {
os.MkdirAll(configDir, 0o755)
os.WriteFile(configPath, []byte(`{"agents":{"defaults":{},"custom-agent":{"foo":"bar"}}}`), 0o644)
c.Edit(testLaunchModels("llama3.2"))
c.Edit([]string{"llama3.2"})
data, _ := os.ReadFile(configPath)
var cfg map[string]any
@@ -1058,7 +707,7 @@ func TestOpenclawEdit_RoundTrip(t *testing.T) {
os.MkdirAll(configDir, 0o755)
os.WriteFile(configPath, []byte(testOpenclawFixture), 0o644)
if err := c.Edit(testLaunchModels("llama3.2", "mistral")); err != nil {
if err := c.Edit([]string{"llama3.2", "mistral"}); err != nil {
t.Fatal(err)
}
@@ -1104,10 +753,10 @@ func TestOpenclawEdit_Idempotent(t *testing.T) {
os.MkdirAll(configDir, 0o755)
os.WriteFile(configPath, []byte(testOpenclawFixture), 0o644)
c.Edit(testLaunchModels("llama3.2", "mistral"))
c.Edit([]string{"llama3.2", "mistral"})
firstData, _ := os.ReadFile(configPath)
c.Edit(testLaunchModels("llama3.2", "mistral"))
c.Edit([]string{"llama3.2", "mistral"})
secondData, _ := os.ReadFile(configPath)
if string(firstData) != string(secondData) {
@@ -1130,7 +779,7 @@ func TestOpenclawEdit_MultipleConsecutiveEdits(t *testing.T) {
if i%2 == 0 {
models = []string{"model-x", "model-y", "model-z"}
}
if err := c.Edit(launchModelsFromNames(models)); err != nil {
if err := c.Edit(models); err != nil {
t.Fatalf("edit %d failed: %v", i, err)
}
}
@@ -1152,18 +801,18 @@ func TestOpenclawEdit_BackupCreated(t *testing.T) {
setTestHome(t, tmpDir)
configDir := filepath.Join(tmpDir, ".openclaw")
configPath := filepath.Join(configDir, "openclaw.json")
backupDir := fileutil.BackupDir()
backupDir := filepath.Join(os.TempDir(), "ollama-backups")
os.MkdirAll(configDir, 0o755)
uniqueMarker := fmt.Sprintf("test-marker-%d", os.Getpid())
original := fmt.Sprintf(`{"theme": "%s"}`, uniqueMarker)
os.WriteFile(configPath, []byte(original), 0o644)
if err := c.Edit(testLaunchModels("model-a")); err != nil {
if err := c.Edit([]string{"model-a"}); err != nil {
t.Fatal(err)
}
backups, _ := filepath.Glob(filepath.Join(backupDir, "openclaw", "openclaw.json.*"))
backups, _ := filepath.Glob(filepath.Join(backupDir, "openclaw.json.*"))
foundBackup := false
for _, backup := range backups {
data, _ := os.ReadFile(backup)
@@ -1281,7 +930,7 @@ func TestOpenclawLegacyPaths(t *testing.T) {
os.WriteFile(filepath.Join(newDir, "openclaw.json"), []byte(`{"theme":"new"}`), 0o644)
os.WriteFile(filepath.Join(legacyDir, "clawdbot.json"), []byte(`{"theme":"legacy"}`), 0o644)
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
if err := c.Edit([]string{"llama3.2"}); err != nil {
t.Fatal(err)
}
@@ -1300,7 +949,7 @@ func TestOpenclawLegacyPaths(t *testing.T) {
os.MkdirAll(legacyDir, 0o755)
os.WriteFile(filepath.Join(legacyDir, "clawdbot.json"), []byte(`{"theme":"dark"}`), 0o644)
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
if err := c.Edit([]string{"llama3.2"}); err != nil {
t.Fatal(err)
}
@@ -1328,7 +977,7 @@ func TestOpenclawEdit_CreatesDirectoryIfMissing(t *testing.T) {
t.Fatal("directory should not exist before test")
}
if err := c.Edit(testLaunchModels("model-a")); err != nil {
if err := c.Edit([]string{"model-a"}); err != nil {
t.Fatal(err)
}
@@ -1578,18 +1227,6 @@ func TestOpenclawChannelsConfigured(t *testing.T) {
})
}
func envSliceToMap(entries []string) map[string]string {
env := make(map[string]string, len(entries))
for _, entry := range entries {
key, value, ok := strings.Cut(entry, "=")
if !ok {
continue
}
env[key] = value
}
return env
}
func TestOpenclawChannelSetupPreflight(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell test binary")
@@ -2133,7 +1770,7 @@ func TestPrintOpenclawReady(t *testing.T) {
buf.ReadFrom(r)
output := buf.String()
if !strings.Contains(output, "127.0.0.1:9999") {
if !strings.Contains(output, "localhost:9999") {
t.Errorf("expected port 9999 in output, got:\n%s", output)
}
if strings.Contains(output, "#token=") {
@@ -2245,8 +1882,8 @@ func TestPrintOpenclawReady(t *testing.T) {
}
func TestOpenclawModelConfig(t *testing.T) {
t.Run("minimal model returns base config", func(t *testing.T) {
cfg, _ := openclawModelConfig(fallbackLaunchModel("llama3.2"))
t.Run("nil client returns base config", func(t *testing.T) {
cfg, _ := openclawModelConfig(context.Background(), nil, "llama3.2")
if cfg["id"] != "llama3.2" {
t.Errorf("id = %v, want llama3.2", cfg["id"])
@@ -2257,17 +1894,29 @@ func TestOpenclawModelConfig(t *testing.T) {
if cfg["cost"] == nil {
t.Error("cost should be set")
}
// Should not have capability fields without inventory metadata.
// Should not have capability fields without API
if _, ok := cfg["reasoning"]; ok {
t.Error("reasoning should not be set without metadata")
t.Error("reasoning should not be set without API")
}
if _, ok := cfg["contextWindow"]; ok {
t.Error("contextWindow should not be set without metadata")
t.Error("contextWindow should not be set without API")
}
})
t.Run("sets vision input when model has vision capability", func(t *testing.T) {
cfg, _ := openclawModelConfig(LaunchModel{Name: "llava:7b", Capabilities: []model.Capability{"vision"}, ContextLength: 4096})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":["vision"],"model_info":{"llama.context_length":4096}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg, _ := openclawModelConfig(context.Background(), client, "llava:7b")
input, ok := cfg["input"].([]any)
if !ok || len(input) != 2 {
@@ -2276,7 +1925,19 @@ func TestOpenclawModelConfig(t *testing.T) {
})
t.Run("sets text-only input when model lacks vision", func(t *testing.T) {
cfg, _ := openclawModelConfig(LaunchModel{Name: "llama3.2", Capabilities: []model.Capability{"completion"}})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":["completion"],"model_info":{}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg, _ := openclawModelConfig(context.Background(), client, "llama3.2")
input, ok := cfg["input"].([]any)
if !ok || len(input) != 1 {
@@ -2288,15 +1949,39 @@ func TestOpenclawModelConfig(t *testing.T) {
})
t.Run("sets reasoning when model has thinking capability", func(t *testing.T) {
cfg, _ := openclawModelConfig(LaunchModel{Name: "qwq", Capabilities: []model.Capability{"thinking"}})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg, _ := openclawModelConfig(context.Background(), client, "qwq")
if cfg["reasoning"] != true {
t.Error("expected reasoning = true for thinking model")
}
})
t.Run("sets context window from inventory metadata", func(t *testing.T) {
cfg, _ := openclawModelConfig(LaunchModel{Name: "llama3.2", ContextLength: 131072})
t.Run("extracts context window from model info", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":131072}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg, _ := openclawModelConfig(context.Background(), client, "llama3.2")
if cfg["contextWindow"] != 131072 {
t.Errorf("contextWindow = %v, want 131072", cfg["contextWindow"])
@@ -2304,11 +1989,19 @@ func TestOpenclawModelConfig(t *testing.T) {
})
t.Run("handles all capabilities together", func(t *testing.T) {
cfg, _ := openclawModelConfig(LaunchModel{
Name: "qwen3-vision",
Capabilities: []model.Capability{"vision", "thinking"},
ContextLength: 32768,
})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":["vision","thinking"],"model_info":{"qwen3.context_length":32768}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg, _ := openclawModelConfig(context.Background(), client, "qwen3-vision")
input, ok := cfg["input"].([]any)
if !ok || len(input) != 2 {
@@ -2322,8 +2015,17 @@ func TestOpenclawModelConfig(t *testing.T) {
}
})
t.Run("returns base config when metadata is unavailable", func(t *testing.T) {
cfg, _ := openclawModelConfig(fallbackLaunchModel("missing-model"))
t.Run("returns base config when show fails", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"error":"model not found"}`)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg, _ := openclawModelConfig(context.Background(), client, "missing-model")
if cfg["id"] != "missing-model" {
t.Errorf("id = %v, want missing-model", cfg["id"])
@@ -2333,15 +2035,62 @@ func TestOpenclawModelConfig(t *testing.T) {
t.Error("input should always be set")
}
if _, ok := cfg["reasoning"]; ok {
t.Error("reasoning should not be set when metadata is unavailable")
t.Error("reasoning should not be set when show fails")
}
if _, ok := cfg["contextWindow"]; ok {
t.Error("contextWindow should not be set when metadata is unavailable")
t.Error("contextWindow should not be set when show fails")
}
})
t.Run("times out slow show and returns base config", func(t *testing.T) {
oldTimeout := openclawModelShowTimeout
openclawModelShowTimeout = 50 * time.Millisecond
t.Cleanup(func() { openclawModelShowTimeout = oldTimeout })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
time.Sleep(300 * time.Millisecond)
fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{"llama.context_length":4096}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
start := time.Now()
cfg, _ := openclawModelConfig(context.Background(), client, "slow-model")
elapsed := time.Since(start)
if elapsed >= 250*time.Millisecond {
t.Fatalf("openclawModelConfig took too long: %v", elapsed)
}
if cfg["id"] != "slow-model" {
t.Errorf("id = %v, want slow-model", cfg["id"])
}
if _, ok := cfg["reasoning"]; ok {
t.Error("reasoning should not be set on timeout")
}
if _, ok := cfg["contextWindow"]; ok {
t.Error("contextWindow should not be set on timeout")
}
})
t.Run("skips zero context length", func(t *testing.T) {
cfg, _ := openclawModelConfig(LaunchModel{Name: "test-model", ContextLength: 0})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":0}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg, _ := openclawModelConfig(context.Background(), client, "test-model")
if _, ok := cfg["contextWindow"]; ok {
t.Error("contextWindow should not be set for zero value")
@@ -2349,7 +2098,21 @@ func TestOpenclawModelConfig(t *testing.T) {
})
t.Run("cloud model uses hardcoded limits", func(t *testing.T) {
cfg, isCloud := openclawModelConfig(fallbackLaunchModel("minimax-m2.7:cloud"))
// Use a model name that's in cloudModelLimits and make the server
// report it as a remote/cloud model
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":[],"model_info":{},"remote_model":"minimax-m2.7"}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg, isCloud := openclawModelConfig(context.Background(), client, "minimax-m2.7:cloud")
if !isCloud {
t.Error("expected isCloud = true for cloud model")
@@ -2363,11 +2126,21 @@ func TestOpenclawModelConfig(t *testing.T) {
})
t.Run("cloud model with vision capability gets image input", func(t *testing.T) {
cfg, isCloud := openclawModelConfig(LaunchModel{
Name: "qwen3-vl:235b-cloud",
Remote: true,
Capabilities: []model.Capability{"vision"},
}.WithCloudLimits())
// Regression test: cloud models must not skip capability detection.
// A cloud model that reports vision capability should have input: [text, image].
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":["vision"],"model_info":{},"remote_model":"qwen3-vl"}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg, isCloud := openclawModelConfig(context.Background(), client, "qwen3-vl:235b-cloud")
if !isCloud {
t.Error("expected isCloud = true for cloud vision model")
@@ -2379,11 +2152,21 @@ func TestOpenclawModelConfig(t *testing.T) {
})
t.Run("cloud model with thinking capability gets reasoning flag", func(t *testing.T) {
cfg, isCloud := openclawModelConfig(LaunchModel{
Name: "qwq:cloud",
Remote: true,
Capabilities: []model.Capability{"thinking"},
})
// Regression test: cloud models must not skip capability detection.
// A cloud model that reports thinking capability should have reasoning: true.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{},"remote_model":"qwq-cloud"}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg, isCloud := openclawModelConfig(context.Background(), client, "qwq:cloud")
if !isCloud {
t.Error("expected isCloud = true for cloud thinking model")

View File

@@ -43,7 +43,7 @@ func findOpenCode() (string, bool) {
return "", false
}
func (o *OpenCode) Run(model string, models []LaunchModel, args []string) error {
func (o *OpenCode) Run(model string, args []string) error {
opencodePath, ok := findOpenCode()
if !ok {
return fmt.Errorf("opencode is not installed, install from https://opencode.ai")
@@ -54,7 +54,7 @@ func (o *OpenCode) Run(model string, models []LaunchModel, args []string) error
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
if content := o.resolveContent(model, models); content != "" {
if content := o.resolveContent(model); content != "" {
cmd.Env = append(cmd.Env, "OPENCODE_CONFIG_CONTENT="+content)
}
return cmd.Run()
@@ -63,57 +63,21 @@ func (o *OpenCode) Run(model string, models []LaunchModel, args []string) error
// resolveContent returns the inline config to send via OPENCODE_CONFIG_CONTENT.
// Returns content built by Edit if available, otherwise builds from model.json
// with the requested model as primary (e.g. re-launch with saved config).
func (o *OpenCode) resolveContent(model string, models []LaunchModel) string {
func (o *OpenCode) resolveContent(model string) string {
if o.configContent != "" {
return o.configContent
}
resolvedModels := resolveOpenCodeRunModels(model, models, readModelJSONModels())
if len(resolvedModels) == 0 {
return ""
models := readModelJSONModels()
if !slices.Contains(models, model) {
models = append([]string{model}, models...)
}
content, err := buildInlineConfig(resolvedModels[0], resolvedModels)
content, err := buildInlineConfig(model, models)
if err != nil {
return ""
}
return content
}
func resolveOpenCodeRunModels(primary string, models []LaunchModel, stateModels []string) []LaunchModel {
if primary == "" {
return nil
}
resolved := make([]LaunchModel, 0, 1+len(models)+len(stateModels))
appendModel := func(name string) {
if name == "" || hasLaunchModel(resolved, name) {
return
}
if model, ok := findLaunchModel(models, name); ok {
resolved = append(resolved, model)
return
}
resolved = append(resolved, fallbackLaunchModel(name))
}
appendModel(primary)
for _, model := range models {
appendModel(model.Name)
}
for _, model := range stateModels {
appendModel(model)
}
return resolved
}
func hasLaunchModel(models []LaunchModel, name string) bool {
for _, model := range models {
if launchModelMatches(model.Name, name) || launchModelMatches(name, model.Name) {
return true
}
}
return false
}
func (o *OpenCode) Paths() []string {
sp, err := openCodeStatePath()
if err != nil {
@@ -136,13 +100,12 @@ func openCodeStatePath() (string, error) {
return filepath.Join(home, ".local", "state", "opencode", "model.json"), nil
}
func (o *OpenCode) Edit(models []LaunchModel) error {
modelList := launchModelNames(models)
func (o *OpenCode) Edit(modelList []string) error {
if len(modelList) == 0 {
return nil
}
content, err := buildInlineConfig(models[0], models)
content, err := buildInlineConfig(modelList[0], modelList)
if err != nil {
return err
}
@@ -200,7 +163,7 @@ func (o *OpenCode) Edit(models []LaunchModel) error {
if err != nil {
return err
}
return fileutil.WriteWithBackup(statePath, stateData, "opencode")
return fileutil.WriteWithBackup(statePath, stateData)
}
func (o *OpenCode) Models() []string {
@@ -209,11 +172,10 @@ func (o *OpenCode) Models() []string {
// buildInlineConfig produces the JSON string for OPENCODE_CONFIG_CONTENT.
// primary is the model to launch with, models is the full list of available models.
func buildInlineConfig(primary LaunchModel, models []LaunchModel) (string, error) {
if primary.Name == "" || len(models) == 0 {
func buildInlineConfig(primary string, models []string) (string, error) {
if primary == "" || len(models) == 0 {
return "", fmt.Errorf("buildInlineConfig: primary and models are required")
}
config := map[string]any{
"$schema": "https://opencode.ai/config.json",
"provider": map[string]any{
@@ -226,7 +188,7 @@ func buildInlineConfig(primary LaunchModel, models []LaunchModel) (string, error
"models": buildModelEntries(models),
},
},
"model": "ollama/" + primary.Name,
"model": "ollama/" + primary,
}
data, err := json.Marshal(config)
if err != nil {
@@ -266,29 +228,21 @@ func readModelJSONModels() []string {
return models
}
func buildModelEntries(modelList []LaunchModel) map[string]any {
func buildModelEntries(modelList []string) map[string]any {
models := make(map[string]any)
for _, model := range modelList {
entry := map[string]any{
"name": model.Name,
"name": model,
}
if model.HasCapability("vision") {
entry["modalities"] = map[string]any{
"input": []string{"text", "image"},
"output": []string{"text"},
if isCloudModelName(model) {
if l, ok := lookupCloudModelLimit(model); ok {
entry["limit"] = map[string]any{
"context": l.Context,
"output": l.Output,
}
}
}
if model.ContextLength > 0 || model.MaxOutputTokens > 0 {
limit := make(map[string]any)
if model.ContextLength > 0 {
limit["context"] = model.ContextLength
}
if model.MaxOutputTokens > 0 {
limit["output"] = model.MaxOutputTokens
}
entry["limit"] = limit
}
models[model.Name] = entry
models[model] = entry
}
return models
}

View File

@@ -7,8 +7,6 @@ import (
"path/filepath"
"runtime"
"testing"
"github.com/ollama/ollama/types/model"
)
func TestOpenCodeIntegration(t *testing.T) {
@@ -33,7 +31,7 @@ func TestOpenCodeEdit(t *testing.T) {
t.Run("builds config content with provider", func(t *testing.T) {
setTestHome(t, t.TempDir())
o := &OpenCode{}
if err := o.Edit(testLaunchModels("llama3.2")); err != nil {
if err := o.Edit([]string{"llama3.2"}); err != nil {
t.Fatal(err)
}
@@ -67,7 +65,7 @@ func TestOpenCodeEdit(t *testing.T) {
t.Run("multiple models", func(t *testing.T) {
setTestHome(t, t.TempDir())
o := &OpenCode{}
if err := o.Edit(testLaunchModels("llama3.2", "qwen3:32b")); err != nil {
if err := o.Edit([]string{"llama3.2", "qwen3:32b"}); err != nil {
t.Fatal(err)
}
@@ -92,7 +90,7 @@ func TestOpenCodeEdit(t *testing.T) {
t.Run("empty models is no-op", func(t *testing.T) {
setTestHome(t, t.TempDir())
o := &OpenCode{}
if err := o.Edit(testLaunchModels()); err != nil {
if err := o.Edit([]string{}); err != nil {
t.Fatal(err)
}
if o.configContent != "" {
@@ -104,7 +102,7 @@ func TestOpenCodeEdit(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
o := &OpenCode{}
o.Edit(testLaunchModels("llama3.2"))
o.Edit([]string{"llama3.2"})
configDir := filepath.Join(tmpDir, ".config", "opencode")
@@ -119,7 +117,7 @@ func TestOpenCodeEdit(t *testing.T) {
t.Run("cloud model has limits", func(t *testing.T) {
setTestHome(t, t.TempDir())
o := &OpenCode{}
if err := o.Edit(testLaunchModels("glm-4.7:cloud")); err != nil {
if err := o.Edit([]string{"glm-4.7:cloud"}); err != nil {
t.Fatal(err)
}
@@ -146,7 +144,7 @@ func TestOpenCodeEdit(t *testing.T) {
t.Run("local model has no limits", func(t *testing.T) {
setTestHome(t, t.TempDir())
o := &OpenCode{}
o.Edit(testLaunchModels("llama3.2"))
o.Edit([]string{"llama3.2"})
var cfg map[string]any
json.Unmarshal([]byte(o.configContent), &cfg)
@@ -159,43 +157,6 @@ func TestOpenCodeEdit(t *testing.T) {
t.Errorf("local model should not have limit, got %v", entry["limit"])
}
})
t.Run("vision model gets image input modalities", func(t *testing.T) {
models := buildModelEntries([]LaunchModel{{Name: "gemma4:26b", Capabilities: []model.Capability{"vision"}}})
entry, _ := models["gemma4:26b"].(map[string]any)
modalities, _ := entry["modalities"].(map[string]any)
input, _ := modalities["input"].([]string)
output, _ := modalities["output"].([]string)
if len(input) != 2 || input[0] != "text" || input[1] != "image" {
t.Fatalf("modalities.input = %v, want [text image]", input)
}
if len(output) != 1 || output[0] != "text" {
t.Fatalf("modalities.output = %v, want [text]", output)
}
})
}
func TestBuildModelEntries(t *testing.T) {
t.Run("defaults to model name without capabilities", func(t *testing.T) {
models := buildModelEntries(testLaunchModels("llama3.2"))
entry, _ := models["llama3.2"].(map[string]any)
if entry["name"] != "llama3.2" {
t.Fatalf("name = %v, want llama3.2", entry["name"])
}
if _, ok := entry["modalities"]; ok {
t.Fatalf("modalities should not be set without capabilities, got %v", entry["modalities"])
}
})
t.Run("uses context and output limits from metadata", func(t *testing.T) {
models := buildModelEntries([]LaunchModel{{Name: "glm-5:cloud", ContextLength: 202_752, MaxOutputTokens: 131_072}})
entry, _ := models["glm-5:cloud"].(map[string]any)
limit, _ := entry["limit"].(map[string]any)
if limit["context"] != 202_752 || limit["output"] != 131_072 {
t.Fatalf("limit = %v, want context/output", limit)
}
})
}
func TestOpenCodeModels_ReturnsNil(t *testing.T) {
@@ -323,7 +284,7 @@ func TestOpenCodeEdit_CloudModelLimitStructure(t *testing.T) {
expected := cloudModelLimits["glm-4.7"]
if err := o.Edit(testLaunchModels("glm-4.7:cloud")); err != nil {
if err := o.Edit([]string{"glm-4.7:cloud"}); err != nil {
t.Fatal(err)
}
@@ -353,7 +314,7 @@ func TestOpenCodeEdit_SpecialCharsInModelName(t *testing.T) {
specialModel := `model-with-"quotes"`
err := o.Edit(testLaunchModels(specialModel))
err := o.Edit([]string{specialModel})
if err != nil {
t.Fatalf("Edit with special chars failed: %v", err)
}
@@ -446,7 +407,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
setTestHome(t, tmpDir)
o := &OpenCode{}
if err := o.Edit(testLaunchModels("gemma4")); err != nil {
if err := o.Edit([]string{"gemma4"}); err != nil {
t.Fatal(err)
}
editContent := o.configContent
@@ -461,7 +422,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
data, _ := json.MarshalIndent(state, "", " ")
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
got := o.resolveContent("gemma4", nil)
got := o.resolveContent("gemma4")
if got != editContent {
t.Errorf("resolveContent returned different content than Edit set\ngot: %s\nwant: %s", got, editContent)
}
@@ -483,7 +444,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
o := &OpenCode{}
content := o.resolveContent("llama3.2", nil)
content := o.resolveContent("llama3.2")
if content == "" {
t.Fatal("resolveContent returned empty")
}
@@ -517,7 +478,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
o := &OpenCode{}
content := o.resolveContent("qwen3:32b", nil)
content := o.resolveContent("qwen3:32b")
var cfg map[string]any
json.Unmarshal([]byte(content), &cfg)
@@ -541,7 +502,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
o := &OpenCode{}
content := o.resolveContent("gemma4", nil)
content := o.resolveContent("gemma4")
var cfg map[string]any
json.Unmarshal([]byte(content), &cfg)
@@ -561,56 +522,11 @@ func TestOpenCodeResolveContent(t *testing.T) {
setTestHome(t, tmpDir)
o := &OpenCode{}
if got := o.resolveContent("", nil); got != "" {
if got := o.resolveContent(""); got != "" {
t.Errorf("resolveContent(\"\") = %q, want empty", got)
}
})
t.Run("uses run model metadata when Edit was not called", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
stateDir := filepath.Join(tmpDir, ".local", "state", "opencode")
os.MkdirAll(stateDir, 0o755)
state := map[string]any{
"recent": []any{
map[string]any{"providerID": "ollama", "modelID": "llama3.2"},
},
}
data, _ := json.MarshalIndent(state, "", " ")
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
o := &OpenCode{}
content := o.resolveContent("gemma4", []LaunchModel{
{
Name: "gemma4",
Capabilities: []model.Capability{model.CapabilityVision},
ContextLength: 65_536,
MaxOutputTokens: 8_192,
},
})
if content == "" {
t.Fatal("resolveContent returned empty")
}
var cfg map[string]any
json.Unmarshal([]byte(content), &cfg)
provider, _ := cfg["provider"].(map[string]any)
ollama, _ := provider["ollama"].(map[string]any)
cfgModels, _ := ollama["models"].(map[string]any)
entry, _ := cfgModels["gemma4"].(map[string]any)
limit, _ := entry["limit"].(map[string]any)
if limit["context"] != float64(65_536) || limit["output"] != float64(8_192) {
t.Fatalf("limit = %v, want context/output from launch metadata", limit)
}
if _, ok := entry["modalities"].(map[string]any); !ok {
t.Fatalf("modalities should be set from launch metadata, got %v", entry["modalities"])
}
if cfgModels["llama3.2"] == nil {
t.Fatalf("state model missing from fallback config: %v", cfgModels)
}
})
t.Run("does not mutate configContent on fallback", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
@@ -626,7 +542,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
o := &OpenCode{}
_ = o.resolveContent("llama3.2", nil)
_ = o.resolveContent("llama3.2")
if o.configContent != "" {
t.Errorf("resolveContent should not mutate configContent, got %q", o.configContent)
}
@@ -635,19 +551,19 @@ func TestOpenCodeResolveContent(t *testing.T) {
func TestBuildInlineConfig(t *testing.T) {
t.Run("returns error for empty primary", func(t *testing.T) {
if _, err := buildInlineConfig(LaunchModel{}, testLaunchModels("llama3.2")); err == nil {
if _, err := buildInlineConfig("", []string{"llama3.2"}); err == nil {
t.Error("expected error for empty primary")
}
})
t.Run("returns error for empty models", func(t *testing.T) {
if _, err := buildInlineConfig(fallbackLaunchModel("llama3.2"), nil); err == nil {
if _, err := buildInlineConfig("llama3.2", nil); err == nil {
t.Error("expected error for empty models")
}
})
t.Run("primary differs from first model in list", func(t *testing.T) {
content, err := buildInlineConfig(fallbackLaunchModel("qwen3:32b"), testLaunchModels("llama3.2", "qwen3:32b"))
content, err := buildInlineConfig("qwen3:32b", []string{"llama3.2", "qwen3:32b"})
if err != nil {
t.Fatal(err)
}
@@ -676,7 +592,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) {
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
o := &OpenCode{}
if err := o.Edit(testLaunchModels("new-X")); err != nil {
if err := o.Edit([]string{"new-X"}); err != nil {
t.Fatal(err)
}
@@ -710,7 +626,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) {
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
o := &OpenCode{}
if err := o.Edit(testLaunchModels("X", "Y", "Z")); err != nil {
if err := o.Edit([]string{"X", "Y", "Z"}); err != nil {
t.Fatal(err)
}
@@ -747,7 +663,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) {
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
o := &OpenCode{}
if err := o.Edit(testLaunchModels("qwen3:32b")); err != nil {
if err := o.Edit([]string{"qwen3:32b"}); err != nil {
t.Fatal(err)
}
@@ -784,7 +700,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) {
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
o := &OpenCode{}
if err := o.Edit(testLaunchModels("llama3.2")); err != nil {
if err := o.Edit([]string{"llama3.2"}); err != nil {
t.Fatal(err)
}
@@ -826,7 +742,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) {
// Add 5 new models — should cap at 10 total
o := &OpenCode{}
if err := o.Edit(testLaunchModels("new-0", "new-1", "new-2", "new-3", "new-4")); err != nil {
if err := o.Edit([]string{"new-0", "new-1", "new-2", "new-3", "new-4"}); err != nil {
t.Fatal(err)
}
@@ -847,7 +763,7 @@ func TestOpenCodeEdit_BaseURL(t *testing.T) {
setTestHome(t, tmpDir)
// Default OLLAMA_HOST
o.Edit(testLaunchModels("llama3.2"))
o.Edit([]string{"llama3.2"})
var cfg map[string]any
json.Unmarshal([]byte(o.configContent), &cfg)

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
@@ -13,6 +14,7 @@ import (
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/envconfig"
"github.com/ollama/ollama/types/model"
)
// Pi implements Runner and Editor for Pi (Pi Coding Agent) integration
@@ -26,7 +28,7 @@ const (
func (p *Pi) String() string { return "Pi" }
func (p *Pi) Run(_ string, _ []LaunchModel, args []string) error {
func (p *Pi) Run(model string, args []string) error {
fmt.Fprintf(os.Stderr, "\n%sPreparing Pi...%s\n", ansiGray, ansiReset)
if err := ensureNpmInstalled(); err != nil {
return err
@@ -181,7 +183,7 @@ func (p *Pi) Paths() []string {
return paths
}
func (p *Pi) Edit(models []LaunchModel) error {
func (p *Pi) Edit(models []string) error {
if len(models) == 0 {
return nil
}
@@ -223,7 +225,7 @@ func (p *Pi) Edit(models []LaunchModel) error {
// Build set of selected models to track which need to be added
selectedSet := make(map[string]bool, len(models))
for _, m := range models {
selectedSet[m.Name] = true
selectedSet[m] = true
}
// Build new models list:
@@ -254,9 +256,11 @@ func (p *Pi) Edit(models []LaunchModel) error {
}
// Add newly selected models that weren't already in the list
client := api.NewClient(envconfig.Host(), http.DefaultClient)
ctx := context.Background()
for _, model := range models {
if selectedSet[model.Name] {
newModels = append(newModels, createConfig(model))
if selectedSet[model] {
newModels = append(newModels, createConfig(ctx, client, model))
}
}
@@ -268,7 +272,7 @@ func (p *Pi) Edit(models []LaunchModel) error {
if err != nil {
return err
}
if err := fileutil.WriteWithBackup(configPath, configData, "pi"); err != nil {
if err := fileutil.WriteWithBackup(configPath, configData); err != nil {
return err
}
@@ -280,13 +284,13 @@ func (p *Pi) Edit(models []LaunchModel) error {
}
settings["defaultProvider"] = "ollama"
settings["defaultModel"] = models[0].Name
settings["defaultModel"] = models[0]
settingsData, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
return fileutil.WriteWithBackup(settingsPath, settingsData, "pi")
return fileutil.WriteWithBackup(settingsPath, settingsData)
}
func (p *Pi) Models() []string {
@@ -338,27 +342,54 @@ func hasContextWindow(cfg map[string]any) bool {
}
}
// createConfig builds Pi model config with capability detection.
func createConfig(model LaunchModel) map[string]any {
// createConfig builds Pi model config with capability detection
func createConfig(ctx context.Context, client *api.Client, modelID string) map[string]any {
cfg := map[string]any{
"id": model.Name,
"id": modelID,
"_launch": true,
}
if l, ok := lookupCloudModelLimit(modelID); ok {
cfg["contextWindow"] = l.Context
}
applyCloudContextFallback := func() {
if l, ok := lookupCloudModelLimit(modelID); ok {
cfg["contextWindow"] = l.Context
}
}
resp, err := client.Show(ctx, &api.ShowRequest{Model: modelID})
if err != nil {
applyCloudContextFallback()
return cfg
}
// Set input types based on vision capability
if model.HasCapability("vision") {
if slices.Contains(resp.Capabilities, model.CapabilityVision) {
cfg["input"] = []string{"text", "image"}
} else {
cfg["input"] = []string{"text"}
}
// Set reasoning based on thinking capability
if model.HasCapability("thinking") {
if slices.Contains(resp.Capabilities, model.CapabilityThinking) {
cfg["reasoning"] = true
}
if model.ContextLength > 0 {
cfg["contextWindow"] = model.ContextLength
// Extract context window from ModelInfo. For known cloud models, the
// pre-filled shared limit remains unless the server provides a positive value.
hasContextWindow := false
for key, val := range resp.ModelInfo {
if strings.HasSuffix(key, ".context_length") {
if ctxLen, ok := val.(float64); ok && ctxLen > 0 {
cfg["contextWindow"] = int(ctxLen)
hasContextWindow = true
}
break
}
}
if !hasContextWindow {
applyCloudContextFallback()
}
return cfg

View File

@@ -1,17 +1,19 @@
package launch
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/types/model"
)
@@ -135,7 +137,7 @@ exit 0
})
p := &Pi{}
if err := p.Run("ignored", nil, []string{"--version"}); err != nil {
if err := p.Run("ignored", []string{"--version"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
@@ -178,7 +180,7 @@ exit 0
})
p := &Pi{}
err := p.Run("ignored", nil, nil)
err := p.Run("ignored", nil)
if err == nil || !strings.Contains(err.Error(), "pi installation cancelled") {
t.Fatalf("expected install cancellation error, got %v", err)
}
@@ -200,7 +202,7 @@ exit 0
})
p := &Pi{}
if err := p.Run("ignored", nil, []string{"session"}); err != nil {
if err := p.Run("ignored", []string{"session"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
@@ -235,7 +237,7 @@ exit 0
seedNpmNoop(t, tmpDir)
p := &Pi{}
if err := p.Run("ignored", nil, []string{"doctor"}); err != nil {
if err := p.Run("ignored", []string{"doctor"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
@@ -263,7 +265,7 @@ exit 0
p := &Pi{}
stderr := captureStderr(t, func() {
if err := p.Run("ignored", nil, []string{"session"}); err != nil {
if err := p.Run("ignored", []string{"session"}); err != nil {
t.Fatalf("Run() should continue after web search update failure, got %v", err)
}
})
@@ -298,7 +300,7 @@ exit 0
p := &Pi{}
stderr := captureStderr(t, func() {
if err := p.Run("ignored", nil, []string{"session"}); err != nil {
if err := p.Run("ignored", []string{"session"}); err != nil {
t.Fatalf("Run() should continue after web search install failure, got %v", err)
}
})
@@ -328,7 +330,7 @@ exit 0
p := &Pi{}
stderr := captureStderr(t, func() {
if err := p.Run("ignored", nil, []string{"session"}); err != nil {
if err := p.Run("ignored", []string{"session"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
})
@@ -357,7 +359,7 @@ exit 0
seedPiScript(t, tmpDir)
p := &Pi{}
err := p.Run("ignored", nil, []string{"session"})
err := p.Run("ignored", []string{"session"})
if err == nil || !strings.Contains(err.Error(), "npm (Node.js) is required to launch pi") {
t.Fatalf("expected missing npm error, got %v", err)
}
@@ -432,7 +434,7 @@ func TestPiEdit(t *testing.T) {
}
t.Run("returns nil for empty models", func(t *testing.T) {
if err := pi.Edit(testLaunchModels()); err != nil {
if err := pi.Edit([]string{}); err != nil {
t.Errorf("Edit([]) error = %v, want nil", err)
}
})
@@ -441,7 +443,7 @@ func TestPiEdit(t *testing.T) {
cleanup()
models := []string{"llama3.2", "qwen3:8b"}
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
if err := pi.Edit(models); err != nil {
t.Fatalf("Edit() error = %v", err)
}
@@ -494,7 +496,7 @@ func TestPiEdit(t *testing.T) {
}
models := []string{"new-model"}
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
if err := pi.Edit(models); err != nil {
t.Fatalf("Edit() error = %v", err)
}
@@ -547,7 +549,7 @@ func TestPiEdit(t *testing.T) {
t.Fatal(err)
}
if err := pi.Edit(testLaunchModels("glm-5:cloud")); err != nil {
if err := pi.Edit([]string{"glm-5:cloud"}); err != nil {
t.Fatalf("Edit() error = %v", err)
}
@@ -592,7 +594,7 @@ func TestPiEdit(t *testing.T) {
}
newModels := []string{"new-model-1", "new-model-2"}
if err := pi.Edit(launchModelsFromNames(newModels)); err != nil {
if err := pi.Edit(newModels); err != nil {
t.Fatalf("Edit() error = %v", err)
}
@@ -643,7 +645,7 @@ func TestPiEdit(t *testing.T) {
}
newModels := []string{"keep-model", "add-model"}
if err := pi.Edit(launchModelsFromNames(newModels)); err != nil {
if err := pi.Edit(newModels); err != nil {
t.Fatalf("Edit() error = %v", err)
}
@@ -680,7 +682,7 @@ func TestPiEdit(t *testing.T) {
}
models := []string{"test-model"}
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
if err := pi.Edit(models); err != nil {
t.Fatalf("Edit() should not fail with corrupt config, got %v", err)
}
@@ -729,7 +731,7 @@ func TestPiEdit(t *testing.T) {
// Add a new ollama-managed model
newModels := []string{"new-ollama-model"}
if err := pi.Edit(launchModelsFromNames(newModels)); err != nil {
if err := pi.Edit(newModels); err != nil {
t.Fatalf("Edit() error = %v", err)
}
@@ -790,7 +792,7 @@ func TestPiEdit(t *testing.T) {
}
models := []string{"llama3.2"}
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
if err := pi.Edit(models); err != nil {
t.Fatalf("Edit() error = %v", err)
}
@@ -828,7 +830,7 @@ func TestPiEdit(t *testing.T) {
os.MkdirAll(configDir, 0o755)
models := []string{"qwen3:8b"}
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
if err := pi.Edit(models); err != nil {
t.Fatalf("Edit() error = %v", err)
}
@@ -862,7 +864,7 @@ func TestPiEdit(t *testing.T) {
}
models := []string{"test-model"}
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
if err := pi.Edit(models); err != nil {
t.Fatalf("Edit() should not fail with corrupt settings, got %v", err)
}
@@ -885,62 +887,6 @@ func TestPiEdit(t *testing.T) {
})
}
func TestPiEdit_CreatesDistinctBackupsForEachManagedFile(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprint(w, `{"capabilities":[],"model_info":{}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
pi := &Pi{}
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
configDir := filepath.Join(tmpDir, ".pi", "agent")
modelsPath := filepath.Join(configDir, "models.json")
settingsPath := filepath.Join(configDir, "settings.json")
backupDir := fileutil.BackupDir()
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatal(err)
}
modelsOriginal := fmt.Sprintf(`{"marker":"models-%d","providers":{"ollama":{"models":[]}}}`, os.Getpid())
settingsOriginal := fmt.Sprintf(`{"marker":"settings-%d","defaultProvider":"other","defaultModel":"old"}`, os.Getpid())
if err := os.WriteFile(modelsPath, []byte(modelsOriginal), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(settingsPath, []byte(settingsOriginal), 0o644); err != nil {
t.Fatal(err)
}
if err := pi.Edit(testLaunchModels("llama3.2")); err != nil {
t.Fatalf("Edit() error = %v", err)
}
assertBackupMatches := func(pattern, want string) {
t.Helper()
backups, err := filepath.Glob(filepath.Join(backupDir, pattern))
if err != nil {
t.Fatalf("glob %q failed: %v", pattern, err)
}
for _, backup := range backups {
data, err := os.ReadFile(backup)
if err == nil && string(data) == want {
return
}
}
t.Fatalf("backup matching %q with expected content not found", pattern)
}
assertBackupMatches(filepath.Join("pi", "models.json.*"), modelsOriginal)
assertBackupMatches(filepath.Join("pi", "settings.json.*"), settingsOriginal)
}
func TestPiModels(t *testing.T) {
pi := &Pi{}
@@ -1084,7 +1030,19 @@ func TestIsPiOllamaModel(t *testing.T) {
func TestCreateConfig(t *testing.T) {
t.Run("sets vision input when model has vision capability", func(t *testing.T) {
cfg := createConfig(LaunchModel{Name: "llava:7b", Capabilities: []model.Capability{model.CapabilityVision}})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":["vision"],"model_info":{}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg := createConfig(context.Background(), client, "llava:7b")
if cfg["id"] != "llava:7b" {
t.Errorf("id = %v, want llava:7b", cfg["id"])
@@ -1099,7 +1057,19 @@ func TestCreateConfig(t *testing.T) {
})
t.Run("sets text-only input when model lacks vision", func(t *testing.T) {
cfg := createConfig(LaunchModel{Name: "llama3.2", Capabilities: []model.Capability{model.CapabilityCompletion}})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":["completion"],"model_info":{}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg := createConfig(context.Background(), client, "llama3.2")
input, ok := cfg["input"].([]string)
if !ok || len(input) != 1 || input[0] != "text" {
@@ -1111,15 +1081,39 @@ func TestCreateConfig(t *testing.T) {
})
t.Run("sets reasoning when model has thinking capability", func(t *testing.T) {
cfg := createConfig(LaunchModel{Name: "qwq", Capabilities: []model.Capability{model.CapabilityThinking}})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg := createConfig(context.Background(), client, "qwq")
if cfg["reasoning"] != true {
t.Error("expected reasoning = true for thinking model")
}
})
t.Run("sets context window from metadata", func(t *testing.T) {
cfg := createConfig(LaunchModel{Name: "llama3.2", ContextLength: 131072})
t.Run("extracts context window from model info", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":131072}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg := createConfig(context.Background(), client, "llama3.2")
if cfg["contextWindow"] != 131072 {
t.Errorf("contextWindow = %v, want 131072", cfg["contextWindow"])
@@ -1127,11 +1121,19 @@ func TestCreateConfig(t *testing.T) {
})
t.Run("handles all capabilities together", func(t *testing.T) {
cfg := createConfig(LaunchModel{
Name: "qwen3-vision",
Capabilities: []model.Capability{model.CapabilityVision, model.CapabilityThinking},
ContextLength: 32768,
})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":["vision","thinking"],"model_info":{"qwen3.context_length":32768}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg := createConfig(context.Background(), client, "qwen3-vision")
input := cfg["input"].([]string)
if len(input) != 2 || input[0] != "text" || input[1] != "image" {
@@ -1145,8 +1147,17 @@ func TestCreateConfig(t *testing.T) {
}
})
t.Run("returns minimal config when metadata is unavailable", func(t *testing.T) {
cfg := createConfig(LaunchModel{Name: "missing-model"})
t.Run("returns minimal config when show fails", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"error":"model not found"}`)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg := createConfig(context.Background(), client, "missing-model")
if cfg["id"] != "missing-model" {
t.Errorf("id = %v, want missing-model", cfg["id"])
@@ -1154,29 +1165,49 @@ func TestCreateConfig(t *testing.T) {
if cfg["_launch"] != true {
t.Error("expected _launch = true")
}
// Input defaults to text even when capabilities are unavailable.
input, ok := cfg["input"].([]string)
if !ok || len(input) != 1 || input[0] != "text" {
t.Errorf("input = %v, want [text]", cfg["input"])
// Should not have capability fields
if _, ok := cfg["input"]; ok {
t.Error("input should not be set when show fails")
}
if _, ok := cfg["reasoning"]; ok {
t.Error("reasoning should not be set when metadata is unavailable")
t.Error("reasoning should not be set when show fails")
}
if _, ok := cfg["contextWindow"]; ok {
t.Error("contextWindow should not be set when metadata is unavailable")
t.Error("contextWindow should not be set when show fails")
}
})
t.Run("cloud model falls back to hardcoded context", func(t *testing.T) {
cfg := createConfig(fallbackLaunchModel("kimi-k2.5:cloud"))
t.Run("cloud model falls back to hardcoded context when show fails", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"error":"model not found"}`)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg := createConfig(context.Background(), client, "kimi-k2.5:cloud")
if cfg["contextWindow"] != 262_144 {
t.Errorf("contextWindow = %v, want 262144", cfg["contextWindow"])
}
})
t.Run("cloud model uses hardcoded context when tags omit context", func(t *testing.T) {
cfg := createConfig(fallbackLaunchModel("glm-5:cloud"))
t.Run("cloud model falls back to hardcoded context when show omits model info", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":[],"model_info":{}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg := createConfig(context.Background(), client, "glm-5:cloud")
if cfg["contextWindow"] != 202_752 {
t.Errorf("contextWindow = %v, want 202752", cfg["contextWindow"])
@@ -1184,14 +1215,35 @@ func TestCreateConfig(t *testing.T) {
})
t.Run("cloud model with dash suffix falls back to hardcoded context", func(t *testing.T) {
cfg := createConfig(fallbackLaunchModel("gpt-oss:120b-cloud"))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"error":"model not found"}`)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg := createConfig(context.Background(), client, "gpt-oss:120b-cloud")
if cfg["contextWindow"] != 131_072 {
t.Errorf("contextWindow = %v, want 131072", cfg["contextWindow"])
}
})
t.Run("skips zero context length", func(t *testing.T) {
cfg := createConfig(LaunchModel{Name: "test-model", ContextLength: 0})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":0}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg := createConfig(context.Background(), client, "test-model")
if _, ok := cfg["contextWindow"]; ok {
t.Error("contextWindow should not be set for zero value")

View File

@@ -1,51 +0,0 @@
package launch
import (
"fmt"
"os"
"os/exec"
"runtime"
"github.com/ollama/ollama/envconfig"
)
// Poolside implements Runner for Poolside's CLI.
type Poolside struct{}
var poolsideGOOS = runtime.GOOS
func (p *Poolside) String() string { return "Pool" }
func poolsideUnsupportedError() error {
return fmt.Errorf("Warning: Poolside is not currently supported on Windows")
}
func (p *Poolside) args(model string, extra []string) []string {
var args []string
if model != "" {
args = append(args, "-m", model)
}
args = append(args, extra...)
return args
}
func (p *Poolside) Run(model string, _ []LaunchModel, args []string) error {
if poolsideGOOS == "windows" {
return poolsideUnsupportedError()
}
bin, err := exec.LookPath("pool")
if err != nil {
return fmt.Errorf("pool is not installed")
}
cmd := exec.Command(bin, p.args(model, args)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(),
"POOLSIDE_STANDALONE_BASE_URL="+envconfig.Host().String()+"/v1",
"POOLSIDE_API_KEY=ollama",
)
return cmd.Run()
}

View File

@@ -1,88 +0,0 @@
package launch
import (
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
)
func TestPoolsideArgs(t *testing.T) {
p := &Poolside{}
tests := []struct {
name string
model string
extra []string
want []string
}{
{name: "with model", model: "qwen3.5", want: []string{"-m", "qwen3.5"}},
{name: "without model", extra: []string{"session"}, want: []string{"session"}},
{name: "with model and extra args", model: "llama3.2", extra: []string{"--foo", "bar"}, want: []string{"-m", "llama3.2", "--foo", "bar"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := p.args(tt.model, tt.extra)
if !slices.Equal(got, tt.want) {
t.Fatalf("args(%q, %v) = %v, want %v", tt.model, tt.extra, got, tt.want)
}
})
}
}
func TestPoolsideRunSetsOllamaEnv(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell fake binary")
}
tmpDir := t.TempDir()
logPath := filepath.Join(tmpDir, "pool.log")
poolPath := filepath.Join(tmpDir, "pool")
script := "#!/bin/sh\n" +
"printf 'base=%s\\nkey=%s\\nargs=%s\\n' \"$POOLSIDE_STANDALONE_BASE_URL\" \"$POOLSIDE_API_KEY\" \"$*\" > \"" + logPath + "\"\n"
if err := os.WriteFile(poolPath, []byte(script), 0o755); err != nil {
t.Fatalf("failed to write fake pool binary: %v", err)
}
t.Setenv("PATH", tmpDir)
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
p := &Poolside{}
if err := p.Run("qwen3.5", nil, []string{"session"}); err != nil {
t.Fatalf("Run returned error: %v", err)
}
data, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("failed to read pool log: %v", err)
}
got := string(data)
if !strings.Contains(got, "base=http://127.0.0.1:11434/v1") {
t.Fatalf("expected Poolside base URL override in log, got:\n%s", got)
}
if !strings.Contains(got, "key=ollama") {
t.Fatalf("expected Poolside API key override in log, got:\n%s", got)
}
if !strings.Contains(got, "args=-m qwen3.5 session") {
t.Fatalf("expected model and extra args in log, got:\n%s", got)
}
}
func TestPoolsideRunWindowsUnsupported(t *testing.T) {
prev := poolsideGOOS
poolsideGOOS = "windows"
t.Cleanup(func() { poolsideGOOS = prev })
p := &Poolside{}
err := p.Run("kimi-k2.6:cloud", nil, nil)
if err == nil {
t.Fatal("expected Windows unsupported error")
}
if !strings.Contains(err.Error(), "not currently supported on Windows") {
t.Fatalf("expected Windows warning, got %v", err)
}
}

View File

@@ -33,7 +33,7 @@ type IntegrationInfo struct {
Description string
}
var launcherIntegrationOrder = []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "codex", "copilot", "droid", "pi", "pool"}
var launcherIntegrationOrder = []string{"openclaw", "claude", "opencode", "hermes", "codex", "copilot", "droid", "pi"}
var integrationSpecs = []*IntegrationSpec{
{
@@ -48,19 +48,6 @@ var integrationSpecs = []*IntegrationSpec{
URL: "https://code.claude.com/docs/en/quickstart",
},
},
{
Name: "claude-desktop",
Runner: &ClaudeDesktop{},
Aliases: []string{"claude-app"},
Description: "Claude Desktop with Ollama Cloud",
Hidden: true,
Install: IntegrationInstallSpec{
CheckInstalled: func() bool {
return claudeDesktopInstalled()
},
URL: "https://claude.com/download",
},
},
{
Name: "cline",
Runner: &Cline{},
@@ -87,18 +74,6 @@ var integrationSpecs = []*IntegrationSpec{
Command: []string{"npm", "install", "-g", "@openai/codex"},
},
},
{
Name: "codex-app",
Runner: &CodexApp{},
Aliases: []string{"codex-desktop", "codex-gui"},
Description: "An AI agent you can delegate real work to, by OpenAI",
Install: IntegrationInstallSpec{
CheckInstalled: func() bool {
return codexAppInstalled()
},
URL: "https://developers.openai.com/codex/quickstart",
},
},
{
Name: "kimi",
Runner: &Kimi{},
@@ -191,18 +166,6 @@ var integrationSpecs = []*IntegrationSpec{
Command: []string{"npm", "install", "-g", "@mariozechner/pi-coding-agent@latest"},
},
},
{
Name: "pool",
Runner: &Poolside{},
Description: "Poolside's software agent for enterprise development",
Install: IntegrationInstallSpec{
CheckInstalled: func() bool {
_, err := exec.LookPath("pool")
return err == nil
},
URL: "https://github.com/poolsideai/pool",
},
},
{
Name: "hermes",
Runner: &Hermes{},
@@ -322,12 +285,6 @@ func ListVisibleIntegrationSpecs() []IntegrationSpec {
if spec.Hidden {
continue
}
if supported, ok := spec.Runner.(SupportedIntegration); ok && supported.Supported() != nil {
continue
}
if spec.Name == "pool" && poolsideGOOS == "windows" {
continue
}
visible = append(visible, *spec)
}
@@ -442,16 +399,6 @@ func EnsureIntegrationInstalled(name string, runner Runner) error {
return fmt.Errorf("%s is not installed", runner)
}
if supported, ok := runner.(SupportedIntegration); ok {
if err := supported.Supported(); err != nil {
return err
}
}
if integration.spec.Name == "pool" && poolsideGOOS == "windows" {
return poolsideUnsupportedError()
}
if integration.installed {
return nil
}

View File

@@ -45,14 +45,6 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) {
return filepath.Join(home, ".pi", "agent", "models.json")
},
},
{
name: "pool",
binary: "pool",
runner: &Poolside{},
checkPath: func(home string) string {
return filepath.Join(home, ".poolside", "config")
},
},
{
name: "kimi",
binary: "kimi",
@@ -65,10 +57,6 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.name == "pool" && poolsideGOOS == "windows" {
t.Skip("Poolside is intentionally unsupported on Windows")
}
home := t.TempDir()
setTestHome(t, home)
@@ -84,7 +72,7 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) {
t.Setenv("PATH", binDir)
configPath := tt.checkPath(home)
if err := tt.runner.Run("llama3.2", nil, nil); err != nil {
if err := tt.runner.Run("llama3.2", nil); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if _, err := os.Stat(configPath); !os.IsNotExist(err) {

View File

@@ -35,38 +35,22 @@ type ConfirmOptions struct {
// SingleSelector is a function type for single item selection.
// current is the name of the previously selected item to highlight; empty means no pre-selection.
type SingleSelector func(title string, items []SelectionItem, current string) (string, error)
// SingleSelectorWithUpdates is a single item selector that can receive refreshed item state while open.
type SingleSelectorWithUpdates func(title string, items []SelectionItem, current string, updates <-chan []SelectionItem) (string, error)
type SingleSelector func(title string, items []ModelItem, current string) (string, error)
// MultiSelector is a function type for multi item selection.
type MultiSelector func(title string, items []SelectionItem, preChecked []string) ([]string, error)
// MultiSelectorWithUpdates is a multi item selector that can receive refreshed item state while open.
type MultiSelectorWithUpdates func(title string, items []SelectionItem, preChecked []string, updates <-chan []SelectionItem) ([]string, error)
type MultiSelector func(title string, items []ModelItem, preChecked []string) ([]string, error)
// DefaultSingleSelector is the default single-select implementation.
var DefaultSingleSelector SingleSelector
// DefaultSingleSelectorWithUpdates is the default single-select implementation with live updates.
var DefaultSingleSelectorWithUpdates SingleSelectorWithUpdates
// DefaultMultiSelector is the default multi-select implementation.
var DefaultMultiSelector MultiSelector
// DefaultMultiSelectorWithUpdates is the default multi-select implementation with live updates.
var DefaultMultiSelectorWithUpdates MultiSelectorWithUpdates
// DefaultSignIn provides a TUI-based sign-in flow.
// When set, ensureAuth uses it instead of plain text prompts.
// Returns the signed-in username or an error.
var DefaultSignIn func(modelName, signInURL string) (string, error)
// DefaultUpgrade provides a TUI-based upgrade flow.
// Returns the updated plan or an error.
var DefaultUpgrade func(modelName, requiredPlan string) (string, error)
type launchConfirmPolicy struct {
yes bool
requireYesMessage bool

View File

@@ -41,10 +41,6 @@ func setTestHome(t *testing.T, dir string) {
setLaunchTestHome(t, dir)
}
func testLaunchModels(names ...string) []LaunchModel {
return launchModelsFromNames(names)
}
func SaveIntegration(appName string, models []string) error {
return config.SaveIntegration(appName, models)
}

View File

@@ -126,7 +126,7 @@ const (
minVSCodeVersion = "1.113"
)
func (v *VSCode) Run(model string, _ []LaunchModel, args []string) error {
func (v *VSCode) Run(model string, args []string) error {
v.checkVSCodeVersion()
v.checkCopilotChatVersion()
@@ -238,7 +238,7 @@ func (v *VSCode) Paths() []string {
return nil
}
func (v *VSCode) Edit(models []LaunchModel) error {
func (v *VSCode) Edit(models []string) error {
if len(models) == 0 {
return nil
}
@@ -273,7 +273,7 @@ func (v *VSCode) Edit(models []LaunchModel) error {
if err != nil {
return err
}
if err := fileutil.WriteWithBackup(clmPath, data, "vscode"); err != nil {
if err := fileutil.WriteWithBackup(clmPath, data); err != nil {
return err
}
@@ -350,7 +350,7 @@ func (v *VSCode) updateSettings() {
if err != nil {
return
}
_ = fileutil.WriteWithBackup(settingsPath, updated, "vscode")
_ = fileutil.WriteWithBackup(settingsPath, updated)
}
func (v *VSCode) statePath() string {

View File

@@ -9,7 +9,6 @@ import (
"testing"
_ "github.com/mattn/go-sqlite3"
"github.com/ollama/ollama/cmd/internal/fileutil"
)
func TestVSCodeIntegration(t *testing.T) {
@@ -113,7 +112,7 @@ func TestVSCodeEdit(t *testing.T) {
os.WriteFile(clmPath, []byte(tt.setup), 0o644)
}
if err := v.Edit(launchModelsFromNames(tt.models)); err != nil {
if err := v.Edit(tt.models); err != nil {
t.Fatal(err)
}
@@ -134,7 +133,7 @@ func TestVSCodeEditCleansUpOldSettings(t *testing.T) {
os.MkdirAll(filepath.Dir(settingsPath), 0o755)
os.WriteFile(settingsPath, []byte(`{"github.copilot.chat.byok.ollamaEndpoint": "http://old:11434", "ollama.launch.configured": true, "editor.fontSize": 14}`), 0o644)
if err := v.Edit(testLaunchModels("llama3.2")); err != nil {
if err := v.Edit([]string{"llama3.2"}); err != nil {
t.Fatal(err)
}
@@ -157,52 +156,6 @@ func TestVSCodeEditCleansUpOldSettings(t *testing.T) {
}
}
func TestVSCodeEdit_CreatesDistinctBackupsForManagedFiles(t *testing.T) {
v := &VSCode{}
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("XDG_CONFIG_HOME", "")
clmPath := testVSCodePath(t, tmpDir, "chatLanguageModels.json")
settingsPath := testVSCodePath(t, tmpDir, "settings.json")
backupDir := fileutil.BackupDir()
if err := os.MkdirAll(filepath.Dir(clmPath), 0o755); err != nil {
t.Fatal(err)
}
clmOriginal := `[{"vendor":"ollama","name":"Ollama","url":"http://old:11434"}]`
settingsOriginal := `{"github.copilot.chat.byok.ollamaEndpoint":"http://old:11434","ollama.launch.configured":true,"editor.fontSize":14}`
if err := os.WriteFile(clmPath, []byte(clmOriginal), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(settingsPath, []byte(settingsOriginal), 0o644); err != nil {
t.Fatal(err)
}
if err := v.Edit(testLaunchModels("llama3.2")); err != nil {
t.Fatal(err)
}
assertBackupMatches := func(pattern, want string) {
t.Helper()
backups, err := filepath.Glob(filepath.Join(backupDir, pattern))
if err != nil {
t.Fatalf("glob %q failed: %v", pattern, err)
}
for _, backup := range backups {
data, err := os.ReadFile(backup)
if err == nil && string(data) == want {
return
}
}
t.Fatalf("backup matching %q with expected content not found", pattern)
}
assertBackupMatches(filepath.Join("vscode", "chatLanguageModels.json.*"), clmOriginal)
assertBackupMatches(filepath.Join("vscode", "settings.json.*"), settingsOriginal)
}
func TestVSCodePaths(t *testing.T) {
v := &VSCode{}
tmpDir := t.TempDir()

View File

@@ -35,7 +35,8 @@ var (
Foreground(lipgloss.AdaptiveColor{Light: "235", Dark: "252"})
selectorDefaultTagStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"})
Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}).
Italic(true)
selectorHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "244", Dark: "244"})
@@ -57,39 +58,16 @@ const maxSelectorItems = 10
var ErrCancelled = launch.ErrCancelled
type SelectItem struct {
Name string
Description string
Recommended bool
AvailabilityBadge string
Name string
Description string
Recommended bool
}
type selectorItemsUpdatedMsg struct {
items []SelectItem
}
func waitForSelectorItems(updates <-chan []SelectItem) tea.Cmd {
if updates == nil {
return nil
}
return func() tea.Msg {
items, ok := <-updates
if !ok {
return nil
}
return selectorItemsUpdatedMsg{items: items}
}
}
// ConvertItems converts launch.SelectionItem slice to SelectItem slice.
func ConvertItems(items []launch.SelectionItem) []SelectItem {
// ConvertItems converts launch.ModelItem slice to SelectItem slice.
func ConvertItems(items []launch.ModelItem) []SelectItem {
out := make([]SelectItem, len(items))
for i, item := range items {
out[i] = SelectItem{
Name: item.Name,
Description: item.Description,
Recommended: item.Recommended,
AvailabilityBadge: item.AvailabilityBadge,
}
out[i] = SelectItem{Name: item.Name, Description: item.Description, Recommended: item.Recommended}
}
return out
}
@@ -113,7 +91,6 @@ func ReorderItems(items []SelectItem) []SelectItem {
type selectorModel struct {
title string
items []SelectItem
updates <-chan []SelectItem
filter string
cursor int
scrollOffset int
@@ -133,33 +110,6 @@ func selectorModelWithCurrent(title string, items []SelectItem, current string)
return m
}
func currentItemName(items []SelectItem, cursor int) string {
if cursor < 0 || cursor >= len(items) {
return ""
}
return items[cursor].Name
}
func cursorForItemName(items []SelectItem, name string, fallback int) int {
if len(items) == 0 {
return 0
}
if name != "" {
for i, item := range items {
if item.Name == name {
return i
}
}
}
if fallback < 0 {
return 0
}
if fallback >= len(items) {
return len(items) - 1
}
return fallback
}
func (m selectorModel) filteredItems() []SelectItem {
if m.filter == "" {
return m.items
@@ -175,7 +125,7 @@ func (m selectorModel) filteredItems() []SelectItem {
}
func (m selectorModel) Init() tea.Cmd {
return waitForSelectorItems(m.updates)
return nil
}
// otherStart returns the index of the first non-recommended item in the filtered list.
@@ -285,13 +235,6 @@ func (m selectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case selectorItemsUpdatedMsg:
current := currentItemName(m.filteredItems(), m.cursor)
m.items = msg.items
m.cursor = cursorForItemName(m.filteredItems(), current, m.cursor)
m.updateScroll(m.otherStart())
return m, waitForSelectorItems(m.updates)
case tea.KeyMsg:
switch msg.Type {
case tea.KeyCtrlC, tea.KeyEsc:
@@ -317,17 +260,9 @@ func (m selectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
func cursorItemSuffix(item SelectItem) string {
if item.AvailabilityBadge == "" {
return ""
}
return " " + selectorDefaultTagStyle.Render("("+item.AvailabilityBadge+")")
}
func (m selectorModel) renderItem(s *strings.Builder, item SelectItem, idx int) {
if idx == m.cursor {
s.WriteString(selectorSelectedItemStyle.Render("▸ " + item.Name))
s.WriteString(cursorItemSuffix(item))
} else {
s.WriteString(selectorItemStyle.Render(item.Name))
}
@@ -467,16 +402,11 @@ func cursorForCurrent(items []SelectItem, current string) int {
}
func SelectSingle(title string, items []SelectItem, current string) (string, error) {
return SelectSingleWithUpdates(title, items, current, nil)
}
func SelectSingleWithUpdates(title string, items []SelectItem, current string, updates <-chan []SelectItem) (string, error) {
if len(items) == 0 {
return "", fmt.Errorf("no items to select from")
}
m := selectorModelWithCurrent(title, items, current)
m.updates = updates
p := tea.NewProgram(m)
finalModel, err := p.Run()
@@ -496,7 +426,6 @@ func SelectSingleWithUpdates(title string, items []SelectItem, current string, u
type multiSelectorModel struct {
title string
items []SelectItem
updates <-chan []SelectItem
itemIndex map[string]int
filter string
cursor int
@@ -546,36 +475,6 @@ func newMultiSelectorModel(title string, items []SelectItem, preChecked []string
return m
}
func (m *multiSelectorModel) rebuildItemIndex() {
m.itemIndex = make(map[string]int, len(m.items))
for i, item := range m.items {
m.itemIndex[item.Name] = i
}
}
func (m *multiSelectorModel) replaceItems(items []SelectItem) {
current := currentItemName(m.filteredItems(), m.cursor)
checkedNames := make([]string, 0, len(m.checkOrder))
for _, idx := range m.checkOrder {
if idx >= 0 && idx < len(m.items) {
checkedNames = append(checkedNames, m.items[idx].Name)
}
}
m.items = items
m.rebuildItemIndex()
m.checked = make(map[int]bool, len(checkedNames))
m.checkOrder = nil
for _, name := range checkedNames {
if idx, ok := m.itemIndex[name]; ok {
m.checked[idx] = true
m.checkOrder = append(m.checkOrder, idx)
}
}
m.cursor = cursorForItemName(m.filteredItems(), current, m.cursor)
m.updateScroll(m.otherStart())
}
func (m multiSelectorModel) filteredItems() []SelectItem {
if m.filter == "" {
return m.items
@@ -691,7 +590,7 @@ func (m multiSelectorModel) selectedCount() int {
}
func (m multiSelectorModel) Init() tea.Cmd {
return waitForSelectorItems(m.updates)
return nil
}
func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -704,10 +603,6 @@ func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case selectorItemsUpdatedMsg:
m.replaceItems(msg.items)
return m, waitForSelectorItems(m.updates)
case tea.KeyMsg:
filtered := m.filteredItems()
@@ -794,7 +689,6 @@ func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m multiSelectorModel) renderSingleItem(s *strings.Builder, item SelectItem, idx int) {
if idx == m.cursor {
s.WriteString(selectorSelectedItemStyle.Render("▸ " + item.Name))
s.WriteString(cursorItemSuffix(item))
} else {
s.WriteString(selectorItemStyle.Render(item.Name))
}
@@ -822,7 +716,6 @@ func (m multiSelectorModel) renderMultiItem(s *strings.Builder, item SelectItem,
if idx == m.cursor {
s.WriteString(selectorSelectedItemStyle.Render("▸ " + check + item.Name))
s.WriteString(cursorItemSuffix(item))
} else {
s.WriteString(selectorItemStyle.Render(check + item.Name))
}
@@ -948,16 +841,11 @@ func (m multiSelectorModel) View() string {
}
func SelectMultiple(title string, items []SelectItem, preChecked []string) ([]string, error) {
return SelectMultipleWithUpdates(title, items, preChecked, nil)
}
func SelectMultipleWithUpdates(title string, items []SelectItem, preChecked []string, updates <-chan []SelectItem) ([]string, error) {
if len(items) == 0 {
return nil, fmt.Errorf("no items to select from")
}
m := newMultiSelectorModel(title, items, preChecked)
m.updates = updates
p := tea.NewProgram(m)
finalModel, err := p.Run()

View File

@@ -311,91 +311,6 @@ func TestRenderContent_SelectedItemIndicator(t *testing.T) {
}
}
func TestRenderContent_AvailabilityBadgeOnlyOnCursor(t *testing.T) {
m := selectorModel{
title: "Pick:",
items: []SelectItem{
{Name: "kimi-k2.6:cloud", AvailabilityBadge: "Upgrade required"},
{Name: "qwen3.5:cloud", AvailabilityBadge: "Sign in required"},
{Name: "glm-5:cloud", AvailabilityBadge: "Included"},
},
cursor: 0,
}
content := m.renderContent()
if !strings.Contains(content, "(Upgrade required)") {
t.Fatalf("cursor badge missing:\n%s", content)
}
if strings.Contains(content, "(Sign in required)") {
t.Fatalf("non-cursor badge should not render:\n%s", content)
}
if strings.Contains(content, "Included") {
t.Fatalf("included badge should not render:\n%s", content)
}
}
func TestSelectorModel_ItemsUpdatedPreservesCursorAndRendersBadge(t *testing.T) {
m := selectorModelWithCurrent("Pick:", []SelectItem{
{Name: "kimi-k2.6:cloud", Recommended: true},
{Name: "llama3.2"},
}, "kimi-k2.6:cloud")
updated, _ := m.Update(selectorItemsUpdatedMsg{items: []SelectItem{
{Name: "kimi-k2.6:cloud", Recommended: true, AvailabilityBadge: "Upgrade required"},
{Name: "llama3.2"},
}})
fm := updated.(selectorModel)
if fm.cursor != 0 {
t.Fatalf("cursor = %d, want 0", fm.cursor)
}
content := fm.renderContent()
if !strings.Contains(content, "(Upgrade required)") {
t.Fatalf("updated badge missing:\n%s", content)
}
}
func TestMultiSelector_AvailabilityBadgePreservesDefaultSuffix(t *testing.T) {
m := newMultiSelectorModel("Pick:", []SelectItem{
{Name: "kimi-k2.6:cloud", AvailabilityBadge: "Upgrade required"},
{Name: "qwen3.5:cloud"},
}, []string{"kimi-k2.6:cloud"})
m.multi = true
m.cursor = 0
content := m.View()
if !strings.Contains(content, "(Upgrade required)") {
t.Fatalf("cursor badge missing:\n%s", content)
}
if !strings.Contains(content, "(default)") {
t.Fatalf("default suffix missing:\n%s", content)
}
}
func TestMultiSelector_ItemsUpdatedPreservesCheckedStateAndRendersBadge(t *testing.T) {
m := newMultiSelectorModel("Pick:", []SelectItem{
{Name: "kimi-k2.6:cloud", Recommended: true},
{Name: "llama3.2"},
}, []string{"kimi-k2.6:cloud"})
m.multi = true
updated, _ := m.Update(selectorItemsUpdatedMsg{items: []SelectItem{
{Name: "kimi-k2.6:cloud", Recommended: true, AvailabilityBadge: "Upgrade required"},
{Name: "llama3.2"},
}})
fm := updated.(multiSelectorModel)
idx := fm.itemIndex["kimi-k2.6:cloud"]
if !fm.checked[idx] {
t.Fatalf("checked state was not preserved: %#v", fm.checked)
}
content := fm.View()
if !strings.Contains(content, "(Upgrade required)") {
t.Fatalf("updated badge missing:\n%s", content)
}
if !strings.Contains(content, "(default)") {
t.Fatalf("default suffix missing after update:\n%s", content)
}
}
func TestRenderContent_Description(t *testing.T) {
m := selectorModel{
title: "Pick:",

View File

@@ -19,14 +19,6 @@ type signInCheckMsg struct {
userName string
}
type upgradeTickMsg struct{}
type upgradeCheckMsg struct {
upgraded bool
plan string
err error
}
type signInModel struct {
modelName string
signInURL string
@@ -36,18 +28,6 @@ type signInModel struct {
cancelled bool
}
type upgradeModel struct {
modelName string
requiredPlan string
spinner int
width int
openNow bool
polling bool
plan string
cancelled bool
err error
}
func (m signInModel) Init() tea.Cmd {
return tea.Tick(200*time.Millisecond, func(t time.Time) tea.Msg {
return signInTickMsg{}
@@ -102,85 +82,6 @@ func (m signInModel) View() string {
return renderSignIn(m.modelName, m.signInURL, m.spinner, m.width)
}
func (m upgradeModel) Init() tea.Cmd {
if m.polling {
return upgradeTickCmd()
}
return nil
}
func (m upgradeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
wasSet := m.width > 0
m.width = msg.Width
if wasSet {
return m, tea.EnterAltScreen
}
return m, nil
case tea.KeyMsg:
switch msg.Type {
case tea.KeyCtrlC, tea.KeyEsc:
m.cancelled = true
return m, tea.Quit
case tea.KeyLeft:
if !m.polling {
m.openNow = true
}
case tea.KeyRight:
if !m.polling {
m.openNow = false
}
case tea.KeyEnter:
if !m.polling {
if !m.openNow {
m.cancelled = true
return m, tea.Quit
}
launch.OpenBrowser(launch.DefaultUpgradeURL)
m.polling = true
return m, upgradeTickCmd()
}
}
case upgradeTickMsg:
if !m.polling {
return m, nil
}
m.spinner++
if m.spinner%5 == 0 {
return m, tea.Batch(
upgradeTickCmd(),
checkUpgrade(m.requiredPlan),
)
}
return m, upgradeTickCmd()
case upgradeCheckMsg:
if msg.err != nil {
m.err = msg.err
return m, tea.Quit
}
if msg.upgraded {
m.plan = msg.plan
return m, tea.Quit
}
}
return m, nil
}
func (m upgradeModel) View() string {
if m.plan != "" {
return ""
}
if m.err != nil {
return ""
}
return renderUpgrade(m.modelName, m.spinner, m.width, m.polling, m.openNow)
}
func renderSignIn(modelName, signInURL string, spinner, width int) string {
spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
frame := spinnerFrames[spinner%len(spinnerFrames)]
@@ -209,88 +110,18 @@ func renderSignIn(modelName, signInURL string, spinner, width int) string {
return lipgloss.NewStyle().PaddingLeft(2).Render(s.String())
}
func upgradeTickCmd() tea.Cmd {
return tea.Tick(200*time.Millisecond, func(t time.Time) tea.Msg {
return upgradeTickMsg{}
})
}
func renderUpgrade(modelName string, spinner, width int, polling, openNow bool) string {
spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
frame := spinnerFrames[spinner%len(spinnerFrames)]
urlColor := lipgloss.NewStyle().
Foreground(lipgloss.Color("117"))
urlWrap := lipgloss.NewStyle().PaddingLeft(2)
if width > 4 {
urlWrap = urlWrap.Width(width - 4)
}
var s strings.Builder
fmt.Fprintf(&s, "To use %s, upgrade your Ollama plan.\n\n", selectorSelectedItemStyle.Render(modelName))
s.WriteString("Navigate to:\n")
s.WriteString(urlWrap.Render(urlColor.Render(launch.DefaultUpgradeURL)))
s.WriteString("\n\n")
if !polling {
var yesBtn, noBtn string
if openNow {
yesBtn = confirmActiveStyle.Render(" Yes ")
noBtn = confirmInactiveStyle.Render(" No ")
} else {
yesBtn = confirmInactiveStyle.Render(" Yes ")
noBtn = confirmActiveStyle.Render(" No ")
}
s.WriteString("Open now?\n")
s.WriteString(" " + yesBtn + " " + noBtn)
s.WriteString("\n\n")
s.WriteString(selectorHelpStyle.Render("←/→ navigate • enter confirm • esc cancel"))
} else {
s.WriteString(lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}).Render(
frame + " Waiting for upgrade to complete..."))
s.WriteString("\n\n")
s.WriteString(selectorHelpStyle.Render("esc cancel"))
}
return lipgloss.NewStyle().PaddingLeft(2).Render(s.String())
}
func checkSignIn() tea.Msg {
client, err := api.ClientFromEnvironment()
if err != nil {
return signInCheckMsg{signedIn: false}
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
user, err := client.Whoami(ctx)
user, err := client.Whoami(context.Background())
if err == nil && user != nil && user.Name != "" {
return signInCheckMsg{signedIn: true, userName: user.Name}
}
return signInCheckMsg{signedIn: false}
}
func checkUpgrade(requiredPlan string) tea.Cmd {
return func() tea.Msg {
client, err := api.ClientFromEnvironment()
if err != nil {
return upgradeCheckMsg{err: launch.ErrPlanVerificationUnavailable}
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
user, err := client.Whoami(ctx)
if err != nil {
return upgradeCheckMsg{err: launch.ErrPlanVerificationUnavailable}
}
if err == nil && user != nil && user.Name != "" && launch.PlanSatisfies(user.Plan, requiredPlan) {
return upgradeCheckMsg{upgraded: true, plan: user.Plan}
}
return upgradeCheckMsg{upgraded: false}
}
}
// RunSignIn shows a bubbletea sign-in dialog and polls until the user signs in or cancels.
func RunSignIn(modelName, signInURL string) (string, error) {
launch.OpenBrowser(signInURL)
@@ -313,28 +144,3 @@ func RunSignIn(modelName, signInURL string) (string, error) {
return fm.userName, nil
}
// RunUpgrade shows a bubbletea upgrade dialog and polls until the user's plan is updated or cancelled.
func RunUpgrade(modelName, requiredPlan string) (string, error) {
m := upgradeModel{
modelName: modelName,
requiredPlan: requiredPlan,
openNow: true,
}
p := tea.NewProgram(m)
finalModel, err := p.Run()
if err != nil {
return "", fmt.Errorf("error running upgrade: %w", err)
}
fm := finalModel.(upgradeModel)
if fm.cancelled {
return "", ErrCancelled
}
if fm.err != nil {
return "", fm.err
}
return fm.plan, nil
}

View File

@@ -5,7 +5,6 @@ import (
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/ollama/ollama/cmd/launch"
)
func TestRenderSignIn_ContainsModelName(t *testing.T) {
@@ -26,6 +25,7 @@ func TestRenderSignIn_ContainsURL(t *testing.T) {
}
}
func TestRenderSignIn_ContainsSpinner(t *testing.T) {
got := renderSignIn("test:cloud", "https://example.com", 0, 80)
if !strings.Contains(got, "Waiting for sign in to complete") {
@@ -51,35 +51,6 @@ func TestRenderSignIn_ContainsEscHelp(t *testing.T) {
}
}
func TestRenderUpgrade_AsksBeforeOpening(t *testing.T) {
got := renderUpgrade("kimi-k2.6:cloud", 0, 80, false, true)
if !strings.Contains(got, "kimi-k2.6:cloud") {
t.Error("should contain model name")
}
if !strings.Contains(got, launch.DefaultUpgradeURL) {
t.Error("should contain upgrade URL")
}
if !strings.Contains(got, "Open now?") {
t.Error("should ask before opening")
}
if !strings.Contains(got, "Yes") || !strings.Contains(got, "No") {
t.Error("should show yes/no selector")
}
if strings.Contains(got, "Waiting for upgrade to complete") {
t.Error("should not start waiting before open choice is confirmed")
}
}
func TestRenderUpgrade_PollingShowsWaiting(t *testing.T) {
got := renderUpgrade("kimi-k2.6:cloud", 0, 80, true, true)
if !strings.Contains(got, "Waiting for upgrade to complete") {
t.Error("should contain waiting message")
}
if strings.Contains(got, "Open now?") {
t.Error("should not show open prompt while polling")
}
}
func TestSignInModel_EscCancels(t *testing.T) {
m := signInModel{
modelName: "test:cloud",
@@ -96,35 +67,6 @@ func TestSignInModel_EscCancels(t *testing.T) {
}
}
func TestUpgradeModel_NoCancelsWithoutPolling(t *testing.T) {
m := upgradeModel{
modelName: "kimi-k2.6:cloud",
requiredPlan: "pro",
openNow: true,
}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight})
fm := updated.(upgradeModel)
if fm.openNow {
t.Error("right should select no")
}
if fm.polling {
t.Error("right should not start polling")
}
updated, cmd := fm.Update(tea.KeyMsg{Type: tea.KeyEnter})
fm = updated.(upgradeModel)
if !fm.cancelled {
t.Error("enter on no should cancel")
}
if fm.polling {
t.Error("enter on no should not start polling")
}
if cmd == nil {
t.Error("enter on no should quit")
}
}
func TestSignInModel_CtrlCCancels(t *testing.T) {
m := signInModel{
modelName: "test:cloud",

View File

@@ -45,7 +45,7 @@ type menuItem struct {
isOthers bool
}
const pinnedIntegrationCount = 4
const pinnedIntegrationCount = 3
var runModelMenuItem = menuItem{
title: "Chat with a model",

View File

@@ -29,13 +29,6 @@ func launcherTestState() *launch.LauncherState {
Selectable: true,
Changeable: true,
},
"codex-app": {
Name: "codex-app",
DisplayName: "Codex App",
Description: "An AI agent you can delegate real work to, by OpenAI",
Selectable: true,
Changeable: true,
},
"openclaw": {
Name: "openclaw",
DisplayName: "OpenClaw",
@@ -104,54 +97,18 @@ func compareStrings(got, want []string) string {
return cmp.Diff(want, got)
}
func expectedCollapsedSequence(state *launch.LauncherState) []string {
sequence := []string{"run"}
for _, item := range pinnedIntegrationItems(state) {
sequence = append(sequence, item.integration)
}
if len(otherIntegrationItems(state)) > 0 {
sequence = append(sequence, "more")
}
return sequence
}
func expectedExpandedSequence(state *launch.LauncherState) []string {
sequence := []string{"run"}
for _, item := range pinnedIntegrationItems(state) {
sequence = append(sequence, item.integration)
}
for _, item := range otherIntegrationItems(state) {
sequence = append(sequence, item.integration)
}
return sequence
}
func TestMenuRendersPinnedItemsAndMore(t *testing.T) {
state := launcherTestState()
menu := newModel(state)
wantPrefix := []string{"run", "claude", "codex-app", "hermes", "openclaw"}
if findMenuCursorByIntegration(menu.items, "codex-app") == -1 {
wantPrefix = []string{"run", "claude", "hermes", "openclaw", "opencode"}
}
if got := integrationSequence(menu.items); len(got) < len(wantPrefix) {
t.Fatalf("expected at least %d menu items, got %v", len(wantPrefix), got)
} else if diff := compareStrings(got[:len(wantPrefix)], wantPrefix); diff != "" {
t.Fatalf("unexpected primary TUI order: %s", diff)
}
menu := newModel(launcherTestState())
view := menu.View()
for _, want := range []string{"Chat with a model", "Launch Claude Code", "Launch Hermes Agent", "Launch OpenClaw", "More..."} {
for _, want := range []string{"Chat with a model", "Launch OpenClaw", "Launch Claude Code", "Launch OpenCode", "More..."} {
if !strings.Contains(view, want) {
t.Fatalf("expected menu view to contain %q\n%s", want, view)
}
}
if findMenuCursorByIntegration(menu.items, "codex-app") != -1 && !strings.Contains(view, "Launch Codex App") {
t.Fatalf("expected menu view to contain Codex App\n%s", view)
if strings.Contains(view, "Launch Codex") {
t.Fatalf("expected Codex to be under More, not pinned\n%s", view)
}
if strings.Contains(view, "Launch Claude Desktop") {
t.Fatalf("expected hidden Claude Desktop to be absent\n%s", view)
}
wantOrder := expectedCollapsedSequence(state)
wantOrder := []string{"run", "openclaw", "claude", "opencode", "more"}
if diff := compareStrings(integrationSequence(menu.items), wantOrder); diff != "" {
t.Fatalf("unexpected pinned order: %s", diff)
}
@@ -159,24 +116,20 @@ func TestMenuRendersPinnedItemsAndMore(t *testing.T) {
func TestMenuExpandsOthersFromLastSelection(t *testing.T) {
state := launcherTestState()
overflow := otherIntegrationItems(state)
if len(overflow) == 0 {
t.Fatal("expected at least one overflow integration")
}
state.LastSelection = overflow[0].integration
state.LastSelection = "codex"
menu := newModel(state)
if !menu.showOthers {
t.Fatal("expected others section to expand when last selection is in the overflow list")
}
view := menu.View()
if !strings.Contains(view, overflow[0].title) {
if !strings.Contains(view, "Launch Codex") {
t.Fatalf("expected expanded view to contain overflow integration\n%s", view)
}
if strings.Contains(view, "More...") {
t.Fatalf("expected expanded view to replace More... item\n%s", view)
}
wantOrder := expectedExpandedSequence(state)
wantOrder := []string{"run", "openclaw", "claude", "opencode", "hermes", "codex", "droid", "pi"}
if diff := compareStrings(integrationSequence(menu.items), wantOrder); diff != "" {
t.Fatalf("unexpected expanded order: %s", diff)
}

View File

@@ -316,8 +316,6 @@ func LoadModelMetadata(fsys fs.FS) (ModelKV, *Tokenizer, error) {
conv = &deepseek2Model{}
case "Glm4MoeLiteForCausalLM":
conv = &glm4MoeLiteModel{}
case "LagunaForCausalLM":
conv = &lagunaModel{}
case "GlmOcrForConditionalGeneration":
conv = &glmOcrModel{}
case "Lfm2ForCausalLM", "Lfm2MoeForCausalLM":
@@ -326,8 +324,6 @@ func LoadModelMetadata(fsys fs.FS) (ModelKV, *Tokenizer, error) {
conv = &lfm2VLTextModel{}
case "Qwen3NextForCausalLM", "Qwen3_5ForConditionalGeneration", "Qwen3_5MoeForConditionalGeneration":
conv = &qwen3NextModel{}
case "NemotronH_Nano_VL_V2", "NemotronH_Nano_Omni_Reasoning_V3":
conv = &nemotronHNanoVLModel{}
case "NemotronHForCausalLM":
conv = &nemotronHModel{}
default:
@@ -391,10 +387,6 @@ func ConvertModel(fsys fs.FS, f *os.File) error {
}
func writeFile(f *os.File, kv KV, ts []*ggml.Tensor) error {
for k, v := range sourceTensorKV(ts) {
kv[k] = v
}
for i := range ts {
ts[i].Shape = slices.Clone(ts[i].Shape)
slices.Reverse(ts[i].Shape)

View File

@@ -1,604 +0,0 @@
package convert
import (
"cmp"
"encoding/json"
"fmt"
iofs "io/fs"
"math"
"strings"
"github.com/ollama/ollama/fs/ggml"
)
type lagunaModel struct {
ModelParameters
NumHiddenLayers uint32 `json:"num_hidden_layers"`
HiddenSize uint32 `json:"hidden_size"`
IntermediateSize uint32 `json:"intermediate_size"`
NumAttentionHeads uint32 `json:"num_attention_heads"`
NumKeyValueHeads uint32 `json:"num_key_value_heads"`
HeadDim uint32 `json:"head_dim"`
RMSNormEPS float32 `json:"rms_norm_eps"`
MaxPositionEmbeddings uint32 `json:"max_position_embeddings"`
SlidingWindow uint32 `json:"sliding_window"`
PartialRotaryFactor float32 `json:"partial_rotary_factor"`
Gating lagunaGatingMode `json:"gating"`
QKNormType string `json:"qk_norm_type"`
LayerTypes []string `json:"layer_types"`
NumAttentionHeadsPerLayer []uint32 `json:"num_attention_heads_per_layer"`
NumExperts uint32 `json:"num_experts"`
NumExpertsPerTok uint32 `json:"num_experts_per_tok"`
MoEIntermediateSize uint32 `json:"moe_intermediate_size"`
SharedExpertIntermediateSize uint32 `json:"shared_expert_intermediate_size"`
NormTopKProb bool `json:"norm_topk_prob"`
MoeRoutedScalingFactor float32 `json:"moe_routed_scaling_factor"`
MoERouterUseSigmoid bool `json:"moe_router_use_sigmoid"`
MoEApplyRouterWeightOnInput bool `json:"moe_apply_router_weight_on_input"`
DecoderSparseStep uint32 `json:"decoder_sparse_step"`
MLPOnlyLayers []uint32 `json:"mlp_only_layers"`
MLPLayerTypes []string `json:"mlp_layer_types"`
RopeParameters lagunaRopeParameters `json:"rope_parameters"`
SwaRopeParameters lagunaRopeParameters `json:"swa_rope_parameters"`
SwaAttentionSinkEnabled bool `json:"swa_attention_sink_enabled"`
}
type lagunaGatingMode string
type lagunaRopeParameters struct {
RopeTheta float32 `json:"rope_theta"`
RopeType string `json:"rope_type"`
Type string `json:"type"`
Factor float32 `json:"factor"`
OriginalMaxPositionEmbeddings uint32 `json:"original_max_position_embeddings"`
BetaSlow float32 `json:"beta_slow"`
BetaFast float32 `json:"beta_fast"`
AttentionFactor float32 `json:"attention_factor"`
PartialRotaryFactor float32 `json:"partial_rotary_factor"`
}
type lagunaRopeConfig struct {
flat lagunaRopeParameters
full lagunaRopeParameters
sliding lagunaRopeParameters
nested bool
}
func (g *lagunaGatingMode) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err == nil {
*g = lagunaGatingMode(s)
return nil
}
var enabled bool
if err := json.Unmarshal(b, &enabled); err == nil {
if enabled {
*g = "true"
} else {
*g = "false"
}
return nil
}
if string(b) == "null" {
return nil
}
return fmt.Errorf("unsupported Laguna gating JSON value %s", string(b))
}
func (g lagunaGatingMode) perHead() bool {
return strings.EqualFold(string(g), "per-head") || strings.EqualFold(string(g), "true")
}
func (r *lagunaRopeConfig) UnmarshalJSON(b []byte) error {
if string(b) == "null" {
return nil
}
var probe map[string]json.RawMessage
if err := json.Unmarshal(b, &probe); err != nil {
return err
}
if len(probe) == 0 {
return nil
}
if raw, ok := probe["full_attention"]; ok {
r.nested = true
if err := json.Unmarshal(raw, &r.full); err != nil {
return err
}
if raw = probe["sliding_attention"]; raw != nil {
if err := json.Unmarshal(raw, &r.sliding); err != nil {
return err
}
}
return nil
}
if raw, ok := probe["global_attention"]; ok {
r.nested = true
if err := json.Unmarshal(raw, &r.full); err != nil {
return err
}
if raw = probe["sliding_attention"]; raw != nil {
if err := json.Unmarshal(raw, &r.sliding); err != nil {
return err
}
}
return nil
}
return json.Unmarshal(b, &r.flat)
}
func (r lagunaRopeConfig) fullParams() lagunaRopeParameters {
if r.nested {
return r.full
}
return r.flat
}
func (r lagunaRopeConfig) slidingParams() (lagunaRopeParameters, bool) {
if !r.nested {
return lagunaRopeParameters{}, false
}
return r.sliding, true
}
func (r lagunaRopeParameters) ropeType() string {
return cmp.Or(r.RopeType, r.Type)
}
func (r lagunaRopeParameters) withDefaultPartialRotaryFactor(v float32) lagunaRopeParameters {
if r.PartialRotaryFactor == 0 {
r.PartialRotaryFactor = v
}
return r
}
func (r lagunaRopeParameters) empty() bool {
return r == (lagunaRopeParameters{})
}
type rawLagunaModel struct {
ModelParameters
NumHiddenLayers uint32 `json:"num_hidden_layers"`
HiddenSize uint32 `json:"hidden_size"`
IntermediateSize uint32 `json:"intermediate_size"`
NumAttentionHeads uint32 `json:"num_attention_heads"`
NumKeyValueHeads uint32 `json:"num_key_value_heads"`
HeadDim uint32 `json:"head_dim"`
RMSNormEPS float32 `json:"rms_norm_eps"`
MaxPositionEmbeddings uint32 `json:"max_position_embeddings"`
SlidingWindow uint32 `json:"sliding_window"`
PartialRotaryFactor float32 `json:"partial_rotary_factor"`
Gating lagunaGatingMode `json:"gating"`
QKNormType string `json:"qk_norm_type"`
LayerTypes []string `json:"layer_types"`
NumAttentionHeadsPerLayer []uint32 `json:"num_attention_heads_per_layer"`
NumExperts uint32 `json:"num_experts"`
NumExpertsPerTok uint32 `json:"num_experts_per_tok"`
MoEIntermediateSize uint32 `json:"moe_intermediate_size"`
SharedExpertIntermediateSize uint32 `json:"shared_expert_intermediate_size"`
NormTopKProb *bool `json:"norm_topk_prob"`
MoeRoutedScalingFactor float32 `json:"moe_routed_scaling_factor"`
MoERouterUseSigmoid *bool `json:"moe_router_use_sigmoid"`
MoEApplyRouterWeightOnInput bool `json:"moe_apply_router_weight_on_input"`
DecoderSparseStep uint32 `json:"decoder_sparse_step"`
MLPOnlyLayers []uint32 `json:"mlp_only_layers"`
MLPLayerTypes []string `json:"mlp_layer_types"`
RopeParameters lagunaRopeConfig `json:"rope_parameters"`
SwaRopeParameters lagunaRopeParameters `json:"swa_rope_parameters"`
SwaAttentionSinkEnabled bool `json:"swa_attention_sink_enabled"`
}
func (p *lagunaModel) UnmarshalJSON(b []byte) error {
var raw rawLagunaModel
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
mlpOnlyLayers, err := lagunaDenseLayers(raw.MLPOnlyLayers, raw.MLPLayerTypes)
if err != nil {
return err
}
fullRope := raw.RopeParameters.fullParams().withDefaultPartialRotaryFactor(cmp.Or(raw.PartialRotaryFactor, float32(1)))
swaRope := raw.SwaRopeParameters
if nestedSwa, ok := raw.RopeParameters.slidingParams(); ok && !nestedSwa.empty() {
swaRope = nestedSwa
}
swaRope = swaRope.withDefaultPartialRotaryFactor(cmp.Or(fullRope.PartialRotaryFactor, float32(1)))
*p = lagunaModel{
ModelParameters: raw.ModelParameters,
NumHiddenLayers: raw.NumHiddenLayers,
HiddenSize: raw.HiddenSize,
IntermediateSize: raw.IntermediateSize,
NumAttentionHeads: raw.NumAttentionHeads,
NumKeyValueHeads: raw.NumKeyValueHeads,
HeadDim: raw.HeadDim,
RMSNormEPS: raw.RMSNormEPS,
MaxPositionEmbeddings: raw.MaxPositionEmbeddings,
SlidingWindow: raw.SlidingWindow,
PartialRotaryFactor: cmp.Or(raw.PartialRotaryFactor, fullRope.PartialRotaryFactor),
Gating: raw.Gating,
QKNormType: cmp.Or(raw.QKNormType, "rmsnorm"),
LayerTypes: raw.LayerTypes,
NumAttentionHeadsPerLayer: raw.NumAttentionHeadsPerLayer,
NumExperts: raw.NumExperts,
NumExpertsPerTok: raw.NumExpertsPerTok,
MoEIntermediateSize: raw.MoEIntermediateSize,
SharedExpertIntermediateSize: raw.SharedExpertIntermediateSize,
NormTopKProb: defaultBool(raw.NormTopKProb, true),
MoeRoutedScalingFactor: raw.MoeRoutedScalingFactor,
MoERouterUseSigmoid: defaultBool(raw.MoERouterUseSigmoid, true),
MoEApplyRouterWeightOnInput: raw.MoEApplyRouterWeightOnInput,
DecoderSparseStep: raw.DecoderSparseStep,
MLPOnlyLayers: mlpOnlyLayers,
MLPLayerTypes: raw.MLPLayerTypes,
RopeParameters: fullRope,
SwaRopeParameters: swaRope,
SwaAttentionSinkEnabled: raw.SwaAttentionSinkEnabled,
}
return nil
}
func defaultBool(v *bool, fallback bool) bool {
if v == nil {
return fallback
}
return *v
}
const (
lagunaGatingFuncSoftmax uint32 = 1
lagunaGatingFuncSigmoid uint32 = 2
lagunaLayerTypeGlobal uint32 = 0
lagunaLayerTypeSliding uint32 = 1
)
func (p *lagunaModel) KV(t *Tokenizer) KV {
kv := p.ModelParameters.KV(t)
kv["general.architecture"] = "laguna"
// Laguna's chat template and built-in renderer both emit the leading
// special token explicitly. Auto-prepending BOS here would duplicate it.
kv["tokenizer.ggml.add_bos_token"] = false
kv["tokenizer.ggml.pre"] = "laguna"
// Laguna does not need tokenizer.chat_template at runtime: Ollama create
// sets the Laguna renderer/parser from the architecture, and the renderer
// owns prompt formatting.
delete(kv, "tokenizer.chat_template")
kv["laguna.block_count"] = p.NumHiddenLayers
kv["laguna.context_length"] = p.MaxPositionEmbeddings
kv["laguna.embedding_length"] = p.HiddenSize
kv["laguna.feed_forward_length"] = p.IntermediateSize
if len(p.NumAttentionHeadsPerLayer) == int(p.NumHiddenLayers) {
kv["laguna.attention.head_count"] = p.NumAttentionHeadsPerLayer
} else {
kv["laguna.attention.head_count"] = p.NumAttentionHeads
}
kv["laguna.attention.head_count_kv"] = p.NumKeyValueHeads
kv["laguna.attention.key_length"] = p.HeadDim
kv["laguna.attention.value_length"] = p.HeadDim
kv["laguna.attention.layer_norm_rms_epsilon"] = p.RMSNormEPS
kv["laguna.attention.sliding_window"] = p.SlidingWindow
kv["laguna.attention.sink_enabled"] = p.SwaAttentionSinkEnabled
if len(p.LayerTypes) > 0 {
encoded := make([]uint32, len(p.LayerTypes))
slidingPattern := make([]bool, len(p.LayerTypes))
for i, layerType := range p.LayerTypes {
if lagunaLayerIsSliding(layerType) {
encoded[i] = lagunaLayerTypeSliding
slidingPattern[i] = true
} else {
encoded[i] = lagunaLayerTypeGlobal
}
}
kv["laguna.attention.layer_types"] = encoded
kv["laguna.attention.sliding_window_pattern"] = slidingPattern
}
if p.Gating.perHead() {
kv["laguna.attention.gating_type"] = uint32(1)
} else {
kv["laguna.attention.gating_type"] = uint32(0)
}
kv["laguna.attention.qk_norm"] = p.QKNormType == "rmsnorm"
kv["laguna.expert_count"] = p.NumExperts
kv["laguna.expert_used_count"] = p.NumExpertsPerTok
kv["laguna.expert_feed_forward_length"] = p.MoEIntermediateSize
kv["laguna.expert_shared_feed_forward_length"] = p.SharedExpertIntermediateSize
kv["laguna.expert_shared_count"] = uint32(1)
kv["laguna.expert_weights_norm"] = p.NormTopKProb
kv["laguna.expert_weights_scale"] = p.MoeRoutedScalingFactor
kv["laguna.expert_gating_func"] = lagunaMoeGatingFunc(p.MoERouterUseSigmoid)
kv["laguna.decoder_sparse_step"] = cmp.Or(p.DecoderSparseStep, uint32(1))
if leading, ok := lagunaLeadingDensePrefix(p.MLPOnlyLayers); ok {
kv["laguna.leading_dense_block_count"] = leading
}
if len(p.MLPOnlyLayers) > 0 {
kv["laguna.dense_layers"] = p.MLPOnlyLayers
}
ropeType := p.RopeParameters.ropeType()
kv["laguna.rope.freq_base"] = cmp.Or(p.RopeParameters.RopeTheta, float32(10000))
kv["laguna.rope.scaling.type"] = ropeType
ropeFactor := cmp.Or(p.RopeParameters.Factor, float32(1))
kv["laguna.rope.scaling.factor"] = ropeFactor
kv["laguna.rope.scaling.original_context_length"] = p.RopeParameters.OriginalMaxPositionEmbeddings
kv["laguna.rope.scaling.beta_fast"] = p.RopeParameters.BetaFast
kv["laguna.rope.scaling.beta_slow"] = p.RopeParameters.BetaSlow
kv["laguna.rope.scaling.attn_factor"] = lagunaAttentionFactor(ropeType, ropeFactor, p.RopeParameters.AttentionFactor)
kv["laguna.rope.partial_rotary_factor"] = cmp.Or(p.PartialRotaryFactor, float32(1))
swaRopeType := p.SwaRopeParameters.ropeType()
kv["laguna.rope.swa.freq_base"] = cmp.Or(p.SwaRopeParameters.RopeTheta, float32(10000))
kv["laguna.rope.swa.scaling.type"] = cmp.Or(swaRopeType, "linear")
kv["laguna.rope.swa.scaling.factor"] = cmp.Or(p.SwaRopeParameters.Factor, float32(1))
kv["laguna.rope.swa.partial_rotary_factor"] = cmp.Or(p.SwaRopeParameters.PartialRotaryFactor, float32(1))
headDim := p.HeadDim
if headDim == 0 && p.NumAttentionHeads > 0 {
headDim = p.HiddenSize / p.NumAttentionHeads
}
kv["laguna.rope.dimension_count"] = lagunaRopeDim(headDim, cmp.Or(p.PartialRotaryFactor, float32(1)))
kv["laguna.rope.swa.dimension_count"] = lagunaRopeDim(headDim, cmp.Or(p.SwaRopeParameters.PartialRotaryFactor, float32(1)))
return kv
}
func (p *lagunaModel) parseMore(_ iofs.FS) error {
return p.validate()
}
func (p *lagunaModel) validate() error {
if p.NumHiddenLayers == 0 {
return fmt.Errorf("laguna: num_hidden_layers must be set")
}
if p.HiddenSize == 0 {
return fmt.Errorf("laguna: hidden_size must be set")
}
if p.HeadDim == 0 {
return fmt.Errorf("laguna: head_dim must be set")
}
if p.NumKeyValueHeads == 0 {
return fmt.Errorf("laguna: num_key_value_heads must be set")
}
if p.SwaAttentionSinkEnabled {
return fmt.Errorf("laguna: unsupported swa_attention_sink_enabled=true")
}
if !p.Gating.perHead() {
return fmt.Errorf("laguna: unsupported attention gating %q: only gating=\"per-head\" is supported", p.Gating)
}
if p.QKNormType != "rmsnorm" {
return fmt.Errorf("laguna: unsupported qk_norm_type %q: only rmsnorm is supported", p.QKNormType)
}
if !p.MoERouterUseSigmoid {
return fmt.Errorf("laguna: unsupported moe_router_use_sigmoid=false")
}
if p.MoEApplyRouterWeightOnInput {
return fmt.Errorf("laguna: unsupported moe_apply_router_weight_on_input=true")
}
if p.DecoderSparseStep != 0 && p.DecoderSparseStep != 1 {
return fmt.Errorf("laguna: unsupported decoder_sparse_step=%d: only 1 is supported", p.DecoderSparseStep)
}
if len(p.MLPOnlyLayers) != 1 || p.MLPOnlyLayers[0] != 0 {
return fmt.Errorf("laguna: unsupported mlp_only_layers=%v: only [0] is supported", p.MLPOnlyLayers)
}
if p.NumExperts == 0 {
return fmt.Errorf("laguna: num_experts must be set")
}
if p.NumExpertsPerTok == 0 {
return fmt.Errorf("laguna: num_experts_per_tok must be set")
}
if p.MoEIntermediateSize == 0 {
return fmt.Errorf("laguna: moe_intermediate_size must be set")
}
if p.SharedExpertIntermediateSize == 0 {
return fmt.Errorf("laguna: shared_expert_intermediate_size must be set")
}
if len(p.LayerTypes) > 0 && len(p.LayerTypes) != int(p.NumHiddenLayers) {
return fmt.Errorf("laguna: layer_types has %d entries, expected %d", len(p.LayerTypes), p.NumHiddenLayers)
}
for i, layerType := range p.LayerTypes {
if !lagunaLayerIsGlobal(layerType) && !lagunaLayerIsSliding(layerType) {
return fmt.Errorf("laguna: unsupported layer_types[%d]=%q", i, layerType)
}
}
if len(p.NumAttentionHeadsPerLayer) > 0 && len(p.NumAttentionHeadsPerLayer) != int(p.NumHiddenLayers) {
return fmt.Errorf("laguna: num_attention_heads_per_layer has %d entries, expected %d", len(p.NumAttentionHeadsPerLayer), p.NumHiddenLayers)
}
if len(p.NumAttentionHeadsPerLayer) == 0 && p.NumAttentionHeads == 0 {
return fmt.Errorf("laguna: num_attention_heads or num_attention_heads_per_layer must be set")
}
for i, heads := range p.NumAttentionHeadsPerLayer {
if heads == 0 {
return fmt.Errorf("laguna: num_attention_heads_per_layer[%d] must be non-zero", i)
}
}
return nil
}
func (p *lagunaModel) numHeadsForLayer(layer uint32) uint32 {
if len(p.NumAttentionHeadsPerLayer) > int(layer) && p.NumAttentionHeadsPerLayer[layer] > 0 {
return p.NumAttentionHeadsPerLayer[layer]
}
return p.NumAttentionHeads
}
func (p *lagunaModel) layerUsesMoE(layer uint32) bool {
for _, denseLayer := range p.MLPOnlyLayers {
if denseLayer == layer {
return false
}
}
step := cmp.Or(p.DecoderSparseStep, uint32(1))
return p.NumExperts > 0 && (layer+1)%step == 0
}
func (p *lagunaModel) Replacements() []string {
return []string{
"lm_head", "output",
"model.embed_tokens", "token_embd",
"model.norm", "output_norm",
"model.layers", "blk",
"input_layernorm", "attn_norm",
"post_attention_layernorm", "ffn_norm",
"self_attn.q_proj", "attn_q",
"self_attn.k_proj", "attn_k",
"self_attn.v_proj", "attn_v",
"self_attn.o_proj", "attn_output",
"self_attn.g_proj", "attn_g",
"self_attn.q_norm", "attn_q_norm",
"self_attn.k_norm", "attn_k_norm",
"mlp.gate_proj", "ffn_gate",
"mlp.up_proj", "ffn_up",
"mlp.down_proj", "ffn_down",
"mlp.gate.weight", "ffn_gate_inp.weight",
"mlp.experts.e_score_correction_bias", "exp_probs_b.bias",
"mlp.shared_expert.gate_proj", "ffn_gate_shexp",
"mlp.shared_expert.up_proj", "ffn_up_shexp",
"mlp.shared_expert.down_proj", "ffn_down_shexp",
"mlp.experts.*.gate_proj", "ffn_gate_exps",
"mlp.experts.*.up_proj", "ffn_up_exps",
"mlp.experts.*.down_proj", "ffn_down_exps",
}
}
func (p *lagunaModel) Tensors(ts []Tensor) []*ggml.Tensor {
// Current Laguna drops store routed MoE experts as separate per-expert
// tensors. GGUF stores each projection as one stacked tensor. If future
// drops change expert naming or layout, update these patterns with a
// focused conversion test using the new tensor names.
merges := make([]merge, 0, p.NumHiddenLayers*3)
for i := range p.NumHiddenLayers {
merges = append(merges,
merge{
fmt.Sprintf("blk.%d.mlp.experts.*.gate_proj.weight", i),
fmt.Sprintf("blk.%d.ffn_gate_exps.weight", i),
},
merge{
fmt.Sprintf("blk.%d.mlp.experts.*.up_proj.weight", i),
fmt.Sprintf("blk.%d.ffn_up_exps.weight", i),
},
merge{
fmt.Sprintf("blk.%d.mlp.experts.*.down_proj.weight", i),
fmt.Sprintf("blk.%d.ffn_down_exps.weight", i),
},
)
}
out, rest := mergeTensors(ts, merges...)
for _, t := range rest {
out = append(out, &ggml.Tensor{
Name: t.Name(),
Kind: t.Kind(),
Shape: t.Shape(),
WriterTo: t,
})
}
return out
}
func (p *lagunaModel) specialTokenTypes() []string {
return []string{"bos", "eos", "pad", "unk"}
}
func lagunaLayerIsSliding(layerType string) bool {
return strings.EqualFold(layerType, "sliding_attention")
}
func lagunaLayerIsGlobal(layerType string) bool {
return strings.EqualFold(layerType, "full_attention") || strings.EqualFold(layerType, "global_attention")
}
func lagunaLeadingDensePrefix(layers []uint32) (uint32, bool) {
for i, v := range layers {
if v != uint32(i) {
return 0, false
}
}
return uint32(len(layers)), true
}
func lagunaDenseLayers(mlpOnlyLayers []uint32, mlpLayerTypes []string) ([]uint32, error) {
if len(mlpOnlyLayers) > 0 {
return mlpOnlyLayers, nil
}
if len(mlpLayerTypes) == 0 {
return nil, nil
}
denseLayers := make([]uint32, 0, len(mlpLayerTypes))
for i, layerType := range mlpLayerTypes {
switch {
case strings.EqualFold(layerType, "dense"):
denseLayers = append(denseLayers, uint32(i))
case strings.EqualFold(layerType, "sparse"):
default:
return nil, fmt.Errorf("laguna: unsupported mlp_layer_types[%d]=%q", i, layerType)
}
}
return denseLayers, nil
}
func lagunaMoeGatingFunc(useSigmoid bool) uint32 {
if useSigmoid {
return lagunaGatingFuncSigmoid
}
return lagunaGatingFuncSoftmax
}
func lagunaAttentionFactor(ropeType string, scaleFactor, attentionFactor float32) float32 {
if attentionFactor != 0 {
return attentionFactor
}
if strings.EqualFold(ropeType, "yarn") && scaleFactor > 1 {
return float32(0.1*math.Log(float64(scaleFactor)) + 1)
}
return 1
}
func lagunaRopeDim(headDim uint32, partialRotaryFactor float32) uint32 {
if headDim == 0 {
return 0
}
dim := uint32(float32(headDim) * partialRotaryFactor)
if dim == 0 || dim > headDim {
dim = headDim
}
if dim%2 != 0 {
dim--
}
if dim == 0 {
return headDim
}
return dim
}
var (
_ ModelConverter = (*lagunaModel)(nil)
_ moreParser = (*lagunaModel)(nil)
)

View File

@@ -1,450 +0,0 @@
package convert
import (
"encoding/json"
"fmt"
"io"
"math"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ollama/ollama/fs/ggml"
)
type lagunaTestTensor struct {
tensorBase
}
func newLagunaTestTensor(name string, shape ...uint64) Tensor {
return &lagunaTestTensor{tensorBase: tensorBase{name: name, shape: shape}}
}
func (t *lagunaTestTensor) WriteTo(io.Writer) (int64, error) {
return 0, nil
}
func (t *lagunaTestTensor) Clone() Tensor {
return &lagunaTestTensor{tensorBase: tensorBase{
name: t.name,
shape: append([]uint64(nil), t.shape...),
}}
}
func TestLagunaReplacements(t *testing.T) {
p := lagunaModel{}
r := strings.NewReplacer(p.Replacements()...)
tests := []struct {
name string
in string
want string
}{
{"embed", "model.embed_tokens.weight", "token_embd.weight"},
{"final_norm", "model.norm.weight", "output_norm.weight"},
{"lm_head", "lm_head.weight", "output.weight"},
{"block prefix", "model.layers.7.input_layernorm.weight", "blk.7.attn_norm.weight"},
{"q", "model.layers.3.self_attn.q_proj.weight", "blk.3.attn_q.weight"},
{"k", "model.layers.3.self_attn.k_proj.weight", "blk.3.attn_k.weight"},
{"v", "model.layers.3.self_attn.v_proj.weight", "blk.3.attn_v.weight"},
{"o", "model.layers.3.self_attn.o_proj.weight", "blk.3.attn_output.weight"},
{"g", "model.layers.3.self_attn.g_proj.weight", "blk.3.attn_g.weight"},
{"q_norm", "model.layers.3.self_attn.q_norm.weight", "blk.3.attn_q_norm.weight"},
{"k_norm", "model.layers.3.self_attn.k_norm.weight", "blk.3.attn_k_norm.weight"},
{"post_attn_norm", "model.layers.3.post_attention_layernorm.weight", "blk.3.ffn_norm.weight"},
{"dense gate", "model.layers.0.mlp.gate_proj.weight", "blk.0.ffn_gate.weight"},
{"dense up", "model.layers.0.mlp.up_proj.weight", "blk.0.ffn_up.weight"},
{"dense down", "model.layers.0.mlp.down_proj.weight", "blk.0.ffn_down.weight"},
{"shexp gate", "model.layers.5.mlp.shared_expert.gate_proj.weight", "blk.5.ffn_gate_shexp.weight"},
{"shexp up", "model.layers.5.mlp.shared_expert.up_proj.weight", "blk.5.ffn_up_shexp.weight"},
{"shexp down", "model.layers.5.mlp.shared_expert.down_proj.weight", "blk.5.ffn_down_shexp.weight"},
{"router", "model.layers.5.mlp.gate.weight", "blk.5.ffn_gate_inp.weight"},
{"score bias", "model.layers.5.mlp.experts.e_score_correction_bias", "blk.5.exp_probs_b.bias"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := r.Replace(tc.in); got != tc.want {
t.Errorf("Replace(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestLagunaValidateRejectsUnsupportedVariants(t *testing.T) {
base := validLagunaTestModel()
tests := []struct {
name string
edit func(*lagunaModel)
want string
}{
{
name: "per-element gating",
edit: func(m *lagunaModel) {
m.Gating = "per-element"
},
want: "unsupported attention gating",
},
{
name: "attention sinks",
edit: func(m *lagunaModel) {
m.SwaAttentionSinkEnabled = true
},
want: "swa_attention_sink_enabled=true",
},
{
name: "qk norm disabled",
edit: func(m *lagunaModel) {
m.QKNormType = "none"
},
want: "unsupported qk_norm_type",
},
{
name: "softmax moe",
edit: func(m *lagunaModel) {
m.MoERouterUseSigmoid = false
},
want: "moe_router_use_sigmoid=false",
},
{
name: "router weight on input",
edit: func(m *lagunaModel) {
m.MoEApplyRouterWeightOnInput = true
},
want: "moe_apply_router_weight_on_input=true",
},
{
name: "unknown layer type",
edit: func(m *lagunaModel) {
m.LayerTypes[1] = "local_attention"
},
want: "unsupported layer_types[1]",
},
{
name: "nonstandard dense layout",
edit: func(m *lagunaModel) {
m.MLPOnlyLayers = []uint32{0, 3}
},
want: "unsupported mlp_only_layers",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
m := base
m.LayerTypes = append([]string(nil), base.LayerTypes...)
m.NumAttentionHeadsPerLayer = append([]uint32(nil), base.NumAttentionHeadsPerLayer...)
m.MLPOnlyLayers = append([]uint32(nil), base.MLPOnlyLayers...)
tc.edit(&m)
err := m.validate()
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("validate() error = %v, want substring %q", err, tc.want)
}
})
}
}
func TestLagunaGAConfigNormalizesBoolGatingAndNestedRope(t *testing.T) {
var m lagunaModel
if err := json.Unmarshal([]byte(`{
"architectures": ["LagunaForCausalLM"],
"num_hidden_layers": 1,
"hidden_size": 8,
"num_attention_heads": 2,
"num_key_value_heads": 1,
"head_dim": 4,
"gating": true,
"num_experts": 2,
"num_experts_per_tok": 1,
"moe_intermediate_size": 4,
"shared_expert_intermediate_size": 4,
"decoder_sparse_step": 1,
"mlp_layer_types": ["dense"],
"rope_parameters": {
"full_attention": {
"rope_theta": 500000,
"rope_type": "yarn",
"factor": 32,
"original_max_position_embeddings": 4096,
"beta_fast": 64,
"beta_slow": 1,
"attention_factor": 1,
"partial_rotary_factor": 0.5
},
"sliding_attention": {
"rope_theta": 10000,
"rope_type": "default",
"partial_rotary_factor": 1
}
}
}`), &m); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if err := m.validate(); err != nil {
t.Fatalf("validate() error = %v", err)
}
if m.Gating != "true" {
t.Fatalf("Gating = %q, want raw true marker", m.Gating)
}
if !m.Gating.perHead() {
t.Fatal("expected bool gating to normalize as per-head support")
}
if m.QKNormType != "rmsnorm" {
t.Fatalf("QKNormType = %q, want rmsnorm default", m.QKNormType)
}
if !m.MoERouterUseSigmoid {
t.Fatal("MoERouterUseSigmoid should default true")
}
if !m.NormTopKProb {
t.Fatal("NormTopKProb should default true")
}
if diff := cmp.Diff(m.MLPOnlyLayers, []uint32{0}); diff != "" {
t.Fatalf("MLPOnlyLayers mismatch (-got +want):\n%s", diff)
}
if m.RopeParameters.RopeTheta != 500000 || m.RopeParameters.PartialRotaryFactor != 0.5 {
t.Fatalf("full rope = %#v, want theta=500000 partial=0.5", m.RopeParameters)
}
if m.SwaRopeParameters.RopeTheta != 10000 || m.SwaRopeParameters.PartialRotaryFactor != 1 {
t.Fatalf("swa rope = %#v, want theta=10000 partial=1", m.SwaRopeParameters)
}
}
func validLagunaTestModel() lagunaModel {
return lagunaModel{
ModelParameters: ModelParameters{
VocabSize: 32,
},
NumHiddenLayers: 2,
HiddenSize: 8,
IntermediateSize: 16,
NumAttentionHeads: 2,
NumKeyValueHeads: 1,
HeadDim: 4,
RMSNormEPS: 1e-6,
MaxPositionEmbeddings: 4096,
SlidingWindow: 512,
Gating: "per-head",
QKNormType: "rmsnorm",
LayerTypes: []string{"global_attention", "sliding_attention"},
NumAttentionHeadsPerLayer: []uint32{2, 2},
NumExperts: 2,
NumExpertsPerTok: 1,
MoEIntermediateSize: 4,
SharedExpertIntermediateSize: 4,
NormTopKProb: true,
MoeRoutedScalingFactor: 2.5,
MoERouterUseSigmoid: true,
DecoderSparseStep: 1,
MLPOnlyLayers: []uint32{0},
}
}
func validLagunaTestTensors(m lagunaModel) []Tensor {
ts := []Tensor{
newLagunaTestTensor("token_embd.weight", uint64(m.VocabSize), uint64(m.HiddenSize)),
newLagunaTestTensor("output_norm.weight", uint64(m.HiddenSize)),
}
for layer := range m.NumHiddenLayers {
prefix := fmt.Sprintf("blk.%d", layer)
heads := uint64(m.numHeadsForLayer(layer))
attnWidth := heads * uint64(m.HeadDim)
kvWidth := uint64(m.NumKeyValueHeads * m.HeadDim)
ts = append(ts,
newLagunaTestTensor(prefix+".attn_norm.weight", uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".ffn_norm.weight", uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".attn_q.weight", attnWidth, uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".attn_k.weight", kvWidth, uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".attn_v.weight", kvWidth, uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".attn_output.weight", uint64(m.HiddenSize), attnWidth),
newLagunaTestTensor(prefix+".attn_g.weight", heads, uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".attn_q_norm.weight", uint64(m.HeadDim)),
newLagunaTestTensor(prefix+".attn_k_norm.weight", uint64(m.HeadDim)),
)
if m.layerUsesMoE(layer) {
ts = append(ts,
newLagunaTestTensor(prefix+".ffn_gate_inp.weight", uint64(m.NumExperts), uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".exp_probs_b.bias", uint64(m.NumExperts)),
newLagunaTestTensor(prefix+".ffn_gate_shexp.weight", uint64(m.SharedExpertIntermediateSize), uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".ffn_up_shexp.weight", uint64(m.SharedExpertIntermediateSize), uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".ffn_down_shexp.weight", uint64(m.HiddenSize), uint64(m.SharedExpertIntermediateSize)),
)
for expert := range m.NumExperts {
ts = append(ts,
newLagunaTestTensor(fmt.Sprintf("%s.mlp.experts.%d.gate_proj.weight", prefix, expert), uint64(m.MoEIntermediateSize), uint64(m.HiddenSize)),
newLagunaTestTensor(fmt.Sprintf("%s.mlp.experts.%d.up_proj.weight", prefix, expert), uint64(m.MoEIntermediateSize), uint64(m.HiddenSize)),
newLagunaTestTensor(fmt.Sprintf("%s.mlp.experts.%d.down_proj.weight", prefix, expert), uint64(m.HiddenSize), uint64(m.MoEIntermediateSize)),
)
}
} else {
ts = append(ts,
newLagunaTestTensor(prefix+".ffn_gate.weight", uint64(m.IntermediateSize), uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".ffn_up.weight", uint64(m.IntermediateSize), uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".ffn_down.weight", uint64(m.HiddenSize), uint64(m.IntermediateSize)),
)
}
}
return ts
}
func TestLagunaTensorsMergeRoutedExperts(t *testing.T) {
m := validLagunaTestModel()
out := m.Tensors(validLagunaTestTensors(m))
tensors := make(map[string]*ggml.Tensor, len(out))
for _, t := range out {
tensors[t.Name] = t
}
tests := map[string][]uint64{
"blk.1.ffn_gate_exps.weight": {uint64(m.NumExperts), uint64(m.MoEIntermediateSize), uint64(m.HiddenSize)},
"blk.1.ffn_up_exps.weight": {uint64(m.NumExperts), uint64(m.MoEIntermediateSize), uint64(m.HiddenSize)},
"blk.1.ffn_down_exps.weight": {uint64(m.NumExperts), uint64(m.HiddenSize), uint64(m.MoEIntermediateSize)},
}
for name, wantShape := range tests {
tensor, ok := tensors[name]
if !ok {
t.Fatalf("missing merged tensor %q", name)
}
if diff := cmp.Diff(wantShape, tensor.Shape); diff != "" {
t.Fatalf("%s shape mismatch (-want +got):\n%s", name, diff)
}
}
for expert := range m.NumExperts {
name := fmt.Sprintf("blk.1.mlp.experts.%d.gate_proj.weight", expert)
if _, ok := tensors[name]; ok {
t.Fatalf("unexpected unmerged expert tensor %q", name)
}
}
}
func TestLagunaKVShape(t *testing.T) {
m := lagunaModel{
NumHiddenLayers: 4,
HiddenSize: 128,
IntermediateSize: 256,
NumAttentionHeads: 8,
NumKeyValueHeads: 4,
HeadDim: 16,
RMSNormEPS: 1e-6,
MaxPositionEmbeddings: 4096,
SlidingWindow: 512,
PartialRotaryFactor: 0.5,
Gating: "per-head",
QKNormType: "rmsnorm",
LayerTypes: []string{"full_attention", "sliding_attention", "sliding_attention", "sliding_attention"},
NumAttentionHeadsPerLayer: []uint32{8, 16, 16, 16},
NumExperts: 32,
NumExpertsPerTok: 4,
MoEIntermediateSize: 64,
SharedExpertIntermediateSize: 64,
NormTopKProb: true,
MoeRoutedScalingFactor: 2.5,
MoERouterUseSigmoid: true,
DecoderSparseStep: 1,
MLPOnlyLayers: []uint32{0},
}
m.RopeParameters.RopeTheta = 500000
m.RopeParameters.RopeType = "yarn"
m.RopeParameters.Factor = 32
m.RopeParameters.OriginalMaxPositionEmbeddings = 4096
m.RopeParameters.BetaFast = 64
m.RopeParameters.BetaSlow = 1
m.SwaRopeParameters.RopeTheta = 10000
m.SwaRopeParameters.RopeType = "linear"
m.SwaRopeParameters.Factor = 1
m.SwaRopeParameters.PartialRotaryFactor = 1
kv := m.KV(&Tokenizer{Vocabulary: &Vocabulary{}, Template: "{% include 'chat_template.jinja' %}"})
required := []string{
"general.architecture",
"tokenizer.ggml.pre",
"laguna.block_count",
"laguna.context_length",
"laguna.embedding_length",
"laguna.feed_forward_length",
"laguna.attention.head_count",
"laguna.attention.head_count_kv",
"laguna.attention.key_length",
"laguna.attention.value_length",
"laguna.attention.layer_norm_rms_epsilon",
"laguna.attention.sliding_window",
"laguna.attention.layer_types",
"laguna.attention.sliding_window_pattern",
"laguna.attention.gating_type",
"laguna.attention.qk_norm",
"laguna.expert_count",
"laguna.expert_used_count",
"laguna.expert_feed_forward_length",
"laguna.expert_shared_feed_forward_length",
"laguna.expert_shared_count",
"laguna.expert_weights_norm",
"laguna.expert_weights_scale",
"laguna.expert_gating_func",
"laguna.leading_dense_block_count",
"laguna.dense_layers",
"laguna.rope.freq_base",
"laguna.rope.scaling.type",
"laguna.rope.scaling.factor",
"laguna.rope.partial_rotary_factor",
"laguna.rope.swa.freq_base",
"laguna.rope.swa.scaling.type",
"laguna.rope.dimension_count",
"laguna.rope.swa.dimension_count",
}
for _, k := range required {
if _, ok := kv[k]; !ok {
t.Errorf("missing required KV: %s", k)
}
}
if got := kv["general.architecture"]; got != "laguna" {
t.Errorf("architecture = %v, want laguna", got)
}
if got := kv["tokenizer.ggml.add_bos_token"]; got != false {
t.Errorf("tokenizer.ggml.add_bos_token = %v, want false", got)
}
if _, ok := kv["tokenizer.chat_template"]; ok {
t.Fatal("tokenizer.chat_template should be omitted for Laguna")
}
if got := kv["laguna.expert_gating_func"]; got != lagunaGatingFuncSigmoid {
t.Errorf("expert_gating_func = %v, want sigmoid(%d)", got, lagunaGatingFuncSigmoid)
}
if got := kv["laguna.leading_dense_block_count"]; got != uint32(1) {
t.Errorf("leading_dense_block_count = %v, want 1", got)
}
if got := kv["laguna.rope.dimension_count"]; got != uint32(8) {
t.Errorf("rope.dimension_count = %v, want 8", got)
}
if got := kv["laguna.rope.swa.dimension_count"]; got != uint32(16) {
t.Errorf("rope.swa.dimension_count = %v, want 16", got)
}
if got, ok := kv["laguna.attention.layer_types"].([]uint32); !ok || len(got) != 4 || got[0] != 0 || got[1] != 1 || got[2] != 1 || got[3] != 1 {
t.Fatalf("layer_types = %#v, want [0 1 1 1]", kv["laguna.attention.layer_types"])
}
if got, ok := kv["laguna.attention.sliding_window_pattern"].([]bool); !ok || len(got) != 4 || got[0] || !got[1] || !got[2] || !got[3] {
t.Fatalf("sliding_window_pattern = %#v, want [false true true true]", kv["laguna.attention.sliding_window_pattern"])
}
}
func TestLagunaKVYarnAttentionFactorFallback(t *testing.T) {
m := validLagunaTestModel()
m.RopeParameters.RopeType = "yarn"
m.RopeParameters.Factor = 32
kv := m.KV(&Tokenizer{Vocabulary: &Vocabulary{}})
got, ok := kv["laguna.rope.scaling.attn_factor"].(float32)
if !ok {
t.Fatalf("attn_factor type = %T, want float32", kv["laguna.rope.scaling.attn_factor"])
}
want := float32(0.1*math.Log(32) + 1)
if diff := math.Abs(float64(got - want)); diff > 1e-6 {
t.Fatalf("attn_factor = %v, want %v", got, want)
}
}

View File

@@ -3,7 +3,6 @@ package convert
import (
"cmp"
"encoding/json"
"errors"
"fmt"
"io/fs"
"math"
@@ -70,415 +69,7 @@ type nemotronHModel struct {
ExpertGroupUsedCount uint32 `json:"topk_group"`
}
type nemotronHNanoVLModel struct {
ModelParameters
MaxSequenceLength uint32 `json:"max_sequence_length"`
ForceImageSize uint32 `json:"force_image_size"`
DownsampleRatio float32 `json:"downsample_ratio"`
PatchSize uint32 `json:"patch_size"`
UseThumbnail *bool `json:"use_thumbnail"`
ImgContextTokenID uint32 `json:"img_context_token_id"`
ImgContextToken string `json:"img_context_token"`
ImgStartToken string `json:"img_start_token"`
ImgEndToken string `json:"img_end_token"`
VitHiddenSize uint32 `json:"vit_hidden_size"`
ProjectorHidden uint32 `json:"projector_hidden_size"`
SoundContextTokenID uint32 `json:"sound_context_token_id"`
SoundContextToken string `json:"sound_context_token"`
NormMean []float32 `json:"norm_mean"`
NormStd []float32 `json:"norm_std"`
VisionConfig radioConfig `json:"vision_config"`
SoundConfig soundConfig `json:"sound_config"`
LLMConfig nemotronHModel `json:"llm_config"`
Preprocessor struct {
ImageSize uint32 `json:"image_size"`
PatchSize uint32 `json:"patch_size"`
DownsampleRatio float32 `json:"downsample_ratio"`
MaxNumTiles uint32 `json:"max_num_tiles"`
UseThumbnail *bool `json:"use_thumbnail"`
NormMean []float32 `json:"norm_mean"`
NormStd []float32 `json:"norm_std"`
}
}
type soundConfig struct {
ModelType string `json:"model_type"`
HiddenSize uint32 `json:"hidden_size"`
NumAttentionHeads uint32 `json:"num_attention_heads"`
NumHiddenLayers uint32 `json:"num_hidden_layers"`
IntermediateSize uint32 `json:"intermediate_size"`
ConvKernelSize uint32 `json:"conv_kernel_size"`
SubsamplingConvChannels uint32 `json:"subsampling_conv_channels"`
SubsamplingConvKernelSize uint32 `json:"subsampling_conv_kernel_size"`
SubsamplingConvStride uint32 `json:"subsampling_conv_stride"`
SubsamplingFactor uint32 `json:"subsampling_factor"`
NumMelBins uint32 `json:"num_mel_bins"`
ProjectionHiddenSize uint32 `json:"projection_hidden_size"`
SamplingRate uint32 `json:"sampling_rate"`
ScaleInput bool `json:"scale_input"`
}
type radioConfig struct {
Version string `json:"version"`
PatchSize uint32 `json:"patch_size"`
MaxResolution uint32 `json:"max_resolution"`
MinNumPatches uint32 `json:"min_num_patches"`
MaxNumPatches uint32 `json:"max_num_patches"`
SeparateVideoEmbedder bool `json:"separate_video_embedder"`
Args struct {
MinNumPatches uint32 `json:"min_num_patches"`
MaxNumPatches uint32 `json:"max_num_patches"`
} `json:"args"`
}
var _ ModelConverter = (*nemotronHModel)(nil)
var _ ModelConverter = (*nemotronHNanoVLModel)(nil)
func (n *nemotronHNanoVLModel) parseMore(fsys fs.FS) error {
if n.MaxSequenceLength > 0 {
n.LLMConfig.MaxPositionEmbeddings = n.MaxSequenceLength
}
if err := n.LLMConfig.parseMore(fsys); err != nil {
return err
}
if bts, err := fs.ReadFile(fsys, "preprocessor_config.json"); err == nil {
if err := json.Unmarshal(bts, &n.Preprocessor); err != nil {
return fmt.Errorf("nemotron_h_omni: parse preprocessor_config.json: %w", err)
}
} else if !errors.Is(err, fs.ErrNotExist) {
return err
}
if version := strings.TrimSpace(n.VisionConfig.Version); version != "" && version != "radio_v2.5-h" {
return fmt.Errorf("nemotron_h_omni: unsupported RADIO version %q", version)
}
if patchSize := n.visionPatchSize(); patchSize != 16 {
return fmt.Errorf("nemotron_h_omni: unsupported vision patch_size=%d", patchSize)
}
if scale := n.visionProjectorScaleFactor(); scale != 2 {
return fmt.Errorf("nemotron_h_omni: unsupported vision projector scale factor=%d", scale)
}
if n.SoundConfig.NumHiddenLayers > 0 {
if modelType := strings.TrimSpace(n.SoundConfig.ModelType); modelType != "" && modelType != "parakeet" {
return fmt.Errorf("nemotron_h_omni: unsupported sound model_type %q", modelType)
}
if n.soundHiddenSize() == 0 {
return fmt.Errorf("nemotron_h_omni: sound hidden_size must be set")
}
if n.soundAttentionHeads() == 0 {
return fmt.Errorf("nemotron_h_omni: sound num_attention_heads must be set")
}
if n.soundSubsamplingFactor() != 8 {
return fmt.Errorf("nemotron_h_omni: unsupported sound subsampling_factor=%d", n.soundSubsamplingFactor())
}
if n.soundMelBins() != 128 {
return fmt.Errorf("nemotron_h_omni: unsupported sound num_mel_bins=%d", n.soundMelBins())
}
}
return nil
}
func (n *nemotronHNanoVLModel) KV(t *Tokenizer) KV {
kv := n.LLMConfig.KV(t)
kv["general.architecture"] = "nemotron_h_omni"
kv["vision.block_count"] = n.visionBlockCount()
kv["vision.embedding_length"] = n.visionEmbeddingLength()
kv["vision.feed_forward_length"] = n.visionFeedForwardLength()
kv["vision.attention.head_count"] = n.visionAttentionHeads()
kv["vision.attention.layer_norm_epsilon"] = float32(1e-6)
kv["vision.patch_size"] = n.visionPatchSize()
kv["vision.image_size"] = n.visionImageSize()
kv["vision.max_tiles"] = n.visionMaxTiles()
kv["vision.use_thumbnail"] = n.visionUseThumbnail()
if minPatches := n.visionMinNumPatches(); minPatches > 0 {
kv["vision.min_num_patches"] = minPatches
}
if maxPatches := n.visionMaxNumPatches(); maxPatches > 0 {
kv["vision.max_num_patches"] = maxPatches
}
kv["vision.num_channels"] = uint32(3)
kv["vision.image_mean"] = slices.Clone(defaultFloat32Slice(n.visionMean(), imageNetStandardMean))
kv["vision.image_std"] = slices.Clone(defaultFloat32Slice(n.visionStd(), imageNetStandardSTD))
kv["vision.projector.scale_factor"] = n.visionProjectorScaleFactor()
setTokenID := func(key string, explicit uint32, token string) {
if explicit > 0 {
kv[key] = explicit
return
}
if t == nil || t.Vocabulary == nil {
return
}
for i, v := range t.Vocabulary.Tokens {
if v == token {
kv[key] = uint32(i)
return
}
}
}
setTokenID("vision.image_token_id", n.ImgContextTokenID, cmp.Or(n.ImgContextToken, "<image>"))
setTokenID("vision.image_start_token_id", 0, cmp.Or(n.ImgStartToken, "<img>"))
setTokenID("vision.image_end_token_id", 0, cmp.Or(n.ImgEndToken, "</img>"))
if n.SoundConfig.NumHiddenLayers > 0 {
kv["audio.block_count"] = n.SoundConfig.NumHiddenLayers
kv["audio.embedding_length"] = n.soundHiddenSize()
kv["audio.feed_forward_length"] = n.soundFeedForwardLength()
kv["audio.attention.head_count"] = n.soundAttentionHeads()
kv["audio.attention.layer_norm_epsilon"] = float32(1e-5)
kv["audio.conv_kernel_size"] = n.soundConvKernelSize()
kv["audio.num_mel_bins"] = n.soundMelBins()
kv["audio.sample_rate"] = n.soundSampleRate()
kv["audio.subsampling_factor"] = n.soundSubsamplingFactor()
kv["audio.subsampling_conv_channels"] = n.soundSubsamplingConvChannels()
kv["audio.subsampling_conv_kernel_size"] = n.soundSubsamplingConvKernelSize()
kv["audio.subsampling_conv_stride"] = n.soundSubsamplingConvStride()
kv["audio.projection_hidden_size"] = n.soundProjectionHiddenSize()
kv["audio.scale_input"] = n.SoundConfig.ScaleInput
setTokenID("audio.sound_token_id", n.SoundContextTokenID, cmp.Or(n.SoundContextToken, "<so_embedding>"))
}
return kv
}
func (n *nemotronHNanoVLModel) Tensors(ts []Tensor) []*ggml.Tensor {
var textTensors []Tensor
var out []*ggml.Tensor
for _, t := range ts {
switch {
case isNemotronHNanoVLOmittedTensor(t.Name()):
continue
case strings.Contains(t.Name(), ".attn_qkv"):
out = append(out, slices.Collect(splitDim(t, 0,
split{Replacer: strings.NewReplacer("attn_qkv", "attn_q")},
split{Replacer: strings.NewReplacer("attn_qkv", "attn_k")},
split{Replacer: strings.NewReplacer("attn_qkv", "attn_v")},
))...)
case t.Name() == "v.position_embd":
shape := t.Shape()
if len(shape) == 3 && shape[0] == 1 {
shape = shape[1:]
}
out = append(out, &ggml.Tensor{
Name: t.Name(),
Kind: t.Kind(),
Shape: shape,
WriterTo: t,
})
case strings.HasPrefix(t.Name(), "a.") || strings.HasPrefix(t.Name(), "v.") || strings.HasPrefix(t.Name(), "mm."):
name := t.Name()
shape := slices.Clone(t.Shape())
if strings.HasPrefix(name, "a.blk.") && strings.Contains(name, ".conv_dw.") && strings.HasSuffix(name, ".weight") && len(shape) == 3 {
t.SetRepacker(squeezeMiddleDim)
shape = []uint64{shape[0], shape[2]}
}
if strings.HasPrefix(name, "a.blk.") && (strings.Contains(name, ".conv_pw1.") || strings.Contains(name, ".conv_pw2.")) && strings.HasSuffix(name, ".weight") && len(shape) == 3 && shape[2] == 1 {
t.SetRepacker(squeezeLastDim)
shape = shape[:2]
}
out = append(out, &ggml.Tensor{
Name: name,
Kind: t.Kind(),
Shape: shape,
WriterTo: t,
})
default:
textTensors = append(textTensors, t)
}
}
return append(n.LLMConfig.Tensors(textTensors), out...)
}
func (n *nemotronHNanoVLModel) Replacements() []string {
return append([]string{
"language_model.", "",
"vision_model.radio_model.model.patch_generator.embedder", "v.patch_embd",
"vision_model.radio_model.model.patch_generator.pos_embed", "v.position_embd",
"vision_model.radio_model.model.patch_generator.cls_token.token", "v.cls_embd",
"vision_model.radio_model.model.blocks", "v.blk",
"attn.qkv", "attn_qkv",
"attn.proj", "attn_out",
"mlp.fc1", "ffn_up",
"mlp.fc2", "ffn_down",
"norm1", "ln1",
"norm2", "ln2",
"mlp1.0", "mm.norm",
"mlp1.1", "mm.1",
"mlp1.3", "mm.2",
"sound_encoder.encoder.feature_extractor.featurizer.fb", "a.feature_extractor.fb",
"sound_encoder.encoder.feature_extractor.featurizer.window", "a.feature_extractor.window",
"sound_encoder.encoder.subsampling.layers.0", "a.subsampling.conv0",
"sound_encoder.encoder.subsampling.layers.2", "a.subsampling.dw1",
"sound_encoder.encoder.subsampling.layers.3", "a.subsampling.pw1",
"sound_encoder.encoder.subsampling.layers.5", "a.subsampling.dw2",
"sound_encoder.encoder.subsampling.layers.6", "a.subsampling.pw2",
"sound_encoder.encoder.subsampling.linear", "a.subsampling.linear",
"sound_encoder.encoder.layers", "a.blk",
"feed_forward1.linear1", "ffn1_up",
"feed_forward1.linear2", "ffn1_down",
"feed_forward2.linear1", "ffn2_up",
"feed_forward2.linear2", "ffn2_down",
"norm_feed_forward1", "ffn1_norm",
"norm_feed_forward2", "ffn2_norm",
"norm_self_att", "attn_norm",
"norm_conv", "conv_norm",
"norm_out", "out_norm",
"self_attn.q_proj", "attn_q",
"self_attn.k_proj", "attn_k",
"self_attn.v_proj", "attn_v",
"self_attn.o_proj", "attn_out",
"self_attn.relative_k_proj", "attn_rel_k",
"self_attn.bias_u", "attn_bias_u",
"self_attn.bias_v", "attn_bias_v",
"conv.pointwise_conv1", "conv_pw1",
"conv.pointwise_conv2", "conv_pw2",
"conv.depthwise_conv", "conv_dw",
"conv.norm", "conv_bn",
"sound_projection.norm", "mm.a.norm",
"sound_projection.linear1", "mm.a.1",
"sound_projection.linear2", "mm.a.2",
}, n.LLMConfig.Replacements()...)
}
func (n *nemotronHNanoVLModel) specialTokenTypes() []string {
return n.LLMConfig.specialTokenTypes()
}
func isNemotronHNanoVLOmittedTensor(name string) bool {
return strings.HasSuffix(name, ".conv_bn.num_batches_tracked") ||
strings.HasPrefix(name, "vision_model.radio_model.input_conditioner.") ||
strings.HasPrefix(name, "vision_model.radio_model.model.patch_generator.video_embedder")
}
func squeezeLastDim(_ string, data []float32, _ []uint64) ([]float32, error) {
return data, nil
}
func (n *nemotronHNanoVLModel) visionImageSize() uint32 {
return cmp.Or(n.ForceImageSize, n.Preprocessor.ImageSize, uint32(512))
}
func (n *nemotronHNanoVLModel) visionPatchSize() uint32 {
return cmp.Or(n.PatchSize, n.Preprocessor.PatchSize, n.VisionConfig.PatchSize, uint32(16))
}
func (n *nemotronHNanoVLModel) visionProjectorScaleFactor() uint32 {
ratio := cmp.Or(n.DownsampleRatio, n.Preprocessor.DownsampleRatio, float32(0.5))
if ratio <= 0 {
return 2
}
return max(uint32(1), uint32(math.Round(1.0/float64(ratio))))
}
func (n *nemotronHNanoVLModel) visionBlockCount() uint32 {
return 32
}
func (n *nemotronHNanoVLModel) visionEmbeddingLength() uint32 {
return cmp.Or(n.VitHiddenSize, uint32(1280))
}
func (n *nemotronHNanoVLModel) visionAttentionHeads() uint32 {
return 16
}
func (n *nemotronHNanoVLModel) visionFeedForwardLength() uint32 {
return 4 * n.visionEmbeddingLength()
}
func (n *nemotronHNanoVLModel) visionMaxTiles() uint32 {
return cmp.Or(n.Preprocessor.MaxNumTiles, uint32(12))
}
func (n *nemotronHNanoVLModel) visionMinNumPatches() uint32 {
return cmp.Or(n.VisionConfig.MinNumPatches, n.VisionConfig.Args.MinNumPatches)
}
func (n *nemotronHNanoVLModel) visionMaxNumPatches() uint32 {
return cmp.Or(n.VisionConfig.MaxNumPatches, n.VisionConfig.Args.MaxNumPatches)
}
func (n *nemotronHNanoVLModel) visionUseThumbnail() bool {
for _, v := range []*bool{n.UseThumbnail, n.Preprocessor.UseThumbnail} {
if v != nil {
return *v
}
}
return true
}
func (n *nemotronHNanoVLModel) visionMean() []float32 {
if len(n.NormMean) > 0 {
return n.NormMean
}
return n.Preprocessor.NormMean
}
func (n *nemotronHNanoVLModel) visionStd() []float32 {
if len(n.NormStd) > 0 {
return n.NormStd
}
return n.Preprocessor.NormStd
}
func (n *nemotronHNanoVLModel) soundHiddenSize() uint32 {
return cmp.Or(n.SoundConfig.HiddenSize, uint32(1024))
}
func (n *nemotronHNanoVLModel) soundAttentionHeads() uint32 {
return cmp.Or(n.SoundConfig.NumAttentionHeads, uint32(8))
}
func (n *nemotronHNanoVLModel) soundFeedForwardLength() uint32 {
return cmp.Or(n.SoundConfig.IntermediateSize, 4*n.soundHiddenSize())
}
func (n *nemotronHNanoVLModel) soundConvKernelSize() uint32 {
return cmp.Or(n.SoundConfig.ConvKernelSize, uint32(9))
}
func (n *nemotronHNanoVLModel) soundMelBins() uint32 {
return cmp.Or(n.SoundConfig.NumMelBins, uint32(128))
}
func (n *nemotronHNanoVLModel) soundSampleRate() uint32 {
return cmp.Or(n.SoundConfig.SamplingRate, uint32(16000))
}
func (n *nemotronHNanoVLModel) soundSubsamplingFactor() uint32 {
return cmp.Or(n.SoundConfig.SubsamplingFactor, uint32(8))
}
func (n *nemotronHNanoVLModel) soundSubsamplingConvChannels() uint32 {
return cmp.Or(n.SoundConfig.SubsamplingConvChannels, uint32(256))
}
func (n *nemotronHNanoVLModel) soundSubsamplingConvKernelSize() uint32 {
return cmp.Or(n.SoundConfig.SubsamplingConvKernelSize, uint32(3))
}
func (n *nemotronHNanoVLModel) soundSubsamplingConvStride() uint32 {
return cmp.Or(n.SoundConfig.SubsamplingConvStride, uint32(2))
}
func (n *nemotronHNanoVLModel) soundProjectionHiddenSize() uint32 {
return cmp.Or(n.SoundConfig.ProjectionHiddenSize, uint32(4096))
}
var (
imageNetStandardMean = []float32{0.48145466, 0.4578275, 0.40821073}
imageNetStandardSTD = []float32{0.26862954, 0.26130258, 0.27577711}
)
func (n *nemotronHModel) parseMore(_ fs.FS) error {
if n.NumHiddenLayers == 0 {

View File

@@ -217,316 +217,6 @@ func TestNemotronHLoadModelMetadata(t *testing.T) {
}
}
func TestNemotronHNanoVLLoadModelMetadata(t *testing.T) {
tempDir := t.TempDir()
config := `{
"architectures": ["NemotronH_Nano_VL_V2"],
"model_type": "NemotronH_Nano_VL_V2",
"max_sequence_length": 131072,
"force_image_size": 512,
"downsample_ratio": 0.5,
"patch_size": 16,
"use_thumbnail": true,
"img_context_token_id": 18,
"img_context_token": "<image>",
"img_start_token": "<img>",
"img_end_token": "</img>",
"sound_context_token_id": 27,
"sound_context_token": "<so_embedding>",
"vit_hidden_size": 1280,
"projector_hidden_size": 20480,
"norm_mean": [0.48145466, 0.4578275, 0.40821073],
"norm_std": [0.26862954, 0.26130258, 0.27577711],
"vision_config": {
"version": "radio_v2.5-h",
"patch_size": 16,
"max_resolution": 2048,
"separate_video_embedder": true
},
"sound_config": {
"model_type": "parakeet",
"hidden_size": 1024,
"num_attention_heads": 8,
"num_hidden_layers": 24,
"intermediate_size": 4096,
"conv_kernel_size": 9,
"subsampling_conv_channels": 256,
"subsampling_conv_kernel_size": 3,
"subsampling_conv_stride": 2,
"subsampling_factor": 8,
"num_mel_bins": 128,
"projection_hidden_size": 4096,
"sampling_rate": 16000
},
"llm_config": {
"architectures": ["NemotronHForCausalLM"],
"model_type": "nemotron_h",
"num_hidden_layers": 4,
"hidden_size": 512,
"max_position_embeddings": 262144,
"num_attention_heads": 8,
"num_key_value_heads": 2,
"head_dim": 64,
"layer_norm_epsilon": 1e-5,
"conv_kernel": 4,
"ssm_state_size": 128,
"mamba_num_heads": 16,
"mamba_head_dim": 32,
"n_groups": 8,
"hybrid_override_pattern": "ME*M",
"n_routed_experts": 16,
"num_experts_per_tok": 4,
"moe_intermediate_size": 256
}
}`
if err := os.WriteFile(filepath.Join(tempDir, "config.json"), []byte(config), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tempDir, "preprocessor_config.json"), []byte(`{
"image_size": 512,
"patch_size": 16,
"downsample_ratio": 0.5,
"max_num_tiles": 12,
"use_thumbnail": true,
"norm_mean": [0.48145466, 0.4578275, 0.40821073],
"norm_std": [0.26862954, 0.26130258, 0.27577711]
}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tempDir, "tokenizer.json"), []byte(`{}`), 0o644); err != nil {
t.Fatal(err)
}
conv, tokenizer, err := LoadModelMetadata(os.DirFS(tempDir))
if err != nil {
t.Fatal(err)
}
if _, ok := conv.(*nemotronHNanoVLModel); !ok {
t.Fatalf("unexpected converter type: %T", conv)
}
kv := conv.KV(tokenizer)
if got, want := kv["general.architecture"], "nemotron_h_omni"; got != want {
t.Fatalf("unexpected architecture: got %v want %v", got, want)
}
if got, want := kv["context_length"], uint32(131072); got != want {
t.Fatalf("unexpected context length: got %v want %v", got, want)
}
if got, want := kv["vision.block_count"], uint32(32); got != want {
t.Fatalf("unexpected vision block count: got %v want %v", got, want)
}
if got, want := kv["vision.image_size"], uint32(512); got != want {
t.Fatalf("unexpected vision image size: got %v want %v", got, want)
}
if got, want := kv["vision.projector.scale_factor"], uint32(2); got != want {
t.Fatalf("unexpected projector scale factor: got %v want %v", got, want)
}
if got, want := kv["audio.block_count"], uint32(24); got != want {
t.Fatalf("unexpected audio block count: got %v want %v", got, want)
}
if got, want := kv["audio.sound_token_id"], uint32(27); got != want {
t.Fatalf("unexpected audio token id: got %v want %v", got, want)
}
if got, want := kv["audio.subsampling_factor"], uint32(8); got != want {
t.Fatalf("unexpected audio subsampling factor: got %v want %v", got, want)
}
}
func TestNemotronHNanoOmniReasoningV3LoadModelMetadata(t *testing.T) {
tempDir := t.TempDir()
config := `{
"architectures": ["NemotronH_Nano_Omni_Reasoning_V3"],
"model_type": "NemotronH_Nano_Omni_Reasoning_V3",
"max_sequence_length": 131072,
"force_image_size": 512,
"downsample_ratio": 0.5,
"patch_size": 16,
"img_context_token_id": 18,
"img_context_token": "<image>",
"img_start_token": "<img>",
"img_end_token": "</img>",
"sound_context_token_id": 27,
"sound_context_token": "<so_embedding>",
"vit_hidden_size": 1280,
"projector_hidden_size": 4096,
"vision_config": {
"version": "radio_v2.5-h",
"patch_size": 16,
"min_num_patches": 1024,
"max_num_patches": 13312,
"args": {
"min_num_patches": 1024,
"max_num_patches": 13312
}
},
"sound_config": {
"model_type": "parakeet",
"hidden_size": 1024,
"num_attention_heads": 8,
"num_hidden_layers": 24,
"intermediate_size": 4096,
"conv_kernel_size": 9,
"subsampling_conv_channels": 256,
"subsampling_conv_kernel_size": 3,
"subsampling_conv_stride": 2,
"subsampling_factor": 8,
"num_mel_bins": 128,
"projection_hidden_size": 4096,
"sampling_rate": 16000
},
"llm_config": {
"architectures": ["NemotronHForCausalLM"],
"model_type": "nemotron_h",
"num_hidden_layers": 4,
"hidden_size": 512,
"max_position_embeddings": 262144,
"num_attention_heads": 8,
"num_key_value_heads": 2,
"head_dim": 64,
"layer_norm_epsilon": 1e-5,
"conv_kernel": 4,
"ssm_state_size": 128,
"mamba_num_heads": 16,
"mamba_head_dim": 32,
"n_groups": 8,
"hybrid_override_pattern": "ME*M",
"n_routed_experts": 16,
"num_experts_per_tok": 4,
"moe_intermediate_size": 256
}
}`
if err := os.WriteFile(filepath.Join(tempDir, "config.json"), []byte(config), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tempDir, "tokenizer.json"), []byte(`{}`), 0o644); err != nil {
t.Fatal(err)
}
conv, tokenizer, err := LoadModelMetadata(os.DirFS(tempDir))
if err != nil {
t.Fatal(err)
}
if _, ok := conv.(*nemotronHNanoVLModel); !ok {
t.Fatalf("unexpected converter type: %T", conv)
}
kv := conv.KV(tokenizer)
if got, want := kv["general.architecture"], "nemotron_h_omni"; got != want {
t.Fatalf("unexpected architecture: got %v want %v", got, want)
}
if got, want := kv["vision.block_count"], uint32(32); got != want {
t.Fatalf("unexpected vision block count: got %v want %v", got, want)
}
if got, want := kv["vision.min_num_patches"], uint32(1024); got != want {
t.Fatalf("unexpected vision min patches: got %v want %v", got, want)
}
if got, want := kv["vision.max_num_patches"], uint32(13312); got != want {
t.Fatalf("unexpected vision max patches: got %v want %v", got, want)
}
if got, want := kv["audio.block_count"], uint32(24); got != want {
t.Fatalf("unexpected audio block count: got %v want %v", got, want)
}
if got, want := kv["audio.sound_token_id"], uint32(27); got != want {
t.Fatalf("unexpected audio token id: got %v want %v", got, want)
}
}
func TestNemotronHNanoVLTensorsRetainVisionAndAudio(t *testing.T) {
m := &nemotronHNanoVLModel{
LLMConfig: nemotronHModel{NGroups: 8},
}
in := []Tensor{
&fakeTensor{
name: "blk.0.ssm_a",
shape: []uint64{4},
data: []float32{0, 1, 2, 3},
},
&fakeTensor{name: "v.blk.0.attn_qkv.weight", shape: []uint64{3840, 1280}},
&fakeTensor{name: "v.position_embd", shape: []uint64{1, 16384, 1280}},
&fakeTensor{name: "v.cls_embd", shape: []uint64{10, 1280}},
&fakeTensor{name: "mm.norm.weight", shape: []uint64{5120}},
&fakeTensor{name: "a.feature_extractor.fb", shape: []uint64{1, 128, 257}},
&fakeTensor{name: "a.subsampling.dw1.weight", shape: []uint64{256, 1, 3, 3}},
&fakeTensor{name: "a.blk.0.conv_dw.weight", shape: []uint64{1024, 1, 9}},
&fakeTensor{name: "a.blk.0.conv_pw1.weight", shape: []uint64{2048, 1024, 1}},
&fakeTensor{name: "a.blk.0.conv_bn.num_batches_tracked", shape: []uint64{1}},
&fakeTensor{name: "mm.a.1.weight", shape: []uint64{4096, 1024}},
}
out := m.Tensors(in)
got := map[string][]uint64{}
for _, tns := range out {
got[tns.Name] = tns.Shape
}
for _, name := range []string{
"blk.0.ssm_a",
"v.blk.0.attn_q.weight",
"v.blk.0.attn_k.weight",
"v.blk.0.attn_v.weight",
"v.position_embd",
"v.cls_embd",
"mm.norm.weight",
"a.feature_extractor.fb",
"a.subsampling.dw1.weight",
"a.blk.0.conv_dw.weight",
"a.blk.0.conv_pw1.weight",
"mm.a.1.weight",
} {
if _, ok := got[name]; !ok {
t.Fatalf("expected tensor %q in output", name)
}
}
if gotShape, want := got["blk.0.ssm_a"], []uint64{4, 1}; !slices.Equal(gotShape, want) {
t.Fatalf("unexpected ssm_a shape: got %v want %v", gotShape, want)
}
if gotShape, want := got["v.position_embd"], []uint64{16384, 1280}; !slices.Equal(gotShape, want) {
t.Fatalf("unexpected position embedding shape: got %v want %v", gotShape, want)
}
if gotShape, want := got["a.blk.0.conv_dw.weight"], []uint64{1024, 9}; !slices.Equal(gotShape, want) {
t.Fatalf("unexpected audio conv_dw shape: got %v want %v", gotShape, want)
}
if gotShape, want := got["a.blk.0.conv_pw1.weight"], []uint64{2048, 1024}; !slices.Equal(gotShape, want) {
t.Fatalf("unexpected audio conv_pw1 shape: got %v want %v", gotShape, want)
}
if _, ok := got["a.blk.0.conv_bn.num_batches_tracked"]; ok {
t.Fatal("audio batchnorm num_batches_tracked should be omitted")
}
}
func TestNemotronHNanoVLReplacements(t *testing.T) {
m := &nemotronHNanoVLModel{}
r := strings.NewReplacer(m.Replacements()...)
if got, want := r.Replace("language_model.backbone.layers.1.mixer.fc1_latent_proj.weight"), "blk.1.ffn_latent_in.weight"; got != want {
t.Fatalf("unexpected fc1 replacement: got %q want %q", got, want)
}
if got, want := r.Replace("language_model.lm_head.weight"), "output.weight"; got != want {
t.Fatalf("unexpected lm_head replacement: got %q want %q", got, want)
}
if got, want := r.Replace("vision_model.radio_model.model.blocks.0.attn.qkv.weight"), "v.blk.0.attn_qkv.weight"; got != want {
t.Fatalf("unexpected vision replacement: got %q want %q", got, want)
}
if got, want := r.Replace("mlp1.1.weight"), "mm.1.weight"; got != want {
t.Fatalf("unexpected projector replacement: got %q want %q", got, want)
}
if got, want := r.Replace("sound_encoder.encoder.layers.0.self_attn.q_proj.weight"), "a.blk.0.attn_q.weight"; got != want {
t.Fatalf("unexpected audio q_proj replacement: got %q want %q", got, want)
}
if got, want := r.Replace("sound_encoder.encoder.layers.0.conv.pointwise_conv1.weight"), "a.blk.0.conv_pw1.weight"; got != want {
t.Fatalf("unexpected audio conv replacement: got %q want %q", got, want)
}
if got, want := r.Replace("sound_projection.linear2.weight"), "mm.a.2.weight"; got != want {
t.Fatalf("unexpected audio projector replacement: got %q want %q", got, want)
}
}
func TestNemotronHReplacementsLatentProjections(t *testing.T) {
m := &nemotronHModel{}
r := strings.NewReplacer(m.Replacements()...)

View File

@@ -42,10 +42,8 @@ func (t tensorBase) Kind() uint32 {
strings.HasSuffix(t.name, ".bias") ||
strings.HasSuffix(t.name, ".shortconv.conv.weight") ||
strings.HasSuffix(t.name, ".ssm_conv1d.weight") || // SSM conv kernel must be F32 for Metal
strings.HasPrefix(t.name, "a.feature_extractor.") || // audio feature-extractor constants are read with BackendGet and must be real F32 values
strings.HasPrefix(t.name, "a.conv1d.") || // audio SSCP conv weights are kept F32 for im2col; this likely slows audio and should be revisited
strings.HasPrefix(t.name, "a.subsampling.") || // audio Parakeet subsampling weights are kept F32 for conv/linear stability; this likely slows audio and should be revisited
strings.Contains(t.name, ".conv_dw.") || // audio depthwise conv weights are kept F32; this likely slows audio and should be revisited
strings.HasPrefix(t.name, "a.conv1d.") || // audio SSCP conv weights must be F32 for im2col
strings.Contains(t.name, ".conv_dw.") || // audio depthwise conv weights must be F32
t.name == "token_types.weight" ||
t.name == "v.positional_embedding_vlm" ||
t.name == "v.position_embd.weight" ||

View File

@@ -5,12 +5,10 @@ import (
"bytes"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"maps"
"math"
"slices"
"strings"
@@ -25,11 +23,6 @@ type safetensorMetadata struct {
}
func parseSafetensors(fsys fs.FS, replacer *strings.Replacer, ps ...string) ([]Tensor, error) {
fp8Block, err := safetensorsFP8BlockSize(fsys)
if err != nil {
return nil, err
}
var ts []Tensor
for _, p := range ps {
f, err := fsys.Open(p)
@@ -57,47 +50,24 @@ func parseSafetensors(fsys fs.FS, replacer *strings.Replacer, ps ...string) ([]T
names := make(map[string]struct{}, len(keys))
fp8Scales, err := collectSafetensorsFP8Scales(n, headers)
if err != nil {
return nil, err
}
for _, key := range keys {
if value := headers[key]; value.Type != "" {
if _, ok := fp8Scales.consumed[key]; ok {
continue
}
// Scalar tensors (e.g. clipped linear min/max) are 0-dim in safetensors.
// Promote them to 1-dim so they can be stored in GGUF.
if len(value.Shape) == 0 {
value.Shape = []uint64{1}
}
var scale *safetensorScale
if value.Type == "F8_E4M3" {
if !fp8Block.ok {
return nil, fmt.Errorf("missing fp8 block size metadata for tensor %q", key)
}
scale = fp8Scales.byWeight[key]
if scale == nil {
return nil, fmt.Errorf("missing fp8 scale companion for tensor %q", key)
}
}
ggufName := replacer.Replace(key)
if _, ok := names[ggufName]; ok {
return nil, fmt.Errorf("duplicate tensor name '%s' was found for this model", ggufName)
}
names[ggufName] = struct{}{}
ts = append(ts, safetensor{
fs: fsys,
path: p,
dtype: value.Type,
offset: safetensorsPad(n, value.Offsets[0]),
size: safetensorsPad(n, value.Offsets[1]) - safetensorsPad(n, value.Offsets[0]),
scale: scale,
fp8Block: fp8Block,
fs: fsys,
path: p,
dtype: value.Type,
offset: safetensorsPad(n, value.Offsets[0]),
size: safetensorsPad(n, value.Offsets[1]) - safetensorsPad(n, value.Offsets[0]),
tensorBase: &tensorBase{
name: ggufName,
shape: value.Shape,
@@ -115,22 +85,12 @@ func safetensorsPad(n, offset int64) int64 {
return 8 + n + offset
}
type safetensorScale struct {
name string
type safetensor struct {
fs fs.FS
path string
dtype string
shape []uint64
offset int64
size int64
}
type safetensor struct {
fs fs.FS
path string
dtype string
offset int64
size int64
scale *safetensorScale
fp8Block safetensorFP8BlockSize
*tensorBase
}
@@ -144,26 +104,17 @@ func (st safetensor) Kind() uint32 {
kind != tensorKindFP32 {
kind = tensorKindBF16
}
if st.dtype == "F8_E4M3" && kind != tensorKindFP32 {
kind = tensorKindBF16
}
return kind
}
func (st safetensor) SourceDType() string {
return st.dtype
}
func (st safetensor) Clone() Tensor {
return &safetensor{
fs: st.fs,
path: st.path,
dtype: st.dtype,
offset: st.offset,
size: st.size,
scale: st.scale.Clone(),
fp8Block: st.fp8Block,
fs: st.fs,
path: st.path,
dtype: st.dtype,
offset: st.offset,
size: st.size,
tensorBase: &tensorBase{
name: st.name,
repacker: st.repacker,
@@ -172,19 +123,6 @@ func (st safetensor) Clone() Tensor {
}
}
func (ss *safetensorScale) Clone() *safetensorScale {
if ss == nil {
return nil
}
return &safetensorScale{
name: ss.name,
dtype: ss.dtype,
shape: slices.Clone(ss.shape),
offset: ss.offset,
size: ss.size,
}
}
func (st safetensor) WriteTo(w io.Writer) (int64, error) {
f, err := st.fs.Open(st.path)
if err != nil {
@@ -242,16 +180,6 @@ func (st safetensor) WriteTo(w io.Writer) (int64, error) {
}
f32s = bfloat16.DecodeFloat32(u8s)
case "F8_E4M3":
u8s := make([]uint8, st.size)
if err = binary.Read(br, binary.LittleEndian, u8s); err != nil {
return 0, err
}
f32s, err = st.decodeFP8E4M3(u8s)
if err != nil {
return 0, err
}
default:
return 0, fmt.Errorf("unknown data type: %s", st.dtype)
}
@@ -280,334 +208,3 @@ func (st safetensor) WriteTo(w io.Writer) (int64, error) {
return 0, fmt.Errorf("unknown storage type: %d", st.Kind())
}
}
type safetensorsFP8Scales struct {
byWeight map[string]*safetensorScale
consumed map[string]struct{}
}
func collectSafetensorsFP8Scales(n int64, headers map[string]safetensorMetadata) (safetensorsFP8Scales, error) {
scales := safetensorsFP8Scales{
byWeight: make(map[string]*safetensorScale),
consumed: make(map[string]struct{}),
}
for key, value := range headers {
if value.Type != "F8_E4M3" {
continue
}
scaleKey, scaleValue, ok, err := safetensorsFP8Scale(key, headers)
if err != nil {
return safetensorsFP8Scales{}, err
}
if !ok {
continue
}
if _, ok := scales.consumed[scaleKey]; ok {
return safetensorsFP8Scales{}, fmt.Errorf("fp8 scale companion %q is used by multiple tensors", scaleKey)
}
scales.byWeight[key] = &safetensorScale{
name: scaleKey,
dtype: scaleValue.Type,
shape: slices.Clone(scaleValue.Shape),
offset: safetensorsPad(n, scaleValue.Offsets[0]),
size: safetensorsPad(n, scaleValue.Offsets[1]) - safetensorsPad(n, scaleValue.Offsets[0]),
}
scales.consumed[scaleKey] = struct{}{}
}
return scales, nil
}
func safetensorsFP8Scale(key string, headers map[string]safetensorMetadata) (string, safetensorMetadata, bool, error) {
candidates := safetensorsFP8ScaleCandidates(key)
var scaleKey string
var scaleValue safetensorMetadata
if strings.HasSuffix(key, ".weight") {
// Keep support for compressed-tensors exports that place the scale name
// between the module path and weight suffix.
base := strings.TrimSuffix(key, ".weight")
candidates = appendUnique(candidates, base+".weight_scale")
candidates = appendUnique(candidates, base+".weight_scale_inv")
}
for _, candidate := range candidates {
if value, ok := headers[candidate]; ok && value.Type != "" {
if scaleKey != "" {
return "", safetensorMetadata{}, false, fmt.Errorf("multiple fp8 scale companions for tensor %q: %q and %q", key, scaleKey, candidate)
}
scaleKey = candidate
scaleValue = value
}
}
if scaleKey == "" {
return "", safetensorMetadata{}, false, nil
}
return scaleKey, scaleValue, true, nil
}
func safetensorsFP8ScaleCandidates(key string) []string {
var candidates []string
candidates = appendUnique(candidates, key+"_scale")
candidates = appendUnique(candidates, key+"_scale_inv")
candidates = appendUnique(candidates, key+".scale")
candidates = appendUnique(candidates, key+".scale_inv")
return candidates
}
func appendUnique(values []string, value string) []string {
if !slices.Contains(values, value) {
values = append(values, value)
}
return values
}
type safetensorFP8BlockSize struct {
rows int
cols int
ok bool
}
type safetensorsSourceQuantization struct {
QuantMethod string `json:"quant_method"`
Format string `json:"format"`
WeightBlockSize []int `json:"weight_block_size"`
ConfigGroups map[string]struct {
Format string `json:"format"`
Weights struct {
BlockStructure []int `json:"block_structure"`
NumBits int `json:"num_bits"`
Type string `json:"type"`
} `json:"weights"`
} `json:"config_groups"`
}
type safetensorsModelConfig struct {
Quantization safetensorsSourceQuantization `json:"quantization"`
QuantizationConfig safetensorsSourceQuantization `json:"quantization_config"`
CompressionConfig safetensorsSourceQuantization `json:"compression_config"`
TextConfig struct {
Quantization safetensorsSourceQuantization `json:"quantization"`
QuantizationConfig safetensorsSourceQuantization `json:"quantization_config"`
CompressionConfig safetensorsSourceQuantization `json:"compression_config"`
} `json:"text_config"`
}
func safetensorsFP8BlockSize(fsys fs.FS) (safetensorFP8BlockSize, error) {
bts, err := fs.ReadFile(fsys, "config.json")
if errors.Is(err, fs.ErrNotExist) {
return safetensorFP8BlockSize{}, nil
}
if err != nil {
return safetensorFP8BlockSize{}, err
}
bts = sanitizeNonFiniteJSON(bts)
var cfg safetensorsModelConfig
if err := json.Unmarshal(bts, &cfg); err != nil {
return safetensorFP8BlockSize{}, fmt.Errorf("parse config.json fp8 metadata: %w", err)
}
var blocks []safetensorFP8BlockSize
for _, q := range []safetensorsSourceQuantization{
cfg.Quantization,
cfg.QuantizationConfig,
cfg.CompressionConfig,
cfg.TextConfig.Quantization,
cfg.TextConfig.QuantizationConfig,
cfg.TextConfig.CompressionConfig,
} {
if strings.EqualFold(q.QuantMethod, "fp8") && len(q.WeightBlockSize) == 2 {
block, err := newSafetensorFP8BlockSize(q.WeightBlockSize[0], q.WeightBlockSize[1])
if err != nil {
return safetensorFP8BlockSize{}, err
}
blocks = append(blocks, block)
}
if !strings.EqualFold(q.QuantMethod, "compressed-tensors") && !strings.EqualFold(q.Format, "float-quantized") {
continue
}
for _, group := range q.ConfigGroups {
if !strings.EqualFold(group.Format, "float-quantized") ||
group.Weights.NumBits != 8 ||
!strings.EqualFold(group.Weights.Type, "float") ||
len(group.Weights.BlockStructure) != 2 {
continue
}
block, err := newSafetensorFP8BlockSize(group.Weights.BlockStructure[0], group.Weights.BlockStructure[1])
if err != nil {
return safetensorFP8BlockSize{}, err
}
blocks = append(blocks, block)
}
}
if len(blocks) == 0 {
return safetensorFP8BlockSize{}, nil
}
block := blocks[0]
for _, other := range blocks[1:] {
if other.rows != block.rows || other.cols != block.cols {
return safetensorFP8BlockSize{}, fmt.Errorf("multiple fp8 block sizes in config.json: %dx%d and %dx%d", block.rows, block.cols, other.rows, other.cols)
}
}
return block, nil
}
func newSafetensorFP8BlockSize(rows, cols int) (safetensorFP8BlockSize, error) {
if rows <= 0 || cols <= 0 {
return safetensorFP8BlockSize{}, fmt.Errorf("invalid fp8 block size %dx%d", rows, cols)
}
return safetensorFP8BlockSize{rows: rows, cols: cols, ok: true}, nil
}
func (st safetensor) decodeFP8E4M3(data []byte) ([]float32, error) {
if st.scale == nil {
return nil, fmt.Errorf("missing fp8 scale companion for tensor %q", st.name)
}
if !st.fp8Block.ok {
return nil, fmt.Errorf("missing fp8 block size metadata for tensor %q", st.name)
}
if len(st.shape) != 2 {
return nil, fmt.Errorf("expected 2D fp8 tensor %q, got shape %v", st.name, st.shape)
}
rows, cols := int(st.shape[0]), int(st.shape[1])
if rows < 0 || cols < 0 || rows*cols != len(data) {
return nil, fmt.Errorf("fp8 tensor %q shape %v does not match %d bytes", st.name, st.shape, len(data))
}
scale, err := st.readScale()
if err != nil {
return nil, err
}
if len(st.scale.shape) != 2 {
return nil, fmt.Errorf("expected 2D fp8 scale tensor %q, got shape %v", st.scale.name, st.scale.shape)
}
blockRows := st.fp8Block.rows
blockCols := st.fp8Block.cols
scaleRows, scaleCols := int(st.scale.shape[0]), int(st.scale.shape[1])
expectedRows := (rows + blockRows - 1) / blockRows
expectedCols := (cols + blockCols - 1) / blockCols
if scaleRows != expectedRows || scaleCols != expectedCols {
return nil, fmt.Errorf("unexpected fp8 scale shape %v for tensor %q shape %v; want [%d %d]", st.scale.shape, st.name, st.shape, expectedRows, expectedCols)
}
if len(scale) != scaleRows*scaleCols {
return nil, fmt.Errorf("fp8 scale tensor %q shape %v does not match decoded length %d", st.scale.name, st.scale.shape, len(scale))
}
f32s := make([]float32, len(data))
for r := range rows {
scaleRow := r / blockRows
rowOffset := r * cols
for c := range cols {
f32s[rowOffset+c] = decodeFloat8E4M3FN(data[rowOffset+c]) * scale[scaleRow*scaleCols+c/blockCols]
}
}
return f32s, nil
}
func (st safetensor) readScale() ([]float32, error) {
r, err := st.sectionReader(st.scale.offset, st.scale.size)
if err != nil {
return nil, fmt.Errorf("failed to read fp8 scale tensor %q: %w", st.scale.name, err)
}
if closer, ok := r.(io.Closer); ok {
defer closer.Close()
}
br := bufio.NewReaderSize(r, min(32<<10, int(st.scale.size)))
switch st.scale.dtype {
case "F32":
f32s := make([]float32, st.scale.size/4)
if err := binary.Read(br, binary.LittleEndian, f32s); err != nil {
return nil, err
}
return f32s, nil
case "F16":
u16s := make([]uint16, st.scale.size/2)
if err := binary.Read(br, binary.LittleEndian, u16s); err != nil {
return nil, err
}
f32s := make([]float32, len(u16s))
for i := range u16s {
f32s[i] = float16.Frombits(u16s[i]).Float32()
}
return f32s, nil
case "BF16":
u8s := make([]uint8, st.scale.size)
if err := binary.Read(br, binary.LittleEndian, u8s); err != nil {
return nil, err
}
return bfloat16.DecodeFloat32(u8s), nil
default:
return nil, fmt.Errorf("unsupported fp8 scale dtype %q for tensor %q", st.scale.dtype, st.scale.name)
}
}
func (st safetensor) sectionReader(offset, size int64) (io.Reader, error) {
f, err := st.fs.Open(st.path)
if err != nil {
return nil, err
}
if readerAt, ok := f.(io.ReaderAt); ok {
return &readCloserReader{
Reader: io.NewSectionReader(readerAt, offset, size),
Closer: f,
}, nil
}
if seeker, ok := f.(io.Seeker); ok {
if _, err := seeker.Seek(offset, io.SeekStart); err != nil {
f.Close()
return nil, err
}
return &readCloserReader{
Reader: io.LimitReader(f, size),
Closer: f,
}, nil
}
if _, err := io.CopyN(io.Discard, f, offset); err != nil {
f.Close()
return nil, err
}
return &readCloserReader{
Reader: io.LimitReader(f, size),
Closer: f,
}, nil
}
type readCloserReader struct {
io.Reader
io.Closer
}
func decodeFloat8E4M3FN(v byte) float32 {
sign := float32(1)
if v&0x80 != 0 {
sign = -1
}
exp := int((v >> 3) & 0x0f)
mant := int(v & 0x07)
if exp == 0 {
if mant == 0 {
return 0 * sign
}
return sign * float32(math.Ldexp(float64(mant)/8, -6))
}
if exp == 0x0f && mant == 0x07 {
return float32(math.NaN())
}
return sign * float32(math.Ldexp(1+float64(mant)/8, exp-7))
}

View File

@@ -3,10 +3,8 @@ package convert
import (
"bytes"
"encoding/binary"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/d4l3k/go-bfloat16"
@@ -233,222 +231,6 @@ func TestSafetensors(t *testing.T) {
}
}
func TestSafetensorWriteToFP8E4M3(t *testing.T) {
root, err := os.OpenRoot(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer root.Close()
path := filepath.Base(t.Name())
f, err := root.Create(path)
if err != nil {
t.Fatal(err)
}
// E4M3FN encodings for 1.0, 2.0, 0.5, and -1.0.
if _, err := f.Write([]byte{0x38, 0x40, 0x30, 0xb8}); err != nil {
t.Fatal(err)
}
if _, err := f.Write(bfloat16.EncodeFloat32([]float32{2})); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
st := safetensor{
fs: root.FS(),
path: path,
dtype: "F8_E4M3",
offset: 0,
size: 4,
fp8Block: safetensorFP8BlockSize{rows: 128, cols: 128, ok: true},
scale: &safetensorScale{
name: "linear.weight_scale",
dtype: "BF16",
shape: []uint64{1, 1},
offset: 4,
size: 2,
},
tensorBase: &tensorBase{
name: "linear.weight",
shape: []uint64{2, 2},
},
}
var b bytes.Buffer
if _, err := st.WriteTo(&b); err != nil {
t.Fatal(err)
}
want := bfloat16.EncodeFloat32([]float32{2, 4, 1, -2})
if diff := cmp.Diff(want, b.Bytes()); diff != "" {
t.Errorf("safetensor.WriteTo() mismatch (-want +got):\n%s", diff)
}
}
func TestSafetensorWriteToFP8E4M3UsesConfiguredBlockSize(t *testing.T) {
root, err := os.OpenRoot(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer root.Close()
path := filepath.Base(t.Name())
f, err := root.Create(path)
if err != nil {
t.Fatal(err)
}
if _, err := f.Write(bytes.Repeat([]byte{0x38}, 12)); err != nil {
t.Fatal(err)
}
if _, err := f.Write(bfloat16.EncodeFloat32([]float32{1, 2, 3, 4})); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
st := safetensor{
fs: root.FS(),
path: path,
dtype: "F8_E4M3",
offset: 0,
size: 12,
fp8Block: safetensorFP8BlockSize{rows: 2, cols: 3, ok: true},
scale: &safetensorScale{
name: "linear.weight_scale",
dtype: "BF16",
shape: []uint64{2, 2},
offset: 12,
size: 8,
},
tensorBase: &tensorBase{
name: "linear.weight",
shape: []uint64{3, 4},
},
}
var b bytes.Buffer
if _, err := st.WriteTo(&b); err != nil {
t.Fatal(err)
}
want := bfloat16.EncodeFloat32([]float32{
1, 1, 1, 2,
1, 1, 1, 2,
3, 3, 3, 4,
})
if diff := cmp.Diff(want, b.Bytes()); diff != "" {
t.Errorf("safetensor.WriteTo() mismatch (-want +got):\n%s", diff)
}
}
func TestParseSafetensorsConsumesFP8ScaleCompanion(t *testing.T) {
tempDir := t.TempDir()
generateSafetensorTestData(t, tempDir, map[string]*tensorData{
"linear.weight": {
Offsets: []int{0, 4},
Type: "F8_E4M3",
Shape: []int{2, 2},
},
"linear.weight_scale": {
Offsets: []int{4, 6},
Type: "BF16",
Shape: []int{1, 1},
},
})
writeFP8BlockConfig(t, tempDir, 128, 128)
tensors, err := parseSafetensors(os.DirFS(tempDir), strings.NewReplacer(), "model-00001-of-00001.safetensors")
if err != nil {
t.Fatal(err)
}
if len(tensors) != 1 {
t.Fatalf("expected one tensor, got %d", len(tensors))
}
if got := tensors[0].Name(); got != "linear.weight" {
t.Fatalf("unexpected tensor name %q", got)
}
if got := tensors[0].Kind(); got != tensorKindBF16 {
t.Fatalf("unexpected fp8 converted kind %d, want %d", got, tensorKindBF16)
}
}
func TestParseSafetensorsRejectsFP8WithoutBlockMetadata(t *testing.T) {
tempDir := t.TempDir()
generateSafetensorTestData(t, tempDir, map[string]*tensorData{
"linear.weight": {
Offsets: []int{0, 4},
Type: "F8_E4M3",
Shape: []int{2, 2},
},
"linear.weight_scale": {
Offsets: []int{4, 6},
Type: "BF16",
Shape: []int{1, 1},
},
})
_, err := parseSafetensors(os.DirFS(tempDir), strings.NewReplacer(), "model-00001-of-00001.safetensors")
if err == nil || !strings.Contains(err.Error(), "missing fp8 block size metadata") {
t.Fatalf("expected missing fp8 block size metadata error, got %v", err)
}
}
func TestParseSafetensorsRejectsAmbiguousFP8ScaleCompanion(t *testing.T) {
tempDir := t.TempDir()
generateSafetensorTestData(t, tempDir, map[string]*tensorData{
"linear.weight": {
Offsets: []int{0, 4},
Type: "F8_E4M3",
Shape: []int{2, 2},
},
"linear.weight_scale": {
Offsets: []int{4, 6},
Type: "BF16",
Shape: []int{1, 1},
},
"linear.weight.scale": {
Offsets: []int{6, 8},
Type: "BF16",
Shape: []int{1, 1},
},
})
writeFP8BlockConfig(t, tempDir, 128, 128)
_, err := parseSafetensors(os.DirFS(tempDir), strings.NewReplacer(), "model-00001-of-00001.safetensors")
if err == nil || !strings.Contains(err.Error(), "multiple fp8 scale companions") {
t.Fatalf("expected ambiguous fp8 scale companion error, got %v", err)
}
}
func writeFP8BlockConfig(t *testing.T, dir string, rows, cols int) {
t.Helper()
config := fmt.Sprintf(`{
"architectures": ["GenericForCausalLM"],
"compression_config": {
"format": "float-quantized",
"config_groups": {
"group_0": {
"format": "float-quantized",
"weights": {
"type": "float",
"num_bits": 8,
"block_structure": [%d, %d]
}
}
}
}
}`, rows, cols)
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(config), 0o644); err != nil {
t.Fatal(err)
}
}
func TestSafetensorKind(t *testing.T) {
tests := []struct {
name string
@@ -477,17 +259,6 @@ func TestSafetensorKind(t *testing.T) {
},
expected: tensorKindFP16,
},
{
name: "BF16 audio feature extractor constants should return FP32",
st: safetensor{
tensorBase: &tensorBase{
name: "a.feature_extractor.fb",
shape: []uint64{1, 128, 257},
},
dtype: "BF16",
},
expected: tensorKindFP32,
},
{
name: "BF16 dtype with FP32 base kind should return FP32",
st: safetensor{

View File

@@ -5,7 +5,6 @@ import (
"errors"
"io"
"iter"
"maps"
"path"
"slices"
"strconv"
@@ -154,54 +153,3 @@ func (g mergeGroup) WriteTo(w io.Writer) (int64, error) {
return 0, nil
}
func sourceTensorKV(ts []*ggml.Tensor) KV {
sourceFP8 := make(map[string]struct{})
for _, t := range ts {
if writerSourceDType(t.WriterTo) == "F8_E4M3" {
sourceFP8[t.Name] = struct{}{}
}
}
if len(sourceFP8) == 0 {
return nil
}
return KV{
"source_quantization": "hf_fp8",
"source_fp8_tensors": slices.Sorted(maps.Keys(sourceFP8)),
}
}
type sourceDTypeTensor interface {
SourceDType() string
}
func writerSourceDType(w io.WriterTo) string {
switch w := w.(type) {
case sourceDTypeTensor:
return w.SourceDType()
case mergeGroup:
if len(w) == 0 {
return ""
}
dtype := sourceDType(w[0])
if dtype == "" {
return ""
}
for _, t := range w[1:] {
if sourceDType(t) != dtype {
return ""
}
}
return dtype
default:
return ""
}
}
func sourceDType(t Tensor) string {
if t, ok := t.(sourceDTypeTensor); ok {
return t.SourceDType()
}
return ""
}

View File

@@ -21,8 +21,7 @@ type fakeTensor struct {
shape []uint64
data []float32
sourceDType string
repacker Repacker
repacker Repacker
}
func (f fakeTensor) Name() string {
@@ -37,21 +36,16 @@ func (f fakeTensor) Kind() uint32 {
return 0
}
func (f fakeTensor) SourceDType() string {
return f.sourceDType
}
func (f *fakeTensor) SetRepacker(fn Repacker) {
f.repacker = fn
}
func (f fakeTensor) Clone() Tensor {
return &fakeTensor{
name: f.name,
shape: slices.Clone(f.shape),
data: slices.Clone(f.data),
sourceDType: f.sourceDType,
repacker: f.repacker,
name: f.name,
shape: slices.Clone(f.shape),
data: slices.Clone(f.data),
repacker: f.repacker,
}
}
@@ -1001,43 +995,3 @@ func TestMergeOrder(t *testing.T) {
})
}
}
func TestSourceTensorKVRecordsFP8OutputTensors(t *testing.T) {
fp8 := &fakeTensor{name: "linear.weight", shape: []uint64{2, 2}, sourceDType: "F8_E4M3"}
bf16 := &fakeTensor{name: "other.weight", shape: []uint64{2, 2}, sourceDType: "BF16"}
kv := sourceTensorKV([]*ggml.Tensor{
{Name: "blk.0.linear.weight", WriterTo: fp8},
{Name: "blk.0.other.weight", WriterTo: bf16},
})
if got := kv["source_quantization"]; got != "hf_fp8" {
t.Fatalf("source_quantization = %v, want hf_fp8", got)
}
got, ok := kv["source_fp8_tensors"].([]string)
if !ok {
t.Fatalf("source_fp8_tensors = %#v, want []string", kv["source_fp8_tensors"])
}
if diff := cmp.Diff([]string{"blk.0.linear.weight"}, got); diff != "" {
t.Fatalf("source_fp8_tensors mismatch (-want +got):\n%s", diff)
}
}
func TestSourceTensorKVRecordsMergedFP8OutputTensors(t *testing.T) {
fp8A := &fakeTensor{name: "expert.0.weight", shape: []uint64{2, 2}, sourceDType: "F8_E4M3"}
fp8B := &fakeTensor{name: "expert.1.weight", shape: []uint64{2, 2}, sourceDType: "F8_E4M3"}
bf16 := &fakeTensor{name: "expert.2.weight", shape: []uint64{2, 2}, sourceDType: "BF16"}
kv := sourceTensorKV([]*ggml.Tensor{
{Name: "ffn_exps.weight", WriterTo: mergeGroup{fp8A, fp8B}},
{Name: "mixed_exps.weight", WriterTo: mergeGroup{fp8A, bf16}},
})
got, ok := kv["source_fp8_tensors"].([]string)
if !ok {
t.Fatalf("source_fp8_tensors = %#v, want []string", kv["source_fp8_tensors"])
}
if diff := cmp.Diff([]string{"ffn_exps.weight"}, got); diff != "" {
t.Fatalf("source_fp8_tensors mismatch (-want +got):\n%s", diff)
}
}

View File

@@ -103,8 +103,6 @@ func parseTokenizer(fsys fs.FS, specialTokenTypes []string) (*Tokenizer, error)
t.Pre = "qwen2"
case "00431aed57e696b747435f734d1e3b9b1bfd931a121fb5cac7129e97c181e9ba":
t.Pre = "qwen35"
case "b92c0824a58e1d8dc3221cf3e12c433c3a86f57e46d57229993489f0798e7702":
t.Pre = "laguna"
case "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855":
// noop, empty pretokenizer
default:

Some files were not shown because too many files have changed in this diff Show More