mirror of
https://github.com/ollama/ollama.git
synced 2026-09-10 13:10:22 -04:00
Compare commits
93
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39df91c982 | ||
|
|
7ce88bd686 | ||
|
|
01d04d50f8 | ||
|
|
9a56a0e845 | ||
|
|
88313499e0 | ||
|
|
2b4a99376c | ||
|
|
e922bc7125 | ||
|
|
950dd9ac67 | ||
|
|
b6b1b258c3 | ||
|
|
4138e853d5 | ||
|
|
641df5e5ad | ||
|
|
6a261db7d8 | ||
|
|
948f69330a | ||
|
|
96fb6d2fa9 | ||
|
|
400164d47c | ||
|
|
bb7bba885e | ||
|
|
4f066a6fb0 | ||
|
|
a836eb8c3c | ||
|
|
1a9e4235ac | ||
|
|
43f4eda808 | ||
|
|
acdf81510d | ||
|
|
1e85fe8e9a | ||
|
|
5fcf71b8b8 | ||
|
|
60bdc23467 | ||
|
|
694487c65b | ||
|
|
af5b627672 | ||
|
|
8713570d3c | ||
|
|
5a173edb63 | ||
|
|
b880b76c43 | ||
|
|
cf129bbb11 | ||
|
|
c1bf60d7b1 | ||
|
|
0fcfc99ea0 | ||
|
|
e7fbd528f7 | ||
|
|
2f84872ce0 | ||
|
|
f91cb0d6a7 | ||
|
|
8dd34b77d1 | ||
|
|
35f71382de | ||
|
|
144893850f | ||
|
|
26936bea45 | ||
|
|
43983edf18 | ||
|
|
c82ebbd5bf | ||
|
|
8edecb5c69 | ||
|
|
8d8c701d6a | ||
|
|
b63eed94b6 | ||
|
|
4f9d09ef52 | ||
|
|
ba8f2a324d | ||
|
|
721f05049d | ||
|
|
accd6d656a | ||
|
|
bd3f22e2f7 | ||
|
|
5db07cad71 | ||
|
|
acf96e7ab7 | ||
|
|
a199313eb3 | ||
|
|
b205993ed4 | ||
|
|
9ea503f505 | ||
|
|
3ff2dcb649 | ||
|
|
4713800b08 | ||
|
|
0e2e34aa86 | ||
|
|
76929b0a8a | ||
|
|
bf7be180e3 | ||
|
|
eec8e0b945 | ||
|
|
be7572e2cf | ||
|
|
64ee2f9847 | ||
|
|
132e0ca25d | ||
|
|
9eef4a7195 | ||
|
|
3f07e022ac | ||
|
|
551809688b | ||
|
|
08edcb8f2c | ||
|
|
d6f69da04d | ||
|
|
6cd40001a9 | ||
|
|
a84b315e7b | ||
|
|
83d4311ffe | ||
|
|
fce745fe5e | ||
|
|
1fd1ccf7ad | ||
|
|
efb7e3c55e | ||
|
|
b517b9bd01 | ||
|
|
479664e7aa | ||
|
|
a51df81573 | ||
|
|
a18c230189 | ||
|
|
e21d5327b0 | ||
|
|
4d1b53e6fb | ||
|
|
6100aca085 | ||
|
|
72116bafb3 | ||
|
|
e2c2edcc27 | ||
|
|
de1ce45913 | ||
|
|
51fc00122b | ||
|
|
445284b428 | ||
|
|
e8f7c93a0b | ||
|
|
0de38190d7 | ||
|
|
681dfaedcc | ||
|
|
9893d39218 | ||
|
|
5ba17e6fdf | ||
|
|
6f3b997dec | ||
|
|
cc62676656 |
No files matched your search
@@ -93,6 +93,7 @@ jobs:
|
||||
windows-depends:
|
||||
needs: setup-environment
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows]
|
||||
arch: [amd64]
|
||||
@@ -124,6 +125,22 @@ jobs:
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 13 ARM64'
|
||||
build-steps: cuda13Arm64Cross
|
||||
install: https://packages.nvidia.com/prerelease/cuda/13.4.0/local_installers/cuda_13.4.0_windows_x86_64.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"cudart_cross"'
|
||||
- '"nvcc"'
|
||||
- '"nvcc_cross"'
|
||||
- '"cublas_cross"'
|
||||
- '"cublas_dev"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.4'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'ROCm 7'
|
||||
@@ -198,8 +215,18 @@ jobs:
|
||||
name: Install CUDA ${{ matrix.cuda-version }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
break
|
||||
} catch {
|
||||
if ($attempt -eq 3) { throw }
|
||||
Write-Host "CUDA installer download attempt $attempt failed: $($_.Exception.Message); retrying in 15s"
|
||||
Start-Sleep -Seconds 15
|
||||
}
|
||||
}
|
||||
$subpackages = @(${{ join(matrix.cuda-components, ', ') }}) | Foreach-Object {"${_}_${{ matrix.cuda-version }}"}
|
||||
Start-Process -FilePath .\install.exe -ArgumentList (@("-s") + $subpackages) -NoNewWindow -Wait
|
||||
}
|
||||
@@ -434,6 +461,7 @@ jobs:
|
||||
|
||||
linux-depends:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
@@ -515,6 +543,7 @@ jobs:
|
||||
# and just assembles, runs the Go build, pushes the final image, and extracts release bundles.
|
||||
docker-build-push:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: linux
|
||||
@@ -665,6 +694,7 @@ jobs:
|
||||
# Merge Docker images for the same flavor into a single multi-arch manifest
|
||||
docker-merge-push:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suffix: ['', '-rocm']
|
||||
runs-on: linux
|
||||
|
||||
@@ -321,6 +321,22 @@ jobs:
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 13 ARM64'
|
||||
build-steps: cuda13Arm64Cross
|
||||
install: https://packages.nvidia.com/prerelease/cuda/13.4.0/local_installers/cuda_13.4.0_windows_x86_64.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"cudart_cross"'
|
||||
- '"nvcc"'
|
||||
- '"nvcc_cross"'
|
||||
- '"cublas_cross"'
|
||||
- '"cublas_dev"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.4'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'ROCm 7'
|
||||
@@ -365,8 +381,18 @@ jobs:
|
||||
name: Install CUDA ${{ matrix.cuda-version }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
break
|
||||
} catch {
|
||||
if ($attempt -eq 3) { throw }
|
||||
Write-Host "CUDA installer download attempt $attempt failed: $($_.Exception.Message); retrying in 15s"
|
||||
Start-Sleep -Seconds 15
|
||||
}
|
||||
}
|
||||
$subpackages = @(${{ join(matrix.cuda-components, ', ') }}) | Foreach-Object {"${_}_${{ matrix.cuda-version }}"}
|
||||
Start-Process -FilePath .\install.exe -ArgumentList (@("-s") + $subpackages) -NoNewWindow -Wait
|
||||
}
|
||||
|
||||
@@ -416,5 +416,3 @@ jobs:
|
||||
run: go test -count=1 -tags updater_live ./app/...
|
||||
|
||||
- uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
only-new-issues: true
|
||||
+8
-8
@@ -15,9 +15,9 @@ FROM scratch AS local-mlx
|
||||
FROM scratch AS local-mlx-c
|
||||
|
||||
FROM --platform=linux/amd64 rocm/dev-almalinux-8:${ROCMVERSION}-complete AS base-amd64
|
||||
RUN dnf install -y yum-utils ccache gcc-toolset-11-gcc gcc-toolset-11-gcc-c++ gcc-toolset-11-binutils \
|
||||
RUN dnf install -y yum-utils ccache gcc-toolset-13-gcc gcc-toolset-13-gcc-c++ gcc-toolset-13-binutils \
|
||||
&& yum-config-manager --add-repo https://developer.download.nvidia.com/compute/cuda/repos/rhel8/x86_64/cuda-rhel8.repo
|
||||
ENV PATH=/opt/rh/gcc-toolset-11/root/usr/bin:$PATH
|
||||
ENV PATH=/opt/rh/gcc-toolset-13/root/usr/bin:$PATH
|
||||
|
||||
FROM --platform=linux/arm64 almalinux:8 AS base-arm64
|
||||
# install epel-release for ccache
|
||||
@@ -42,8 +42,8 @@ ENV LDFLAGS=-s
|
||||
#
|
||||
|
||||
FROM base AS cpu-deps
|
||||
RUN dnf install -y gcc-toolset-11-gcc gcc-toolset-11-gcc-c++
|
||||
ENV PATH=/opt/rh/gcc-toolset-11/root/usr/bin:$PATH
|
||||
RUN dnf install -y gcc-toolset-13-gcc gcc-toolset-13-gcc-c++
|
||||
ENV PATH=/opt/rh/gcc-toolset-13/root/usr/bin:$PATH
|
||||
|
||||
FROM base AS cuda-12-deps
|
||||
ARG CUDA12VERSION=12.8
|
||||
@@ -91,8 +91,8 @@ RUN --mount=type=cache,target=/root/.ccache \
|
||||
&& for lib in \
|
||||
/usr/lib64/libgomp.so* \
|
||||
/usr/lib64/libomp.so* \
|
||||
/opt/rh/gcc-toolset-11/root/usr/lib64/libgomp.so* \
|
||||
/opt/rh/gcc-toolset-11/root/usr/lib64/libomp.so*; do \
|
||||
/opt/rh/gcc-toolset-13/root/usr/lib64/libgomp.so* \
|
||||
/opt/rh/gcc-toolset-13/root/usr/lib64/libomp.so*; do \
|
||||
[ -e "$lib" ] && cp -a "$lib" dist/lib/ollama/ || true; \
|
||||
done
|
||||
|
||||
@@ -124,7 +124,7 @@ FROM scratch AS publish-llama-server-cuda_v13
|
||||
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM rocm-7-deps AS llama-server-rocm_v7_2
|
||||
ENV CC=clang CXX=clang++
|
||||
ENV CC=clang CXX=clang++ CXXFLAGS=--gcc-toolchain=/opt/rh/gcc-toolset-13/root/usr
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
@@ -213,7 +213,7 @@ ENV CGO_LDFLAGS="-L/usr/local/cuda-13/lib64 -L/usr/local/cuda-13/targets/x86_64-
|
||||
WORKDIR /go/src/github.com/ollama/ollama
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY cmake cmake
|
||||
COPY x/imagegen/mlx x/imagegen/mlx
|
||||
COPY x/mlxrunner/mlx x/mlxrunner/mlx
|
||||
COPY go.mod go.sum .
|
||||
COPY MLX_VERSION MLX_C_VERSION .
|
||||
RUN curl -fsSL https://golang.org/dl/go$(awk '/^go/ { print $2 }' go.mod).linux-$(case $(uname -m) in x86_64) echo amd64 ;; aarch64) echo arm64 ;; esac).tar.gz | tar xz -C /usr/local
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
b9888
|
||||
b10380
|
||||
+1
-1
@@ -1 +1 @@
|
||||
de7b4ed986b6d6f55b8ace5e73c24d1ca0bea89b
|
||||
3abd0fd6b3eb9d9d3a34cb65e8a2189c57260399
|
||||
@@ -65,7 +65,7 @@ To launch a specific integration:
|
||||
ollama launch claude
|
||||
```
|
||||
|
||||
Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Copilot CLI](https://docs.ollama.com/integrations/copilot-cli), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
|
||||
Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Copilot CLI](https://docs.ollama.com/integrations/copilot-cli), [DeepSeek Harness](https://docs.ollama.com/integrations/deepseek-harness), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
|
||||
|
||||
### AI assistant
|
||||
|
||||
|
||||
@@ -474,7 +474,6 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
||||
batch := toolBatchResult{
|
||||
messages: make([]api.Message, 0, len(calls)),
|
||||
}
|
||||
projectedMessages := append([]api.Message(nil), messages...)
|
||||
// Pre-compute the full-history token estimate once per batch instead of
|
||||
// re-marshaling the entire history for each tool call. Per-call deltas
|
||||
// (tool messages already appended this batch) are tracked in batchTokens
|
||||
@@ -530,7 +529,6 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
||||
for _, plan := range plans {
|
||||
msg := s.toolMessageForContext(plan.toolName, plan.call.ID, content, opts, historyTokens+batchTokens)
|
||||
batch.messages = append(batch.messages, msg)
|
||||
projectedMessages = append(projectedMessages, msg)
|
||||
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
||||
deniedContent := msg.Content
|
||||
if emitErr := s.emit(newToolFinished(meta, "denied", plan.call.ID, plan.toolName, "", plan.args, deniedContent, deniedContent)); emitErr != nil {
|
||||
@@ -559,7 +557,6 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
||||
content := fmt.Sprintf("Error: unknown tool: %s", toolName)
|
||||
msg := s.toolMessageForContext(toolName, call.ID, content, opts, historyTokens+batchTokens)
|
||||
batch.messages = append(batch.messages, msg)
|
||||
projectedMessages = append(projectedMessages, msg)
|
||||
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
||||
content = msg.Content
|
||||
if toolOutputFullyOmitted(content) {
|
||||
@@ -580,7 +577,6 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
||||
rawContent := fmt.Sprintf("Error: %v", err)
|
||||
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, historyTokens+batchTokens)
|
||||
batch.messages = append(batch.messages, msg)
|
||||
projectedMessages = append(projectedMessages, msg)
|
||||
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
||||
content := msg.Content
|
||||
if toolOutputFullyOmitted(content) {
|
||||
@@ -609,7 +605,6 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
||||
|
||||
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, historyTokens+batchTokens)
|
||||
batch.messages = append(batch.messages, msg)
|
||||
projectedMessages = append(projectedMessages, msg)
|
||||
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
||||
content := msg.Content
|
||||
|
||||
@@ -637,7 +632,6 @@ func (s *Session) disabledToolCalls(ctx context.Context, runID string, opts RunO
|
||||
batch := toolBatchResult{
|
||||
messages: make([]api.Message, 0, len(calls)),
|
||||
}
|
||||
projectedMessages := append([]api.Message(nil), messages...)
|
||||
historyTokens := s.estimateRunPromptTokens(opts, messages)
|
||||
batchTokens := 0
|
||||
for _, call := range calls {
|
||||
@@ -645,7 +639,6 @@ func (s *Session) disabledToolCalls(ctx context.Context, runID string, opts RunO
|
||||
args := call.Function.Arguments.ToMap()
|
||||
msg := s.toolMessageForContext(toolName, call.ID, toolExecutionDisabledMessage, opts, historyTokens+batchTokens)
|
||||
batch.messages = append(batch.messages, msg)
|
||||
projectedMessages = append(projectedMessages, msg)
|
||||
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
||||
if emitErr := s.emitIgnoringCanceled(ctx, newToolFinished(meta, "disabled", call.ID, toolName, "", args, msg.Content, msg.Content)); emitErr != nil {
|
||||
return toolBatchResult{}, emitErr
|
||||
|
||||
+375
@@ -1,8 +1,10 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -271,6 +273,354 @@ type skillRoot struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// SkillImportResult describes one import attempt. Failed skills do not prevent
|
||||
// other valid skills in the same source root from being imported.
|
||||
type SkillImportResult struct {
|
||||
Source string
|
||||
SourceDir string
|
||||
Destination string
|
||||
Imported []string
|
||||
Existing []string
|
||||
Failures []SkillImportFailure
|
||||
}
|
||||
|
||||
// SkillImportFailure identifies a source skill that was deliberately skipped.
|
||||
// The destination is never changed for a failed skill.
|
||||
type SkillImportFailure struct {
|
||||
Name string
|
||||
Err error
|
||||
}
|
||||
|
||||
// ImportSkills imports skills from a conventional coding-agent source into the
|
||||
// canonical Ollama skills directory. Supported sources are codex, claude, and
|
||||
// pi. Existing skills are left untouched: an identical directory is reported
|
||||
// as existing, and a differing one is reported as a conflict.
|
||||
func ImportSkills(source string) (SkillImportResult, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return SkillImportResult{}, fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
|
||||
destination, err := SkillsDir()
|
||||
if err != nil {
|
||||
return SkillImportResult{}, fmt.Errorf("resolve Ollama skills directory: %w", err)
|
||||
}
|
||||
return importSkillsFromRoots(source, conventionalSkillImportRoots(home), destination)
|
||||
}
|
||||
|
||||
func conventionalSkillImportRoots(home string) map[string]string {
|
||||
return map[string]string{
|
||||
"codex": filepath.Join(home, ".codex", "skills"),
|
||||
"claude": filepath.Join(home, ".claude", "skills"),
|
||||
"pi": filepath.Join(home, ".pi", "agent", "skills"),
|
||||
}
|
||||
}
|
||||
|
||||
func importSkillsFromRoots(source string, roots map[string]string, destination string) (SkillImportResult, error) {
|
||||
source = strings.ToLower(strings.TrimSpace(source))
|
||||
sourceDir, ok := roots[source]
|
||||
if !ok {
|
||||
return SkillImportResult{}, fmt.Errorf("unknown skill source %q", source)
|
||||
}
|
||||
return importSkillsFromDir(source, sourceDir, destination)
|
||||
}
|
||||
|
||||
func importSkillsFromDir(source, sourceDir, destination string) (SkillImportResult, error) {
|
||||
result := SkillImportResult{Source: source, SourceDir: sourceDir, Destination: destination}
|
||||
info, err := os.Lstat(sourceDir)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return result, nil
|
||||
}
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("inspect %s skills directory: %w", source, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return result, fmt.Errorf("inspect %s skills directory: symlinks are not supported", source)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return result, fmt.Errorf("inspect %s skills directory: not a directory", source)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(sourceDir)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("read %s skills directory: %w", source, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
path := filepath.Join(sourceDir, name)
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: errors.New("symlinked skill directories are not supported")})
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: fmt.Errorf("inspect source: %w", err)})
|
||||
continue
|
||||
}
|
||||
if !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
if !skillName.MatchString(name) {
|
||||
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: errors.New("invalid skill directory name")})
|
||||
continue
|
||||
}
|
||||
if err := validateImportSkill(path, name); err != nil {
|
||||
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: err})
|
||||
continue
|
||||
}
|
||||
|
||||
state, err := importSkillDirectory(path, filepath.Join(destination, name))
|
||||
if err != nil {
|
||||
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: err})
|
||||
continue
|
||||
}
|
||||
if state == skillImportExisting {
|
||||
result.Existing = append(result.Existing, name)
|
||||
} else {
|
||||
result.Imported = append(result.Imported, name)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validateImportSkill(dir, name string) error {
|
||||
manifest := filepath.Join(dir, skillFilename)
|
||||
info, err := os.Lstat(manifest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect %s: %w", skillFilename, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("%s must be a regular, non-symlinked file", skillFilename)
|
||||
}
|
||||
if _, err := parseSkill(manifest, name); err != nil {
|
||||
return err
|
||||
}
|
||||
return walkImportTree(dir, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
|
||||
if info.IsDir() || path == dir {
|
||||
return nil
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("only regular files may be imported: %s", path)
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
return file.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func walkImportTree(root string, visit func(string, fs.DirEntry, fs.FileInfo) error) error {
|
||||
return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return fmt.Errorf("unsafe skill path %q", path)
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("symlinks may not be imported: %s", path)
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return visit(path, entry, info)
|
||||
})
|
||||
}
|
||||
|
||||
type skillImportState int
|
||||
|
||||
const (
|
||||
skillImportCopied skillImportState = iota
|
||||
skillImportExisting
|
||||
)
|
||||
|
||||
func importSkillDirectory(source, destination string) (skillImportState, error) {
|
||||
if info, err := os.Lstat(destination); err == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return 0, errors.New("destination exists but is not a regular directory")
|
||||
}
|
||||
same, err := sameImportTree(source, destination)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("inspect existing destination: %w", err)
|
||||
}
|
||||
if same {
|
||||
return skillImportExisting, nil
|
||||
}
|
||||
return 0, errors.New("destination skill already exists with different contents")
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
return 0, fmt.Errorf("inspect destination: %w", err)
|
||||
}
|
||||
|
||||
if err := ensureImportDestination(filepath.Dir(destination)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
stage, err := os.MkdirTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".import-")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create import staging directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(stage)
|
||||
if err := copyImportTree(source, stage); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := os.Lstat(destination); err == nil {
|
||||
return 0, errors.New("destination skill was created during import")
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
return 0, fmt.Errorf("inspect destination before install: %w", err)
|
||||
}
|
||||
if err := os.Rename(stage, destination); err != nil {
|
||||
return 0, fmt.Errorf("install imported skill: %w", err)
|
||||
}
|
||||
return skillImportCopied, nil
|
||||
}
|
||||
|
||||
func ensureImportDestination(dir string) error {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create Ollama skills directory: %w", err)
|
||||
}
|
||||
info, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect Ollama skills directory: %w", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return errors.New("Ollama skills directory must be a regular, non-symlinked directory")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyImportTree(source, destination string) error {
|
||||
return walkImportTree(source, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
|
||||
rel, err := filepath.Rel(source, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := destination
|
||||
if rel != "." {
|
||||
target = filepath.Join(destination, rel)
|
||||
}
|
||||
if info.IsDir() {
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
return os.Mkdir(target, info.Mode().Perm())
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("only regular files may be imported: %s", path)
|
||||
}
|
||||
return copyImportFile(path, target, info.Mode().Perm())
|
||||
})
|
||||
}
|
||||
|
||||
func copyImportFile(source, destination string, mode fs.FileMode) error {
|
||||
in, err := os.Open(source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", source, err)
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", destination, err)
|
||||
}
|
||||
_, copyErr := io.Copy(out, in)
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("copy %s: %w", source, copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("write %s: %w", destination, closeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameImportTree(source, destination string) (bool, error) {
|
||||
seen := make(map[string]struct{})
|
||||
same := true
|
||||
err := walkImportTree(source, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
|
||||
rel, err := filepath.Rel(source, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seen[rel] = struct{}{}
|
||||
other := destination
|
||||
if rel != "." {
|
||||
other = filepath.Join(destination, rel)
|
||||
}
|
||||
otherInfo, err := os.Lstat(other)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
same = false
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if otherInfo.Mode()&os.ModeSymlink != 0 || otherInfo.IsDir() != info.IsDir() || (!info.IsDir() && !otherInfo.Mode().IsRegular()) {
|
||||
same = false
|
||||
return nil
|
||||
}
|
||||
if info.Mode().IsRegular() {
|
||||
equal, err := sameImportFile(path, other)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !equal {
|
||||
same = false
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil || !same {
|
||||
return same, err
|
||||
}
|
||||
err = walkImportTree(destination, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
|
||||
rel, err := filepath.Rel(destination, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := seen[rel]; !ok {
|
||||
same = false
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return same, err
|
||||
}
|
||||
|
||||
func sameImportFile(first, second string) (bool, error) {
|
||||
a, err := os.Open(first)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer a.Close()
|
||||
b, err := os.Open(second)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer b.Close()
|
||||
|
||||
left := make([]byte, 32*1024)
|
||||
right := make([]byte, len(left))
|
||||
for {
|
||||
n, errA := a.Read(left)
|
||||
m, errB := b.Read(right)
|
||||
if n != m || !bytes.Equal(left[:n], right[:m]) {
|
||||
return false, nil
|
||||
}
|
||||
if errA == io.EOF && errB == io.EOF {
|
||||
return true, nil
|
||||
}
|
||||
if errA != nil && errA != io.EOF {
|
||||
return false, errA
|
||||
}
|
||||
if errB != nil && errB != io.EOF {
|
||||
return false, errB
|
||||
}
|
||||
if errA == io.EOF || errB == io.EOF {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// defaultSkillRoots returns skill directories ordered lowest- to
|
||||
// highest-precedence. Non-existent directories are scanned harmlessly
|
||||
// (DiscoverSkills skips them).
|
||||
@@ -325,6 +675,31 @@ func (c *SkillCatalog) Diagnostics() []error {
|
||||
return append([]error(nil), c.diagnostics...)
|
||||
}
|
||||
|
||||
// ExcludeNames removes skills whose names are reserved by a caller. It returns
|
||||
// the excluded names in sorted order.
|
||||
func (c *SkillCatalog) ExcludeNames(names []string) []string {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
reserved := make(map[string]struct{}, len(names))
|
||||
for _, name := range names {
|
||||
name = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(name)), "/")
|
||||
if name != "" {
|
||||
reserved[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
var excluded []string
|
||||
for name := range c.skills {
|
||||
if _, ok := reserved[name]; !ok {
|
||||
continue
|
||||
}
|
||||
delete(c.skills, name)
|
||||
excluded = append(excluded, name)
|
||||
}
|
||||
sort.Strings(excluded)
|
||||
return excluded
|
||||
}
|
||||
|
||||
func (c *SkillCatalog) Load(name string) (Skill, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if !skillName.MatchString(name) {
|
||||
|
||||
@@ -21,6 +21,21 @@ func writeCatalogSkill(t *testing.T, dir, name, content string) {
|
||||
}
|
||||
}
|
||||
|
||||
func writeImportFixtureSkill(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
contents, err := os.ReadFile(filepath.Join("testdata", "import", "release-notes", skillFilename))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, "release-notes", skillFilename)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, contents, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverAndLoadSkills(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeCatalogSkill(t, dir, "release-notes", "---\nname: release-notes\ndescription: Draft concise release notes.\nmetadata:\n author: Ollama\n labels:\n - release\n - docs\n---\n# Release notes\n\nUse short bullets.")
|
||||
@@ -257,6 +272,30 @@ func TestLoadDefaultSkillsPrecedenceAndCollisions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillCatalogExcludeNames(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range []string{"release-notes", "system", "exit"} {
|
||||
writeCatalogSkill(t, dir, name, "instructions")
|
||||
}
|
||||
catalog, err := DiscoverSkills(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got, want := strings.Join(catalog.ExcludeNames([]string{"/system", "EXIT"}), ","), "exit,system"; got != want {
|
||||
t.Fatalf("excluded skills = %q, want %q", got, want)
|
||||
}
|
||||
if _, err := catalog.Load("system"); err == nil {
|
||||
t.Fatal("excluded system skill should not load")
|
||||
}
|
||||
if _, err := catalog.Load("exit"); err == nil {
|
||||
t.Fatal("excluded exit skill should not load")
|
||||
}
|
||||
if _, err := catalog.Load("release-notes"); err != nil {
|
||||
t.Fatalf("non-conflicting skill should remain available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillContentListsDirectoryAndResources(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
skillDir := filepath.Join(root, "pdf-processing")
|
||||
@@ -291,3 +330,187 @@ func TestSkillContentListsDirectoryAndResources(t *testing.T) {
|
||||
t.Fatalf("content missing resource listing: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSkillsCopiesFixtureAndIsIdempotent(t *testing.T) {
|
||||
source := t.TempDir()
|
||||
destination := t.TempDir()
|
||||
writeImportFixtureSkill(t, source)
|
||||
writeCatalogSkill(t, source, "broken", "---\nname: another-skill\ndescription: Deliberately invalid.\n---\nIgnore this.")
|
||||
if err := os.MkdirAll(filepath.Join(source, "release-notes", "references"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(source, "release-notes", "references", "style.txt"), []byte("Keep it short.\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(source, "release-notes", "scripts"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(source, "release-notes", "scripts", "prepare.sh"), []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(source, "ignored.md"), []byte("Ignored root file.\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := importSkillsFromDir("codex", source, destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := strings.Join(result.Imported, ","), "release-notes"; got != want {
|
||||
t.Fatalf("imported = %q, want %q", got, want)
|
||||
}
|
||||
catalog, err := DiscoverSkills(destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skill, err := catalog.Load("release-notes")
|
||||
if err != nil || skill.Description != "Draft concise release notes." {
|
||||
t.Fatalf("imported skill = %#v, %v", skill, err)
|
||||
}
|
||||
if got := len(result.Failures); got != 1 || result.Failures[0].Name != "broken" {
|
||||
t.Fatalf("failures = %#v, want broken fixture failure", result.Failures)
|
||||
}
|
||||
for _, file := range []string{skillFilename, filepath.Join("references", "style.txt"), filepath.Join("scripts", "prepare.sh")} {
|
||||
if _, err := os.Stat(filepath.Join(destination, "release-notes", file)); err != nil {
|
||||
t.Fatalf("imported fixture file %q: %v", file, err)
|
||||
}
|
||||
}
|
||||
|
||||
result, err = importSkillsFromDir("codex", source, destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := strings.Join(result.Existing, ","), "release-notes"; got != want {
|
||||
t.Fatalf("existing = %q, want %q", got, want)
|
||||
}
|
||||
if len(result.Imported) != 0 {
|
||||
t.Fatalf("repeated import copied skills: %#v", result.Imported)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSkillsLeavesConflictsAndUnsafeSourcesUntouched(t *testing.T) {
|
||||
source := t.TempDir()
|
||||
destination := t.TempDir()
|
||||
writeCatalogSkill(t, source, "release-notes", "source instructions")
|
||||
writeCatalogSkill(t, destination, "release-notes", "existing instructions")
|
||||
writeCatalogSkill(t, source, "nested-link", "safe manifest")
|
||||
if err := os.Symlink(filepath.Join(source, "release-notes", skillFilename), filepath.Join(source, "nested-link", "reference")); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(source, "release-notes"), filepath.Join(source, "linked-skill")); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
|
||||
result, err := importSkillsFromDir("codex", source, destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Imported) != 0 || len(result.Existing) != 0 {
|
||||
t.Fatalf("unexpected successful import: %#v", result)
|
||||
}
|
||||
if got, err := os.ReadFile(filepath.Join(destination, "release-notes", skillFilename)); err != nil || !strings.Contains(string(got), "existing instructions") {
|
||||
t.Fatalf("conflicting destination changed: %q, %v", got, err)
|
||||
}
|
||||
failed := make(map[string]bool)
|
||||
for _, failure := range result.Failures {
|
||||
failed[failure.Name] = true
|
||||
}
|
||||
for _, name := range []string{"release-notes", "nested-link", "linked-skill"} {
|
||||
if !failed[name] {
|
||||
t.Fatalf("missing failure for %q: %#v", name, result.Failures)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSkillsRejectsSymlinkedRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
source := filepath.Join(t.TempDir(), "codex-skills")
|
||||
if err := os.Symlink(root, source); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
result, err := importSkillsFromDir("codex", source, t.TempDir())
|
||||
if err == nil || !strings.Contains(err.Error(), "symlinks are not supported") {
|
||||
t.Fatalf("symlinked root error = %v", err)
|
||||
}
|
||||
if len(result.Imported) != 0 || len(result.Existing) != 0 || len(result.Failures) != 0 {
|
||||
t.Fatalf("symlinked root result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSkillsMissingRootAndConfiguredRoots(t *testing.T) {
|
||||
result, err := importSkillsFromDir("codex", filepath.Join(t.TempDir(), "missing"), t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Imported) != 0 || len(result.Existing) != 0 || len(result.Failures) != 0 {
|
||||
t.Fatalf("missing root result = %#v", result)
|
||||
}
|
||||
|
||||
destination := t.TempDir()
|
||||
rootBase := t.TempDir()
|
||||
roots := map[string]string{
|
||||
"codex": filepath.Join(rootBase, "codex"),
|
||||
"claude": filepath.Join(rootBase, "claude"),
|
||||
"pi": filepath.Join(rootBase, "pi"),
|
||||
}
|
||||
for _, test := range []struct {
|
||||
source string
|
||||
root string
|
||||
name string
|
||||
}{
|
||||
{source: "codex", root: roots["codex"], name: "from-codex"},
|
||||
{source: "claude", root: roots["claude"], name: "from-claude"},
|
||||
{source: "pi", root: roots["pi"], name: "from-pi"},
|
||||
} {
|
||||
t.Run(test.source, func(t *testing.T) {
|
||||
writeCatalogSkill(t, test.root, test.name, "from "+test.source)
|
||||
result, err = importSkillsFromRoots(test.source, roots, destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.SourceDir != test.root {
|
||||
t.Fatalf("source dir = %q, want %q", result.SourceDir, test.root)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destination, test.name, skillFilename)); err != nil {
|
||||
t.Fatalf("conventional source was not imported: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := importSkillsFromRoots("unknown", roots, destination); err == nil || !strings.Contains(err.Error(), "unknown skill source") {
|
||||
t.Fatalf("unknown source error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConventionalSkillImportRoots(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
roots := conventionalSkillImportRoots(home)
|
||||
for source, want := range map[string]string{
|
||||
"codex": filepath.Join(home, ".codex", "skills"),
|
||||
"claude": filepath.Join(home, ".claude", "skills"),
|
||||
"pi": filepath.Join(home, ".pi", "agent", "skills"),
|
||||
} {
|
||||
if got := roots[source]; got != want {
|
||||
t.Fatalf("%s root = %q, want %q", source, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSkillsRejectsUnreadableManifest(t *testing.T) {
|
||||
source := t.TempDir()
|
||||
writeCatalogSkill(t, source, "private", "do not read")
|
||||
manifest := filepath.Join(source, "private", skillFilename)
|
||||
if err := os.Chmod(manifest, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(manifest, 0o644) })
|
||||
if _, err := os.ReadFile(manifest); err == nil {
|
||||
t.Skip("test user can read a mode-000 file")
|
||||
}
|
||||
result, err := importSkillsFromDir("codex", source, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Failures) != 1 || result.Failures[0].Name != "private" {
|
||||
t.Fatalf("failures = %#v", result.Failures)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: release-notes
|
||||
description: Draft concise release notes.
|
||||
---
|
||||
|
||||
# Release notes
|
||||
|
||||
Use short bullets.
|
||||
+183
-30
@@ -2,11 +2,14 @@ package tools
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -105,26 +108,37 @@ func (e *Edit) Name() string {
|
||||
}
|
||||
|
||||
func (e *Edit) Description() string {
|
||||
return "Edit a text file in the current working directory by replacing exact text."
|
||||
return "Edit a text file in the current working directory by replacing exact text. Pass multiple edits to change separate parts of the file in one call."
|
||||
}
|
||||
|
||||
func (e *Edit) Schema() api.ToolFunction {
|
||||
editProps := api.NewToolPropertiesMap()
|
||||
editProps.Set("old_text", api.ToolProperty{
|
||||
Type: api.PropertyType{"string"},
|
||||
Description: "Exact text for one targeted replacement. Must match the original file exactly once and must not overlap with any other edit's old_text.",
|
||||
})
|
||||
editProps.Set("new_text", api.ToolProperty{
|
||||
Type: api.PropertyType{"string"},
|
||||
Description: "Replacement text for this targeted edit.",
|
||||
})
|
||||
|
||||
props := api.NewToolPropertiesMap()
|
||||
props.Set("path", api.ToolProperty{
|
||||
Type: api.PropertyType{"string"},
|
||||
Description: "Path to the file to edit, relative to the working directory.",
|
||||
})
|
||||
props.Set("old_text", api.ToolProperty{
|
||||
Type: api.PropertyType{"string"},
|
||||
Description: "Exact text to replace.",
|
||||
})
|
||||
props.Set("new_text", api.ToolProperty{
|
||||
Type: api.PropertyType{"string"},
|
||||
Description: "Replacement text.",
|
||||
props.Set("edits", api.ToolProperty{
|
||||
Type: api.PropertyType{"array"},
|
||||
Items: api.ToolProperty{
|
||||
Type: api.PropertyType{"object"},
|
||||
Properties: editProps,
|
||||
Required: []string{"old_text", "new_text"},
|
||||
},
|
||||
Description: "One or more exact-text replacements. Each is matched against the original file, not against the output of earlier edits. Keep old_text as small as possible while still unique in the file; merge changes to the same or adjacent lines into a single edit.",
|
||||
})
|
||||
props.Set("replace_all", api.ToolProperty{
|
||||
Type: api.PropertyType{"boolean"},
|
||||
Description: "Replace every occurrence. Defaults to false and requires old_text to match exactly once.",
|
||||
Description: "Replace every occurrence. Defaults to false; only applies when a single edit is provided.",
|
||||
})
|
||||
return api.ToolFunction{
|
||||
Name: e.Name(),
|
||||
@@ -132,7 +146,7 @@ func (e *Edit) Schema() api.ToolFunction {
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: props,
|
||||
Required: []string{"path", "old_text", "new_text"},
|
||||
Required: []string{"path", "edits"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -148,18 +162,11 @@ func (e *Edit) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[
|
||||
return agent.ToolResult{}, fmt.Errorf("path parameter is required")
|
||||
}
|
||||
|
||||
oldText, ok := args["old_text"].(string)
|
||||
if !ok || oldText == "" {
|
||||
return agent.ToolResult{}, fmt.Errorf("old_text parameter is required")
|
||||
edits, replaceAll, err := parseEditArgs(args)
|
||||
if err != nil {
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
|
||||
newText, ok := args["new_text"].(string)
|
||||
if !ok {
|
||||
return agent.ToolResult{}, fmt.Errorf("new_text parameter is required")
|
||||
}
|
||||
|
||||
replaceAll, _ := args["replace_all"].(bool)
|
||||
|
||||
if err := rejectFinalSymlink(toolCtx.WorkingDir, path); err != nil {
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
@@ -188,19 +195,56 @@ func (e *Edit) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
content := string(contentBytes)
|
||||
matches := strings.Count(content, oldText)
|
||||
if matches == 0 {
|
||||
return agent.ToolResult{}, fmt.Errorf("old_text was not found in %s", path)
|
||||
}
|
||||
if matches > 1 && !replaceAll {
|
||||
return agent.ToolResult{}, fmt.Errorf("old_text matched %d times in %s; set replace_all to true to replace every match", matches, path)
|
||||
}
|
||||
|
||||
var updated string
|
||||
replacements := 0
|
||||
if replaceAll {
|
||||
updated = strings.ReplaceAll(content, oldText, newText)
|
||||
matches := strings.Count(content, edits[0].OldText)
|
||||
if matches == 0 {
|
||||
return agent.ToolResult{}, fmt.Errorf("old_text was not found in %s", path)
|
||||
}
|
||||
updated = strings.ReplaceAll(content, edits[0].OldText, edits[0].NewText)
|
||||
replacements = matches
|
||||
} else {
|
||||
updated = strings.Replace(content, oldText, newText, 1)
|
||||
// Every edit is matched against the original file content rather
|
||||
// than the output of earlier edits, so each edit must match exactly
|
||||
// once and edits must target disjoint regions.
|
||||
matched := make([]editMatch, 0, len(edits))
|
||||
for i, edit := range edits {
|
||||
count := strings.Count(content, edit.OldText)
|
||||
if count == 0 {
|
||||
return agent.ToolResult{}, editNotFoundError(path, i, len(edits))
|
||||
}
|
||||
if count > 1 {
|
||||
return agent.ToolResult{}, editAmbiguousError(path, i, len(edits), count)
|
||||
}
|
||||
matched = append(matched, editMatch{
|
||||
editIndex: i,
|
||||
offset: strings.Index(content, edit.OldText),
|
||||
length: len(edit.OldText),
|
||||
newText: edit.NewText,
|
||||
})
|
||||
replacements++
|
||||
}
|
||||
|
||||
slices.SortFunc(matched, func(a, b editMatch) int { return cmp.Compare(a.offset, b.offset) })
|
||||
for i := 1; i < len(matched); i++ {
|
||||
prev, cur := matched[i-1], matched[i]
|
||||
if prev.offset+prev.length > cur.offset {
|
||||
return agent.ToolResult{}, fmt.Errorf("edits[%d] and edits[%d] overlap in %s; merge them into one edit or target disjoint text", prev.editIndex, cur.editIndex, path)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply from the end of the file backwards so earlier offsets stay valid.
|
||||
updated = content
|
||||
for i := len(matched) - 1; i >= 0; i-- {
|
||||
m := matched[i]
|
||||
updated = updated[:m.offset] + m.newText + updated[m.offset+m.length:]
|
||||
}
|
||||
}
|
||||
|
||||
if updated == content {
|
||||
return agent.ToolResult{}, fmt.Errorf("edit produced no changes in %s; replacement text is identical to the original", path)
|
||||
}
|
||||
if len(updated) > maxReadBytes {
|
||||
return agent.ToolResult{}, fmt.Errorf("edited content is too large (%d bytes)", len(updated))
|
||||
@@ -210,7 +254,116 @@ func (e *Edit) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
|
||||
return agent.ToolResult{Content: fmt.Sprintf("Updated %s (%d replacement%s).", path, matches, plural(matches))}, nil
|
||||
return agent.ToolResult{Content: fmt.Sprintf("Updated %s (%d edit%s, %d replacement%s).", path, len(edits), plural(len(edits)), replacements, plural(replacements))}, nil
|
||||
}
|
||||
|
||||
// editReplacement is one targeted replacement within an edit call.
|
||||
type editReplacement struct {
|
||||
OldText string
|
||||
NewText string
|
||||
}
|
||||
|
||||
// editMatch locates one editReplacement within the original file content.
|
||||
type editMatch struct {
|
||||
editIndex int
|
||||
offset int
|
||||
length int
|
||||
newText string
|
||||
}
|
||||
|
||||
// parseEditArgs normalizes edit arguments from a tool call into a list of
|
||||
// replacements. It accepts the `edits` array form and tolerates legacy
|
||||
// top-level old_text/new_text args as well as stringified JSON, mirroring
|
||||
// the pi coding agent's argument handling.
|
||||
func parseEditArgs(args map[string]any) ([]editReplacement, bool, error) {
|
||||
replaceAll, _ := args["replace_all"].(bool)
|
||||
|
||||
var edits []editReplacement
|
||||
if raw, ok := args["edits"]; ok {
|
||||
parsed, err := parseEditArray(raw)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
edits = parsed
|
||||
}
|
||||
|
||||
// Fold a legacy top-level old_text/new_text pair into edits.
|
||||
if oldText, ok := args["old_text"].(string); ok {
|
||||
newText, ok := args["new_text"].(string)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("new_text parameter is required")
|
||||
}
|
||||
edits = append(edits, editReplacement{OldText: oldText, NewText: newText})
|
||||
}
|
||||
|
||||
if len(edits) == 0 {
|
||||
return nil, false, fmt.Errorf("edits parameter is required")
|
||||
}
|
||||
for i, edit := range edits {
|
||||
if edit.OldText == "" {
|
||||
if len(edits) == 1 {
|
||||
return nil, false, fmt.Errorf("old_text parameter is required")
|
||||
}
|
||||
return nil, false, fmt.Errorf("edits[%d].old_text must not be empty", i)
|
||||
}
|
||||
}
|
||||
if replaceAll && len(edits) != 1 {
|
||||
return nil, false, fmt.Errorf("replace_all only applies to a single edit")
|
||||
}
|
||||
return edits, replaceAll, nil
|
||||
}
|
||||
|
||||
func parseEditArray(raw any) ([]editReplacement, error) {
|
||||
if s, ok := raw.(string); ok {
|
||||
// Some models serialize array arguments as a JSON string.
|
||||
if err := json.Unmarshal([]byte(s), &raw); err != nil {
|
||||
return nil, fmt.Errorf("edits must be an array of {old_text, new_text} objects")
|
||||
}
|
||||
}
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("edits must be an array of {old_text, new_text} objects")
|
||||
}
|
||||
|
||||
edits := make([]editReplacement, 0, len(items))
|
||||
for i, item := range items {
|
||||
entry, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("edits[%d] must be an object with old_text and new_text", i)
|
||||
}
|
||||
oldText, oldOK := editTextArg(entry, "old_text", "oldText")
|
||||
newText, newOK := editTextArg(entry, "new_text", "newText")
|
||||
if !oldOK || !newOK {
|
||||
return nil, fmt.Errorf("edits[%d] must be an object with old_text and new_text", i)
|
||||
}
|
||||
edits = append(edits, editReplacement{OldText: oldText, NewText: newText})
|
||||
}
|
||||
return edits, nil
|
||||
}
|
||||
|
||||
// editTextArg reads the first present string key, tolerating both snake_case
|
||||
// and camelCase spellings that models emit.
|
||||
func editTextArg(entry map[string]any, keys ...string) (string, bool) {
|
||||
for _, key := range keys {
|
||||
if value, ok := entry[key].(string); ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func editNotFoundError(path string, editIndex, totalEdits int) error {
|
||||
if totalEdits == 1 {
|
||||
return fmt.Errorf("old_text was not found in %s", path)
|
||||
}
|
||||
return fmt.Errorf("edits[%d].old_text was not found in %s", editIndex, path)
|
||||
}
|
||||
|
||||
func editAmbiguousError(path string, editIndex, totalEdits, occurrences int) error {
|
||||
if totalEdits == 1 {
|
||||
return fmt.Errorf("old_text matched %d times in %s; set replace_all to true to replace every match", occurrences, path)
|
||||
}
|
||||
return fmt.Errorf("edits[%d].old_text matched %d times in %s; each edit must match exactly once, so provide more surrounding context", editIndex, occurrences, path)
|
||||
}
|
||||
|
||||
func cleanRelativePath(path string) (string, error) {
|
||||
|
||||
@@ -59,6 +59,239 @@ func TestEditRequiresUniqueMatchByDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditAppliesMultipleEdits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("alpha beta gamma delta\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"edits": []any{
|
||||
map[string]any{"old_text": "beta", "new_text": "BETA"},
|
||||
map[string]any{"old_text": "delta", "new_text": "DELTA"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(result.Content, "2 edits, 2 replacements") {
|
||||
t.Fatalf("result = %q", result.Content)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "alpha BETA gamma DELTA\n" {
|
||||
t.Fatalf("content = %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditMatchesEditsAgainstOriginalContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("abc def\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// edits[1] must target the original "def", not the one introduced by edits[0].
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"edits": []any{
|
||||
map[string]any{"old_text": "abc", "new_text": "def"},
|
||||
map[string]any{"old_text": "def", "new_text": "ghi"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "def ghi\n" {
|
||||
t.Fatalf("content = %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRejectsOverlappingEdits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("abc\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"edits": []any{
|
||||
map[string]any{"old_text": "ab", "new_text": "x"},
|
||||
map[string]any{"old_text": "bc", "new_text": "y"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected overlapping edits to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "overlap") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditMultipleEditsNotFoundIndexed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("hello world\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"edits": []any{
|
||||
map[string]any{"old_text": "hello", "new_text": "hi"},
|
||||
map[string]any{"old_text": "missing", "new_text": "x"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected missing edit to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "edits[1]") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditMultipleEditsAmbiguousIndexed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("hello same same\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"edits": []any{
|
||||
map[string]any{"old_text": "hello", "new_text": "hi"},
|
||||
map[string]any{"old_text": "same", "new_text": "x"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected ambiguous edit to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "edits[1]") || !strings.Contains(err.Error(), "matched 2 times") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRejectsEmptyEdits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for name, args := range map[string]map[string]any{
|
||||
"missing edits": {"path": "note.txt"},
|
||||
"empty edits": {"path": "note.txt", "edits": []any{}},
|
||||
} {
|
||||
if _, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, args); err == nil {
|
||||
t.Fatalf("%s: expected error", name)
|
||||
} else if !strings.Contains(err.Error(), "edits parameter is required") {
|
||||
t.Fatalf("%s: err = %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRejectsEmptyOldTextInArray(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"edits": []any{
|
||||
map[string]any{"old_text": "hello", "new_text": "hi"},
|
||||
map[string]any{"old_text": "", "new_text": "x"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected empty old_text to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "edits[1].old_text must not be empty") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditAcceptsJSONStringEdits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("hello world\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Some models serialize array arguments as a JSON string.
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"edits": `[{"oldText": "hello", "newText": "hi"}, {"oldText": "world", "newText": "earth"}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "hi earth\n" {
|
||||
t.Fatalf("content = %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRejectsReplaceAllWithMultipleEdits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("a b c\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"replace_all": true,
|
||||
"edits": []any{
|
||||
map[string]any{"old_text": "a", "new_text": "x"},
|
||||
map[string]any{"old_text": "b", "new_text": "y"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected replace_all with multiple edits to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "replace_all") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRejectsNoChange(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"old_text": "hello",
|
||||
"new_text": "hello",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected no-change edit to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no changes") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRejectsEscapingPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
|
||||
@@ -9,8 +9,9 @@ import (
|
||||
)
|
||||
|
||||
// Skill is the model-facing adapter for the core agent skill catalog.
|
||||
// It only supplies instructions; regular tools retain their own approval
|
||||
// requirements for filesystem or network access.
|
||||
// Model-initiated loads require approval because a skill's instructions can
|
||||
// influence the rest of the run. Explicit user activation is handled by the
|
||||
// session's synthetic skill call and bypasses this adapter.
|
||||
type Skill struct{ Catalog *agent.SkillCatalog }
|
||||
|
||||
func (t *Skill) Name() string { return "skill" }
|
||||
@@ -25,6 +26,8 @@ func (t *Skill) Schema() api.ToolFunction {
|
||||
return api.ToolFunction{Name: t.Name(), Description: t.Description(), Parameters: api.ToolFunctionParameters{Type: "object", Properties: props, Required: []string{"name"}}}
|
||||
}
|
||||
|
||||
func (t *Skill) RequiresApproval(map[string]any) bool { return true }
|
||||
|
||||
func (t *Skill) Execute(_ context.Context, _ agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||
name, ok := args["name"].(string)
|
||||
if !ok {
|
||||
|
||||
+138
-9
@@ -8,9 +8,117 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/agent"
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func TestSkillLoadsCoreCatalogWithoutApproval(t *testing.T) {
|
||||
func TestSkillLoadsCoreCatalogWithApproval(t *testing.T) {
|
||||
catalog := testSkillCatalog(t)
|
||||
tool := &Skill{Catalog: catalog}
|
||||
if !agent.ToolRequiresApproval(tool, map[string]any{"name": "release-notes"}) {
|
||||
t.Fatal("model-initiated skill loading should require approval")
|
||||
}
|
||||
result, err := tool.Execute(context.Background(), agent.ToolContext{}, map[string]any{"name": "release-notes"})
|
||||
if err != nil || !strings.Contains(result.Content, "Use concise bullets.") {
|
||||
t.Fatalf("tool result = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelSkillLoadRequiresApproval(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
approval agent.Approval
|
||||
prompt bool
|
||||
wantCalls int
|
||||
wantPrompts int
|
||||
wantResult string
|
||||
}{
|
||||
{name: "rejected", approval: agent.Approval{Reason: "Skill loading denied."}, prompt: true, wantCalls: 1, wantPrompts: 1, wantResult: "Skill loading denied."},
|
||||
{name: "approved", approval: agent.Approval{Allow: true}, prompt: true, wantCalls: 2, wantPrompts: 1, wantResult: "Use concise bullets."},
|
||||
{name: "headless denied", wantCalls: 1, wantResult: "Tool execution requires approval"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
catalog := testSkillCatalog(t)
|
||||
args := api.NewToolCallFunctionArguments()
|
||||
args.Set("name", "release-notes")
|
||||
client := &skillTestClient{responses: [][]api.ChatResponse{
|
||||
{{Message: api.Message{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
ID: "call_skill_1",
|
||||
Function: api.ToolCallFunction{Name: "skill", Arguments: args},
|
||||
}}}}},
|
||||
{{Message: api.Message{Role: "assistant", Content: "done"}}},
|
||||
}}
|
||||
var prompter *skillApprovalPrompter
|
||||
var approvalPrompter agent.ApprovalPrompter
|
||||
if tt.prompt {
|
||||
prompter = &skillApprovalPrompter{result: tt.approval}
|
||||
approvalPrompter = prompter
|
||||
}
|
||||
registry := &agent.Registry{}
|
||||
registry.Register(&Skill{Catalog: catalog})
|
||||
|
||||
result, err := (&agent.Session{
|
||||
Client: client,
|
||||
Tools: registry,
|
||||
ApprovalPrompter: approvalPrompter,
|
||||
}).Run(context.Background(), agent.RunOptions{
|
||||
Model: "test",
|
||||
NewMessages: []api.Message{{Role: "user", Content: "load the release-notes skill"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tt.prompt {
|
||||
if got := len(prompter.requests); got != tt.wantPrompts {
|
||||
t.Fatalf("approval prompts = %d, want %d", got, tt.wantPrompts)
|
||||
}
|
||||
request := prompter.requests[0]
|
||||
if len(request.Calls) != 1 || request.Calls[0].ToolName != "skill" || request.Calls[0].ApprovalScope != "skill" || request.Calls[0].Args["name"] != "release-notes" {
|
||||
t.Fatalf("approval request = %#v", request)
|
||||
}
|
||||
}
|
||||
if got := client.calls; got != tt.wantCalls {
|
||||
t.Fatalf("model calls = %d, want %d", got, tt.wantCalls)
|
||||
}
|
||||
var toolResult string
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == "tool" && message.ToolCallID == "call_skill_1" {
|
||||
toolResult = message.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(toolResult, tt.wantResult) {
|
||||
t.Fatalf("skill tool result = %q, want it to contain %q", toolResult, tt.wantResult)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitSkillActivationBypassesApproval(t *testing.T) {
|
||||
catalog := testSkillCatalog(t)
|
||||
client := &skillTestClient{responses: [][]api.ChatResponse{{{Message: api.Message{Role: "assistant", Content: "done"}}}}}
|
||||
prompter := &skillApprovalPrompter{result: agent.Approval{}}
|
||||
result, err := (&agent.Session{
|
||||
Client: client,
|
||||
Skills: catalog,
|
||||
ApprovalPrompter: prompter,
|
||||
}).Run(context.Background(), agent.RunOptions{
|
||||
Model: "test",
|
||||
NewMessages: []api.Message{{Role: "user", Content: "draft release notes"}},
|
||||
SkillName: "release-notes",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(prompter.requests) != 0 {
|
||||
t.Fatalf("explicit activation prompted for approval: %#v", prompter.requests)
|
||||
}
|
||||
if len(result.Messages) != 4 || result.Messages[2].ToolName != "skill" || !strings.Contains(result.Messages[2].Content, "Use concise bullets.") {
|
||||
t.Fatalf("synthetic skill activation = %#v", result.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func testSkillCatalog(t *testing.T) *agent.SkillCatalog {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "release-notes")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
@@ -23,12 +131,33 @@ func TestSkillLoadsCoreCatalogWithoutApproval(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tool := &Skill{Catalog: catalog}
|
||||
if agent.ToolRequiresApproval(tool, map[string]any{"name": "release-notes"}) {
|
||||
t.Fatal("loading a skill must not change ordinary tool approval semantics")
|
||||
}
|
||||
result, err := tool.Execute(context.Background(), agent.ToolContext{}, map[string]any{"name": "release-notes"})
|
||||
if err != nil || !strings.Contains(result.Content, "Use concise bullets.") {
|
||||
t.Fatalf("tool result = %#v, %v", result, err)
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
|
||||
type skillTestClient struct {
|
||||
responses [][]api.ChatResponse
|
||||
calls int
|
||||
}
|
||||
|
||||
func (c *skillTestClient) Chat(_ context.Context, _ *api.ChatRequest, fn api.ChatResponseFunc) error {
|
||||
if c.calls >= len(c.responses) {
|
||||
return nil
|
||||
}
|
||||
for _, response := range c.responses[c.calls] {
|
||||
if err := fn(response); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c.calls++
|
||||
return nil
|
||||
}
|
||||
|
||||
type skillApprovalPrompter struct {
|
||||
requests []agent.ApprovalRequest
|
||||
result agent.Approval
|
||||
}
|
||||
|
||||
func (p *skillApprovalPrompter) PromptApproval(_ context.Context, request agent.ApprovalRequest) (agent.Approval, error) {
|
||||
p.requests = append(p.requests, request)
|
||||
return p.result, nil
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -388,6 +389,64 @@ func TestClientWebSearchExperimentalUsesLocalRoute(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientWebSearchExperimentalErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
assertError func(*testing.T, error)
|
||||
}{
|
||||
{
|
||||
name: "unauthorized retains sign in URL",
|
||||
status: http.StatusUnauthorized,
|
||||
body: `{"error":"unauthorized","signin_url":"https://ollama.com/signin/example"}`,
|
||||
assertError: func(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
var authErr AuthorizationError
|
||||
if !errors.As(err, &authErr) {
|
||||
t.Fatalf("error = %T, want AuthorizationError", err)
|
||||
}
|
||||
if authErr.StatusCode != http.StatusUnauthorized || authErr.SigninURL != "https://ollama.com/signin/example" {
|
||||
t.Fatalf("authorization error = %#v", authErr)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rate limit retains status",
|
||||
status: http.StatusTooManyRequests,
|
||||
body: `{"error":"rate limit exceeded"}`,
|
||||
assertError: func(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
var statusErr StatusError
|
||||
if !errors.As(err, &statusErr) {
|
||||
t.Fatalf("error = %T, want StatusError", err)
|
||||
}
|
||||
if statusErr.StatusCode != http.StatusTooManyRequests || statusErr.ErrorMessage != "rate limit exceeded" {
|
||||
t.Fatalf("status error = %#v", statusErr)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(tt.status)
|
||||
_, _ = w.Write([]byte(tt.body))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := NewClient(&url.URL{Scheme: "http", Host: ts.Listener.Addr().String()}, http.DefaultClient)
|
||||
_, err := client.WebSearchExperimental(t.Context(), &WebSearchRequest{Query: "ollama"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
tt.assertError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientWebFetchExperimentalUsesLocalRoute(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotMethod string
|
||||
|
||||
+5
-30
@@ -127,20 +127,6 @@ type GenerateRequest struct {
|
||||
// each with an associated log probability. Only applies when Logprobs is true.
|
||||
// Valid values are 0-20. Default is 0 (only return the selected token's logprob).
|
||||
TopLogprobs int `json:"top_logprobs,omitempty"`
|
||||
|
||||
// Experimental: Image generation fields (may change or be removed)
|
||||
|
||||
// Width is the width of the generated image in pixels.
|
||||
// Only used for image generation models.
|
||||
Width int32 `json:"width,omitempty"`
|
||||
|
||||
// Height is the height of the generated image in pixels.
|
||||
// Only used for image generation models.
|
||||
Height int32 `json:"height,omitempty"`
|
||||
|
||||
// Steps is the number of diffusion steps for image generation.
|
||||
// Only used for image generation models.
|
||||
Steps int32 `json:"steps,omitempty"`
|
||||
}
|
||||
|
||||
// ChatRequest describes a request sent by [Client.Chat].
|
||||
@@ -706,8 +692,11 @@ type CreateRequest struct {
|
||||
// Messages is a list of messages added to the model before chat and generation requests.
|
||||
Messages []Message `json:"messages,omitempty"`
|
||||
|
||||
// Renderer is the name of the renderer used when constructing a request to the model.
|
||||
Renderer string `json:"renderer,omitempty"`
|
||||
Parser string `json:"parser,omitempty"`
|
||||
|
||||
// Parser is the name of the parser used to parse the output of the request.
|
||||
Parser string `json:"parser,omitempty"`
|
||||
|
||||
// Requires is the minimum version of Ollama required by the model.
|
||||
Requires string `json:"requires,omitempty"`
|
||||
@@ -938,20 +927,6 @@ type GenerateResponse struct {
|
||||
// Logprobs contains log probability information for the generated tokens,
|
||||
// if requested via the Logprobs parameter.
|
||||
Logprobs []Logprob `json:"logprobs,omitempty"`
|
||||
|
||||
// Experimental: Image generation fields (may change or be removed)
|
||||
|
||||
// Image contains a base64-encoded generated image.
|
||||
// Only present for image generation models.
|
||||
Image string `json:"image,omitempty"`
|
||||
|
||||
// Completed is the number of completed steps in image generation.
|
||||
// Only present for image generation models during streaming.
|
||||
Completed int64 `json:"completed,omitempty"`
|
||||
|
||||
// Total is the total number of steps for image generation.
|
||||
// Only present for image generation models during streaming.
|
||||
Total int64 `json:"total,omitempty"`
|
||||
}
|
||||
|
||||
// ModelDetails provides details about a model.
|
||||
@@ -1130,7 +1105,7 @@ func DefaultOptions() Options {
|
||||
TopP: 0.9,
|
||||
TypicalP: 1.0,
|
||||
RepeatLastN: 64,
|
||||
RepeatPenalty: 1.1,
|
||||
RepeatPenalty: 1.0,
|
||||
PresencePenalty: 0.0,
|
||||
FrequencyPenalty: 0.0,
|
||||
Seed: -1,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">
|
||||
<style>@media (prefers-color-scheme: dark) { path { fill: #fff; } }</style>
|
||||
<path d="M48.8354 10.0479C48.3232 9.79199 48.1025 10.2798 47.8032 10.5278C46.7793 11.624 45.9048 12.1597 44.7622 12.0957C43.0923 12 41.666 12.5356 40.4058 13.8398C40.1377 12.2319 39.2476 11.272 37.8926 10.6558C36.4668 10.0156 35.9702 9.31982 35.356 7.72754C35.2456 7.3999 35.1353 7.06396 34.7651 7.00781C34.3633 6.94385 34.2056 7.2876 34.0479 7.57568C33.418 8.75195 33.1733 10.0479 33.1973 11.3599C33.2524 14.312 34.4736 16.6641 36.8999 18.3359C37.1758 18.5278 37.2466 18.7197 37.1597 19C36.9946 19.5757 36.7974 20.1357 36.624 20.7119C36.5137 21.0801 36.3486 21.1597 35.9624 21C32.4092 19.4878 30.0381 16.2319 27.2334 13.52C26.7764 13.1758 26.3193 12.856 25.8467 12.5518C23.8618 10.584 26.1069 8.96777 26.627 8.77588C27.1704 8.57568 26.8159 7.8877 25.0591 7.896C22.8691 7.90381 20.4507 9.06396 18.7095 9.58398C16.8501 9.22363 14.9199 9.14355 12.9033 9.37598C5.30859 10.2397 1.15674 16.4717 1.30664 27.2559C2.11768 31.9521 4.46582 35.8398 8.07373 38.8799C11.8159 42.0322 16.1255 43.5762 21.041 43.2803C24.0269 43.104 27.3516 42.6963 31.1016 39.4561C33.0396 40.1279 37.1758 40.208 38.1211 40.0078C39.6021 39.688 39.4995 38.2881 38.9639 38.0322C34.623 35.9678 35.5762 36.8081 34.71 36.1279C36.9155 33.4639 40.2402 30.6958 41.54 21.728C41.6426 21.0161 41.5557 20.5679 41.54 19.9917C41.5322 19.6396 41.6108 19.5039 42.0049 19.4639C46.6924 18.9116 49.064 15.9038 49.3315 11.2559C49.3711 10.7837 49.3237 10.2959 48.8354 10.0479ZM24.3262 37.8398C20.1196 34.4639 18.0791 33.3521 17.2358 33.3999C16.4482 33.4482 16.5898 34.3682 16.7632 34.9678C16.9443 35.5601 17.1812 35.9683 17.5117 36.4878C17.7402 36.832 17.8979 37.3442 17.2832 37.728C15.9282 38.584 13.5728 37.4399 13.4624 37.3838C7.97949 34.0879 4.48926 28.9282 4.19775 21.3677C4.1582 20.5757 4.38672 20.2959 5.15869 20.1519C11.8945 18.8799 17.165 22.0879 19.2529 25.7759C23.5381 30.104 25.335 35.1523 30.479 39.104C28.8643 39.2881 26.1699 39.3281 24.3262 37.8398ZM26.3433 24.6001C26.3433 24.248 26.6191 23.9678 26.9658 23.9678C27.3042 23.9678 27.5801 24.248 27.5801 24.6001C27.5801 24.9521 27.3042 25.2319 26.9575 25.2319C26.6108 25.2319 26.3433 24.9521 26.3433 24.6001ZM32.6064 27.8799C31.6372 28.2881 30.6289 28.3042 29.8096 27.688C28.6987 26.8555 28.6279 25.7759 28.7305 24.9199C28.8721 24.248 28.7144 23.8159 28.2495 23.4238C27.8716 23.104 27.3911 23.0161 26.8633 23.0161C26.666 23.0161 26.4849 22.9277 26.3511 22.856C25.8467 22.5762 25.9805 22.1758 26.5088 21.688C28.0996 20.7598 29.6362 21.9917 30.834 23.3281C31.6216 24.2559 32.8901 26.312 33.1104 26.9521C33.2446 27.3521 33.0713 27.6802 32.6064 27.8799Z" fill="#000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -77,6 +77,14 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [
|
||||
description: "Factory's coding agent across terminal and IDEs",
|
||||
icon: "/launch-icons/droid.svg",
|
||||
},
|
||||
{
|
||||
id: "dsh",
|
||||
name: "DeepSeek Harness",
|
||||
command: "ollama launch dsh",
|
||||
description: "DeepSeek's open-source agent harness",
|
||||
icon: "/launch-icons/deepseek-harness.svg",
|
||||
iconClassName: "h-7 w-7",
|
||||
},
|
||||
{
|
||||
id: "pi",
|
||||
name: "Pi",
|
||||
|
||||
@@ -153,10 +153,12 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
|
||||
return err
|
||||
}
|
||||
|
||||
// In case of slow downloads, continue the update check in the background
|
||||
// In case of slow downloads, continue the update check in the background.
|
||||
// Drain the goroutine before returning: it reads package-level knobs
|
||||
// (e.g. UpdateCheckInterval), which callers may mutate once we return.
|
||||
bgctx, bgcancel := context.WithCancel(downloadCtx)
|
||||
defer bgcancel()
|
||||
go func() {
|
||||
var bgwg sync.WaitGroup
|
||||
bgwg.Go(func() {
|
||||
for {
|
||||
select {
|
||||
case <-bgctx.Done():
|
||||
@@ -165,6 +167,10 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
|
||||
u.checkForUpdate(bgctx)
|
||||
}
|
||||
}
|
||||
})
|
||||
defer func() {
|
||||
bgcancel()
|
||||
bgwg.Wait()
|
||||
}()
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
|
||||
@@ -190,6 +190,23 @@ func TestDownloadNewReleaseDoesNotUseRawETagAsPathComponent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// waitDownloadIdle blocks until no download is in flight, so staged-file
|
||||
// handles close before t.TempDir cleanup removes the stage directory. After
|
||||
// the context is cancelled a new download can't write (it aborts at the HEAD
|
||||
// request), so reaching idle makes cleanup race-free.
|
||||
func (u *Updater) waitDownloadIdle() {
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
u.cancelDownloadLock.Lock()
|
||||
idle := u.cancelDownload == nil
|
||||
u.cancelDownloadLock.Unlock()
|
||||
if idle {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackgroundCheckerSkipsAlreadyStagedETagDownload(t *testing.T) {
|
||||
UpdateStageDir = t.TempDir()
|
||||
oldInstaller := Installer
|
||||
@@ -276,6 +293,7 @@ func TestBackgroundCheckerSkipsAlreadyStagedETagDownload(t *testing.T) {
|
||||
callbacks <- ver
|
||||
return nil
|
||||
})
|
||||
t.Cleanup(updater.waitDownloadIdle)
|
||||
|
||||
for range 2 {
|
||||
select {
|
||||
@@ -364,6 +382,7 @@ func TestBackgoundChecker(t *testing.T) {
|
||||
}
|
||||
|
||||
updater.StartBackgroundUpdaterChecker(ctx, cb)
|
||||
t.Cleanup(updater.waitDownloadIdle)
|
||||
select {
|
||||
case <-stallTimer.C:
|
||||
t.Fatal("stalled")
|
||||
@@ -426,6 +445,7 @@ func TestAutoUpdateDisabledSkipsDownload(t *testing.T) {
|
||||
}
|
||||
|
||||
updater.StartBackgroundUpdaterChecker(ctx, cb)
|
||||
t.Cleanup(updater.waitDownloadIdle)
|
||||
|
||||
// Wait enough time for multiple check cycles
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
@@ -488,6 +508,7 @@ func TestAutoUpdateReenabledDownloadsUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
upd.StartBackgroundUpdaterChecker(ctx, cb)
|
||||
t.Cleanup(upd.waitDownloadIdle)
|
||||
|
||||
// Wait for a few cycles with auto-update disabled - no download should happen
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
@@ -556,7 +577,9 @@ func TestCancelOngoingDownload(t *testing.T) {
|
||||
_, resp := updater.checkForUpdate(ctx)
|
||||
|
||||
// Start download in goroutine
|
||||
downloadDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(downloadDone)
|
||||
_ = updater.DownloadNewRelease(ctx, resp)
|
||||
}()
|
||||
|
||||
@@ -577,6 +600,10 @@ func TestCancelOngoingDownload(t *testing.T) {
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("download cancellation was not received by server")
|
||||
}
|
||||
|
||||
// Wait for the download goroutine to unwind: it drags along a background
|
||||
// update-check loop that reads package-level knobs the next test rewrites.
|
||||
<-downloadDone
|
||||
}
|
||||
|
||||
func TestTriggerImmediateCheck(t *testing.T) {
|
||||
@@ -615,6 +642,7 @@ func TestTriggerImmediateCheck(t *testing.T) {
|
||||
}
|
||||
|
||||
updater.StartBackgroundUpdaterChecker(ctx, cb)
|
||||
t.Cleanup(updater.waitDownloadIdle)
|
||||
|
||||
// Wait for the initial check that fires after the initial delay
|
||||
select {
|
||||
|
||||
+12
-8
@@ -191,7 +191,18 @@ if(OLLAMA_MLX_BACKENDS)
|
||||
USES_TERMINAL_DOWNLOAD TRUE)
|
||||
list(APPEND _mlx_source_targets ollama-mlx-c-source)
|
||||
endif()
|
||||
add_custom_target(ollama-mlx-sources DEPENDS ${_mlx_source_targets})
|
||||
# Refresh the vendored MLX-C headers once the sources are present. Every MLX
|
||||
# backend variant shares this destination in the source tree, so the copy has
|
||||
# to happen here rather than in each variant's build.
|
||||
add_custom_target(ollama-mlx-vendor-headers
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DMLX_C_HEADERS_DIR=${OLLAMA_MLX_C_SOURCE_DIR}/mlx/c
|
||||
-DMLX_C_HEADERS_DEST=${CMAKE_SOURCE_DIR}/x/mlxrunner/mlx/include/mlx/c
|
||||
-P "${CMAKE_SOURCE_DIR}/cmake/vendor-mlx-c-headers.cmake"
|
||||
DEPENDS ${_mlx_source_targets}
|
||||
COMMENT "Vendoring MLX-C headers"
|
||||
VERBATIM)
|
||||
add_custom_target(ollama-mlx-sources DEPENDS ollama-mlx-vendor-headers)
|
||||
endif()
|
||||
|
||||
set(OLLAMA_BUILD_PARALLEL "" CACHE STRING
|
||||
@@ -528,15 +539,8 @@ endfunction()
|
||||
find_program(GO_EXECUTABLE go)
|
||||
|
||||
if(OLLAMA_MLX_BACKENDS)
|
||||
set(_mlx_c_headers_dir "${OLLAMA_MLX_C_SOURCE_DIR}/mlx/c")
|
||||
set(_mlx_c_headers_dest "${CMAKE_SOURCE_DIR}/x/mlxrunner/mlx/include/mlx/c")
|
||||
|
||||
if(GO_EXECUTABLE AND (NOT APPLE OR CMAKE_SYSTEM_PROCESSOR STREQUAL CMAKE_HOST_SYSTEM_PROCESSOR))
|
||||
add_custom_target(ollama-mlx-generate-wrappers
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DMLX_C_HEADERS_DIR=${_mlx_c_headers_dir}
|
||||
-DMLX_C_HEADERS_DEST=${_mlx_c_headers_dest}
|
||||
-P "${CMAKE_SOURCE_DIR}/cmake/vendor-mlx-c-headers.cmake"
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
CC= CGO_CFLAGS= CGO_CXXFLAGS=
|
||||
${GO_EXECUTABLE} generate ./x/...
|
||||
|
||||
@@ -50,7 +50,12 @@ endif()
|
||||
option(OLLAMA_MLX_GENERATE_WRAPPERS "Regenerate MLX Go wrappers" OFF)
|
||||
|
||||
message(STATUS "Setting up MLX (this takes a while...)")
|
||||
add_subdirectory(${OLLAMA_SOURCE_DIR}/x/imagegen/mlx ${CMAKE_BINARY_DIR}/x/imagegen/mlx)
|
||||
foreach(_cudnn_var CUDNN_INCLUDE_PATH CUDNN_LIBRARY_PATH)
|
||||
if((NOT DEFINED ${_cudnn_var} OR "${${_cudnn_var}}" STREQUAL "") AND DEFINED ENV{${_cudnn_var}})
|
||||
set(${_cudnn_var} "$ENV{${_cudnn_var}}" CACHE PATH "")
|
||||
endif()
|
||||
endforeach()
|
||||
add_subdirectory(${OLLAMA_SOURCE_DIR}/x/mlxrunner/mlx ${CMAKE_BINARY_DIR}/x/mlxrunner/mlx)
|
||||
|
||||
# Find CUDA toolkit if MLX is built with CUDA support.
|
||||
find_package(CUDAToolkit)
|
||||
@@ -65,6 +70,7 @@ elseif(DEFINED ENV{CUDNN_ROOT_DIR})
|
||||
set(_cudnn_root "$ENV{CUDNN_ROOT_DIR}")
|
||||
endif()
|
||||
if(_cudnn_root)
|
||||
file(TO_CMAKE_PATH "${_cudnn_root}" _cudnn_root)
|
||||
# cuDNN 9.x has versioned subdirectories under bin/ (e.g., bin/13.0/).
|
||||
file(GLOB CUDNN_BIN_SUBDIRS "${_cudnn_root}/bin/*")
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CUDNN_BIN_SUBDIRS})
|
||||
@@ -293,9 +299,10 @@ endif()
|
||||
# RUNTIME_DEPENDENCIES auto-excludes it via POST_EXCLUDE_FILES_STRICT because
|
||||
# dlfcn-win32 is a known CMake target with its own install rules (which install
|
||||
# to the wrong destination). We must install it explicitly here.
|
||||
if(WIN32)
|
||||
install(FILES ${OLLAMA_BUILD_DIR}/dl.dll
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
if(WIN32 AND TARGET dl)
|
||||
install(TARGETS dl
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
|
||||
@@ -309,7 +316,25 @@ if(CUDAToolkit_FOUND)
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc-builtins.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcufft.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcudnn.so*")
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcudnn*.so*")
|
||||
if(WIN32)
|
||||
file(GLOB MLX_CUDA_DLLS
|
||||
"${CUDAToolkit_BIN_DIR}/nvrtc-builtins64_*.dll"
|
||||
"${CUDAToolkit_BIN_DIR}/x64/nvrtc-builtins64_*.dll")
|
||||
list(APPEND MLX_CUDA_LIBS ${MLX_CUDA_DLLS})
|
||||
endif()
|
||||
find_library(MLX_CUDNN_LIBRARY NAMES cudnn HINTS "$ENV{CUDNN_LIBRARY_PATH}")
|
||||
if(MLX_CUDNN_LIBRARY)
|
||||
get_filename_component(MLX_CUDNN_LIBRARY_DIR "${MLX_CUDNN_LIBRARY}" DIRECTORY)
|
||||
file(GLOB MLX_CUDNN_LIBS "${MLX_CUDNN_LIBRARY_DIR}/libcudnn*.so*")
|
||||
list(APPEND MLX_CUDA_LIBS ${MLX_CUDNN_LIBS})
|
||||
endif()
|
||||
if(WIN32 AND _cudnn_root)
|
||||
file(GLOB MLX_CUDNN_DLLS
|
||||
"${_cudnn_root}/bin/${CUDAToolkit_VERSION_MAJOR}.0/cudnn*.dll"
|
||||
"${_cudnn_root}/bin/x64/cudnn*.dll")
|
||||
list(APPEND MLX_CUDA_LIBS ${MLX_CUDNN_DLLS})
|
||||
endif()
|
||||
if(MLX_CUDA_LIBS)
|
||||
install(FILES ${MLX_CUDA_LIBS}
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"inherits": [ "default" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_FLAGS": "-t 2",
|
||||
"MLX_BUILD_CUDA": "ON",
|
||||
"MLX_BUILD_METAL": "OFF",
|
||||
"OLLAMA_RUNNER_DIR": "mlx_cuda_v13"
|
||||
}
|
||||
},
|
||||
|
||||
+30
-239
@@ -28,7 +28,6 @@ import (
|
||||
|
||||
type agentTUIOptions struct {
|
||||
Model string
|
||||
OpenModelPicker bool
|
||||
System string
|
||||
Format string
|
||||
Options map[string]any
|
||||
@@ -40,142 +39,6 @@ type agentTUIOptions struct {
|
||||
MultiModal bool
|
||||
}
|
||||
|
||||
func registerAgentFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().String("model", "", "Model to use")
|
||||
cmd.Flags().String("keepalive", "", "Duration to keep a model loaded (e.g. 5m)")
|
||||
cmd.Flags().String("format", "", "Response format (e.g. json)")
|
||||
cmd.Flags().String("think", "", "Enable thinking mode: true/false or high/medium/low for supported models")
|
||||
cmd.Flags().Lookup("think").NoOptDefVal = "true"
|
||||
cmd.Flags().Bool("auto-approve-tools", false, "Allow agent tools to run without prompting")
|
||||
cmd.Flags().Bool("yolo", false, "Alias for --auto-approve-tools")
|
||||
cmd.Flags().Bool("no-tools", false, "Disable agent tools")
|
||||
}
|
||||
|
||||
func AgentHandler(cmd *cobra.Command, _ []string) error {
|
||||
opts := agentTUIOptions{
|
||||
Model: strings.TrimSpace(config.LastModel()),
|
||||
Options: map[string]any{},
|
||||
}
|
||||
thinkExplicit, err := applyAgentFlags(cmd, &opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(opts.Model) == "" {
|
||||
opts.OpenModelPicker = true
|
||||
} else if cmd.Flags().Lookup("model") == nil || !cmd.Flags().Lookup("model").Changed {
|
||||
opts.OpenModelPicker = true
|
||||
}
|
||||
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.OpenModelPicker {
|
||||
modelName, err := selectAgentModel(cmd.Context(), client, opts.Model)
|
||||
if errors.Is(err, launch.ErrCancelled) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Model = modelName
|
||||
opts.OpenModelPicker = false
|
||||
}
|
||||
|
||||
if strings.TrimSpace(opts.Model) != "" {
|
||||
info, err := prepareAgentModel(cmd, client, &opts, thinkExplicit)
|
||||
if err != nil {
|
||||
if handleCloudAuthorizationError(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
opts.System = info.System
|
||||
if err := saveLastAgentModel(opts.Model); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := GenerateAgentTUI(cmd, client, opts); err != nil {
|
||||
if handleCloudAuthorizationError(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("error running agent: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyAgentFlags(cmd *cobra.Command, opts *agentTUIOptions) (bool, error) {
|
||||
if flag := cmd.Flags().Lookup("model"); flag != nil && flag.Changed {
|
||||
modelName, err := cmd.Flags().GetString("model")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
modelName = strings.TrimSpace(modelName)
|
||||
if modelName == "" {
|
||||
return false, errors.New("--model cannot be empty")
|
||||
}
|
||||
opts.Model = modelName
|
||||
opts.OpenModelPicker = false
|
||||
}
|
||||
|
||||
format, err := cmd.Flags().GetString("format")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
opts.Format = format
|
||||
|
||||
thinkExplicit := false
|
||||
thinkFlag := cmd.Flags().Lookup("think")
|
||||
if thinkFlag != nil && thinkFlag.Changed {
|
||||
thinkExplicit = true
|
||||
thinkStr, err := cmd.Flags().GetString("think")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch thinkStr {
|
||||
case "", "true":
|
||||
opts.Think = &api.ThinkValue{Value: true}
|
||||
case "false":
|
||||
opts.Think = &api.ThinkValue{Value: false}
|
||||
case "high", "medium", "low", "max":
|
||||
opts.Think = &api.ThinkValue{Value: thinkStr}
|
||||
default:
|
||||
return false, fmt.Errorf("invalid value for --think: %q (must be true, false, high, medium, low, or max)", thinkStr)
|
||||
}
|
||||
}
|
||||
|
||||
keepAlive, err := cmd.Flags().GetString("keepalive")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if keepAlive != "" {
|
||||
d, err := time.ParseDuration(keepAlive)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
opts.KeepAlive = &api.Duration{Duration: d}
|
||||
}
|
||||
|
||||
autoApprove, err := cmd.Flags().GetBool("auto-approve-tools")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
yolo, err := cmd.Flags().GetBool("yolo")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
opts.AllowAllTools = autoApprove || yolo
|
||||
toolsDisabled, err := cmd.Flags().GetBool("no-tools")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
opts.ToolsDisabled = toolsDisabled
|
||||
return thinkExplicit, nil
|
||||
}
|
||||
|
||||
func saveLastAgentModel(model string) error {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
@@ -185,24 +48,16 @@ func saveLastAgentModel(model string) error {
|
||||
}
|
||||
|
||||
func prepareAgentModel(cmd *cobra.Command, client *api.Client, opts *agentTUIOptions, thinkExplicit bool) (*api.ShowResponse, error) {
|
||||
requestedCloud := modelref.HasExplicitCloudSource(opts.Model)
|
||||
info, err := func() (*api.ShowResponse, error) {
|
||||
info, err := client.Show(cmd.Context(), &api.ShowRequest{Model: opts.Model})
|
||||
var se api.StatusError
|
||||
if errors.As(err, &se) && se.StatusCode == http.StatusNotFound {
|
||||
if requestedCloud {
|
||||
return nil, err
|
||||
}
|
||||
if err := PullHandler(cmd, []string{opts.Model}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.Show(cmd.Context(), &api.ShowRequest{Model: opts.Model})
|
||||
}
|
||||
return info, err
|
||||
}()
|
||||
// Unlike `ollama run`, the bare `ollama` root command doesn't define
|
||||
// --insecure, so GetBool would error; treat it as false.
|
||||
insecure, _ := cmd.Flags().GetBool("insecure")
|
||||
info, resolved, err := showOrPullModel(cmd, client, opts.Model, insecure, "run")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The model may have been resolved to a different name (e.g. its
|
||||
// ":cloud" variant).
|
||||
opts.Model = resolved
|
||||
|
||||
ensureCloudStub(cmd.Context(), client, opts.Model)
|
||||
opts.Think, err = inferThinkingOption(&info.Capabilities, &runOptions{Model: opts.Model, Think: opts.Think}, thinkExplicit)
|
||||
@@ -220,12 +75,23 @@ func GenerateAgentTUI(cmd *cobra.Command, client *api.Client, opts agentTUIOptio
|
||||
return agentContextWindowForModel(ctx, client, model, fallback)
|
||||
}
|
||||
|
||||
skillCatalog, err := coreagent.LoadDefaultSkills(cwd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load agent skills: %w", err)
|
||||
var skillCatalog *coreagent.SkillCatalog
|
||||
reloadSkills := func() (*coreagent.SkillCatalog, error) {
|
||||
catalog, err := coreagent.LoadDefaultSkills(cwd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ignored := catalog.ExcludeNames(agentchat.BuiltinSlashCommandNames()); len(ignored) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m ignoring agent skill(s): %s\n", strings.Join(ignored, ", "))
|
||||
}
|
||||
for _, diagnostic := range catalog.Diagnostics() {
|
||||
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m ignored invalid agent skill: %v\n", diagnostic)
|
||||
}
|
||||
skillCatalog = catalog
|
||||
return catalog, nil
|
||||
}
|
||||
for _, diagnostic := range skillCatalog.Diagnostics() {
|
||||
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m ignored invalid agent skill: %v\n", diagnostic)
|
||||
if _, err := reloadSkills(); err != nil {
|
||||
return fmt.Errorf("load agent skills: %w", err)
|
||||
}
|
||||
var registry *coreagent.Registry
|
||||
registryForModel := func(ctx context.Context, model string) *coreagent.Registry {
|
||||
@@ -236,7 +102,7 @@ func GenerateAgentTUI(cmd *cobra.Command, client *api.Client, opts agentTUIOptio
|
||||
}
|
||||
systemPrompt := agentSystemPromptWithWorkingDir(opts.Model, opts.System, agentSkillSystemContext(skillCatalog, registry, opts.ToolsDisabled), cwd)
|
||||
|
||||
_, err = agentchat.Run(cmd.Context(), agentchat.Options{
|
||||
_, err := agentchat.Run(cmd.Context(), agentchat.Options{
|
||||
Model: opts.Model,
|
||||
Client: client,
|
||||
Tools: registry,
|
||||
@@ -255,6 +121,8 @@ func GenerateAgentTUI(cmd *cobra.Command, client *api.Client, opts agentTUIOptio
|
||||
return agentSystemPromptWithWorkingDir(model, agentSystemFromShow(ctx, client, model), agentSkillSystemContext(skillCatalog, registry, toolsDisabled), cwd)
|
||||
},
|
||||
Skills: skillCatalog,
|
||||
ImportSkills: coreagent.ImportSkills,
|
||||
ReloadSkills: reloadSkills,
|
||||
SystemPrompt: systemPrompt,
|
||||
WorkingDir: cwd,
|
||||
Format: opts.Format,
|
||||
@@ -302,32 +170,12 @@ func agentSkillSystemContext(catalog *coreagent.SkillCatalog, registry *coreagen
|
||||
return catalog.SystemContext()
|
||||
}
|
||||
|
||||
func selectAgentModel(ctx context.Context, client *api.Client, current string) (string, error) {
|
||||
models, err := agentModelOptions(ctx, client)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(models) == 0 {
|
||||
return "", errors.New("no models available, run 'ollama pull <model>' first")
|
||||
}
|
||||
|
||||
items := agentSelectionItems(models)
|
||||
switch {
|
||||
case launch.DefaultSingleSelectorWithUpdates != nil:
|
||||
return launch.DefaultSingleSelectorWithUpdates("Select model to run:", items, current, nil)
|
||||
case launch.DefaultSingleSelector != nil:
|
||||
return launch.DefaultSingleSelector("Select model to run:", items, current)
|
||||
default:
|
||||
return "", errors.New("no selector configured")
|
||||
}
|
||||
}
|
||||
|
||||
func agentSelectionItems(models []agentchat.ModelOption) []launch.SelectionItem {
|
||||
items := make([]launch.SelectionItem, 0, len(models))
|
||||
for _, model := range models {
|
||||
items = append(items, launch.SelectionItem{
|
||||
Name: model.Name,
|
||||
Description: agentSelectionDescription(model),
|
||||
Description: strings.TrimSpace(model.Description),
|
||||
Recommended: model.Recommended,
|
||||
AvailabilityBadge: model.AvailabilityBadge,
|
||||
})
|
||||
@@ -335,10 +183,6 @@ func agentSelectionItems(models []agentchat.ModelOption) []launch.SelectionItem
|
||||
return items
|
||||
}
|
||||
|
||||
func agentSelectionDescription(model agentchat.ModelOption) string {
|
||||
return strings.TrimSpace(model.Description)
|
||||
}
|
||||
|
||||
var agentGetwd = os.Getwd
|
||||
|
||||
func agentWorkingDir() string {
|
||||
@@ -477,7 +321,7 @@ func agentContextWindowForModel(ctx context.Context, client *api.Client, modelNa
|
||||
if client == nil || strings.TrimSpace(modelName) == "" {
|
||||
return fallback
|
||||
}
|
||||
if tokens := loadedContextWindowForModel(ctx, client, modelName); tokens > 0 {
|
||||
if tokens := launch.LoadedContextWindow(ctx, client, modelName); tokens > 0 {
|
||||
return tokens
|
||||
}
|
||||
if modelref.HasExplicitCloudSource(modelName) {
|
||||
@@ -511,40 +355,13 @@ func contextWindowFromRecommendations(modelName string, recommendations []api.Mo
|
||||
if rec.ContextLength <= 0 {
|
||||
continue
|
||||
}
|
||||
if sameModelRef(modelName, rec.Model) {
|
||||
if launch.SameModelRef(modelName, rec.Model) {
|
||||
return rec.ContextLength
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func sameModelRef(a, b string) bool {
|
||||
a = comparableModelRef(a)
|
||||
b = comparableModelRef(b)
|
||||
if strings.EqualFold(a, b) {
|
||||
return true
|
||||
}
|
||||
pa, errA := modelref.ParseRef(a)
|
||||
pb, errB := modelref.ParseRef(b)
|
||||
if errA != nil || errB != nil {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(pa.Base, pb.Base) {
|
||||
return false
|
||||
}
|
||||
return pa.Source == pb.Source ||
|
||||
pa.Source == modelref.ModelSourceUnspecified ||
|
||||
pb.Source == modelref.ModelSourceUnspecified
|
||||
}
|
||||
|
||||
func comparableModelRef(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.HasSuffix(strings.ToLower(value), ":latest") {
|
||||
return strings.TrimSpace(value[:len(value)-len(":latest")])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func showResponseContextWindow(resp *api.ShowResponse) int {
|
||||
if resp == nil {
|
||||
return 0
|
||||
@@ -616,33 +433,7 @@ func preloadAgentModelIfLocal(ctx context.Context, client *api.Client, opts agen
|
||||
}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return loadedContextWindowForModel(ctx, client, modelName), nil
|
||||
}
|
||||
|
||||
func loadedContextWindowForModel(ctx context.Context, client *api.Client, modelName string) int {
|
||||
if client == nil || strings.TrimSpace(modelName) == "" {
|
||||
return 0
|
||||
}
|
||||
resp, err := client.ListRunning(ctx)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return processContextWindowForModel(modelName, resp)
|
||||
}
|
||||
|
||||
func processContextWindowForModel(modelName string, resp *api.ProcessResponse) int {
|
||||
if resp == nil {
|
||||
return 0
|
||||
}
|
||||
for _, running := range resp.Models {
|
||||
if running.ContextLength <= 0 {
|
||||
continue
|
||||
}
|
||||
if sameModelRef(modelName, running.Name) || sameModelRef(modelName, running.Model) {
|
||||
return running.ContextLength
|
||||
}
|
||||
}
|
||||
return 0
|
||||
return launch.LoadedContextWindow(ctx, client, modelName), nil
|
||||
}
|
||||
|
||||
func agentModelOptions(ctx context.Context, client *api.Client) ([]agentchat.ModelOption, error) {
|
||||
|
||||
+33
-30
@@ -9,8 +9,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
coreagent "github.com/ollama/ollama/agent"
|
||||
agenttools "github.com/ollama/ollama/agent/tools"
|
||||
"github.com/ollama/ollama/api"
|
||||
@@ -97,6 +95,39 @@ func TestAgentSkillSystemContextRequiresAvailableEnabledSkillTool(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentSkillCommandCollisionsAreIgnored(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range []string{"release-notes", "system", "exit"} {
|
||||
if err := os.Mkdir(filepath.Join(dir, name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "---\nname: " + name + "\ndescription: Test skill.\n---\nInstructions."
|
||||
if err := os.WriteFile(filepath.Join(dir, name, "SKILL.md"), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
catalog, err := coreagent.DiscoverSkills(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ignored := catalog.ExcludeNames(agentchat.BuiltinSlashCommandNames())
|
||||
if got, want := strings.Join(ignored, ", "), "exit, system"; got != want {
|
||||
t.Fatalf("ignored skills = %q, want %q", got, want)
|
||||
}
|
||||
if _, err := catalog.Load("release-notes"); err != nil {
|
||||
t.Fatalf("non-conflicting skill should remain available: %v", err)
|
||||
}
|
||||
if context := catalog.SystemContext(); !strings.Contains(context, "release-notes: Test skill.") || strings.Contains(context, "system: Test skill.") || strings.Contains(context, "exit: Test skill.") {
|
||||
t.Fatalf("skill context = %q", context)
|
||||
}
|
||||
for _, name := range []string{"system", "exit"} {
|
||||
if _, err := catalog.Load(name); err == nil {
|
||||
t.Fatalf("conflicting skill %q should be ignored", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentSelectionItemsUseLaunchSections(t *testing.T) {
|
||||
items := agentSelectionItems([]agentchat.ModelOption{
|
||||
{Name: "glm-5.2:cloud", Description: "cloud", Recommended: true, Cloud: true},
|
||||
@@ -139,18 +170,6 @@ func TestShowResponseContextWindowReadsArchitectureContextLength(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessContextWindowForModelMatchesLatestAlias(t *testing.T) {
|
||||
got := processContextWindowForModel("ornith", &api.ProcessResponse{
|
||||
Models: []api.ProcessModelResponse{
|
||||
{Name: "other:latest", Model: "other:latest", ContextLength: 32768},
|
||||
{Name: "ornith:latest", Model: "ornith:latest", ContextLength: 262144},
|
||||
},
|
||||
})
|
||||
if got != 262144 {
|
||||
t.Fatalf("context window = %d, want 262144", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveLastAgentModel(t *testing.T) {
|
||||
setCmdTestHome(t, t.TempDir())
|
||||
|
||||
@@ -168,19 +187,3 @@ func TestSaveLastAgentModel(t *testing.T) {
|
||||
t.Fatalf("blank save changed last model to %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAgentFlagsNoTools(t *testing.T) {
|
||||
cmd := &cobra.Command{}
|
||||
registerAgentFlags(cmd)
|
||||
if err := cmd.Flags().Set("no-tools", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var opts agentTUIOptions
|
||||
if _, err := applyAgentFlags(cmd, &opts); err != nil {
|
||||
t.Fatalf("applyAgentFlags returned error: %v", err)
|
||||
}
|
||||
if !opts.ToolsDisabled {
|
||||
t.Fatal("--no-tools should disable tools")
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package cmd
|
||||
|
||||
import "syscall"
|
||||
|
||||
// backgroundServerSysProcAttr returns SysProcAttr for running the server in the background on Unix.
|
||||
// Setpgid prevents the server from being killed when the parent process exits.
|
||||
func backgroundServerSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import "syscall"
|
||||
|
||||
// backgroundServerSysProcAttr returns SysProcAttr for running the server in the background on Windows.
|
||||
// CREATE_NO_WINDOW (0x08000000) prevents a console window from appearing.
|
||||
func backgroundServerSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{
|
||||
CreationFlags: 0x08000000,
|
||||
HideWindow: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/launch"
|
||||
"github.com/ollama/ollama/internal/modelref"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
// for testing
|
||||
var (
|
||||
isInteractiveTerminal = func() bool {
|
||||
return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
|
||||
}
|
||||
|
||||
confirmCloudSuggestion = func(prompt string) (bool, error) {
|
||||
// Zero-value options default to Yes being preselected.
|
||||
return launch.ConfirmPromptWithOptions(prompt, launch.ConfirmOptions{})
|
||||
}
|
||||
)
|
||||
|
||||
// pullModelNotFoundMessage is how a registry 404 during pull surfaces to
|
||||
// clients: os.ErrNotExist wrapped server-side and flattened into the error
|
||||
// string of the pull stream.
|
||||
const pullModelNotFoundMessage = "pull model manifest: file does not exist"
|
||||
|
||||
// isPullNotFoundErr reports whether err is a pull failure caused by the
|
||||
// requested model or tag not existing in the registry.
|
||||
func isPullNotFoundErr(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), pullModelNotFoundMessage)
|
||||
}
|
||||
|
||||
// cloudSuggestionCandidate reports whether a failed pull of name should
|
||||
// trigger a ":cloud" suggestion, and if so returns the cloud model name to
|
||||
// suggest. It only applies to default-tag lookups (e.g. "kimi-k3") against
|
||||
// the default registry whose pull failed because the tag doesn't exist.
|
||||
func cloudSuggestionCandidate(name string, pullErr error, insecure bool) (string, bool) {
|
||||
if !isPullNotFoundErr(pullErr) {
|
||||
return "", false
|
||||
}
|
||||
return cloudSuggestionName(name, insecure)
|
||||
}
|
||||
|
||||
// cloudSuggestionName applies the name-based eligibility checks for the
|
||||
// ":cloud" suggestion, returning the cloud model name to suggest.
|
||||
func cloudSuggestionName(name string, insecure bool) (string, bool) {
|
||||
// --insecure implies a non-default registry, where an ollama.com cloud
|
||||
// model wouldn't be a meaningful suggestion.
|
||||
if insecure {
|
||||
return "", false
|
||||
}
|
||||
|
||||
ref, err := modelref.ParseRef(name)
|
||||
if err != nil || ref.Source != modelref.ModelSourceUnspecified {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if modelref.HasExplicitTag(ref.Base) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Only default-registry names qualify: the existence probe forwards the name
|
||||
// to ollama.com, and custom-registry model names shouldn't be sent there.
|
||||
if n := model.ParseName(ref.Base); !n.IsValid() || !strings.EqualFold(n.Host, model.DefaultName().Host) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return ref.Base + ":cloud", true
|
||||
}
|
||||
|
||||
// pullWithCloudSuggestion pulls `name`, and if the model's default tag
|
||||
// doesn't exist but a ":cloud" tag does, offers it: either interactively via
|
||||
// a confirmation prompt, or by augmenting the returned error when not at a
|
||||
// terminal. It returns the name that was actually pulled. `verb` is the
|
||||
// user-facing command ("run" or "pull") used in the hint text.
|
||||
func pullWithCloudSuggestion(ctx context.Context, client *api.Client, name string, insecure bool, verb string) (string, error) {
|
||||
// If a suggestion prompt may follow a failed pull, erase the failed
|
||||
// attempt's progress display instead of leaving its "pulling manifest"
|
||||
// line to stack up against the accepted pull's identical one.
|
||||
_, eligible := cloudSuggestionName(name, insecure)
|
||||
clearNotFound := eligible && isInteractiveTerminal()
|
||||
|
||||
pullErr := pullModelWithProgress(ctx, client, name, insecure, clearNotFound)
|
||||
if pullErr == nil {
|
||||
return name, nil
|
||||
}
|
||||
|
||||
cloudName, ok := cloudSuggestionCandidate(name, pullErr, insecure)
|
||||
if !ok || ctx.Err() != nil {
|
||||
return "", pullErr
|
||||
}
|
||||
|
||||
// Showing a ":cloud" model is proxied to ollama.com and mirrors its status,
|
||||
// so this reliably answers "does a cloud version exist?". Any error (no
|
||||
// cloud tag, cloud disabled, older server, offline) means no suggestion.
|
||||
if _, err := client.Show(ctx, &api.ShowRequest{Model: cloudName}); err != nil {
|
||||
return "", pullErr
|
||||
}
|
||||
|
||||
if !isInteractiveTerminal() {
|
||||
return "", fmt.Errorf("%w\n\n%q is available as a cloud model. Try:\n ollama %s %s", pullErr, cloudName, verb, cloudName)
|
||||
}
|
||||
|
||||
accepted, err := confirmCloudSuggestion(fmt.Sprintf("Did you mean %q?", cloudName))
|
||||
if err != nil || !accepted {
|
||||
// Declining or cancelling falls back to the original error.
|
||||
return "", pullErr
|
||||
}
|
||||
|
||||
if err := pullModelWithProgress(ctx, client, cloudName, insecure, false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cloudName, nil
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/launch"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func TestCloudSuggestionCandidate(t *testing.T) {
|
||||
notFoundErr := errors.New("pull model manifest: file does not exist")
|
||||
suggestedErr := errors.New("pull model manifest: file does not exist\n\nTry one of these models:\n some-model:cloud")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
pullErr error
|
||||
insecure bool
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{name: "default tag not found", model: "some-model", pullErr: notFoundErr, want: "some-model:cloud", wantOK: true},
|
||||
{name: "composes with server tag suggestions", model: "some-model", pullErr: suggestedErr, want: "some-model:cloud", wantOK: true},
|
||||
{name: "namespaced default tag", model: "user/some-model", pullErr: notFoundErr, want: "user/some-model:cloud", wantOK: true},
|
||||
{name: "nil error", model: "some-model", pullErr: nil},
|
||||
{name: "unrelated error", model: "some-model", pullErr: errors.New("boom")},
|
||||
{name: "insecure registry", model: "some-model", pullErr: notFoundErr, insecure: true},
|
||||
{name: "explicit tag", model: "some-model:9b", pullErr: notFoundErr},
|
||||
{name: "explicit latest tag", model: "some-model:latest", pullErr: notFoundErr},
|
||||
{name: "explicit cloud source", model: "some-model:cloud", pullErr: notFoundErr},
|
||||
{name: "explicit legacy cloud tag", model: "some-model:9b-cloud", pullErr: notFoundErr},
|
||||
{name: "explicit local source", model: "some-model:local", pullErr: notFoundErr},
|
||||
{name: "custom registry host", model: "internal.example.com/team/private-model", pullErr: notFoundErr},
|
||||
{name: "custom registry host with port", model: "registry.example.com:5000/team/private-model", pullErr: notFoundErr},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := cloudSuggestionCandidate(tt.model, tt.pullErr, tt.insecure)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("cloudSuggestionCandidate(%q) ok = %v, want %v", tt.model, ok, tt.wantOK)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("cloudSuggestionCandidate(%q) = %q, want %q", tt.model, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// stubCloudSuggest replaces the TTY check and confirmation prompt for the
|
||||
// duration of the test. If confirm is nil, any prompt fails the test.
|
||||
func stubCloudSuggest(t *testing.T, interactive bool, confirm func(prompt string) (bool, error)) *[]string {
|
||||
t.Helper()
|
||||
|
||||
oldTTY, oldConfirm := isInteractiveTerminal, confirmCloudSuggestion
|
||||
t.Cleanup(func() {
|
||||
isInteractiveTerminal, confirmCloudSuggestion = oldTTY, oldConfirm
|
||||
})
|
||||
|
||||
isInteractiveTerminal = func() bool { return interactive }
|
||||
|
||||
prompts := &[]string{}
|
||||
confirmCloudSuggestion = func(prompt string) (bool, error) {
|
||||
*prompts = append(*prompts, prompt)
|
||||
if confirm == nil {
|
||||
t.Errorf("unexpected cloud suggestion prompt: %q", prompt)
|
||||
return false, nil
|
||||
}
|
||||
return confirm(prompt)
|
||||
}
|
||||
return prompts
|
||||
}
|
||||
|
||||
type cloudSuggestServer struct {
|
||||
cloudName string // model name whose show/pull succeeds (e.g. "some-model:cloud")
|
||||
cloudExists bool // whether showing/pulling cloudName succeeds
|
||||
pullErr string // error message for failing pulls
|
||||
|
||||
showModels []string
|
||||
pullModels []string
|
||||
generateModels []string
|
||||
}
|
||||
|
||||
// start serves mock /api/show, /api/pull, /api/tags, and /api/generate
|
||||
// endpoints: only cloudName is known (when cloudExists), and pulling any other
|
||||
// model fails with pullErr streamed the way real servers do (an in-band error
|
||||
// under HTTP 200).
|
||||
func (s *cloudSuggestServer) start(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/api/show" && r.Method == http.MethodPost:
|
||||
var req api.ShowRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := cmp.Or(req.Model, req.Name)
|
||||
s.showModels = append(s.showModels, name)
|
||||
if s.cloudExists && name == s.cloudName {
|
||||
if err := json.NewEncoder(w).Encode(api.ShowResponse{
|
||||
Capabilities: []model.Capability{model.CapabilityCompletion},
|
||||
RemoteModel: strings.TrimSuffix(s.cloudName, ":cloud"),
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "model '" + name + "' not found",
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
case r.URL.Path == "/api/pull" && r.Method == http.MethodPost:
|
||||
var req api.PullRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := cmp.Or(req.Model, req.Name)
|
||||
s.pullModels = append(s.pullModels, name)
|
||||
var body any
|
||||
if s.cloudExists && name == s.cloudName {
|
||||
body = api.ProgressResponse{Status: "success"}
|
||||
} else {
|
||||
body = map[string]string{"error": s.pullErr}
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
case r.URL.Path == "/api/tags" && r.Method == http.MethodGet:
|
||||
if err := json.NewEncoder(w).Encode(api.ListResponse{
|
||||
Models: []api.ListModelResponse{{Name: s.cloudName}},
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
case r.URL.Path == "/api/generate" && r.Method == http.MethodPost:
|
||||
var req api.GenerateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.generateModels = append(s.generateModels, req.Model)
|
||||
if err := json.NewEncoder(w).Encode(api.GenerateResponse{Done: true}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
|
||||
t.Setenv("OLLAMA_HOST", mockServer.URL)
|
||||
t.Cleanup(mockServer.Close)
|
||||
}
|
||||
|
||||
func newCloudSuggestServer(t *testing.T) *cloudSuggestServer {
|
||||
t.Helper()
|
||||
s := &cloudSuggestServer{
|
||||
cloudName: "some-model:cloud",
|
||||
cloudExists: true,
|
||||
pullErr: "pull model manifest: file does not exist",
|
||||
}
|
||||
s.start(t)
|
||||
return s
|
||||
}
|
||||
|
||||
func newPullTestCmd(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetContext(t.Context())
|
||||
cmd.Flags().Bool("insecure", false, "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newRunTestCmd(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetContext(t.Context())
|
||||
cmd.Flags().String("keepalive", "", "")
|
||||
cmd.Flags().Bool("truncate", false, "")
|
||||
cmd.Flags().Int("dimensions", 0, "")
|
||||
cmd.Flags().Bool("verbose", false, "")
|
||||
cmd.Flags().Bool("insecure", false, "")
|
||||
cmd.Flags().Bool("nowordwrap", false, "")
|
||||
cmd.Flags().String("format", "", "")
|
||||
cmd.Flags().String("think", "", "")
|
||||
cmd.Flags().Bool("hidethinking", false, "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestPullHandler_SuccessfulPullNoSuggestion(t *testing.T) {
|
||||
server := newCloudSuggestServer(t)
|
||||
server.cloudName = "some-model" // the requested model itself pulls fine
|
||||
stubCloudSuggest(t, true, nil)
|
||||
|
||||
if err := PullHandler(newPullTestCmd(t), []string{"some-model"}); err != nil {
|
||||
t.Fatalf("PullHandler returned error: %v", err)
|
||||
}
|
||||
if want := []string{"some-model"}; !slices.Equal(server.pullModels, want) {
|
||||
t.Fatalf("pulled models = %v, want %v", server.pullModels, want)
|
||||
}
|
||||
if len(server.showModels) != 0 {
|
||||
t.Fatalf("show models = %v, want no probe after a successful pull", server.showModels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullHandler_CloudSuggestionAccepted(t *testing.T) {
|
||||
server := newCloudSuggestServer(t)
|
||||
prompts := stubCloudSuggest(t, true, func(string) (bool, error) { return true, nil })
|
||||
|
||||
if err := PullHandler(newPullTestCmd(t), []string{"some-model"}); err != nil {
|
||||
t.Fatalf("PullHandler returned error: %v", err)
|
||||
}
|
||||
|
||||
if want := []string{"some-model", "some-model:cloud"}; !slices.Equal(server.pullModels, want) {
|
||||
t.Fatalf("pulled models = %v, want %v", server.pullModels, want)
|
||||
}
|
||||
if len(*prompts) != 1 || !strings.Contains((*prompts)[0], `"some-model:cloud"`) {
|
||||
t.Fatalf("prompts = %v, want one prompt mentioning some-model:cloud", *prompts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullHandler_CloudSuggestionDeclined(t *testing.T) {
|
||||
server := newCloudSuggestServer(t)
|
||||
stubCloudSuggest(t, true, func(string) (bool, error) { return false, nil })
|
||||
|
||||
err := PullHandler(newPullTestCmd(t), []string{"some-model"})
|
||||
if err == nil {
|
||||
t.Fatal("PullHandler returned nil, want an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "pull model manifest: file does not exist") {
|
||||
t.Fatalf("error = %q, want it to contain the original pull error", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "Try:") {
|
||||
t.Fatalf("error = %q, want no non-interactive hint after declining", err)
|
||||
}
|
||||
if want := []string{"some-model"}; !slices.Equal(server.pullModels, want) {
|
||||
t.Fatalf("pulled models = %v, want %v", server.pullModels, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullHandler_CloudSuggestionCancelled(t *testing.T) {
|
||||
server := newCloudSuggestServer(t)
|
||||
stubCloudSuggest(t, true, func(string) (bool, error) { return false, launch.ErrCancelled })
|
||||
|
||||
err := PullHandler(newPullTestCmd(t), []string{"some-model"})
|
||||
if err == nil {
|
||||
t.Fatal("PullHandler returned nil, want an error")
|
||||
}
|
||||
if errors.Is(err, launch.ErrCancelled) {
|
||||
t.Fatalf("error = %v, want the original pull error rather than ErrCancelled", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "pull model manifest: file does not exist") {
|
||||
t.Fatalf("error = %q, want it to contain the original pull error", err)
|
||||
}
|
||||
if want := []string{"some-model"}; !slices.Equal(server.pullModels, want) {
|
||||
t.Fatalf("pulled models = %v, want %v", server.pullModels, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullHandler_CloudSuggestionNonInteractive(t *testing.T) {
|
||||
server := newCloudSuggestServer(t)
|
||||
stubCloudSuggest(t, false, nil)
|
||||
|
||||
err := PullHandler(newPullTestCmd(t), []string{"some-model"})
|
||||
if err == nil {
|
||||
t.Fatal("PullHandler returned nil, want an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "pull model manifest: file does not exist") {
|
||||
t.Fatalf("error = %q, want it to contain the original pull error", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ollama pull some-model:cloud") {
|
||||
t.Fatalf("error = %q, want it to hint at 'ollama pull some-model:cloud'", err)
|
||||
}
|
||||
if want := []string{"some-model"}; !slices.Equal(server.pullModels, want) {
|
||||
t.Fatalf("pulled models = %v, want %v", server.pullModels, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullHandler_CloudSuggestionNoCloudTag(t *testing.T) {
|
||||
server := newCloudSuggestServer(t)
|
||||
server.cloudExists = false
|
||||
stubCloudSuggest(t, true, nil)
|
||||
|
||||
err := PullHandler(newPullTestCmd(t), []string{"some-model"})
|
||||
if err == nil || err.Error() != "pull model manifest: file does not exist" {
|
||||
t.Fatalf("error = %v, want the unmodified pull error", err)
|
||||
}
|
||||
if want := []string{"some-model:cloud"}; !slices.Equal(server.showModels, want) {
|
||||
t.Fatalf("show models = %v, want the cloud existence probe %v", server.showModels, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullHandler_CloudSuggestionExplicitTag(t *testing.T) {
|
||||
server := newCloudSuggestServer(t)
|
||||
stubCloudSuggest(t, true, nil)
|
||||
|
||||
err := PullHandler(newPullTestCmd(t), []string{"some-model:9b"})
|
||||
if err == nil || err.Error() != "pull model manifest: file does not exist" {
|
||||
t.Fatalf("error = %v, want the unmodified pull error", err)
|
||||
}
|
||||
if len(server.showModels) != 0 {
|
||||
t.Fatalf("show models = %v, want no cloud probe for explicitly tagged models", server.showModels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullHandler_CloudSuggestionExplicitCloud(t *testing.T) {
|
||||
server := newCloudSuggestServer(t)
|
||||
server.cloudExists = false // make the explicit :cloud pull fail too
|
||||
stubCloudSuggest(t, true, nil)
|
||||
|
||||
err := PullHandler(newPullTestCmd(t), []string{"some-model:cloud"})
|
||||
if err == nil || err.Error() != "pull model manifest: file does not exist" {
|
||||
t.Fatalf("error = %v, want the unmodified pull error", err)
|
||||
}
|
||||
if len(server.showModels) != 0 {
|
||||
t.Fatalf("show models = %v, want no probe for explicit :cloud requests", server.showModels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullHandler_CloudSuggestionInsecure(t *testing.T) {
|
||||
server := newCloudSuggestServer(t)
|
||||
stubCloudSuggest(t, true, nil)
|
||||
|
||||
cmd := newPullTestCmd(t)
|
||||
if err := cmd.Flags().Set("insecure", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := PullHandler(cmd, []string{"some-model"})
|
||||
if err == nil || err.Error() != "pull model manifest: file does not exist" {
|
||||
t.Fatalf("error = %v, want the unmodified pull error", err)
|
||||
}
|
||||
if len(server.showModels) != 0 {
|
||||
t.Fatalf("show models = %v, want no probe for --insecure pulls", server.showModels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullHandler_CloudSuggestionUnrelatedError(t *testing.T) {
|
||||
server := newCloudSuggestServer(t)
|
||||
server.pullErr = "boom"
|
||||
stubCloudSuggest(t, true, nil)
|
||||
|
||||
err := PullHandler(newPullTestCmd(t), []string{"some-model"})
|
||||
if err == nil || err.Error() != "boom" {
|
||||
t.Fatalf("error = %v, want the unmodified pull error %q", err, "boom")
|
||||
}
|
||||
if len(server.showModels) != 0 {
|
||||
t.Fatalf("show models = %v, want no probe for unrelated pull errors", server.showModels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHandler_CloudSuggestionAccepted_RunsCloudModel(t *testing.T) {
|
||||
server := newCloudSuggestServer(t)
|
||||
stubCloudSuggest(t, true, func(string) (bool, error) { return true, nil })
|
||||
|
||||
if err := RunHandler(newRunTestCmd(t), []string{"some-model", "hi"}); err != nil {
|
||||
t.Fatalf("RunHandler returned error: %v", err)
|
||||
}
|
||||
|
||||
if want := []string{"some-model", "some-model:cloud"}; !slices.Equal(server.pullModels, want) {
|
||||
t.Fatalf("pulled models = %v, want %v", server.pullModels, want)
|
||||
}
|
||||
if want := []string{"some-model:cloud"}; !slices.Equal(server.generateModels, want) {
|
||||
t.Fatalf("generate models = %v, want %v", server.generateModels, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHandler_CloudSuggestionDeclined_ReturnsNotFound(t *testing.T) {
|
||||
server := newCloudSuggestServer(t)
|
||||
stubCloudSuggest(t, true, func(string) (bool, error) { return false, nil })
|
||||
|
||||
err := RunHandler(newRunTestCmd(t), []string{"some-model", "hi"})
|
||||
if err == nil {
|
||||
t.Fatal("RunHandler returned nil, want an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "pull model manifest: file does not exist") {
|
||||
t.Fatalf("error = %q, want it to contain the original pull error", err)
|
||||
}
|
||||
if len(server.generateModels) != 0 {
|
||||
t.Fatalf("generate models = %v, want none after declining", server.generateModels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHandler_CloudSuggestionNonInteractive_Hint(t *testing.T) {
|
||||
server := newCloudSuggestServer(t)
|
||||
stubCloudSuggest(t, false, nil)
|
||||
|
||||
err := RunHandler(newRunTestCmd(t), []string{"some-model", "hi"})
|
||||
if err == nil {
|
||||
t.Fatal("RunHandler returned nil, want an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ollama run some-model:cloud") {
|
||||
t.Fatalf("error = %q, want it to hint at 'ollama run some-model:cloud'", err)
|
||||
}
|
||||
if len(server.generateModels) != 0 {
|
||||
t.Fatalf("generate models = %v, want none in non-interactive mode", server.generateModels)
|
||||
}
|
||||
}
|
||||
+60
-84
@@ -16,7 +16,6 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path"
|
||||
"path/filepath"
|
||||
@@ -57,7 +56,6 @@ import (
|
||||
"github.com/ollama/ollama/version"
|
||||
xcreate "github.com/ollama/ollama/x/create"
|
||||
xcreateclient "github.com/ollama/ollama/x/create/client"
|
||||
"github.com/ollama/ollama/x/imagegen"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -193,7 +191,7 @@ func resolveExperimentalLocalModelDir(ref, filename string) string {
|
||||
}
|
||||
|
||||
candidate := filepath.Join(filepath.Dir(filename), ref)
|
||||
if xcreate.IsSafetensorsModelDir(candidate) || xcreate.IsTensorModelDir(candidate) {
|
||||
if xcreate.IsSafetensorsModelDir(candidate) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
@@ -231,8 +229,7 @@ func CreateHandler(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("invalid model name: %s", modelName)
|
||||
}
|
||||
|
||||
// Check for --experimental flag for safetensors model creation
|
||||
// This gates both safetensors LLM and imagegen model creation
|
||||
// Check for --experimental flag for safetensors model creation.
|
||||
experimental, _ := cmd.Flags().GetBool("experimental")
|
||||
draftQuantize, _ := cmd.Flags().GetString("draft-quantize")
|
||||
if experimental {
|
||||
@@ -710,6 +707,32 @@ func hasListedModelName(models []api.ListModelResponse, name string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// showOrPullModel returns model info for name, pulling the model if it isn't
|
||||
// available locally. If the pull finds no default tag but a ":cloud" tag
|
||||
// exists, the user may be offered the cloud model instead (see
|
||||
// pullWithCloudSuggestion), in which case the returned name is the cloud
|
||||
// name the caller should continue with. verb is the user-facing command
|
||||
// ("run" or "pull") used in hint text.
|
||||
func showOrPullModel(cmd *cobra.Command, client *api.Client, name string, insecure bool, verb string) (*api.ShowResponse, string, error) {
|
||||
info, err := client.Show(cmd.Context(), &api.ShowRequest{Model: name})
|
||||
if err == nil {
|
||||
return info, name, nil
|
||||
}
|
||||
|
||||
var se api.StatusError
|
||||
if !errors.As(err, &se) || se.StatusCode != http.StatusNotFound || modelref.HasExplicitCloudSource(name) {
|
||||
return nil, name, err
|
||||
}
|
||||
|
||||
resolved, err := pullWithCloudSuggestion(cmd.Context(), client, name, insecure, verb)
|
||||
if err != nil {
|
||||
return nil, name, err
|
||||
}
|
||||
|
||||
info, err = client.Show(cmd.Context(), &api.ShowRequest{Model: resolved})
|
||||
return info, resolved, err
|
||||
}
|
||||
|
||||
func RunHandler(cmd *cobra.Command, args []string) error {
|
||||
interactive := true
|
||||
|
||||
@@ -805,30 +828,21 @@ func RunHandler(cmd *cobra.Command, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
name := args[0]
|
||||
requestedCloud := modelref.HasExplicitCloudSource(name)
|
||||
insecure, err := cmd.Flags().GetBool("insecure")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info, err := func() (*api.ShowResponse, error) {
|
||||
showReq := &api.ShowRequest{Name: name}
|
||||
info, err := client.Show(cmd.Context(), showReq)
|
||||
var se api.StatusError
|
||||
if errors.As(err, &se) && se.StatusCode == http.StatusNotFound {
|
||||
if requestedCloud {
|
||||
return nil, err
|
||||
}
|
||||
if err := PullHandler(cmd, []string{name}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.Show(cmd.Context(), &api.ShowRequest{Name: name})
|
||||
}
|
||||
return info, err
|
||||
}()
|
||||
info, name, err := showOrPullModel(cmd, client, args[0], insecure, "run")
|
||||
if err != nil {
|
||||
if handleCloudAuthorizationError(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
// The model may have been resolved to a different name (e.g. its ":cloud"
|
||||
// variant), so make sure downstream requests use it.
|
||||
opts.Model = name
|
||||
|
||||
ensureCloudStub(cmd.Context(), client, name)
|
||||
|
||||
@@ -878,12 +892,8 @@ func RunHandler(cmd *cobra.Command, args []string) error {
|
||||
return generateEmbedding(cmd, name, opts.Prompt, opts.KeepAlive, truncate, dimensions)
|
||||
}
|
||||
|
||||
// Check if this is an image generation model
|
||||
if slices.Contains(info.Capabilities, model.CapabilityImage) {
|
||||
if opts.Prompt == "" && !interactive {
|
||||
return errors.New("image generation models require a prompt. Usage: ollama run " + name + " \"your prompt here\"")
|
||||
}
|
||||
return imagegen.RunCLI(cmd, name, opts.Prompt, interactive, opts.KeepAlive)
|
||||
return errors.New("image generation models are not currently supported")
|
||||
}
|
||||
|
||||
if interactive {
|
||||
@@ -1248,6 +1258,10 @@ func ShowHandler(cmd *cobra.Command, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if slices.Contains(resp.Capabilities, model.CapabilityImage) {
|
||||
return errors.New("image generation models are not currently supported")
|
||||
}
|
||||
|
||||
if flagsSet == 1 {
|
||||
switch showType {
|
||||
case "license":
|
||||
@@ -1509,6 +1523,15 @@ func PullHandler(cmd *cobra.Command, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = pullWithCloudSuggestion(cmd.Context(), client, args[0], insecure, "pull")
|
||||
return err
|
||||
}
|
||||
|
||||
// pullModelWithProgress pulls name, rendering progress to stderr. When
|
||||
// clearNotFound is set and the pull fails because the model doesn't exist,
|
||||
// the progress display is erased rather than left behind; callers set it
|
||||
// when a ":cloud" suggestion prompt may immediately follow the failure.
|
||||
func pullModelWithProgress(ctx context.Context, client *api.Client, name string, insecure, clearNotFound bool) error {
|
||||
p := progress.NewProgress(os.Stderr)
|
||||
defer p.Stop()
|
||||
|
||||
@@ -1569,8 +1592,13 @@ func PullHandler(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
request := api.PullRequest{Name: args[0], Insecure: insecure}
|
||||
return client.Pull(cmd.Context(), &request, fn)
|
||||
request := api.PullRequest{Name: name, Insecure: insecure}
|
||||
err := client.Pull(ctx, &request, fn)
|
||||
if clearNotFound && isPullNotFoundErr(err) {
|
||||
// The deferred Stop becomes a no-op after this.
|
||||
p.StopAndClear()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type generateContextKey string
|
||||
@@ -2099,40 +2127,6 @@ Environment Variables:
|
||||
cmd.SetUsageTemplate(cmd.UsageTemplate() + envUsage)
|
||||
}
|
||||
|
||||
// ensureServerRunning checks if the ollama server is running and starts it in the background if not.
|
||||
func ensureServerRunning(ctx context.Context) error {
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if server is already running
|
||||
if err := client.Heartbeat(ctx); err == nil {
|
||||
return nil // server is already running
|
||||
}
|
||||
|
||||
// Server not running, start it in the background
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not find executable: %w", err)
|
||||
}
|
||||
|
||||
serverCmd := exec.CommandContext(ctx, exe, "serve")
|
||||
serverCmd.Env = os.Environ()
|
||||
serverCmd.SysProcAttr = backgroundServerSysProcAttr()
|
||||
if err := serverCmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start server: %w", err)
|
||||
}
|
||||
|
||||
// Wait for the server to be ready
|
||||
for {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if err := client.Heartbeat(ctx); err == nil {
|
||||
return nil // server has started
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func launchInteractiveModel(cmd *cobra.Command, modelName string) error {
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
@@ -2166,9 +2160,9 @@ func launchInteractiveModel(cmd *cobra.Command, modelName string) error {
|
||||
|
||||
// runInteractiveTUI runs the main interactive TUI menu.
|
||||
func runInteractiveTUI(cmd *cobra.Command) {
|
||||
// Ensure the server is running before showing the TUI
|
||||
if err := ensureServerRunning(cmd.Context()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err)
|
||||
// Ensure the server is running via the shared checkServerHeartbeat path.
|
||||
if err := checkServerHeartbeat(cmd, nil); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2365,21 +2359,6 @@ func NewCLI() *cobra.Command {
|
||||
runCmd.Flags().Bool("truncate", false, "For embedding models: truncate inputs exceeding context length (default: true). Set --truncate=false to error instead")
|
||||
runCmd.Flags().Int("dimensions", 0, "Truncate output embeddings to specified dimension (embedding models only)")
|
||||
|
||||
// Image generation flags (width, height, steps, seed, etc.)
|
||||
imagegen.RegisterFlags(runCmd)
|
||||
|
||||
runCmd.Flags().Bool("imagegen", false, "Use the imagegen runner for LLM inference")
|
||||
runCmd.Flags().MarkHidden("imagegen")
|
||||
|
||||
agentCmd := &cobra.Command{
|
||||
Use: "agent",
|
||||
Short: "Run an agent",
|
||||
Args: cobra.ExactArgs(0),
|
||||
PreRunE: checkServerHeartbeat,
|
||||
RunE: AgentHandler,
|
||||
}
|
||||
registerAgentFlags(agentCmd)
|
||||
|
||||
stopCmd := &cobra.Command{
|
||||
Use: "stop MODEL",
|
||||
Short: "Stop a running model",
|
||||
@@ -2510,7 +2489,6 @@ func NewCLI() *cobra.Command {
|
||||
createCmd,
|
||||
showCmd,
|
||||
runCmd,
|
||||
agentCmd,
|
||||
stopCmd,
|
||||
pullCmd,
|
||||
pushCmd,
|
||||
@@ -2522,7 +2500,6 @@ func NewCLI() *cobra.Command {
|
||||
} {
|
||||
switch cmd {
|
||||
case runCmd:
|
||||
imagegen.AppendFlagsDocs(cmd)
|
||||
appendEnvDocs(cmd, []envconfig.EnvVar{envVars["OLLAMA_EDITOR"], envVars["OLLAMA_HOST"], envVars["OLLAMA_NOHISTORY"]})
|
||||
case serveCmd:
|
||||
appendEnvDocs(cmd, []envconfig.EnvVar{
|
||||
@@ -2558,7 +2535,6 @@ func NewCLI() *cobra.Command {
|
||||
createCmd,
|
||||
showCmd,
|
||||
runCmd,
|
||||
agentCmd,
|
||||
stopCmd,
|
||||
pullCmd,
|
||||
pushCmd,
|
||||
|
||||
+1
-1
@@ -2079,7 +2079,7 @@ func TestRunOptions_Copy_ThinkValueVariants(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowInfoImageGen(t *testing.T) {
|
||||
func TestShowInfoImageCapability(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
err := showInfo(&api.ShowResponse{
|
||||
Details: api.ModelDetails{
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/internal/modelref"
|
||||
)
|
||||
|
||||
// LoadedContextWindow reports the context length model is currently running
|
||||
// with, per the server's process list — the size the scheduler actually
|
||||
// allocated, which VRAM fit or server configuration may hold below the
|
||||
// model's trained maximum. Returns 0 when it cannot be determined.
|
||||
func LoadedContextWindow(ctx context.Context, client *api.Client, model string) int {
|
||||
if client == nil || strings.TrimSpace(model) == "" {
|
||||
return 0
|
||||
}
|
||||
resp, err := client.ListRunning(ctx)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return processContextWindow(model, resp)
|
||||
}
|
||||
|
||||
func processContextWindow(model string, resp *api.ProcessResponse) int {
|
||||
if resp == nil {
|
||||
return 0
|
||||
}
|
||||
for _, running := range resp.Models {
|
||||
if running.ContextLength <= 0 {
|
||||
continue
|
||||
}
|
||||
if SameModelRef(model, running.Name) || SameModelRef(model, running.Model) {
|
||||
return running.ContextLength
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// SameModelRef reports whether two references name the same model, tolerating
|
||||
// an explicit ":latest" tag and an unspecified source on either side.
|
||||
func SameModelRef(a, b string) bool {
|
||||
a = comparableModelRef(a)
|
||||
b = comparableModelRef(b)
|
||||
if strings.EqualFold(a, b) {
|
||||
return true
|
||||
}
|
||||
pa, errA := modelref.ParseRef(a)
|
||||
pb, errB := modelref.ParseRef(b)
|
||||
if errA != nil || errB != nil {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(pa.Base, pb.Base) {
|
||||
return false
|
||||
}
|
||||
return pa.Source == pb.Source ||
|
||||
pa.Source == modelref.ModelSourceUnspecified ||
|
||||
pb.Source == modelref.ModelSourceUnspecified
|
||||
}
|
||||
|
||||
func comparableModelRef(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.HasSuffix(strings.ToLower(value), ":latest") {
|
||||
return strings.TrimSpace(value[:len(value)-len(":latest")])
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func TestProcessContextWindowMatchesLatestAlias(t *testing.T) {
|
||||
got := processContextWindow("ornith", &api.ProcessResponse{
|
||||
Models: []api.ProcessModelResponse{
|
||||
{Name: "other:latest", Model: "other:latest", ContextLength: 32768},
|
||||
{Name: "ornith:latest", Model: "ornith:latest", ContextLength: 262144},
|
||||
},
|
||||
})
|
||||
if got != 262144 {
|
||||
t.Fatalf("context window = %d, want 262144", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
deepSeekHarnessIntegrationName = "dsh"
|
||||
deepSeekHarnessNpmPackage = "@deepseek-ai/dsh@latest"
|
||||
deepSeekHarnessProvider = "ollama"
|
||||
deepSeekHarnessAPIKeyEnv = "OLLAMA_LAUNCH_DSH_API_KEY"
|
||||
deepSeekHarnessWebSettings = "web-search-deepseek"
|
||||
)
|
||||
|
||||
var (
|
||||
deepSeekHarnessLookPath = exec.LookPath
|
||||
deepSeekHarnessCommand = exec.Command
|
||||
deepSeekHarnessGOOS = runtime.GOOS
|
||||
)
|
||||
|
||||
// DeepSeekHarness is the Ollama-managed DeepSeek Harness integration.
|
||||
// It redirects only the settings provider for this invocation to an
|
||||
// Ollama-owned document. The user's normal DSH_HOME, profiles, sessions,
|
||||
// credentials, and patch layers remain available and untouched.
|
||||
type DeepSeekHarness struct{}
|
||||
|
||||
func (d *DeepSeekHarness) String() string { return "DeepSeek Harness" }
|
||||
|
||||
func (d *DeepSeekHarness) Run(_ string, _ []LaunchModel, args []string) error {
|
||||
if err := validateDeepSeekHarnessArgs(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bin, err := deepSeekHarnessLookPath("dsh")
|
||||
if err != nil {
|
||||
return fmt.Errorf("dsh is not installed: %w", err)
|
||||
}
|
||||
patchPath, err := deepSeekHarnessPatchPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd, err := deepSeekHarnessExecutableCommand(bin, deepSeekHarnessLaunchArgs(patchPath, args))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = deepSeekHarnessLaunchEnv(os.Environ())
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func deepSeekHarnessLaunchArgs(patchPath string, args []string) []string {
|
||||
launchArgs := []string{"web", "--patch", patchPath}
|
||||
return append(launchArgs, args...)
|
||||
}
|
||||
|
||||
func validateDeepSeekHarnessArgs(args []string) error {
|
||||
for _, arg := range args {
|
||||
if arg == "--patch" || strings.HasPrefix(arg, "--patch=") {
|
||||
return fmt.Errorf("conflicting extra argument %q: ollama launch dsh manages --patch", arg)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deepSeekHarnessLaunchEnv(env []string) []string {
|
||||
return deepSeekHarnessUpsertEnv(env, deepSeekHarnessAPIKeyEnv, "ollama")
|
||||
}
|
||||
|
||||
func deepSeekHarnessUpsertEnv(env []string, key, value string) []string {
|
||||
prefix := key + "="
|
||||
out := make([]string, 0, len(env)+1)
|
||||
for _, entry := range env {
|
||||
if strings.HasPrefix(entry, prefix) {
|
||||
continue
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return append(out, prefix+value)
|
||||
}
|
||||
|
||||
func ensureDeepSeekHarnessInstalled() (string, error) {
|
||||
if path, err := deepSeekHarnessLookPath("dsh"); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
npm, err := deepSeekHarnessLookPath("npm")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("dsh is not installed and npm (Node.js) is required\n\nInstall Node.js first:\n https://nodejs.org/\n\nThen re-run:\n ollama launch dsh")
|
||||
}
|
||||
|
||||
ok, err := ConfirmPrompt("DeepSeek Harness is not installed. Install with npm?")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", fmt.Errorf("deepseek harness installation cancelled")
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr, "\nInstalling DeepSeek Harness...")
|
||||
cmd, err := deepSeekHarnessNpmCommand(npm, []string{"install", "-g", deepSeekHarnessNpmPackage})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to install deepseek harness: %w", err)
|
||||
}
|
||||
|
||||
path, err := deepSeekHarnessLookPath("dsh")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("deepseek harness was installed but dsh was not found on PATH\n\nYou may need to restart your shell")
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "%sDeepSeek Harness installed successfully%s\n\n", ansiGreen, ansiReset)
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func deepSeekHarnessExecutableCommand(bin string, args []string) (*exec.Cmd, error) {
|
||||
return deepSeekHarnessNodeShimCommand(bin, []string{"node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"}, args)
|
||||
}
|
||||
|
||||
func deepSeekHarnessNpmCommand(bin string, args []string) (*exec.Cmd, error) {
|
||||
return deepSeekHarnessNodeShimCommand(bin, []string{"node_modules", "npm", "bin", "npm-cli.js"}, args)
|
||||
}
|
||||
|
||||
// Windows npm binaries are .cmd shims, which cannot be passed safely to
|
||||
// CreateProcess with an argv. Invoke their JavaScript entrypoints with Node so
|
||||
// passthrough arguments remain data rather than cmd.exe syntax.
|
||||
func deepSeekHarnessNodeShimCommand(shim string, entrypointParts, args []string) (*exec.Cmd, error) {
|
||||
if deepSeekHarnessGOOS != "windows" || !deepSeekHarnessIsCommandShim(shim) {
|
||||
return deepSeekHarnessCommand(shim, args...), nil
|
||||
}
|
||||
|
||||
node, err := deepSeekHarnessLookPath("node")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("node is required to run %s on Windows: %w", filepath.Base(shim), err)
|
||||
}
|
||||
entrypoint := filepath.Join(append([]string{filepath.Dir(shim)}, entrypointParts...)...)
|
||||
if _, err := os.Stat(entrypoint); err != nil {
|
||||
return nil, fmt.Errorf("resolve Windows entrypoint for %s: %w", filepath.Base(shim), err)
|
||||
}
|
||||
return deepSeekHarnessCommand(node, append([]string{entrypoint}, args...)...), nil
|
||||
}
|
||||
|
||||
func deepSeekHarnessIsCommandShim(path string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
return ext == ".cmd" || ext == ".bat"
|
||||
}
|
||||
|
||||
func (d *DeepSeekHarness) Paths() []string {
|
||||
settingsPath, settingsErr := deepSeekHarnessSettingsPath()
|
||||
patchPath, patchErr := deepSeekHarnessPatchPath()
|
||||
if settingsErr != nil || patchErr != nil {
|
||||
return nil
|
||||
}
|
||||
return []string{settingsPath, patchPath}
|
||||
}
|
||||
|
||||
func (d *DeepSeekHarness) Configure(modelName string) error {
|
||||
return d.ConfigureWithModels(modelName, []LaunchModel{fallbackLaunchModel(modelName)})
|
||||
}
|
||||
|
||||
func (d *DeepSeekHarness) ConfigureWithModels(primary string, models []LaunchModel) error {
|
||||
if strings.TrimSpace(primary) == "" {
|
||||
return nil
|
||||
}
|
||||
if len(models) == 0 {
|
||||
models = []LaunchModel{fallbackLaunchModel(primary)}
|
||||
}
|
||||
if selected, ok := findLaunchModel(models, primary); ok {
|
||||
primary = selected.Name
|
||||
}
|
||||
|
||||
settingsPath, err := deepSeekHarnessSettingsPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings, err := readDeepSeekHarnessYAMLDocument(settingsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse deepseek harness launch settings: %w", err)
|
||||
}
|
||||
if err := applyDeepSeekHarnessSettings(settings, primary, models, shouldManageOllamaWebSearch()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settingsData, err := yaml.Marshal(settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeDeepSeekHarnessFile(settingsPath, settingsData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
patchPath, err := deepSeekHarnessPatchPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patchData, err := yaml.Marshal([]map[string]any{
|
||||
{
|
||||
"id": "settings",
|
||||
"config": map[string]any{
|
||||
"path": settingsPath,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeDeepSeekHarnessFile(patchPath, patchData)
|
||||
}
|
||||
|
||||
func readDeepSeekHarnessYAML(path string) (map[string]any, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]any), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
settings := make(map[string]any)
|
||||
if err := yaml.Unmarshal(data, &settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if settings == nil {
|
||||
settings = make(map[string]any)
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func readDeepSeekHarnessYAMLDocument(path string) (*yaml.Node, error) {
|
||||
document := &yaml.Node{Kind: yaml.DocumentNode}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
document.Content = []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}}
|
||||
return document, nil
|
||||
}
|
||||
if err := yaml.Unmarshal(data, document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(document.Content) == 0 || document.Content[0].Kind == yaml.ScalarNode && document.Content[0].Tag == "!!null" {
|
||||
document.Content = []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}}
|
||||
}
|
||||
if document.Content[0].Kind != yaml.MappingNode {
|
||||
return nil, fmt.Errorf("settings root must be a mapping")
|
||||
}
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func applyDeepSeekHarnessSettings(document *yaml.Node, primary string, models []LaunchModel, manageWebSearch bool) error {
|
||||
settings := document.Content[0]
|
||||
selected := deepSeekHarnessEnsureYAMLMapping(settings, "agent-default-model")
|
||||
for key, value := range map[string]string{
|
||||
"provider": deepSeekHarnessProvider,
|
||||
"model": primary,
|
||||
} {
|
||||
if err := deepSeekHarnessSetYAMLValue(selected, key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
llm := deepSeekHarnessEnsureYAMLMapping(settings, "llm-pi-ai")
|
||||
providers := deepSeekHarnessEnsureYAMLMapping(llm, "providers")
|
||||
provider := deepSeekHarnessEnsureYAMLMapping(providers, deepSeekHarnessProvider)
|
||||
for key, value := range map[string]any{
|
||||
"displayName": "Ollama",
|
||||
"apiKeyEnv": deepSeekHarnessAPIKeyEnv,
|
||||
"api": "openai-completions",
|
||||
"baseURL": deepSeekHarnessBaseURL(),
|
||||
"models": deepSeekHarnessModelConfigs(primary, models),
|
||||
} {
|
||||
if err := deepSeekHarnessSetYAMLValue(provider, key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !manageWebSearch {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Harness's bundled search provider appends /messages to this /v1 base and
|
||||
// sends the Anthropic web_search server tool. This is separate from the main
|
||||
// model provider above; Harness does not expose a configured way to send the
|
||||
// native OpenAI Responses web_search tool.
|
||||
web := deepSeekHarnessEnsureYAMLMapping(settings, deepSeekHarnessWebSettings)
|
||||
for key, value := range map[string]string{
|
||||
"apiKeyEnv": deepSeekHarnessAPIKeyEnv,
|
||||
"baseURL": deepSeekHarnessBaseURL(),
|
||||
"model": primary,
|
||||
} {
|
||||
if err := deepSeekHarnessSetYAMLValue(web, key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deepSeekHarnessEnsureYAMLMapping(mapping *yaml.Node, key string) *yaml.Node {
|
||||
if value := deepSeekHarnessYAMLValue(mapping, key); value != nil && value.Kind == yaml.MappingNode {
|
||||
return value
|
||||
}
|
||||
value := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
deepSeekHarnessSetYAMLNode(mapping, key, value)
|
||||
return value
|
||||
}
|
||||
|
||||
func deepSeekHarnessSetYAMLValue(mapping *yaml.Node, key string, value any) error {
|
||||
node := &yaml.Node{}
|
||||
if err := node.Encode(value); err != nil {
|
||||
return err
|
||||
}
|
||||
deepSeekHarnessSetYAMLNode(mapping, key, node)
|
||||
return nil
|
||||
}
|
||||
|
||||
func deepSeekHarnessSetYAMLNode(mapping *yaml.Node, key string, value *yaml.Node) {
|
||||
for i := 0; i+1 < len(mapping.Content); i += 2 {
|
||||
if mapping.Content[i].Value == key {
|
||||
mapping.Content[i+1] = value
|
||||
return
|
||||
}
|
||||
}
|
||||
mapping.Content = append(mapping.Content,
|
||||
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
|
||||
value,
|
||||
)
|
||||
}
|
||||
|
||||
func deepSeekHarnessYAMLValue(mapping *yaml.Node, key string) *yaml.Node {
|
||||
for i := 0; i+1 < len(mapping.Content); i += 2 {
|
||||
if mapping.Content[i].Value == key {
|
||||
return mapping.Content[i+1]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deepSeekHarnessModelConfigs(primary string, models []LaunchModel) []any {
|
||||
ordered := append([]LaunchModel(nil), models...)
|
||||
if selected, ok := findLaunchModel(ordered, primary); ok {
|
||||
ordered = append([]LaunchModel{selected}, removeLaunchModel(ordered, primary)...)
|
||||
} else {
|
||||
ordered = append([]LaunchModel{fallbackLaunchModel(primary)}, ordered...)
|
||||
}
|
||||
|
||||
configs := make([]any, 0, len(ordered))
|
||||
seen := make(map[string]bool, len(ordered))
|
||||
for _, item := range ordered {
|
||||
if item.Name == "" || seen[item.Name] {
|
||||
continue
|
||||
}
|
||||
seen[item.Name] = true
|
||||
entry := map[string]any{
|
||||
"id": item.Name,
|
||||
"name": item.Name,
|
||||
"input": []string{"text"},
|
||||
}
|
||||
if slices.Contains(item.Capabilities, model.CapabilityVision) {
|
||||
entry["input"] = []string{"text", "image"}
|
||||
}
|
||||
if item.ContextLength > 0 {
|
||||
entry["contextWindow"] = item.ContextLength
|
||||
}
|
||||
if item.MaxOutputTokens > 0 {
|
||||
entry["maxTokens"] = item.MaxOutputTokens
|
||||
}
|
||||
configs = append(configs, entry)
|
||||
}
|
||||
return configs
|
||||
}
|
||||
|
||||
func (d *DeepSeekHarness) CurrentModel() string {
|
||||
settingsPath, err := deepSeekHarnessSettingsPath()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if !deepSeekHarnessPatchHealthy(settingsPath) {
|
||||
return ""
|
||||
}
|
||||
settings, err := readDeepSeekHarnessYAML(settingsPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
selected, _ := settings["agent-default-model"].(map[string]any)
|
||||
if selected == nil || selected["provider"] != deepSeekHarnessProvider {
|
||||
return ""
|
||||
}
|
||||
modelName, _ := selected["model"].(string)
|
||||
if modelName == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
llm, _ := settings["llm-pi-ai"].(map[string]any)
|
||||
providers, _ := llm["providers"].(map[string]any)
|
||||
provider, _ := providers[deepSeekHarnessProvider].(map[string]any)
|
||||
if !deepSeekHarnessProviderHealthy(provider, modelName) {
|
||||
return ""
|
||||
}
|
||||
if !shouldManageOllamaWebSearch() {
|
||||
return modelName
|
||||
}
|
||||
web, _ := settings[deepSeekHarnessWebSettings].(map[string]any)
|
||||
if !deepSeekHarnessWebProviderHealthy(web, modelName) {
|
||||
return ""
|
||||
}
|
||||
return modelName
|
||||
}
|
||||
|
||||
func deepSeekHarnessPatchHealthy(settingsPath string) bool {
|
||||
patchPath, err := deepSeekHarnessPatchPath()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
data, err := os.ReadFile(patchPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var patches []struct {
|
||||
ID string `yaml:"id"`
|
||||
Config struct {
|
||||
Path string `yaml:"path"`
|
||||
} `yaml:"config"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &patches); err != nil || len(patches) != 1 {
|
||||
return false
|
||||
}
|
||||
return patches[0].ID == "settings" && patches[0].Config.Path == settingsPath
|
||||
}
|
||||
|
||||
func deepSeekHarnessProviderHealthy(provider map[string]any, modelName string) bool {
|
||||
if provider == nil || provider["api"] != "openai-completions" || provider["apiKeyEnv"] != deepSeekHarnessAPIKeyEnv {
|
||||
return false
|
||||
}
|
||||
baseURL, _ := provider["baseURL"].(string)
|
||||
if strings.TrimRight(baseURL, "/") != strings.TrimRight(deepSeekHarnessBaseURL(), "/") {
|
||||
return false
|
||||
}
|
||||
models, _ := provider["models"].([]any)
|
||||
for _, raw := range models {
|
||||
entry, _ := raw.(map[string]any)
|
||||
if entry["id"] == modelName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func deepSeekHarnessWebProviderHealthy(web map[string]any, modelName string) bool {
|
||||
if web == nil || web["apiKeyEnv"] != deepSeekHarnessAPIKeyEnv || web["model"] != modelName {
|
||||
return false
|
||||
}
|
||||
baseURL, _ := web["baseURL"].(string)
|
||||
return strings.TrimRight(baseURL, "/") == strings.TrimRight(deepSeekHarnessBaseURL(), "/")
|
||||
}
|
||||
|
||||
func (d *DeepSeekHarness) Onboard() error {
|
||||
return config.MarkIntegrationOnboarded(deepSeekHarnessIntegrationName)
|
||||
}
|
||||
|
||||
func (d *DeepSeekHarness) RequiresInteractiveOnboarding() bool { return false }
|
||||
|
||||
func deepSeekHarnessBaseURL() string {
|
||||
return strings.TrimRight(envconfig.ConnectableHost().String(), "/") + "/v1"
|
||||
}
|
||||
|
||||
func deepSeekHarnessConfigDir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".ollama", "launch", "dsh"), nil
|
||||
}
|
||||
|
||||
func deepSeekHarnessSettingsPath() (string, error) {
|
||||
dir, err := deepSeekHarnessConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "settings.yaml"), nil
|
||||
}
|
||||
|
||||
func deepSeekHarnessPatchPath() (string, error) {
|
||||
dir, err := deepSeekHarnessConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "ollama.cordis.yml"), nil
|
||||
}
|
||||
|
||||
func writeDeepSeekHarnessFile(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fileutil.WriteWithBackup(path, data, deepSeekHarnessIntegrationName); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(path, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/types/model"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestDeepSeekHarnessRegistry(t *testing.T) {
|
||||
spec, err := LookupIntegrationSpec("deepseek-harness")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec.Name != deepSeekHarnessIntegrationName {
|
||||
t.Fatalf("canonical name = %q, want %q", spec.Name, deepSeekHarnessIntegrationName)
|
||||
}
|
||||
if spec.Runner.String() != "DeepSeek Harness" {
|
||||
t.Fatalf("display name = %q", spec.Runner.String())
|
||||
}
|
||||
if got := strings.Join(spec.Install.Command, " "); got != "npm install -g @deepseek-ai/dsh@latest" {
|
||||
t.Fatalf("install command = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepSeekHarnessConfigurePreservesSettingsAndIsIdempotent(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setTestHome(t, home)
|
||||
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:12345")
|
||||
|
||||
settingsPath, err := deepSeekHarnessSettingsPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := []byte("# keep-comment\ndefaults: &defaults\n mode: dark\ntheme: *defaults\nagent-default-model:\n # keep-reasoning-comment\n reasoningEffort: high\nllm-pi-ai:\n providers:\n custom:\n api: openai-completions\n baseURL: https://example.invalid/v1\n models:\n - id: custom-model\n ollama:\n # keep-retry-comment\n retryPolicy:\n maxAttempts: 2\nweb-search-deepseek:\n maxUses: 3\n")
|
||||
if err := os.WriteFile(settingsPath, existing, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
models := []LaunchModel{
|
||||
{Name: "qwen3.5:latest", ContextLength: 262144, MaxOutputTokens: 32768, Capabilities: []model.Capability{model.CapabilityVision}},
|
||||
{Name: "kimi-k2.6:cloud", ContextLength: 262144, MaxOutputTokens: 262144},
|
||||
}
|
||||
dsh := &DeepSeekHarness{}
|
||||
if err := dsh.ConfigureWithModels("qwen3.5", models); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstSettings, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
patchPath, err := deepSeekHarnessPatchPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstPatch, err := os.ReadFile(patchPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := dsh.ConfigureWithModels("qwen3.5", models); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondSettings, _ := os.ReadFile(settingsPath)
|
||||
secondPatch, _ := os.ReadFile(patchPath)
|
||||
if string(firstSettings) != string(secondSettings) || string(firstPatch) != string(secondPatch) {
|
||||
t.Fatal("repeated configuration changed Ollama-managed files")
|
||||
}
|
||||
for _, preserved := range []string{"# keep-comment", "&defaults", "*defaults", "# keep-reasoning-comment", "# keep-retry-comment"} {
|
||||
if !strings.Contains(string(firstSettings), preserved) {
|
||||
t.Fatalf("settings did not preserve %q:\n%s", preserved, firstSettings)
|
||||
}
|
||||
}
|
||||
|
||||
var settings map[string]any
|
||||
if err := yaml.Unmarshal(firstSettings, &settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if theme, _ := settings["theme"].(map[string]any); theme["mode"] != "dark" {
|
||||
t.Fatalf("unrelated settings were not preserved: %#v", settings["theme"])
|
||||
}
|
||||
selected, _ := settings["agent-default-model"].(map[string]any)
|
||||
if selected["provider"] != deepSeekHarnessProvider || selected["model"] != "qwen3.5:latest" {
|
||||
t.Fatalf("default model = %#v", selected)
|
||||
}
|
||||
if selected["reasoningEffort"] != "high" {
|
||||
t.Fatalf("default model settings were not preserved: %#v", selected)
|
||||
}
|
||||
llm, _ := settings["llm-pi-ai"].(map[string]any)
|
||||
providers, _ := llm["providers"].(map[string]any)
|
||||
if providers["custom"] == nil {
|
||||
t.Fatal("custom provider was removed")
|
||||
}
|
||||
provider, _ := providers[deepSeekHarnessProvider].(map[string]any)
|
||||
if provider["baseURL"] != "http://127.0.0.1:12345/v1" || provider["apiKeyEnv"] != deepSeekHarnessAPIKeyEnv {
|
||||
t.Fatalf("Ollama provider = %#v", provider)
|
||||
}
|
||||
retryPolicy, _ := provider["retryPolicy"].(map[string]any)
|
||||
if retryPolicy["maxAttempts"] != 2 {
|
||||
t.Fatalf("Ollama provider settings were not preserved: %#v", provider)
|
||||
}
|
||||
configuredModels, _ := provider["models"].([]any)
|
||||
if len(configuredModels) != 2 {
|
||||
t.Fatalf("configured models = %#v", configuredModels)
|
||||
}
|
||||
local, _ := configuredModels[0].(map[string]any)
|
||||
if local["id"] != "qwen3.5:latest" || local["contextWindow"] != 262144 || local["maxTokens"] != 32768 {
|
||||
t.Fatalf("local model = %#v", local)
|
||||
}
|
||||
if got, _ := local["input"].([]any); !slices.Equal(got, []any{"text", "image"}) {
|
||||
t.Fatalf("local model input = %#v", local["input"])
|
||||
}
|
||||
cloud, _ := configuredModels[1].(map[string]any)
|
||||
if cloud["id"] != "kimi-k2.6:cloud" || cloud["contextWindow"] != 262144 || cloud["maxTokens"] != 262144 {
|
||||
t.Fatalf("cloud model = %#v", cloud)
|
||||
}
|
||||
web, _ := settings[deepSeekHarnessWebSettings].(map[string]any)
|
||||
if web["baseURL"] != "http://127.0.0.1:12345/v1" || web["apiKeyEnv"] != deepSeekHarnessAPIKeyEnv || web["model"] != "qwen3.5:latest" {
|
||||
t.Fatalf("Ollama web search provider = %#v", web)
|
||||
}
|
||||
if web["maxUses"] != 3 {
|
||||
t.Fatalf("existing web search settings were not preserved: %#v", web)
|
||||
}
|
||||
|
||||
var patches []map[string]any
|
||||
if err := yaml.Unmarshal(firstPatch, &patches); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(patches) != 1 || patches[0]["id"] != "settings" {
|
||||
t.Fatalf("patches = %#v", patches)
|
||||
}
|
||||
patchConfig, _ := patches[0]["config"].(map[string]any)
|
||||
if patchConfig["path"] != settingsPath {
|
||||
t.Fatalf("settings patch path = %#v", patchConfig["path"])
|
||||
}
|
||||
if got := dsh.CurrentModel(); got != "qwen3.5:latest" {
|
||||
t.Fatalf("CurrentModel() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepSeekHarnessConfigureSkipsWebSearchWhenCloudDisabled(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setTestHome(t, home)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/status" {
|
||||
fmt.Fprint(w, `{"cloud":{"disabled":true,"source":"config"}}`)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
|
||||
settingsPath, err := deepSeekHarnessSettingsPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := []byte("web-search-deepseek:\n maxUses: 3\n")
|
||||
if err := os.WriteFile(settingsPath, existing, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dsh := &DeepSeekHarness{}
|
||||
if err := dsh.ConfigureWithModels("qwen3.5", []LaunchModel{{Name: "qwen3.5:latest"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
settings, err := readDeepSeekHarnessYAML(settingsPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
web, _ := settings[deepSeekHarnessWebSettings].(map[string]any)
|
||||
if len(web) != 1 || web["maxUses"] != 3 {
|
||||
t.Fatalf("web search settings = %#v", web)
|
||||
}
|
||||
if got := dsh.CurrentModel(); got != "qwen3.5:latest" {
|
||||
t.Fatalf("CurrentModel() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepSeekHarnessCurrentModelRejectsDrift(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
|
||||
dsh := &DeepSeekHarness{}
|
||||
if err := dsh.Configure("qwen3.5"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999")
|
||||
if got := dsh.CurrentModel(); got != "" {
|
||||
t.Fatalf("CurrentModel() = %q for stale endpoint", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepSeekHarnessCurrentModelRejectsPatchDrift(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
|
||||
dsh := &DeepSeekHarness{}
|
||||
if err := dsh.Configure("qwen3.5"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
patchPath, err := deepSeekHarnessPatchPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for name, data := range map[string][]byte{
|
||||
"missing": nil,
|
||||
"malformed": []byte("["),
|
||||
"wrong path": []byte(`- id: settings
|
||||
config:
|
||||
path: /tmp/not-managed.yaml
|
||||
`),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := dsh.Configure("qwen3.5"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if data == nil {
|
||||
if err := os.Remove(patchPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if err := os.WriteFile(patchPath, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := dsh.CurrentModel(); got != "" {
|
||||
t.Fatalf("CurrentModel() = %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepSeekHarnessConfigureRejectsMalformedSettingsWithoutOverwrite(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
settingsPath, err := deepSeekHarnessSettingsPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
malformed := []byte("llm-pi-ai: [")
|
||||
if err := os.WriteFile(settingsPath, malformed, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = (&DeepSeekHarness{}).Configure("qwen3.5")
|
||||
if err == nil || !strings.Contains(err.Error(), "parse deepseek harness launch settings") {
|
||||
t.Fatalf("Configure() error = %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(got, malformed) {
|
||||
t.Fatalf("malformed settings were overwritten: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepSeekHarnessConfigureAcceptsNullSettings(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
settingsPath, err := deepSeekHarnessSettingsPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(settingsPath, []byte("null\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := (&DeepSeekHarness{}).Configure("qwen3.5"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepSeekHarnessRunUsesManagedPatchAndCredential(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell test binary")
|
||||
}
|
||||
|
||||
home := t.TempDir()
|
||||
setTestHome(t, home)
|
||||
binDir := t.TempDir()
|
||||
logPath := filepath.Join(home, "dsh-invocation")
|
||||
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$DSH_TEST_LOG\"\nprintf '%s\\n' \"$OLLAMA_LAUNCH_DSH_API_KEY\" >> \"$DSH_TEST_LOG\"\n"
|
||||
bin := filepath.Join(binDir, "dsh")
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", strings.Join([]string{binDir, "/bin", "/usr/bin"}, string(os.PathListSeparator)))
|
||||
t.Setenv("DSH_TEST_LOG", logPath)
|
||||
t.Setenv(deepSeekHarnessAPIKeyEnv, "do-not-keep")
|
||||
|
||||
dsh := &DeepSeekHarness{}
|
||||
if err := dsh.Configure("qwen3.5"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := dsh.Run("qwen3.5", nil, []string{"--port", "0"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
patchPath, _ := deepSeekHarnessPatchPath()
|
||||
want := "web\n--patch\n" + patchPath + "\n--port\n0\nollama\n"
|
||||
if string(data) != want {
|
||||
t.Fatalf("invocation = %q, want %q", data, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepSeekHarnessRejectsManagedPatchArgument(t *testing.T) {
|
||||
for _, args := range [][]string{{"--patch", "other.yml"}, {"--patch=other.yml"}} {
|
||||
if err := (&DeepSeekHarness{}).Run("qwen3.5", nil, args); err == nil || !strings.Contains(err.Error(), "manages --patch") {
|
||||
t.Fatalf("Run(%v) error = %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDeepSeekHarnessInstalledUsesPublicNpmPackage(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell test binary")
|
||||
}
|
||||
|
||||
home := t.TempDir()
|
||||
binDir := t.TempDir()
|
||||
logPath := filepath.Join(home, "npm-invocation")
|
||||
npm := filepath.Join(binDir, "npm")
|
||||
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$DSH_NPM_LOG\"\nprintf '#!/bin/sh\\nexit 0\\n' > \"$DSH_INSTALLED_BIN\"\nchmod +x \"$DSH_INSTALLED_BIN\"\n"
|
||||
if err := os.WriteFile(npm, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dshBin := filepath.Join(binDir, "dsh")
|
||||
t.Setenv("PATH", strings.Join([]string{binDir, "/bin", "/usr/bin"}, string(os.PathListSeparator)))
|
||||
t.Setenv("DSH_NPM_LOG", logPath)
|
||||
t.Setenv("DSH_INSTALLED_BIN", dshBin)
|
||||
|
||||
restore := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
|
||||
defer restore()
|
||||
path, err := ensureDeepSeekHarnessInstalled()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path != dshBin {
|
||||
t.Fatalf("installed path = %q, want %q", path, dshBin)
|
||||
}
|
||||
data, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "install\n-g\n@deepseek-ai/dsh@latest\n" {
|
||||
t.Fatalf("npm invocation = %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepSeekHarnessLaunchArgs(t *testing.T) {
|
||||
got := deepSeekHarnessLaunchArgs("/tmp/ollama.cordis.yml", []string{"--port", "0"})
|
||||
want := []string{"web", "--patch", "/tmp/ollama.cordis.yml", "--port", "0"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("launch args = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepSeekHarnessWindowsNodeShims(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
node := filepath.Join(root, "node.exe")
|
||||
dsh := filepath.Join(root, "npm", "dsh.cmd")
|
||||
npm := filepath.Join(root, "node", "npm.cmd")
|
||||
dshEntrypoint := filepath.Join(filepath.Dir(dsh), "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js")
|
||||
npmEntrypoint := filepath.Join(filepath.Dir(npm), "node_modules", "npm", "bin", "npm-cli.js")
|
||||
for _, path := range []string{node, dsh, npm, dshEntrypoint, npmEntrypoint} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, nil, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
originalGOOS := deepSeekHarnessGOOS
|
||||
originalLookPath := deepSeekHarnessLookPath
|
||||
deepSeekHarnessGOOS = "windows"
|
||||
deepSeekHarnessLookPath = func(file string) (string, error) {
|
||||
if file == "node" {
|
||||
return node, nil
|
||||
}
|
||||
return "", exec.ErrNotFound
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
deepSeekHarnessGOOS = originalGOOS
|
||||
deepSeekHarnessLookPath = originalLookPath
|
||||
})
|
||||
|
||||
t.Run("dsh", func(t *testing.T) {
|
||||
cmd, err := deepSeekHarnessExecutableCommand(dsh, []string{"web", "--port", "0", "a&b"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{node, dshEntrypoint, "web", "--port", "0", "a&b"}
|
||||
if !slices.Equal(cmd.Args, want) {
|
||||
t.Fatalf("command args = %v, want %v", cmd.Args, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("npm", func(t *testing.T) {
|
||||
cmd, err := deepSeekHarnessNpmCommand(npm, []string{"install", "-g", deepSeekHarnessNpmPackage})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{node, npmEntrypoint, "install", "-g", deepSeekHarnessNpmPackage}
|
||||
if !slices.Equal(cmd.Args, want) {
|
||||
t.Fatalf("command args = %v, want %v", cmd.Args, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeepSeekHarnessWindowsNodeShimRequiresEntrypoint(t *testing.T) {
|
||||
originalGOOS := deepSeekHarnessGOOS
|
||||
originalLookPath := deepSeekHarnessLookPath
|
||||
deepSeekHarnessGOOS = "windows"
|
||||
deepSeekHarnessLookPath = func(file string) (string, error) {
|
||||
if file == "node" {
|
||||
return filepath.Join(t.TempDir(), "node.exe"), nil
|
||||
}
|
||||
return "", exec.ErrNotFound
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
deepSeekHarnessGOOS = originalGOOS
|
||||
deepSeekHarnessLookPath = originalLookPath
|
||||
})
|
||||
|
||||
_, err := deepSeekHarnessExecutableCommand(filepath.Join(t.TempDir(), "dsh.cmd"), nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "resolve Windows entrypoint") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepSeekHarnessInstallDependencyError(t *testing.T) {
|
||||
originalLookPath := deepSeekHarnessLookPath
|
||||
deepSeekHarnessLookPath = func(file string) (string, error) { return "", exec.ErrNotFound }
|
||||
t.Cleanup(func() { deepSeekHarnessLookPath = originalLookPath })
|
||||
_, err := ensureDeepSeekHarnessInstalled()
|
||||
if err == nil || !strings.Contains(err.Error(), "npm (Node.js) is required") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func (h *HermesDesktop) launchArgs(args []string) []string {
|
||||
}
|
||||
|
||||
func (h *HermesDesktop) shouldSkipDesktopBuild(args []string) bool {
|
||||
if hermesDesktopHasFlag(args, "--skip-build", "--source", "--build-only", "--help", "-h") {
|
||||
if hermesDesktopHasFlag(args, "--skip-build", "--force-build", "--source", "--build-only", "--help", "-h") {
|
||||
return false
|
||||
}
|
||||
return h.packagedAppExists()
|
||||
|
||||
@@ -349,6 +349,46 @@ func TestHermesConfigureUsesLaunchResolvedHostForModelDiscovery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesConfigurePreservesExplicitCloudModel(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withHermesPlatform(t, "darwin")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/tags":
|
||||
fmt.Fprint(w, `{"models":[{"name":"qwen3.5:cloud"},{"name":"gemma4"}]}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
withHermesOllamaURL(t, srv.URL)
|
||||
|
||||
if err := (&Hermes{}).Configure("qwen3.5:cloud"); err != nil {
|
||||
t.Fatalf("Configure returned error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, ".hermes", "config.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var cfg map[string]any
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatalf("failed to parse rewritten yaml: %v", err)
|
||||
}
|
||||
modelCfg, _ := cfg["model"].(map[string]any)
|
||||
if got, _ := modelCfg["default"].(string); got != "qwen3.5:cloud" {
|
||||
t.Fatalf("expected explicit cloud model to be preserved, got %q", got)
|
||||
}
|
||||
providers, _ := cfg["providers"].(map[string]any)
|
||||
provider, _ := providers[hermesProviderKey].(map[string]any)
|
||||
if got, _ := provider["default_model"].(string); got != "qwen3.5:cloud" {
|
||||
t.Fatalf("expected provider default model to be preserved, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesConfigureMigratesLegacyManagedAliases(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
@@ -678,6 +718,13 @@ func TestHermesDesktopRun(t *testing.T) {
|
||||
hasPackage: true,
|
||||
want: "[desktop --skip-build]",
|
||||
},
|
||||
{
|
||||
name: "force build",
|
||||
goos: runtime.GOOS,
|
||||
args: []string{"--force-build"},
|
||||
hasPackage: true,
|
||||
want: "[desktop --force-build]",
|
||||
},
|
||||
{
|
||||
name: "source mode",
|
||||
goos: runtime.GOOS,
|
||||
|
||||
@@ -64,7 +64,11 @@ func TestIntegrationLookup(t *testing.T) {
|
||||
{"codex app gui alias", "codex-gui", true, "ChatGPT"},
|
||||
{"hermes desktop", "hermes-desktop", true, "Hermes Desktop"},
|
||||
{"kimi", "kimi", true, "Kimi Code CLI"},
|
||||
{"muse", "muse", true, "Muse Code"},
|
||||
{"muse alias", "muse-code", true, "Muse Code"},
|
||||
{"droid", "droid", true, "Droid"},
|
||||
{"dsh", "dsh", true, "DeepSeek Harness"},
|
||||
{"deepseek harness alias", "deepseek-harness", true, "DeepSeek Harness"},
|
||||
{"opencode", "opencode", true, "OpenCode"},
|
||||
{"omp", "omp", true, "OMP"},
|
||||
{"pool", "pool", true, "Pool"},
|
||||
@@ -86,7 +90,7 @@ func TestIntegrationLookup(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIntegrationRegistry(t *testing.T) {
|
||||
expectedIntegrations := []string{"claude", "claude-desktop", "cline", "codex", "chatgpt", "kimi", "droid", "opencode", "omp", "hermes", "hermes-desktop", "pool", "qwen"}
|
||||
expectedIntegrations := []string{"claude", "claude-desktop", "cline", "codex", "chatgpt", "kimi", "muse", "droid", "dsh", "opencode", "omp", "hermes", "hermes-desktop", "pool", "qwen"}
|
||||
for _, name := range expectedIntegrations {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r, ok := integrations[name]
|
||||
@@ -127,7 +131,7 @@ func TestChatGPTMigratesLegacyCodexAppLaunchConfig(t *testing.T) {
|
||||
func TestHiddenIntegrationsExcludedFromVisibleLists(t *testing.T) {
|
||||
for _, info := range ListIntegrationInfos() {
|
||||
switch info.Name {
|
||||
case "vscode", "kimi":
|
||||
case "vscode", "kimi", "muse":
|
||||
t.Fatalf("hidden integration %q should not appear in ListIntegrationInfos", info.Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +296,9 @@ Supported integrations:
|
||||
copilot Copilot CLI (aliases: copilot-cli)
|
||||
omp OMP
|
||||
droid Droid
|
||||
dsh DeepSeek Harness (alias: deepseek-harness)
|
||||
kimi Kimi Code CLI
|
||||
muse Muse Code (aliases: muse-code)
|
||||
pi Pi
|
||||
pool Pool
|
||||
cline Cline
|
||||
@@ -311,6 +313,7 @@ Examples:
|
||||
ollama launch chatgpt --restore
|
||||
ollama launch hermes
|
||||
ollama launch hermes-desktop
|
||||
ollama launch dsh
|
||||
ollama launch droid --config (does not auto-launch)
|
||||
ollama launch codex --restore
|
||||
ollama launch codex -- --sandbox workspace-write`,
|
||||
|
||||
@@ -151,6 +151,21 @@ func (r *launcherManagedListRunner) ConfigureWithModels(primary string, models [
|
||||
return r.Configure(primary)
|
||||
}
|
||||
|
||||
type launcherCanonicalManagedListRunner struct {
|
||||
launcherManagedListRunner
|
||||
}
|
||||
|
||||
func (r *launcherCanonicalManagedListRunner) ConfigureWithModels(primary string, models []LaunchModel) error {
|
||||
r.configuredModelLists = append(r.configuredModelLists, launchModelNames(models))
|
||||
r.configured = append(r.configured, primary)
|
||||
if selected, ok := findLaunchModel(models, primary); ok {
|
||||
r.currentModel = selected.Name
|
||||
} else {
|
||||
r.currentModel = primary
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type launcherManagedAutodiscoveryRunner struct {
|
||||
launcherManagedRunner
|
||||
autodiscoveryConfigures int
|
||||
@@ -1089,6 +1104,51 @@ func TestLaunchIntegration_ManagedSingleIntegrationCanConfigureWithModelList(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ManagedSingleIntegrationSavesCanonicalModel(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
withInteractiveSession(t, true)
|
||||
withLauncherHooks(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":"qwen3.5:latest"}]}`)
|
||||
case "/api/show":
|
||||
fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
|
||||
runner := &launcherCanonicalManagedListRunner{}
|
||||
withIntegrationOverride(t, "stubmanaged", runner)
|
||||
|
||||
request := IntegrationLaunchRequest{Name: "stubmanaged", ModelOverride: "qwen3.5"}
|
||||
if err := LaunchIntegration(context.Background(), request); err != nil {
|
||||
t.Fatalf("first LaunchIntegration returned error: %v", err)
|
||||
}
|
||||
|
||||
saved, err := config.LoadIntegration("stubmanaged")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to reload managed integration config: %v", err)
|
||||
}
|
||||
if diff := compareStrings(saved.Models, []string{"qwen3.5:latest"}); diff != "" {
|
||||
t.Fatalf("saved models mismatch: %s", diff)
|
||||
}
|
||||
|
||||
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil {
|
||||
t.Fatalf("second LaunchIntegration returned error: %v", err)
|
||||
}
|
||||
if diff := compareStrings(runner.configured, []string{"qwen3.5"}); diff != "" {
|
||||
t.Fatalf("expected second launch to skip configuration: %s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ManagedAutodiscoverySkipsModelPicker(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
|
||||
@@ -323,6 +323,9 @@ func prepareManagedSingleIntegration(name string, managed ManagedSingleModel, mo
|
||||
if err != nil {
|
||||
return fmt.Errorf("setup failed: %w", err)
|
||||
}
|
||||
if current := managed.CurrentModel(); current != "" {
|
||||
model = current
|
||||
}
|
||||
if err := config.SaveIntegration(name, []string{model}); err != nil {
|
||||
return fmt.Errorf("failed to save: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"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"
|
||||
)
|
||||
|
||||
// Muse implements Runner and Editor for Meta's Muse Code CLI.
|
||||
//
|
||||
// Muse has no flag or environment variable that can supply a model catalog, and
|
||||
// it will not start without one: its provider fetches <origin>/muse-code/models
|
||||
// before the first inference call, reusing only the scheme, host and port of the
|
||||
// configured base URL. The undocumented model_catalog key in settings.json is
|
||||
// the one way to satisfy that without standing up the endpoint, so launch has to
|
||||
// write a settings file.
|
||||
//
|
||||
// That file is also where the base URL lives, and endpoint_transport is a single
|
||||
// global provider switch rather than an additive model list — writing it into
|
||||
// ~/.config/muse/settings.json would repoint the user's whole muse install. So
|
||||
// launch keeps its own config root and passes it to muse as XDG_CONFIG_HOME,
|
||||
// leaving a Meta-backed muse and `ollama launch muse` free to coexist.
|
||||
type Muse struct{}
|
||||
|
||||
const (
|
||||
// Muse checks every catalog row against the session's provider and profile
|
||||
// and drops the ones that disagree; "tbh" is the profile its sessions run
|
||||
// under.
|
||||
museProviderID = "meta"
|
||||
museProfileID = "tbh"
|
||||
|
||||
// Every row needs a context and an output limit, and the inventory only
|
||||
// reports an output limit for cloud models, so local models land on these.
|
||||
museFallbackContextLimit = 32768
|
||||
museFallbackOutputLimit = 32768
|
||||
|
||||
// museLoadTimeout bounds the default model's preload; a cold load of a
|
||||
// large model is tens of seconds, and on timeout the row falls back to
|
||||
// the inventory value rather than blocking the launch.
|
||||
museLoadTimeout = 5 * time.Minute
|
||||
|
||||
museRowDescription = "Served by Ollama"
|
||||
)
|
||||
|
||||
var museGOOS = runtime.GOOS
|
||||
|
||||
// museInstallCommand runs Meta's official installer, which places the muse
|
||||
// launcher in ~/.local/bin and downloads the matching binary next to it.
|
||||
var museInstallCommand = []string{"bash", "-c", "curl -fsSL https://dev.meta.ai/install.sh | bash"}
|
||||
|
||||
// ensureMuseInstalled returns the muse binary path, offering to run the
|
||||
// official installer when it is missing and verifying the binary afterward.
|
||||
func ensureMuseInstalled() (string, error) {
|
||||
if path, err := findMuse(); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
ok, err := ConfirmPrompt("Muse is not installed. Install now?")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", fmt.Errorf("muse installation cancelled")
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nInstalling Muse...\n")
|
||||
cmd := exec.Command(museInstallCommand[0], museInstallCommand[1:]...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("muse installation failed: %w", err)
|
||||
}
|
||||
|
||||
path, err := findMuse()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("muse installer finished but the binary was not found")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// museCatalogRow is one row of muse's settings-side model catalog. Muse requires
|
||||
// model_id, provider_id, profile_id and both limits; the rest only affect how
|
||||
// the model reads in muse's picker.
|
||||
type museCatalogRow struct {
|
||||
ModelID string `json:"model_id"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
ProfileID string `json:"profile_id"`
|
||||
DisplayLabel string `json:"display_label"`
|
||||
Visibility string `json:"visibility"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
ContextLimit int `json:"context_limit"`
|
||||
OutputLimit int `json:"output_limit"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (m *Muse) String() string { return "Muse Code" }
|
||||
|
||||
func (m *Muse) Supported() error {
|
||||
if museGOOS == "windows" {
|
||||
return fmt.Errorf("Warning: Muse is not currently supported on Windows")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Muse) Run(model string, models []LaunchModel, args []string) error {
|
||||
if err := m.Supported(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(model) == "" {
|
||||
return fmt.Errorf("model is required")
|
||||
}
|
||||
|
||||
bin, err := ensureMuseInstalled()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runModels := museApplyLoadedContext(museRunModels(model, models))
|
||||
// When Edit already wrote this catalog, Run only refreshes loaded limits.
|
||||
// Preserve Edit's backup by avoiding a second backup for that refresh.
|
||||
backup := !slices.Equal(m.Models(), launchModelNames(runModels))
|
||||
if err := writeMuseSettingsFile(runModels, backup); err != nil {
|
||||
return fmt.Errorf("failed to configure muse: %w", err)
|
||||
}
|
||||
|
||||
configHome, err := museConfigHome()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+configHome)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func (m *Muse) Edit(models []LaunchModel) error {
|
||||
return writeMuseSettings(models)
|
||||
}
|
||||
|
||||
func (m *Muse) Paths() []string {
|
||||
settingsPath, err := museSettingsPath()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(settingsPath); err != nil {
|
||||
return nil
|
||||
}
|
||||
return []string{settingsPath}
|
||||
}
|
||||
|
||||
func (m *Muse) Models() []string {
|
||||
settingsPath, err := museSettingsPath()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var settings struct {
|
||||
ModelCatalog []museCatalogRow `json:"model_catalog"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var models []string
|
||||
for _, row := range settings.ModelCatalog {
|
||||
if row.ModelID != "" {
|
||||
models = append(models, row.ModelID)
|
||||
}
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
// findMuse locates the muse launcher, which installs itself into ~/.local/bin
|
||||
// and is not always on PATH.
|
||||
func findMuse() (string, error) {
|
||||
if path, err := exec.LookPath("muse"); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fallback := filepath.Join(home, ".local", "bin", "muse")
|
||||
if info, err := os.Stat(fallback); err != nil || info.IsDir() {
|
||||
return "", fmt.Errorf("muse binary not found")
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
// museConfigHome is the XDG_CONFIG_HOME handed to muse at launch. Muse reads
|
||||
// settings.json from $XDG_CONFIG_HOME/muse, so the file sits one level deeper.
|
||||
func museConfigHome() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".ollama", "launch", "muse-config"), nil
|
||||
}
|
||||
|
||||
func museSettingsPath() (string, error) {
|
||||
configHome, err := museConfigHome()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(configHome, "muse", "settings.json"), nil
|
||||
}
|
||||
|
||||
// museUserSettingsPath is where muse keeps settings.json when it runs on its own.
|
||||
func museUserSettingsPath() (string, error) {
|
||||
if configHome := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); configHome != "" {
|
||||
return filepath.Join(configHome, "muse", "settings.json"), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".config", "muse", "settings.json"), nil
|
||||
}
|
||||
|
||||
// museBaseSettings returns the document the generated config is layered onto.
|
||||
//
|
||||
// Muse persists its own settings back into whichever config root it was given,
|
||||
// so once launch's file exists it becomes the base and everything muse recorded
|
||||
// there survives the next launch. Before that, the user's own settings seed it so
|
||||
// a first launch keeps their skills, hooks, MCP servers and TUI preferences.
|
||||
// A launch-owned file that exists but cannot be parsed is an error: falling
|
||||
// through would rewrite it and discard whatever muse persisted there.
|
||||
func museBaseSettings() (map[string]any, error) {
|
||||
if path, err := museSettingsPath(); err == nil {
|
||||
settings, err := fileutil.ReadJSON(path)
|
||||
switch {
|
||||
case err == nil && settings != nil:
|
||||
return settings, nil
|
||||
case err != nil && !os.IsNotExist(err):
|
||||
return nil, fmt.Errorf("read muse settings %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
if path, err := museUserSettingsPath(); err == nil {
|
||||
if settings, err := fileutil.ReadJSON(path); err == nil && settings != nil {
|
||||
return settings, nil
|
||||
}
|
||||
}
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
|
||||
// writeMuseSettings regenerates the settings file launch owns, replacing only
|
||||
// the keys that decide which provider and models muse talks to.
|
||||
func writeMuseSettings(models []LaunchModel) error {
|
||||
return writeMuseSettingsFile(models, true)
|
||||
}
|
||||
|
||||
func writeMuseSettingsFile(models []LaunchModel, backup bool) error {
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
settingsPath, err := museSettingsPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settings, err := museBaseSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := settings["schema_version"]; !ok {
|
||||
settings["schema_version"] = 1
|
||||
}
|
||||
settings["provider"] = museProviderID
|
||||
settings["model"] = models[0].Name
|
||||
settings["endpoint_transport"] = map[string]any{
|
||||
"base_url": envconfig.ConnectableHost().String() + "/v1",
|
||||
// Ollama wants no credential, and muse refuses to start on the default
|
||||
// "bearer" unless one is configured.
|
||||
"auth": "none",
|
||||
}
|
||||
settings["model_catalog"] = museCatalogRows(models)
|
||||
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if backup {
|
||||
return fileutil.WriteWithBackup(settingsPath, data, "muse")
|
||||
}
|
||||
return os.WriteFile(settingsPath, data, 0o600)
|
||||
}
|
||||
|
||||
func museCatalogRows(models []LaunchModel) []museCatalogRow {
|
||||
rows := make([]museCatalogRow, 0, len(models))
|
||||
for i, model := range models {
|
||||
contextLimit := model.ContextLength
|
||||
if contextLimit <= 0 {
|
||||
contextLimit = museFallbackContextLimit
|
||||
}
|
||||
outputLimit := model.MaxOutputTokens
|
||||
if outputLimit <= 0 {
|
||||
outputLimit = min(contextLimit, museFallbackOutputLimit)
|
||||
}
|
||||
|
||||
rows = append(rows, museCatalogRow{
|
||||
ModelID: model.Name,
|
||||
ProviderID: museProviderID,
|
||||
ProfileID: museProfileID,
|
||||
DisplayLabel: model.Name,
|
||||
Visibility: "visible",
|
||||
DisplayOrder: i,
|
||||
IsDefault: i == 0,
|
||||
ContextLimit: contextLimit,
|
||||
OutputLimit: outputLimit,
|
||||
Description: museRowDescription,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// museApplyLoadedContext overwrites the launched model's context length with
|
||||
// the size the server actually loaded it at. Muse budgets prompt packing and
|
||||
// compaction against the catalog row, and VRAM fit or server configuration may
|
||||
// hold the loaded size below the model's trained maximum — which is all the
|
||||
// inventory knows. Run-only on the selected model: editing the config must
|
||||
// not load anything, and the other picker rows are not worth a load each.
|
||||
func museApplyLoadedContext(models []LaunchModel) []LaunchModel {
|
||||
if len(models) == 0 || models[0].Remote {
|
||||
return models
|
||||
}
|
||||
models = cloneLaunchModels(models)
|
||||
if n := museLoadedContextLength(models[0].Name); n > 0 {
|
||||
models[0].ContextLength = n
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
// museLoadedContextLength reports the effective context length of a model as
|
||||
// the server loaded it, swappable so tests never touch a live server.
|
||||
var museLoadedContextLength = loadedContextLength
|
||||
|
||||
// loadedContextLength loads model and reads the running instance's context
|
||||
// length from the process list — the size the scheduler actually allocated.
|
||||
// An empty generate request is ollama's load-only call: it returns once the
|
||||
// model is resident without generating tokens, and the launch pays a load the
|
||||
// first muse request would otherwise pay. Returns 0 when anything fails, and
|
||||
// the caller keeps the inventory value.
|
||||
func loadedContextLength(model string) int {
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), museLoadTimeout)
|
||||
defer cancel()
|
||||
if err := client.Generate(ctx, &api.GenerateRequest{Model: model}, func(api.GenerateResponse) error { return nil }); err != nil {
|
||||
return 0
|
||||
}
|
||||
return LoadedContextWindow(ctx, client, model)
|
||||
}
|
||||
|
||||
// museRunModels puts the model being launched first, since muse takes its
|
||||
// default from the first visible row, and keeps the rest of the selection
|
||||
// available in muse's picker.
|
||||
func museRunModels(primary string, models []LaunchModel) []LaunchModel {
|
||||
resolved := make([]LaunchModel, 0, len(models)+1)
|
||||
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)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
)
|
||||
|
||||
// museGeneratedSettings is the launch-owned view of the file muse reads.
|
||||
type museGeneratedSettings struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Transport struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
Auth string `json:"auth"`
|
||||
} `json:"endpoint_transport"`
|
||||
ModelCatalog []museCatalogRow `json:"model_catalog"`
|
||||
MCPServers map[string]any `json:"mcp_servers"`
|
||||
TUI map[string]any `json:"tui"`
|
||||
}
|
||||
|
||||
func readMuseSettings(t *testing.T) museGeneratedSettings {
|
||||
t.Helper()
|
||||
path, err := museSettingsPath()
|
||||
if err != nil {
|
||||
t.Fatalf("museSettingsPath() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read generated settings: %v", err)
|
||||
}
|
||||
var settings museGeneratedSettings
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
t.Fatalf("generated settings is not valid JSON: %v", err)
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
// stubMuseLoadedContext pins the default model's loaded-context probe so tests
|
||||
// never load a model on a live server; 0 means "probe failed, keep inventory".
|
||||
func stubMuseLoadedContext(t *testing.T, n int) {
|
||||
t.Helper()
|
||||
prev := museLoadedContextLength
|
||||
museLoadedContextLength = func(string) int { return n }
|
||||
t.Cleanup(func() { museLoadedContextLength = prev })
|
||||
}
|
||||
|
||||
func TestMuseWriteSettings_BuildsCatalog(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
t.Setenv("OLLAMA_HOST", "127.0.0.1:11434")
|
||||
|
||||
models := []LaunchModel{
|
||||
{Name: "gpt-oss:20b", ContextLength: 131072, MaxOutputTokens: 32768},
|
||||
{Name: "qwen3:8b"},
|
||||
}
|
||||
if err := writeMuseSettings(models); err != nil {
|
||||
t.Fatalf("writeMuseSettings() error = %v", err)
|
||||
}
|
||||
|
||||
settings := readMuseSettings(t)
|
||||
if settings.Model != "gpt-oss:20b" {
|
||||
t.Errorf("model = %q, want gpt-oss:20b", settings.Model)
|
||||
}
|
||||
if settings.Provider != museProviderID {
|
||||
t.Errorf("provider = %q, want %q", settings.Provider, museProviderID)
|
||||
}
|
||||
if want := "http://127.0.0.1:11434/v1"; settings.Transport.BaseURL != want {
|
||||
t.Errorf("base_url = %q, want %q", settings.Transport.BaseURL, want)
|
||||
}
|
||||
// Anything but "none" makes muse demand a credential it will never need.
|
||||
if settings.Transport.Auth != "none" {
|
||||
t.Errorf("auth = %q, want none", settings.Transport.Auth)
|
||||
}
|
||||
|
||||
if len(settings.ModelCatalog) != 2 {
|
||||
t.Fatalf("model_catalog has %d rows, want 2", len(settings.ModelCatalog))
|
||||
}
|
||||
|
||||
first := settings.ModelCatalog[0]
|
||||
if first.ModelID != "gpt-oss:20b" || first.DisplayOrder != 0 || !first.IsDefault {
|
||||
t.Errorf("first row = %+v, want gpt-oss:20b as the default row", first)
|
||||
}
|
||||
if first.ContextLimit != 131072 || first.OutputLimit != 32768 {
|
||||
t.Errorf("first row limits = %d/%d, want 131072/32768", first.ContextLimit, first.OutputLimit)
|
||||
}
|
||||
|
||||
second := settings.ModelCatalog[1]
|
||||
if second.ModelID != "qwen3:8b" || second.DisplayOrder != 1 || second.IsDefault {
|
||||
t.Errorf("second row = %+v, want qwen3:8b as a non-default row", second)
|
||||
}
|
||||
if second.ContextLimit != museFallbackContextLimit || second.OutputLimit != museFallbackOutputLimit {
|
||||
t.Errorf("second row limits = %d/%d, want the fallbacks %d/%d",
|
||||
second.ContextLimit, second.OutputLimit, museFallbackContextLimit, museFallbackOutputLimit)
|
||||
}
|
||||
|
||||
// Rows that disagree with the session's provider or profile are dropped by
|
||||
// muse, which then falls back to a catalog fetch Ollama cannot serve.
|
||||
for _, row := range settings.ModelCatalog {
|
||||
if row.ProviderID != museProviderID {
|
||||
t.Errorf("row %q provider_id = %q, want %q", row.ModelID, row.ProviderID, museProviderID)
|
||||
}
|
||||
if row.ProfileID != museProfileID {
|
||||
t.Errorf("row %q profile_id = %q, want %q", row.ModelID, row.ProfileID, museProfileID)
|
||||
}
|
||||
if row.Visibility != "visible" {
|
||||
t.Errorf("row %q visibility = %q, want visible", row.ModelID, row.Visibility)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMuseWriteSettings_ClampsOutputLimitToContext(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
|
||||
if err := writeMuseSettings([]LaunchModel{{Name: "tiny:1b", ContextLength: 4096}}); err != nil {
|
||||
t.Fatalf("writeMuseSettings() error = %v", err)
|
||||
}
|
||||
|
||||
row := readMuseSettings(t).ModelCatalog[0]
|
||||
if row.ContextLimit != 4096 || row.OutputLimit != 4096 {
|
||||
t.Errorf("limits = %d/%d, want 4096/4096", row.ContextLimit, row.OutputLimit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMuseApplyLoadedContext pins the property that the launched model's row
|
||||
// carries the context the server actually loaded it with, not the trained
|
||||
// maximum from the inventory: muse budgets prompt packing and compaction
|
||||
// against this row, and the loaded size is what requests really get.
|
||||
func TestMuseApplyLoadedContext(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
stubMuseLoadedContext(t, 8192)
|
||||
|
||||
models := []LaunchModel{
|
||||
{Name: "gpt-oss:20b", ContextLength: 131072},
|
||||
{Name: "qwen3:8b", ContextLength: 131072},
|
||||
}
|
||||
if err := writeMuseSettings(museApplyLoadedContext(models)); err != nil {
|
||||
t.Fatalf("writeMuseSettings() error = %v", err)
|
||||
}
|
||||
|
||||
rows := readMuseSettings(t).ModelCatalog
|
||||
if rows[0].ContextLimit != 8192 || rows[0].OutputLimit != 8192 {
|
||||
t.Errorf("default row limits = %d/%d, want the loaded 8192/8192", rows[0].ContextLimit, rows[0].OutputLimit)
|
||||
}
|
||||
// Only the launched model is preloaded; other rows keep inventory values.
|
||||
if rows[1].ContextLimit != 131072 {
|
||||
t.Errorf("second row context = %d, want the inventory 131072", rows[1].ContextLimit)
|
||||
}
|
||||
// The probe result lands in a copy, not the caller's slice.
|
||||
if models[0].ContextLength != 131072 {
|
||||
t.Errorf("caller's model mutated to ContextLength=%d", models[0].ContextLength)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMuseApplyLoadedContext_SkipsRemoteAndEmpty: a remote (cloud) launch
|
||||
// target must not be preloaded, and an empty selection passes through.
|
||||
func TestMuseApplyLoadedContext_SkipsRemoteAndEmpty(t *testing.T) {
|
||||
prev := museLoadedContextLength
|
||||
museLoadedContextLength = func(string) int {
|
||||
t.Fatal("probe must not run for a remote model")
|
||||
return 0
|
||||
}
|
||||
t.Cleanup(func() { museLoadedContextLength = prev })
|
||||
|
||||
models := museApplyLoadedContext([]LaunchModel{{Name: "big:cloud", Remote: true, ContextLength: 65536}})
|
||||
if models[0].ContextLength != 65536 {
|
||||
t.Errorf("remote model context = %d, want untouched 65536", models[0].ContextLength)
|
||||
}
|
||||
if got := museApplyLoadedContext(nil); got != nil {
|
||||
t.Errorf("nil models = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMuseWriteSettings_KeepsUserPreferences(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setTestHome(t, home)
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
|
||||
userSettings := map[string]any{
|
||||
"schema_version": 2,
|
||||
"provider": "echo",
|
||||
"model": "muse-large",
|
||||
"endpoint_transport": map[string]any{
|
||||
"base_url": "https://api.meta.ai/v1",
|
||||
"auth": "bearer",
|
||||
},
|
||||
"mcp_servers": map[string]any{"github": map[string]any{"transport": "stdio"}},
|
||||
"tui": map[string]any{"theme": "dark"},
|
||||
}
|
||||
userPath := filepath.Join(home, ".config", "muse", "settings.json")
|
||||
if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := json.Marshal(userSettings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(userPath, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := writeMuseSettings([]LaunchModel{{Name: "gpt-oss:20b"}}); err != nil {
|
||||
t.Fatalf("writeMuseSettings() error = %v", err)
|
||||
}
|
||||
|
||||
settings := readMuseSettings(t)
|
||||
if settings.MCPServers["github"] == nil {
|
||||
t.Error("mcp_servers was not carried over from the user's settings")
|
||||
}
|
||||
if settings.TUI["theme"] != "dark" {
|
||||
t.Errorf("tui.theme = %v, want dark", settings.TUI["theme"])
|
||||
}
|
||||
if settings.SchemaVersion != 2 {
|
||||
t.Errorf("schema_version = %d, want the user's 2", settings.SchemaVersion)
|
||||
}
|
||||
if settings.Provider != museProviderID {
|
||||
t.Errorf("provider = %q, want it replaced with %q", settings.Provider, museProviderID)
|
||||
}
|
||||
if settings.Model != "gpt-oss:20b" {
|
||||
t.Errorf("model = %q, want gpt-oss:20b", settings.Model)
|
||||
}
|
||||
if strings.Contains(settings.Transport.BaseURL, "meta.ai") {
|
||||
t.Errorf("base_url = %q, want it repointed at Ollama", settings.Transport.BaseURL)
|
||||
}
|
||||
|
||||
// The user's own settings must be left exactly as they were.
|
||||
after, err := os.ReadFile(userPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(after) != string(data) {
|
||||
t.Errorf("user settings were modified:\n got: %s\nwant: %s", after, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMuseWriteSettings_KeepsWhatMusePersisted(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
|
||||
if err := writeMuseSettings([]LaunchModel{{Name: "gpt-oss:20b"}}); err != nil {
|
||||
t.Fatalf("writeMuseSettings() error = %v", err)
|
||||
}
|
||||
|
||||
// Muse writes its own settings back into the config root it was handed.
|
||||
settingsPath, err := museSettingsPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, err := fileutil.ReadJSON(settingsPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings["tui"] = map[string]any{"foreign_context_notice_shown": true}
|
||||
data, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(settingsPath, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := writeMuseSettings([]LaunchModel{{Name: "qwen3:8b"}}); err != nil {
|
||||
t.Fatalf("writeMuseSettings() error = %v", err)
|
||||
}
|
||||
|
||||
got := readMuseSettings(t)
|
||||
if got.TUI["foreign_context_notice_shown"] != true {
|
||||
t.Errorf("tui = %v, want muse's persisted settings kept", got.TUI)
|
||||
}
|
||||
if got.Model != "qwen3:8b" {
|
||||
t.Errorf("model = %q, want qwen3:8b", got.Model)
|
||||
}
|
||||
if diff := compareStrings(museCatalogModelIDs(got.ModelCatalog), []string{"qwen3:8b"}); diff != "" {
|
||||
t.Errorf("model_catalog mismatch: %s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func museCatalogModelIDs(rows []museCatalogRow) []string {
|
||||
ids := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row.ModelID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestMuseRunModels(t *testing.T) {
|
||||
selection := []LaunchModel{
|
||||
{Name: "gpt-oss:20b"},
|
||||
{Name: "qwen3:8b"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
primary string
|
||||
models []LaunchModel
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "primary already first",
|
||||
primary: "gpt-oss:20b",
|
||||
models: selection,
|
||||
want: []string{"gpt-oss:20b", "qwen3:8b"},
|
||||
},
|
||||
{
|
||||
name: "primary moves to front",
|
||||
primary: "qwen3:8b",
|
||||
models: selection,
|
||||
want: []string{"qwen3:8b", "gpt-oss:20b"},
|
||||
},
|
||||
{
|
||||
name: "primary outside the selection",
|
||||
primary: "llama3.2",
|
||||
models: selection,
|
||||
want: []string{"llama3.2", "gpt-oss:20b", "qwen3:8b"},
|
||||
},
|
||||
{
|
||||
name: "no selection",
|
||||
primary: "llama3.2",
|
||||
want: []string{"llama3.2"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := launchModelNames(museRunModels(tt.primary, tt.models))
|
||||
if diff := compareStrings(got, tt.want); diff != "" {
|
||||
t.Errorf("museRunModels(%q) mismatch: %s", tt.primary, diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMusePathsAndModels(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
m := &Muse{}
|
||||
|
||||
if paths := m.Paths(); paths != nil {
|
||||
t.Errorf("Paths() before configuring = %v, want nil", paths)
|
||||
}
|
||||
if models := m.Models(); models != nil {
|
||||
t.Errorf("Models() before configuring = %v, want nil", models)
|
||||
}
|
||||
|
||||
if err := m.Edit([]LaunchModel{{Name: "gpt-oss:20b"}, {Name: "qwen3:8b"}}); err != nil {
|
||||
t.Fatalf("Edit() error = %v", err)
|
||||
}
|
||||
|
||||
settingsPath, err := museSettingsPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if diff := compareStrings(m.Paths(), []string{settingsPath}); diff != "" {
|
||||
t.Errorf("Paths() mismatch: %s", diff)
|
||||
}
|
||||
if diff := compareStrings(m.Models(), []string{"gpt-oss:20b", "qwen3:8b"}); diff != "" {
|
||||
t.Errorf("Models() mismatch: %s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMuseRun_PointsMuseAtLaunchConfig(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell fake binary")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
stubMuseLoadedContext(t, 0)
|
||||
t.Setenv("OLLAMA_HOST", "127.0.0.1:11434")
|
||||
|
||||
logPath := filepath.Join(tmpDir, "muse-invocation.log")
|
||||
script := fmt.Sprintf(`#!/bin/sh
|
||||
printf "%%s\n" "$XDG_CONFIG_HOME" >> %q
|
||||
for arg in "$@"; do
|
||||
printf "%%s\n" "$arg" >> %q
|
||||
done
|
||||
exit 0
|
||||
`, logPath, logPath)
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "muse"), []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("failed to write fake muse: %v", err)
|
||||
}
|
||||
t.Setenv("PATH", tmpDir)
|
||||
|
||||
m := &Muse{}
|
||||
if err := m.Run("qwen3:8b", testLaunchModels("gpt-oss:20b", "qwen3:8b"), []string{"--trust-workspace"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read invocation log: %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("invocation log = %v, want the config home and one arg", lines)
|
||||
}
|
||||
|
||||
configHome, err := museConfigHome()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lines[0] != configHome {
|
||||
t.Errorf("XDG_CONFIG_HOME = %q, want %q", lines[0], configHome)
|
||||
}
|
||||
if lines[1] != "--trust-workspace" {
|
||||
t.Errorf("extra args = %v, want [--trust-workspace]", lines[1:])
|
||||
}
|
||||
|
||||
// Run configures muse itself, so the launched model leads the catalog even
|
||||
// when Edit never ran.
|
||||
settings := readMuseSettings(t)
|
||||
if settings.Model != "qwen3:8b" {
|
||||
t.Errorf("model = %q, want qwen3:8b", settings.Model)
|
||||
}
|
||||
if len(settings.ModelCatalog) != 2 || !settings.ModelCatalog[0].IsDefault ||
|
||||
settings.ModelCatalog[0].ModelID != "qwen3:8b" {
|
||||
t.Errorf("model_catalog = %+v, want qwen3:8b first and default", settings.ModelCatalog)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMuseRun_PreservesPreEditBackup(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell fake binary")
|
||||
}
|
||||
|
||||
home := t.TempDir()
|
||||
setTestHome(t, home)
|
||||
stubMuseLoadedContext(t, 8192)
|
||||
|
||||
writeFakeBinary(t, home, "muse")
|
||||
t.Setenv("PATH", home)
|
||||
|
||||
settingsPath, err := museSettingsPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := []byte(`{"schema_version":1,"mcp_servers":{"original":{"transport":"stdio"}}}`)
|
||||
if err := os.WriteFile(settingsPath, original, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := &Muse{}
|
||||
models := []LaunchModel{{Name: "gpt-oss:20b", ContextLength: 131072}}
|
||||
if err := m.Edit(models); err != nil {
|
||||
t.Fatalf("Edit() error = %v", err)
|
||||
}
|
||||
if err := m.Run("gpt-oss:20b", models, nil); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
settings := readMuseSettings(t)
|
||||
if settings.ModelCatalog[0].ContextLimit != 8192 {
|
||||
t.Fatalf("context limit = %d, want loaded context 8192", settings.ModelCatalog[0].ContextLimit)
|
||||
}
|
||||
|
||||
backups, err := filepath.Glob(filepath.Join(fileutil.BackupDir(), "muse", "settings.json.*"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(backups) != 1 {
|
||||
t.Fatalf("backup count = %d, want 1", len(backups))
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(backups[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != string(original) {
|
||||
t.Fatalf("pre-Edit settings backup = %s, want %s", data, original)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMuseRun_RequiresModel(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
if err := (&Muse{}).Run("", nil, nil); err == nil {
|
||||
t.Error("Run() without a model = nil, want an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMuseSupported(t *testing.T) {
|
||||
oldGOOS := museGOOS
|
||||
t.Cleanup(func() { museGOOS = oldGOOS })
|
||||
|
||||
m := &Muse{}
|
||||
for _, goos := range []string{"darwin", "linux"} {
|
||||
museGOOS = goos
|
||||
if err := m.Supported(); err != nil {
|
||||
t.Errorf("Supported() on %s = %v, want nil", goos, err)
|
||||
}
|
||||
}
|
||||
|
||||
museGOOS = "windows"
|
||||
if err := m.Supported(); err == nil {
|
||||
t.Error("Supported() on windows = nil, want an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMuseBaseSettings_MalformedLaunchFileFails(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
|
||||
settingsPath, err := museSettingsPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
malformed := []byte("{ this is not json")
|
||||
if err := os.WriteFile(settingsPath, malformed, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := writeMuseSettings([]LaunchModel{{Name: "gpt-oss:20b"}}); err == nil {
|
||||
t.Fatal("writeMuseSettings() = nil, want parse error for malformed launch-owned settings")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != string(malformed) {
|
||||
t.Fatalf("malformed settings were rewritten to %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMuseInstalled(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Muse is not supported on Windows")
|
||||
}
|
||||
|
||||
withConfirm := func(t *testing.T, fn func(prompt string) (bool, error)) {
|
||||
t.Helper()
|
||||
oldConfirm := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
return fn(prompt)
|
||||
}
|
||||
t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm })
|
||||
}
|
||||
|
||||
stubInstaller := func(t *testing.T, script string) {
|
||||
t.Helper()
|
||||
oldCommand := museInstallCommand
|
||||
// Absolute shell path and explicit PATH: these tests clear PATH to
|
||||
// hide any real muse, which also hides the script's own utilities.
|
||||
museInstallCommand = []string{"/bin/sh", "-c", "PATH=/usr/bin:/bin; " + script}
|
||||
t.Cleanup(func() { museInstallCommand = oldCommand })
|
||||
}
|
||||
|
||||
t.Run("already installed skips prompt", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setTestHome(t, home)
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
binDir := filepath.Join(home, ".local", "bin")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFakeBinary(t, binDir, "muse")
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
t.Fatalf("did not expect prompt, got %q", prompt)
|
||||
return false, nil
|
||||
})
|
||||
|
||||
bin, err := ensureMuseInstalled()
|
||||
if err != nil {
|
||||
t.Fatalf("ensureMuseInstalled() error = %v", err)
|
||||
}
|
||||
if bin != filepath.Join(binDir, "muse") {
|
||||
t.Fatalf("bin = %q, want %q", bin, filepath.Join(binDir, "muse"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("installs after confirmation and verifies binary", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setTestHome(t, home)
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
binDir := filepath.Join(home, ".local", "bin")
|
||||
stubInstaller(t, fmt.Sprintf("mkdir -p %q && printf '#!/bin/sh\n' > %q && chmod +x %q",
|
||||
binDir, filepath.Join(binDir, "muse"), filepath.Join(binDir, "muse")))
|
||||
|
||||
prompted := false
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
prompted = true
|
||||
return true, nil
|
||||
})
|
||||
|
||||
bin, err := ensureMuseInstalled()
|
||||
if err != nil {
|
||||
t.Fatalf("ensureMuseInstalled() error = %v", err)
|
||||
}
|
||||
if !prompted {
|
||||
t.Fatal("expected an install confirmation prompt")
|
||||
}
|
||||
if bin != filepath.Join(binDir, "muse") {
|
||||
t.Fatalf("bin = %q, want %q", bin, filepath.Join(binDir, "muse"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("declined prompt cancels", func(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
stubInstaller(t, "exit 0")
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
return false, nil
|
||||
})
|
||||
|
||||
if _, err := ensureMuseInstalled(); err == nil {
|
||||
t.Fatal("ensureMuseInstalled() = nil, want cancellation error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("installer without binary fails verification", func(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
stubInstaller(t, "exit 0")
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
return true, nil
|
||||
})
|
||||
|
||||
if _, err := ensureMuseInstalled(); err == nil {
|
||||
t.Fatal("ensureMuseInstalled() = nil, want verification error")
|
||||
}
|
||||
})
|
||||
}
|
||||
+37
-1
@@ -33,7 +33,7 @@ type IntegrationInfo struct {
|
||||
Description string
|
||||
}
|
||||
|
||||
var launcherIntegrationOrder = []string{"claude", "chatgpt", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp", "cline", "droid", "pi", "pool", "qwen"}
|
||||
var launcherIntegrationOrder = []string{"claude", "chatgpt", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp", "cline", "droid", "dsh", "pi", "pool", "qwen"}
|
||||
|
||||
var integrationSpecs = []*IntegrationSpec{
|
||||
{
|
||||
@@ -123,6 +123,24 @@ var integrationSpecs = []*IntegrationSpec{
|
||||
URL: "https://moonshotai.github.io/kimi-cli/en/guides/getting-started.html",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "muse",
|
||||
Runner: &Muse{},
|
||||
Aliases: []string{"muse-code"},
|
||||
Description: "Meta's agentic coding CLI",
|
||||
Hidden: true,
|
||||
Install: IntegrationInstallSpec{
|
||||
CheckInstalled: func() bool {
|
||||
_, err := findMuse()
|
||||
return err == nil
|
||||
},
|
||||
EnsureInstalled: func() error {
|
||||
_, err := ensureMuseInstalled()
|
||||
return err
|
||||
},
|
||||
Command: museInstallCommand,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "copilot",
|
||||
Runner: &Copilot{},
|
||||
@@ -148,6 +166,24 @@ var integrationSpecs = []*IntegrationSpec{
|
||||
URL: "https://docs.factory.ai/cli/getting-started/quickstart",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: deepSeekHarnessIntegrationName,
|
||||
Runner: &DeepSeekHarness{},
|
||||
Aliases: []string{"deepseek-harness"},
|
||||
Description: "DeepSeek's open-source agent harness",
|
||||
Install: IntegrationInstallSpec{
|
||||
CheckInstalled: func() bool {
|
||||
_, err := deepSeekHarnessLookPath("dsh")
|
||||
return err == nil
|
||||
},
|
||||
EnsureInstalled: func() error {
|
||||
_, err := ensureDeepSeekHarnessInstalled()
|
||||
return err
|
||||
},
|
||||
URL: "https://github.com/deepseek-ai/deepseek-harness",
|
||||
Command: []string{"npm", "install", "-g", deepSeekHarnessNpmPackage},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "opencode",
|
||||
Runner: &OpenCode{},
|
||||
|
||||
@@ -2,6 +2,7 @@ package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -263,11 +264,8 @@ func approvalToolCallDetail(call coreagent.ApprovalToolCall, width int) string {
|
||||
}
|
||||
var lines []string
|
||||
lines = append(lines, "path: "+path)
|
||||
if oldText, ok := rawStringArg(call.Args, "old_text"); ok {
|
||||
lines = append(lines, fmt.Sprintf("old_text: %d chars", len([]rune(oldText))))
|
||||
}
|
||||
if newText, ok := rawStringArg(call.Args, "new_text"); ok {
|
||||
lines = append(lines, fmt.Sprintf("new_text: %d chars", len([]rune(newText))))
|
||||
if summary := editApprovalSummary(call.Args); summary != "" {
|
||||
lines = append(lines, strings.Split(summary, "\n")...)
|
||||
}
|
||||
return chatMetaStyle.Render(strings.Join(lines, "\n"))
|
||||
default:
|
||||
@@ -278,6 +276,77 @@ func approvalToolCallDetail(call coreagent.ApprovalToolCall, width int) string {
|
||||
}
|
||||
}
|
||||
|
||||
// editApprovalSummary summarizes edit tool arguments for the approval dialog:
|
||||
// the edit count and total sizes for the edits array form, or the legacy
|
||||
// top-level old_text/new_text sizes.
|
||||
func editApprovalSummary(args map[string]any) string {
|
||||
edits := rawEditEntries(args)
|
||||
if len(edits) > 0 {
|
||||
oldChars, newChars := 0, 0
|
||||
for _, entry := range edits {
|
||||
if oldText, ok := editEntryText(entry, "old_text"); ok {
|
||||
oldChars += len([]rune(oldText))
|
||||
}
|
||||
if newText, ok := editEntryText(entry, "new_text"); ok {
|
||||
newChars += len([]rune(newText))
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("edits: %d (old: %d chars, new: %d chars)", len(edits), oldChars, newChars)
|
||||
}
|
||||
if raw, ok := args["edits"]; ok {
|
||||
if s, ok := raw.(string); ok {
|
||||
return fmt.Sprintf("edits: %d chars", len([]rune(s)))
|
||||
}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
if oldText, ok := rawStringArg(args, "old_text"); ok {
|
||||
lines = append(lines, fmt.Sprintf("old_text: %d chars", len([]rune(oldText))))
|
||||
}
|
||||
if newText, ok := rawStringArg(args, "new_text"); ok {
|
||||
lines = append(lines, fmt.Sprintf("new_text: %d chars", len([]rune(newText))))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// rawEditEntries extracts the edits array entries from tool arguments,
|
||||
// tolerating a JSON string encoding and camelCase keys.
|
||||
func rawEditEntries(args map[string]any) []map[string]any {
|
||||
raw, ok := args["edits"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if s, ok := raw.(string); ok {
|
||||
var decoded []map[string]any
|
||||
if err := json.Unmarshal([]byte(s), &decoded); err != nil {
|
||||
return nil
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
entries := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
if entry, ok := item.(map[string]any); ok {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func editEntryText(entry map[string]any, snake string) (string, bool) {
|
||||
if value, ok := entry[snake].(string); ok {
|
||||
return value, true
|
||||
}
|
||||
camel := strings.TrimSuffix(snake, "_text") + "Text"
|
||||
if value, ok := entry[camel].(string); ok {
|
||||
return value, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func renderApprovalChoices(request coreagent.ApprovalRequest, cursor int, width int) []string {
|
||||
var lines []string
|
||||
for i, choice := range chatApprovalChoices {
|
||||
|
||||
@@ -134,6 +134,31 @@ func TestChatApprovalUsesShellNameForPermissionPrompt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatApprovalRendersSkillLoad(t *testing.T) {
|
||||
request := coreagent.ApprovalRequest{
|
||||
WorkingDir: "/repo",
|
||||
Calls: []coreagent.ApprovalToolCall{{
|
||||
ToolCallID: "call-skill-1",
|
||||
ToolName: "skill",
|
||||
Args: map[string]any{"name": "release-notes"},
|
||||
ApprovalScope: "skill",
|
||||
}},
|
||||
}
|
||||
|
||||
lines := stripANSI(strings.Join((&chatModel{approvalPrompt: &chatApprovalPrompt{request: request}}).renderApprovalPromptLines(80), "\n"))
|
||||
for _, want := range []string{"name: release-notes", "2. Always allow skill"} {
|
||||
if !strings.Contains(lines, want) {
|
||||
t.Fatalf("skill approval prompt missing %q:\n%s", want, lines)
|
||||
}
|
||||
}
|
||||
|
||||
m := chatModel{}
|
||||
m.upsertApprovalToolEntries(request)
|
||||
if len(m.entries) != 1 || !strings.Contains(stripANSI(toolStatusLine(m.entries[0])), `skill("release-notes") needs approval`) {
|
||||
t.Fatalf("skill approval entry = %#v", m.entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatApprovalPromptOmitsDuplicateBatchDetails(t *testing.T) {
|
||||
request := coreagent.ApprovalRequest{
|
||||
WorkingDir: "/repo",
|
||||
|
||||
+67
-47
@@ -55,6 +55,8 @@ type Options struct {
|
||||
Client coreagent.ChatClient
|
||||
Tools *coreagent.Registry
|
||||
Skills *coreagent.SkillCatalog
|
||||
ImportSkills func(string) (coreagent.SkillImportResult, error)
|
||||
ReloadSkills func() (*coreagent.SkillCatalog, error)
|
||||
ToolRegistryForModel func(context.Context, string) *coreagent.Registry
|
||||
ToolsDisabled bool
|
||||
MultiModalForModel func(context.Context, string) bool
|
||||
@@ -98,46 +100,50 @@ type chatModel struct {
|
||||
entries []chatEntry
|
||||
workingDir string
|
||||
|
||||
input []rune
|
||||
inputCursor int
|
||||
inputCursorSet bool
|
||||
inputAttachments []chatInputAttachment
|
||||
inputPastedTexts []chatInputPastedText
|
||||
nextImageID int
|
||||
nextAudioID int
|
||||
nextPastedTextID int
|
||||
promptHistory []string
|
||||
promptCursor int
|
||||
promptDraft []rune
|
||||
promptActive bool
|
||||
running bool
|
||||
awaitingModel bool
|
||||
compacting bool
|
||||
cancel context.CancelFunc
|
||||
events <-chan tea.Msg
|
||||
compactEvents <-chan tea.Msg
|
||||
detectedToolCalls []chatEntry
|
||||
scroll int
|
||||
toolOutputMode bool
|
||||
toolOutputOpen bool
|
||||
flowPrintedLines int
|
||||
thinking bool
|
||||
thinkingTokens int
|
||||
compactingTokens int
|
||||
contextTokens int
|
||||
contextEstimate bool
|
||||
modelPicker *chatModelPicker
|
||||
modelPickerModels []ModelOption
|
||||
thinkPicker *chatThinkPicker
|
||||
promptDebug *chatPromptDebug
|
||||
approvalPrompt *chatApprovalPrompt
|
||||
approvalController *chatApprovalController
|
||||
approvalState *coreagent.ApprovalState
|
||||
cloudAuthPrompt *cloudAuthPrompt
|
||||
pendingModel string
|
||||
defaultAllowAll bool
|
||||
permissionNotice string
|
||||
selection chatSelection
|
||||
input []rune
|
||||
inputCursor int
|
||||
inputCursorSet bool
|
||||
inputAttachments []chatInputAttachment
|
||||
inputPastedTexts []chatInputPastedText
|
||||
nextImageID int
|
||||
nextAudioID int
|
||||
nextPastedTextID int
|
||||
promptHistory []string
|
||||
promptCursor int
|
||||
promptDraft []rune
|
||||
promptActive bool
|
||||
running bool
|
||||
awaitingModel bool
|
||||
compacting bool
|
||||
cancel context.CancelFunc
|
||||
events <-chan tea.Msg
|
||||
compactEvents <-chan tea.Msg
|
||||
detectedToolCalls []chatEntry
|
||||
scroll int
|
||||
toolOutputMode bool
|
||||
toolOutputOpen bool
|
||||
thinkingDetailsOpen bool
|
||||
flowPrintedLines int
|
||||
thinking bool
|
||||
thinkingPhaseStart int
|
||||
thinkingTokens int
|
||||
compactingTokens int
|
||||
contextTokens int
|
||||
contextEstimate bool
|
||||
modelPicker *chatModelPicker
|
||||
modelPickerModels []ModelOption
|
||||
thinkPicker *chatThinkPicker
|
||||
promptDebug *chatPromptDebug
|
||||
approvalPrompt *chatApprovalPrompt
|
||||
approvalController *chatApprovalController
|
||||
approvalState *coreagent.ApprovalState
|
||||
cloudAuthPrompt *cloudAuthPrompt
|
||||
pendingModel string
|
||||
defaultAllowAll bool
|
||||
permissionNotice string
|
||||
selection chatSelection
|
||||
|
||||
systemPromptDisabled bool
|
||||
|
||||
width int
|
||||
height int
|
||||
@@ -224,9 +230,11 @@ func Run(ctx context.Context, opts Options) (*Result, error) {
|
||||
m.nextImageID, m.nextAudioID = nextInputAttachmentIDsFromMessages(m.messages)
|
||||
m.nextPastedTextID = nextInputPastedTextIDFromMessages(m.messages)
|
||||
m.entries = entriesFromMessages(m.messages)
|
||||
if !m.openModelOnInit {
|
||||
m.refreshContextWindowTokens(m.opts.Model)
|
||||
}
|
||||
// Context window is resolved post-load (chatModelPreloadDoneMsg) rather than
|
||||
// here: for local models /api/ps only reports the running num_ctx after the
|
||||
// model loads, and opts.ContextWindowTokens already holds Show's max as a
|
||||
// pre-load fallback. Refreshing now would just re-derive that same value
|
||||
// (and block construction on a network call).
|
||||
m.contextTokens = m.estimatePromptTokens(m.messages, "")
|
||||
m.contextEstimate = true
|
||||
if m.openModelOnInit {
|
||||
@@ -375,7 +383,8 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if msg.result.WorkingDir != "" {
|
||||
m.workingDir = msg.result.WorkingDir
|
||||
}
|
||||
m.refreshContextWindowTokens(m.responseModelName(&msg.result.Latest))
|
||||
// Context window is settled by preload (local num_ctx) or is
|
||||
// static (cloud); no refresh needed post-run.
|
||||
m.contextTokens = m.estimatePromptTokens(m.messages, "")
|
||||
m.contextEstimate = true
|
||||
if !messagesEndWithCompactionResult(m.messages) {
|
||||
@@ -550,7 +559,7 @@ func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m.updatePromptDebug(msg)
|
||||
}
|
||||
if msg.Type == tea.KeyCtrlO {
|
||||
m.toggleInlineToolOutput()
|
||||
m.toggleInlineTranscriptDetails()
|
||||
m.disarmQuit()
|
||||
m.disarmEsc()
|
||||
return m.withFlowTranscriptRepaint(nil)
|
||||
@@ -589,6 +598,12 @@ func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.insertInputNewline()
|
||||
return m, nil
|
||||
}
|
||||
if m.applySlashCompletion() {
|
||||
return m, nil
|
||||
}
|
||||
if m.applyMentionCompletion() {
|
||||
return m, nil
|
||||
}
|
||||
return m.handleSubmit()
|
||||
case tea.KeyCtrlJ:
|
||||
m.insertInputNewline()
|
||||
@@ -670,10 +685,12 @@ func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *chatModel) toggleInlineToolOutput() {
|
||||
func (m *chatModel) toggleInlineTranscriptDetails() {
|
||||
m.toolOutputMode = true
|
||||
m.toolOutputOpen = !m.toolOutputOpen
|
||||
m.applyToolOutputMode()
|
||||
m.thinkingDetailsOpen = !m.thinkingDetailsOpen
|
||||
m.applyThinkingDetails()
|
||||
m.selection = chatSelection{}
|
||||
m.scroll = 0
|
||||
}
|
||||
@@ -955,6 +972,10 @@ func (m chatModel) flowTranscriptHoldEntryIndex() int {
|
||||
if isToolActiveStatus(entry.status) {
|
||||
return index
|
||||
}
|
||||
case "thinking":
|
||||
if entry.status == "running" {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -1093,7 +1114,6 @@ func (m *chatModel) startSkillRun(name, prompt string) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m *chatModel) startRunWithMessages(displayInput, historyInput string, newMessages []api.Message, extraSystemPrompt, skillName string) (tea.Model, tea.Cmd) {
|
||||
m.refreshContextWindowTokens(m.opts.Model)
|
||||
m.addPromptHistory(historyInput)
|
||||
m.entries = append(m.entries, newChatEntry(chatEntry{role: "user", content: displayInput}))
|
||||
if len(newMessages) > 1 {
|
||||
|
||||
+18
-11
@@ -16,9 +16,11 @@ import (
|
||||
)
|
||||
|
||||
type chatPromptDebug struct {
|
||||
request api.ChatRequest
|
||||
tokens int
|
||||
scroll int
|
||||
request api.ChatRequest
|
||||
tokens int
|
||||
scroll int
|
||||
lines []string
|
||||
linesWidth int
|
||||
}
|
||||
|
||||
const maxPromptDebugToolResultRunes = 400
|
||||
@@ -220,10 +222,13 @@ func (m chatModel) previewChatRequest(opts coreagent.RunOptions, messages []api.
|
||||
return req
|
||||
}
|
||||
|
||||
func (m chatModel) promptDebugLines(width int) []string {
|
||||
func (m *chatModel) promptDebugLines(width int) []string {
|
||||
if m.promptDebug == nil {
|
||||
return nil
|
||||
}
|
||||
if m.promptDebug.lines != nil && m.promptDebug.linesWidth == width {
|
||||
return m.promptDebug.lines
|
||||
}
|
||||
req := m.promptDebug.request
|
||||
innerWidth := max(20, width-2)
|
||||
lines := []string{
|
||||
@@ -259,15 +264,17 @@ func (m chatModel) promptDebugLines(width int) []string {
|
||||
lines = append(lines, "", chatHeaderStyle.Render("Tools"))
|
||||
if len(req.Tools) == 0 {
|
||||
lines = append(lines, chatMetaStyle.Render("none"))
|
||||
return lines
|
||||
}
|
||||
for i, tool := range req.Tools {
|
||||
if i > 0 {
|
||||
lines = append(lines, "")
|
||||
} else {
|
||||
for i, tool := range req.Tools {
|
||||
if i > 0 {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
lines = append(lines, promptDebugToolLines(i+1, tool, innerWidth)...)
|
||||
}
|
||||
lines = append(lines, promptDebugToolLines(i+1, tool, innerWidth)...)
|
||||
}
|
||||
return lines
|
||||
m.promptDebug.lines = lines
|
||||
m.promptDebug.linesWidth = width
|
||||
return m.promptDebug.lines
|
||||
}
|
||||
|
||||
func promptDebugFieldLine(label, value string, width int) string {
|
||||
|
||||
@@ -75,15 +75,18 @@ func (m *chatModel) applyAgentEvent(event coreagent.Event) {
|
||||
case coreagent.EventThinkingDelta:
|
||||
m.awaitingModel = false
|
||||
if event.Thinking != "" {
|
||||
m.thinking = true
|
||||
if event.Tokens > 0 {
|
||||
m.thinkingTokens = max(m.thinkingTokens, event.Tokens)
|
||||
} else {
|
||||
m.thinkingTokens += approximateTokenCount(event.Thinking)
|
||||
}
|
||||
idx := m.ensureLiveAssistantMessage()
|
||||
if !m.thinking {
|
||||
m.thinkingPhaseStart = len(m.liveMessages[idx].Thinking)
|
||||
}
|
||||
m.thinking = true
|
||||
m.liveMessages[idx].Thinking += event.Thinking
|
||||
m.syncThinkingEntry()
|
||||
m.syncThinkingEntry(m.liveMessages[idx].Thinking[m.thinkingPhaseStart:])
|
||||
contextChanged = true
|
||||
}
|
||||
case coreagent.EventMessageDelta:
|
||||
@@ -109,7 +112,6 @@ func (m *chatModel) applyAgentEvent(event coreagent.Event) {
|
||||
contextChanged = true
|
||||
case coreagent.EventToolStarted:
|
||||
m.resetStreamingState()
|
||||
m.refreshContextWindowTokens(m.opts.Model)
|
||||
startedAt := time.Now()
|
||||
idx := m.findActiveToolEntry(event.ToolCallID)
|
||||
if idx < 0 {
|
||||
@@ -127,7 +129,6 @@ func (m *chatModel) applyAgentEvent(event coreagent.Event) {
|
||||
m.markEntryDirty(idx)
|
||||
case coreagent.EventToolFinished:
|
||||
m.resetStreamingState()
|
||||
m.refreshContextWindowTokens(m.opts.Model)
|
||||
if event.WorkingDir != "" {
|
||||
m.workingDir = event.WorkingDir
|
||||
}
|
||||
|
||||
@@ -22,6 +22,84 @@ func TestApplyAgentEventStreamsAssistantContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAgentEventStreamsThinkingThenCollapsesOnAssistantOrTool(t *testing.T) {
|
||||
m := chatModel{running: true}
|
||||
|
||||
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventThinkingDelta, Thinking: "first "})
|
||||
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventThinkingDelta, Thinking: "second", Tokens: 7})
|
||||
if len(m.entries) != 1 || m.entries[0].role != "thinking" || !m.entries[0].expanded || m.entries[0].content != "first second" {
|
||||
t.Fatalf("live thinking entry = %#v", m.entries)
|
||||
}
|
||||
if got := m.liveMessages[0].Thinking; got != "first second" {
|
||||
t.Fatalf("live message thinking = %q, want full streamed value", got)
|
||||
}
|
||||
if view := stripANSI(m.renderTranscript(100)); !strings.Contains(view, "Thinking ↓ 7 tokens") || !strings.Contains(view, "first second") {
|
||||
t.Fatalf("live thinking trace missing from transcript:\n%s", view)
|
||||
}
|
||||
|
||||
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventMessageDelta, Content: "answer"})
|
||||
if m.entries[0].status != "done" || m.entries[0].expanded {
|
||||
t.Fatalf("assistant content should collapse thinking: %#v", m.entries[0])
|
||||
}
|
||||
collapsed := stripANSI(m.renderTranscript(100))
|
||||
if !strings.Contains(collapsed, "Thought") || strings.Contains(collapsed, "7 tokens") || strings.Contains(collapsed, "first second") {
|
||||
t.Fatalf("collapsed thinking should remain as a thought row without trace content:\n%s", collapsed)
|
||||
}
|
||||
if got := m.liveMessages[0].Thinking; got != "first second" {
|
||||
t.Fatalf("collapsing display must not change request history: %q", got)
|
||||
}
|
||||
|
||||
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventThinkingDelta, Thinking: "tool plan"})
|
||||
if entry := m.entries[len(m.entries)-1]; entry.role != "thinking" || entry.content != "tool plan" {
|
||||
t.Fatalf("second thinking phase should contain only its own deltas: %#v", entry)
|
||||
}
|
||||
if got := m.liveMessages[0].Thinking; got != "first secondtool plan" {
|
||||
t.Fatalf("message history should retain both thinking phases exactly: %q", got)
|
||||
}
|
||||
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolStarted, ToolCallID: "call-1", ToolName: "bash"})
|
||||
if entry := m.entries[len(m.entries)-2]; entry.role != "thinking" || entry.status != "done" || entry.expanded {
|
||||
t.Fatalf("tool transition should collapse thinking: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAgentEventDoesNotCreateThinkingEntryWithoutThinking(t *testing.T) {
|
||||
m := chatModel{running: true}
|
||||
|
||||
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventThinkingDelta, Tokens: 12})
|
||||
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventMessageDelta, Content: "answer"})
|
||||
if len(m.entries) != 1 || m.entries[0].role != "assistant" {
|
||||
t.Fatalf("empty thinking event should not create a trace: %#v", m.entries)
|
||||
}
|
||||
if len(m.liveMessages) != 1 || m.liveMessages[0].Thinking != "" {
|
||||
t.Fatalf("empty thinking event should not alter message history: %#v", m.liveMessages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAgentEventPreservesCollapsedThoughtsAcrossToolGrouping(t *testing.T) {
|
||||
m := chatModel{running: true}
|
||||
|
||||
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventThinkingDelta, Thinking: "first plan", Tokens: 1})
|
||||
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolStarted, ToolCallID: "call-1", ToolName: "bash"})
|
||||
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolFinished, ToolCallID: "call-1", ToolName: "bash", Content: "one"})
|
||||
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventThinkingDelta, Thinking: "second plan", Tokens: 1})
|
||||
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolStarted, ToolCallID: "call-2", ToolName: "bash"})
|
||||
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolFinished, ToolCallID: "call-2", ToolName: "bash", Content: "two"})
|
||||
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolStarted, ToolCallID: "call-3", ToolName: "bash"})
|
||||
|
||||
if len(m.entries) != 5 {
|
||||
t.Fatalf("entries = %#v, want two thought rows and three tool rows", m.entries)
|
||||
}
|
||||
for _, index := range []int{0, 2} {
|
||||
entry := m.entries[index]
|
||||
if entry.role != "thinking" || entry.status != "done" || entry.expanded {
|
||||
t.Fatalf("collapsed thought %d = %#v", index, entry)
|
||||
}
|
||||
}
|
||||
if transcript := stripANSI(m.renderTranscript(100)); strings.Count(transcript, "Thought") != 2 || strings.Contains(transcript, "1 token") {
|
||||
t.Fatalf("transcript should retain both thought rows:\n%s", transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAgentEventTracksToolLifecycle(t *testing.T) {
|
||||
m := chatModel{running: true}
|
||||
args := map[string]any{"command": "pwd"}
|
||||
|
||||
+253
-30
@@ -51,12 +51,12 @@ const (
|
||||
)
|
||||
|
||||
var chatSlashCommands = []chatSlashCommand{
|
||||
{name: "/clear", description: "clear this chat"},
|
||||
{name: "/model", description: "switch models"},
|
||||
{name: "/new", description: "start a new chat"},
|
||||
{name: "/think", description: "set thinking mode"},
|
||||
{name: "/tools", description: "toggle tools on or off"},
|
||||
{name: "/skills", description: "list available skills"},
|
||||
{name: "/system", usage: "/system [on|off]", description: "show or set the built-in system prompt"},
|
||||
{name: "/skills", usage: "/skills [import codex|claude|pi]", description: "list or import skills"},
|
||||
{name: "/compact", description: "summarize older context"},
|
||||
{name: "/help", description: "show commands", aliases: []string{"/?"}},
|
||||
{name: "/bye", description: "exit", aliases: []string{"/exit"}},
|
||||
@@ -64,12 +64,33 @@ var chatSlashCommands = []chatSlashCommand{
|
||||
{name: "/save", usage: "/save <filename>", description: "save request JSON; saved as <filename>.json"},
|
||||
}
|
||||
|
||||
var skillsImportCompletions = []chatCompletion{
|
||||
{value: "/skills import codex", label: "/skills import codex", description: "import from ~/.codex/skills"},
|
||||
{value: "/skills import claude", label: "/skills import claude", description: "import from ~/.claude/skills"},
|
||||
{value: "/skills import pi", label: "/skills import pi", description: "import from ~/.pi/agent/skills"},
|
||||
}
|
||||
|
||||
// BuiltinSlashCommandNames returns the names reserved by built-in slash
|
||||
// commands, including aliases.
|
||||
func BuiltinSlashCommandNames() []string {
|
||||
names := make(map[string]struct{})
|
||||
for _, command := range chatSlashCommands {
|
||||
names[strings.TrimPrefix(command.name, "/")] = struct{}{}
|
||||
for _, alias := range command.aliases {
|
||||
names[strings.TrimPrefix(alias, "/")] = struct{}{}
|
||||
}
|
||||
}
|
||||
reserved := make([]string, 0, len(names))
|
||||
for name := range names {
|
||||
reserved = append(reserved, name)
|
||||
}
|
||||
sort.Strings(reserved)
|
||||
return reserved
|
||||
}
|
||||
|
||||
func (m *chatModel) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
m.syncInputPlaceholders()
|
||||
input := strings.TrimSpace(string(m.input))
|
||||
if selected, ok := m.selectedSlashCommand(); ok {
|
||||
input = selected
|
||||
}
|
||||
if input == "" {
|
||||
return *m, nil
|
||||
}
|
||||
@@ -91,16 +112,32 @@ func (m *chatModel) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
return m.submitInput(input)
|
||||
}
|
||||
|
||||
func (m chatModel) selectedSlashCommand() (string, bool) {
|
||||
input := strings.TrimSpace(string(m.input))
|
||||
func (m *chatModel) applySlashCompletion() bool {
|
||||
rawInput := string(m.input)
|
||||
input := strings.TrimSpace(rawInput)
|
||||
if !strings.HasPrefix(input, "/") {
|
||||
return "", false
|
||||
return false
|
||||
}
|
||||
if _, _, known := slashCommandInvocation(input); known && !hasSystemCommandArgument(rawInput) {
|
||||
return false
|
||||
}
|
||||
completions := m.slashCompletions()
|
||||
if len(completions) == 0 || !completionIsSelectable(completions) {
|
||||
return "", false
|
||||
return false
|
||||
}
|
||||
return completions[clamp(m.complete, 0, len(completions)-1)].value, true
|
||||
selected := completions[clamp(m.complete, 0, len(completions)-1)]
|
||||
if strings.EqualFold(selected.value, input) {
|
||||
return false
|
||||
}
|
||||
// Reset prompt-history state: Up/Down is shared between history recall and
|
||||
// slash completion, and a recalled prompt may start with "/" and trigger
|
||||
// completion. Keep the two in sync when we accept a completion.
|
||||
m.resetPromptHistoryCursor()
|
||||
m.input = []rune(selected.value)
|
||||
m.inputCursor = len(m.input)
|
||||
m.inputCursorSet = true
|
||||
m.complete = 0
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
|
||||
@@ -117,8 +154,6 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
|
||||
case command == "/help":
|
||||
m.entries = append(m.entries, newSlashEntry(m.helpSummary()))
|
||||
return *m, nil
|
||||
case command == "/clear" && args == "":
|
||||
return m.resetChat("cleared")
|
||||
case command == "/model":
|
||||
return m.openModelPicker(args)
|
||||
case command == "/think" && args == "":
|
||||
@@ -127,6 +162,8 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
|
||||
return m.handleThinkCommand(args)
|
||||
case command == "/tools":
|
||||
return m.handleToolsCommand(args)
|
||||
case command == "/system":
|
||||
return m.handleSystemCommand(args)
|
||||
case command == "/skills":
|
||||
return m.handleSkillsCommand(args)
|
||||
case command == "/prompt":
|
||||
@@ -150,8 +187,10 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m *chatModel) handleSkillsCommand(args string) (tea.Model, tea.Cmd) {
|
||||
if strings.TrimSpace(args) != "" {
|
||||
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: "usage: /skills"}))
|
||||
if fields := strings.Fields(args); len(fields) == 2 && fields[0] == "import" {
|
||||
return m.handleSkillsImport(fields[1])
|
||||
} else if len(fields) != 0 {
|
||||
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: "usage: /skills [import codex|claude|pi]"}))
|
||||
return *m, nil
|
||||
}
|
||||
skills := m.opts.Skills.List()
|
||||
@@ -172,6 +211,61 @@ func (m *chatModel) handleSkillsCommand(args string) (tea.Model, tea.Cmd) {
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func (m *chatModel) handleSkillsImport(source string) (tea.Model, tea.Cmd) {
|
||||
importSkills := m.opts.ImportSkills
|
||||
if importSkills == nil {
|
||||
importSkills = coreagent.ImportSkills
|
||||
}
|
||||
result, err := importSkills(source)
|
||||
if err != nil {
|
||||
m.status = "error"
|
||||
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: fmt.Sprintf("Could not import %s skills: %v", source, err)}))
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
if len(result.Imported) != 0 || len(result.Existing) != 0 {
|
||||
reload := m.opts.ReloadSkills
|
||||
if reload == nil {
|
||||
reload = func() (*coreagent.SkillCatalog, error) {
|
||||
return coreagent.LoadDefaultSkills(m.currentWorkingDir())
|
||||
}
|
||||
}
|
||||
catalog, err := reload()
|
||||
if err != nil {
|
||||
m.status = "error"
|
||||
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: fmt.Sprintf("%s\n\nCould not reload skills: %v", skillsImportSummary(result), err)}))
|
||||
return *m, nil
|
||||
}
|
||||
m.opts.Skills = catalog
|
||||
if m.opts.ToolRegistryForModel != nil && m.opts.Model != "" {
|
||||
m.opts.Tools = m.opts.ToolRegistryForModel(m.ctx, m.opts.Model)
|
||||
}
|
||||
if m.opts.SystemPromptForModel != nil {
|
||||
m.opts.SystemPrompt = m.opts.SystemPromptForModel(m.ctx, m.opts.Model, m.opts.Tools, m.opts.ToolsDisabled)
|
||||
}
|
||||
m.status = "skills reloaded"
|
||||
}
|
||||
m.entries = append(m.entries, newSlashEntry(skillsImportSummary(result)))
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func skillsImportSummary(result coreagent.SkillImportResult) string {
|
||||
if len(result.Imported) == 0 && len(result.Existing) == 0 && len(result.Failures) == 0 {
|
||||
return fmt.Sprintf("No %s skills found at %s.", result.Source, result.SourceDir)
|
||||
}
|
||||
var lines []string
|
||||
if len(result.Imported) != 0 {
|
||||
lines = append(lines, fmt.Sprintf("Imported %d skill%s from %s.", len(result.Imported), pluralSuffix(len(result.Imported)), result.SourceDir))
|
||||
}
|
||||
if len(result.Existing) != 0 {
|
||||
lines = append(lines, "Already present (left unchanged): "+strings.Join(result.Existing, ", ")+".")
|
||||
}
|
||||
for _, failure := range result.Failures {
|
||||
lines = append(lines, fmt.Sprintf("Skipped %s: %v.", failure.Name, failure.Err))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func skillsDirForDisplay(catalog *coreagent.SkillCatalog) string {
|
||||
if catalog != nil && catalog.Dir() != "" {
|
||||
return catalog.Dir()
|
||||
@@ -228,6 +322,40 @@ func (m *chatModel) handleToolsCommand(args string) (tea.Model, tea.Cmd) {
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func (m *chatModel) handleSystemCommand(args string) (tea.Model, tea.Cmd) {
|
||||
switch strings.ToLower(strings.TrimSpace(args)) {
|
||||
case "":
|
||||
m.entries = append(m.entries, newSlashEntry(m.systemCommandOutput()))
|
||||
case "on":
|
||||
m.systemPromptDisabled = false
|
||||
m.status = "system prompt on"
|
||||
m.entries = append(m.entries, newSlashEntry(m.systemCommandOutput()))
|
||||
case "off":
|
||||
m.systemPromptDisabled = true
|
||||
m.status = "system prompt off"
|
||||
m.entries = append(m.entries, newSlashEntry(m.systemCommandOutput()))
|
||||
default:
|
||||
m.status = "error"
|
||||
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: "usage: /system [on|off]"}))
|
||||
}
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func (m chatModel) systemPromptState() string {
|
||||
if m.systemPromptDisabled {
|
||||
return "off"
|
||||
}
|
||||
return "on"
|
||||
}
|
||||
|
||||
func (m chatModel) systemCommandOutput() string {
|
||||
prompt := strings.TrimSpace(m.opts.SystemPrompt)
|
||||
if prompt == "" {
|
||||
prompt = "(empty)"
|
||||
}
|
||||
return "Built-in system prompt is " + m.systemPromptState() + ".\n\n" + prompt + "\n\nWarning: Changing the system prompt during a session breaks the prompt cache."
|
||||
}
|
||||
|
||||
func (m chatModel) slashInputIsMultimodalFile(input string) bool {
|
||||
if !m.opts.MultiModal {
|
||||
return false
|
||||
@@ -1108,13 +1236,19 @@ func (m chatModel) completions() []chatCompletion {
|
||||
|
||||
func (m chatModel) slashCompletions() []chatCompletion {
|
||||
rawInput := string(m.input)
|
||||
input := strings.TrimSpace(rawInput)
|
||||
input := strings.TrimLeftFunc(rawInput, unicode.IsSpace)
|
||||
if !strings.HasPrefix(input, "/") {
|
||||
return nil
|
||||
}
|
||||
if argument, ok := systemCommandArgument(rawInput); ok {
|
||||
return systemCommandCompletions(argument)
|
||||
}
|
||||
if m.skillSlashPromptStarted(rawInput) {
|
||||
return nil
|
||||
}
|
||||
if completions := matchingSkillsImportCompletions(input); completions != nil {
|
||||
return completions
|
||||
}
|
||||
|
||||
commands := matchingSlashCommands(input)
|
||||
completions := make([]chatCompletion, 0, len(commands))
|
||||
@@ -1125,6 +1259,13 @@ func (m chatModel) slashCompletions() []chatCompletion {
|
||||
description: command.description,
|
||||
})
|
||||
}
|
||||
if strings.EqualFold(input, "/skills") {
|
||||
completions = append(completions, chatCompletion{
|
||||
value: "/skills import",
|
||||
label: "/skills import",
|
||||
description: "import skills from Codex, Claude, or Pi",
|
||||
})
|
||||
}
|
||||
// Each catalog skill is also invocable as "/<skill-name>"; surface them as
|
||||
// completions so they are discoverable by typing.
|
||||
if m.opts.Skills != nil {
|
||||
@@ -1154,6 +1295,74 @@ func (m chatModel) slashCompletions() []chatCompletion {
|
||||
return completions
|
||||
}
|
||||
|
||||
func matchingSkillsImportCompletions(input string) []chatCompletion {
|
||||
const importCommand = "/skills import"
|
||||
lower := strings.ToLower(input)
|
||||
if lower == "/skills" {
|
||||
return nil // Preserve Enter on /skills as the listing command.
|
||||
}
|
||||
if !strings.HasPrefix(lower, "/skills ") {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(importCommand, lower) {
|
||||
return []chatCompletion{{
|
||||
value: importCommand,
|
||||
label: importCommand,
|
||||
description: "import skills from Codex, Claude, or Pi",
|
||||
}}
|
||||
}
|
||||
if !strings.HasPrefix(lower, importCommand) {
|
||||
return nil
|
||||
}
|
||||
prefix := strings.TrimSpace(strings.TrimPrefix(lower, importCommand))
|
||||
completions := make([]chatCompletion, 0, len(skillsImportCompletions))
|
||||
for _, completion := range skillsImportCompletions {
|
||||
if strings.HasPrefix(strings.TrimPrefix(completion.value, importCommand+" "), prefix) {
|
||||
completions = append(completions, completion)
|
||||
}
|
||||
}
|
||||
if len(completions) == 0 {
|
||||
return []chatCompletion{{label: "No matching skill sources"}}
|
||||
}
|
||||
return completions
|
||||
}
|
||||
|
||||
func hasSystemCommandArgument(input string) bool {
|
||||
_, ok := systemCommandArgument(input)
|
||||
return ok
|
||||
}
|
||||
|
||||
func systemCommandArgument(input string) (string, bool) {
|
||||
input = strings.TrimLeftFunc(input, unicode.IsSpace)
|
||||
end := strings.IndexFunc(input, unicode.IsSpace)
|
||||
if end < 0 {
|
||||
return "", false
|
||||
}
|
||||
command, _, known := slashCommandInvocation(input[:end])
|
||||
if !known || command != "/system" {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(input[end:]), true
|
||||
}
|
||||
|
||||
func systemCommandCompletions(argument string) []chatCompletion {
|
||||
argument = strings.ToLower(argument)
|
||||
options := []chatCompletion{
|
||||
{value: "/system on", label: "on", description: "enable the built-in system prompt"},
|
||||
{value: "/system off", label: "off", description: "disable the built-in system prompt"},
|
||||
}
|
||||
completions := make([]chatCompletion, 0, len(options))
|
||||
for _, option := range options {
|
||||
if strings.HasPrefix(option.label, argument) {
|
||||
completions = append(completions, option)
|
||||
}
|
||||
}
|
||||
if len(completions) == 0 {
|
||||
return []chatCompletion{{label: "No matching options"}}
|
||||
}
|
||||
return completions
|
||||
}
|
||||
|
||||
func (m chatModel) skillSlashPromptStarted(input string) bool {
|
||||
input = strings.TrimLeftFunc(input, unicode.IsSpace)
|
||||
end := strings.IndexFunc(input, unicode.IsSpace)
|
||||
@@ -1210,8 +1419,7 @@ func slashCommandInvocation(input string) (string, string, bool) {
|
||||
}
|
||||
|
||||
func (m chatModel) mentionCompletions() []chatCompletion {
|
||||
input := string(m.input)
|
||||
_, query, ok := activeMentionToken(input)
|
||||
_, query, ok := activeMentionToken(m.input, m.normalizedInputCursor())
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
@@ -1275,13 +1483,13 @@ func (m chatModel) mentionCompletions() []chatCompletion {
|
||||
return completions
|
||||
}
|
||||
|
||||
func activeMentionToken(input string) (int, string, bool) {
|
||||
runes := []rune(input)
|
||||
start := len(runes)
|
||||
for start > 0 && !unicode.IsSpace(runes[start-1]) {
|
||||
func activeMentionToken(input []rune, cursor int) (int, string, bool) {
|
||||
cursor = clamp(cursor, 0, len(input))
|
||||
start := cursor
|
||||
for start > 0 && !unicode.IsSpace(input[start-1]) {
|
||||
start--
|
||||
}
|
||||
token := string(runes[start:])
|
||||
token := string(input[start:cursor])
|
||||
if !strings.HasPrefix(token, "@") {
|
||||
return 0, "", false
|
||||
}
|
||||
@@ -1339,6 +1547,7 @@ func (m *chatModel) applyCompletion() bool {
|
||||
}
|
||||
m.resetPromptHistoryCursor()
|
||||
selected := completions[clamp(m.complete, 0, len(completions)-1)]
|
||||
cursor := m.normalizedInputCursor()
|
||||
input := string(m.input)
|
||||
if strings.HasPrefix(strings.TrimSpace(input), "/") {
|
||||
m.input = []rune(selected.value)
|
||||
@@ -1348,22 +1557,35 @@ func (m *chatModel) applyCompletion() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
start, _, ok := activeMentionToken(input)
|
||||
start, _, ok := activeMentionToken(m.input, cursor)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
suffix := ""
|
||||
if !selected.directory {
|
||||
suffix = " "
|
||||
completed := []rune("@" + selected.value)
|
||||
if !selected.directory && (cursor == len(m.input) || !unicode.IsSpace(m.input[cursor])) {
|
||||
completed = append(completed, ' ')
|
||||
}
|
||||
next := make([]rune, 0, len(m.input)-cursor+start+len(completed))
|
||||
next = append(next, m.input[:start]...)
|
||||
next = append(next, completed...)
|
||||
next = append(next, m.input[cursor:]...)
|
||||
m.input = next
|
||||
m.inputCursor = start + len(completed)
|
||||
if !selected.directory && m.inputCursor < len(m.input) && unicode.IsSpace(m.input[m.inputCursor]) {
|
||||
m.inputCursor++
|
||||
}
|
||||
next := string([]rune(input)[:start]) + "@" + selected.value + suffix
|
||||
m.input = []rune(next)
|
||||
m.inputCursor = len(m.input)
|
||||
m.inputCursorSet = true
|
||||
m.complete = 0
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *chatModel) applyMentionCompletion() bool {
|
||||
if strings.HasPrefix(strings.TrimSpace(string(m.input)), "/") {
|
||||
return false
|
||||
}
|
||||
return m.applyCompletion()
|
||||
}
|
||||
|
||||
func completionIsSelectable(completions []chatCompletion) bool {
|
||||
return len(completions) > 0 && completions[0].value != ""
|
||||
}
|
||||
@@ -1389,6 +1611,7 @@ func (m chatModel) helpSummary() string {
|
||||
"",
|
||||
"- `shift+enter`: insert a newline",
|
||||
"- `shift+tab`: toggle permission mode",
|
||||
"- `ctrl+o`: toggle transcript details",
|
||||
"- `↑/↓`: previous or next prompt",
|
||||
"- `ctrl+a/e`: move to line start or end",
|
||||
)
|
||||
@@ -1397,7 +1620,7 @@ func (m chatModel) helpSummary() string {
|
||||
|
||||
func (m chatModel) systemPrompt(extra string) string {
|
||||
var parts []string
|
||||
if strings.TrimSpace(m.opts.SystemPrompt) != "" {
|
||||
if !m.systemPromptDisabled && strings.TrimSpace(m.opts.SystemPrompt) != "" {
|
||||
parts = append(parts, strings.TrimSpace(m.opts.SystemPrompt))
|
||||
}
|
||||
if strings.TrimSpace(extra) != "" {
|
||||
|
||||
+340
-5
@@ -30,6 +30,7 @@ func TestChatHelpCommandShowsV1Commands(t *testing.T) {
|
||||
"**Commands**",
|
||||
"- `/model`: switch models",
|
||||
"- `/think`: set thinking mode",
|
||||
"- `/system [on|off]`: show or set the built-in system prompt",
|
||||
"- `/compact`: summarize older context",
|
||||
"- `/help`: show commands",
|
||||
"- `/bye`: exit",
|
||||
@@ -38,6 +39,7 @@ func TestChatHelpCommandShowsV1Commands(t *testing.T) {
|
||||
"**Shortcuts**",
|
||||
"- `shift+enter`: insert a newline",
|
||||
"- `shift+tab`: toggle permission mode",
|
||||
"- `ctrl+o`: toggle transcript details",
|
||||
} {
|
||||
if !strings.Contains(fm.entries[0].content, want) {
|
||||
t.Fatalf("help output missing %q:\n%s", want, fm.entries[0].content)
|
||||
@@ -370,6 +372,36 @@ func TestChatPromptDebugMouseWheelScrolls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatPromptDebugCachesLinesByWidth(t *testing.T) {
|
||||
m := chatModel{
|
||||
promptDebug: &chatPromptDebug{
|
||||
request: api.ChatRequest{
|
||||
Model: "llama3.2",
|
||||
Messages: []api.Message{{
|
||||
Role: "user",
|
||||
Content: strings.Repeat("a long prompt line ", 20),
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
first := m.promptDebugLines(80)
|
||||
if len(first) == 0 || m.promptDebug.linesWidth != 80 {
|
||||
t.Fatalf("prompt cache = %#v, want lines cached at width 80", m.promptDebug)
|
||||
}
|
||||
if &first[0] != &m.promptDebugLines(80)[0] {
|
||||
t.Fatal("prompt debug should reuse cached lines at the same width")
|
||||
}
|
||||
|
||||
resized := m.promptDebugLines(120)
|
||||
if m.promptDebug.linesWidth != 120 {
|
||||
t.Fatalf("prompt cache width = %d, want 120", m.promptDebug.linesWidth)
|
||||
}
|
||||
if &first[0] == &resized[0] {
|
||||
t.Fatal("prompt debug should rebuild lines after a width change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateInputLineUsesDisplayWidth(t *testing.T) {
|
||||
line := truncateInputLine(strings.Repeat("界", 10), 10)
|
||||
if got := lipgloss.Width(line); got > 10 {
|
||||
@@ -570,6 +602,76 @@ func TestSkillCommandsListAndPersistSyntheticToolCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsImportReloadsCatalogRegistryAndSystemPrompt(t *testing.T) {
|
||||
before := writeTestSkillCatalog(t)
|
||||
dir := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(dir, "from-codex"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "from-codex", "SKILL.md"), []byte("---\nname: from-codex\ndescription: Imported skill.\n---\nImported instructions."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := coreagent.DiscoverSkills(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := &coreagent.Registry{}
|
||||
var reloaded, rebuilt, prompted bool
|
||||
m := chatModel{
|
||||
ctx: context.Background(),
|
||||
opts: Options{
|
||||
Model: "test",
|
||||
Skills: before,
|
||||
ImportSkills: func(source string) (coreagent.SkillImportResult, error) {
|
||||
if source != "codex" {
|
||||
t.Fatalf("source = %q", source)
|
||||
}
|
||||
return coreagent.SkillImportResult{Source: source, SourceDir: "/source", Imported: []string{"from-codex"}}, nil
|
||||
},
|
||||
ReloadSkills: func() (*coreagent.SkillCatalog, error) {
|
||||
reloaded = true
|
||||
return after, nil
|
||||
},
|
||||
ToolRegistryForModel: func(context.Context, string) *coreagent.Registry {
|
||||
rebuilt = true
|
||||
return registry
|
||||
},
|
||||
SystemPromptForModel: func(_ context.Context, _ string, got *coreagent.Registry, _ bool) string {
|
||||
prompted = got == registry
|
||||
return after.SystemContext()
|
||||
},
|
||||
},
|
||||
input: []rune("/skills import codex"),
|
||||
}
|
||||
|
||||
updated, cmd := m.handleSubmit()
|
||||
if cmd != nil {
|
||||
t.Fatal("skills import should not start a model run")
|
||||
}
|
||||
m = updated.(chatModel)
|
||||
if !reloaded || !rebuilt || !prompted {
|
||||
t.Fatalf("reload=%v rebuilt=%v prompted=%v", reloaded, rebuilt, prompted)
|
||||
}
|
||||
if m.opts.Skills != after || m.opts.Tools != registry || !strings.Contains(m.opts.SystemPrompt, "from-codex") {
|
||||
t.Fatalf("reloaded options = %#v", m.opts)
|
||||
}
|
||||
if m.status != "skills reloaded" || len(m.entries) != 1 || !strings.Contains(m.entries[0].content, "Imported 1 skill") {
|
||||
t.Fatalf("import result = status %q entries %#v", m.status, m.entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsImportUsage(t *testing.T) {
|
||||
m := chatModel{input: []rune("/skills import")}
|
||||
updated, cmd := m.handleSubmit()
|
||||
if cmd != nil {
|
||||
t.Fatal("invalid skills import should not start a model run")
|
||||
}
|
||||
m = updated.(chatModel)
|
||||
if len(m.entries) != 1 || m.entries[0].role != "error" || !strings.Contains(m.entries[0].content, "usage: /skills [import codex|claude|pi]") {
|
||||
t.Fatalf("entries = %#v", m.entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSlashCommandPromptBecomesUserMessage(t *testing.T) {
|
||||
catalog := writeTestSkillCatalog(t)
|
||||
m := chatModel{ctx: context.Background(), opts: Options{Model: "test", Skills: catalog, Client: chatTestClient{}}, input: []rune("/release-notes draft the v1.2 notes")}
|
||||
@@ -665,6 +767,31 @@ func TestSkillSlashCommandAppearsInCompletions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsImportSlashCompletions(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{input: "/skills", want: []string{"/skills", "/skills import"}},
|
||||
{input: "/skills impo", want: []string{"/skills import"}},
|
||||
{input: "/skills import ", want: []string{"/skills import codex", "/skills import claude", "/skills import pi"}},
|
||||
{input: "/skills import c", want: []string{"/skills import codex", "/skills import claude"}},
|
||||
{input: "/skills import pi", want: []string{"/skills import pi"}},
|
||||
} {
|
||||
t.Run(test.input, func(t *testing.T) {
|
||||
m := chatModel{input: []rune(test.input)}
|
||||
completions := m.slashCompletions()
|
||||
got := make([]string, 0, len(completions))
|
||||
for _, completion := range completions {
|
||||
got = append(got, completion.value)
|
||||
}
|
||||
if strings.Join(got, "\n") != strings.Join(test.want, "\n") {
|
||||
t.Fatalf("completions = %#v, want %#v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSlashPromptHidesCommandCompletions(t *testing.T) {
|
||||
catalog := writeTestSkillCatalog(t)
|
||||
for _, input := range []string{"/release-notes ", "/release-notes draft the release notes"} {
|
||||
@@ -708,7 +835,7 @@ func TestSkillSlashNameResolvesAndRejectsArgsAndUnknown(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestChatDeletedSlashCommandsAreUnknown(t *testing.T) {
|
||||
for _, command := range []string{"/copy", "/copy-all", "/launch", "/system", "/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
|
||||
for _, command := range []string{"/clear", "/copy", "/copy-all", "/launch", "/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
m := chatModel{input: []rune(command)}
|
||||
|
||||
@@ -732,12 +859,12 @@ func TestChatViewRendersSlashCommandSuggestions(t *testing.T) {
|
||||
}
|
||||
|
||||
view := stripANSI(m.View())
|
||||
for _, want := range []string{"/clear", "/model", "/new", "/think", "/tools"} {
|
||||
for _, want := range []string{"/model", "/new", "/think", "/tools", "/system"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("view missing %s suggestion: %q", want, view)
|
||||
}
|
||||
}
|
||||
for _, removed := range []string{"/copy", "/copy-all", "/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
|
||||
for _, removed := range []string{"/clear", "/copy", "/copy-all", "/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
|
||||
if strings.Contains(view, removed) {
|
||||
t.Fatalf("bare slash should hide removed command %s: %q", removed, view)
|
||||
}
|
||||
@@ -843,6 +970,127 @@ func TestChatToolsCommandUsage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatSystemCommandControlsBuiltInSystemPrompt(t *testing.T) {
|
||||
client := &chatCaptureClient{}
|
||||
m := chatModel{
|
||||
ctx: context.Background(),
|
||||
input: []rune("/system"),
|
||||
opts: Options{
|
||||
Model: "test",
|
||||
Client: client,
|
||||
SystemPrompt: "canonical agent prompt",
|
||||
},
|
||||
}
|
||||
|
||||
updated, cmd := m.handleSubmit()
|
||||
if cmd != nil {
|
||||
t.Fatal("/system should not start a run")
|
||||
}
|
||||
m = updated.(chatModel)
|
||||
if len(m.entries) != 1 || m.entries[0].role != "slash" || m.entries[0].content != "Built-in system prompt is on.\n\ncanonical agent prompt\n\nWarning: Changing the system prompt during a session breaks the prompt cache." {
|
||||
t.Fatalf("/system entry = %#v", m.entries)
|
||||
}
|
||||
|
||||
m.input = []rune("/system off")
|
||||
updated, _ = m.handleSubmit()
|
||||
m = updated.(chatModel)
|
||||
if !m.systemPromptDisabled || m.status != "system prompt off" {
|
||||
t.Fatalf("/system off state = disabled:%v status:%q", m.systemPromptDisabled, m.status)
|
||||
}
|
||||
m.input = []rune("/system")
|
||||
updated, _ = m.handleSubmit()
|
||||
m = updated.(chatModel)
|
||||
if got := m.entries[len(m.entries)-1].content; got != "Built-in system prompt is off.\n\ncanonical agent prompt\n\nWarning: Changing the system prompt during a session breaks the prompt cache." {
|
||||
t.Fatalf("/system off entry = %q", got)
|
||||
}
|
||||
updated, cmd = m.startRun("hello")
|
||||
if cmd == nil {
|
||||
t.Fatal("run after /system off should start")
|
||||
}
|
||||
m = updated.(chatModel)
|
||||
if done := waitForRunDone(t, m.events); done.err != nil {
|
||||
t.Fatalf("run after /system off: %v", done.err)
|
||||
}
|
||||
if len(client.requests) != 1 || len(client.requests[0].Messages) != 1 || client.requests[0].Messages[0].Role != "user" {
|
||||
t.Fatalf("request after /system off = %#v", client.requests)
|
||||
}
|
||||
|
||||
m.input = []rune("/system ON")
|
||||
updated, _ = m.handleSubmit()
|
||||
m = updated.(chatModel)
|
||||
if m.systemPromptDisabled || m.status != "system prompt on" {
|
||||
t.Fatalf("/system on state = disabled:%v status:%q", m.systemPromptDisabled, m.status)
|
||||
}
|
||||
updated, cmd = m.startRun("hello again")
|
||||
if cmd == nil {
|
||||
t.Fatal("run after /system on should start")
|
||||
}
|
||||
m = updated.(chatModel)
|
||||
if done := waitForRunDone(t, m.events); done.err != nil {
|
||||
t.Fatalf("run after /system on: %v", done.err)
|
||||
}
|
||||
if len(client.requests) != 2 {
|
||||
t.Fatalf("client requests = %d, want 2", len(client.requests))
|
||||
}
|
||||
request := client.requests[1]
|
||||
if len(request.Messages) != 2 || request.Messages[0].Role != "system" || request.Messages[0].Content != "canonical agent prompt" {
|
||||
t.Fatalf("request after /system on = %#v", request.Messages)
|
||||
}
|
||||
|
||||
m.input = []rune("/system sometimes")
|
||||
updated, _ = m.handleSubmit()
|
||||
m = updated.(chatModel)
|
||||
if m.status != "error" || len(m.entries) == 0 || m.entries[len(m.entries)-1].content != "usage: /system [on|off]" {
|
||||
t.Fatalf("invalid /system result = status:%q entries:%#v", m.status, m.entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatSystemCommandArgumentCompletions(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{input: "/system ", want: []string{"/system on", "/system off"}},
|
||||
{input: "/system o", want: []string{"/system on", "/system off"}},
|
||||
{input: "/system on", want: []string{"/system on"}},
|
||||
} {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
m := chatModel{input: []rune(tt.input)}
|
||||
completions := m.slashCompletions()
|
||||
if len(completions) != len(tt.want) {
|
||||
t.Fatalf("completions = %#v, want %d", completions, len(tt.want))
|
||||
}
|
||||
for i, want := range tt.want {
|
||||
if completions[i].value != want {
|
||||
t.Fatalf("completion %d = %q, want %q", i, completions[i].value, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
m := chatModel{input: []rune("/system ")}
|
||||
lines := stripANSI(strings.Join(m.slashCommandLines(80), "\n"))
|
||||
for _, want := range []string{"on", "enable the built-in system prompt", "off", "disable the built-in system prompt"} {
|
||||
if !strings.Contains(lines, want) {
|
||||
t.Fatalf("/system option suggestions missing %q: %q", want, lines)
|
||||
}
|
||||
}
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if cmd != nil {
|
||||
t.Fatal("selecting /system on should not submit the command")
|
||||
}
|
||||
m = updated.(chatModel)
|
||||
if got := string(m.input); got != "/system on" {
|
||||
t.Fatalf("input = %q, want /system on", got)
|
||||
}
|
||||
|
||||
m.input = []rune("/system maybe")
|
||||
completions := m.slashCompletions()
|
||||
if len(completions) != 1 || completions[0].label != "No matching options" {
|
||||
t.Fatalf("invalid argument completions = %#v", completions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatSlashCommandSuggestionsIncludePromptAndSave(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
input string
|
||||
@@ -872,19 +1120,65 @@ func TestChatSlashCommandSuggestionsIncludeThink(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatEnterAcceptsSelectedSlashCommand(t *testing.T) {
|
||||
func TestChatEnterFillsSelectedSlashCommandBeforeSubmitting(t *testing.T) {
|
||||
m := chatModel{input: []rune("/th")}
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m = updated.(chatModel)
|
||||
if cmd != nil {
|
||||
t.Fatal("filling a slash command should not return a command")
|
||||
}
|
||||
if got := string(m.input); got != "/think" {
|
||||
t.Fatalf("input = %q, want completed command", got)
|
||||
}
|
||||
if m.thinkPicker != nil {
|
||||
t.Fatal("filling a slash command should not open its picker")
|
||||
}
|
||||
|
||||
updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
m = updated.(chatModel)
|
||||
if cmd != nil {
|
||||
t.Fatal("think command should not return a command")
|
||||
}
|
||||
if m.thinkPicker == nil {
|
||||
t.Fatal("selected /think command should open picker")
|
||||
t.Fatal("second enter should submit the completed /think command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatEnterSubmitsExactSlashCommandAliases(t *testing.T) {
|
||||
t.Run("help", func(t *testing.T) {
|
||||
m := chatModel{input: []rune("/?")}
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if cmd != nil {
|
||||
t.Fatal("help alias should not return a command")
|
||||
}
|
||||
m = updated.(chatModel)
|
||||
if len(m.entries) != 1 || m.entries[0].role != "slash" {
|
||||
t.Fatalf("entries = %#v, want help output", m.entries)
|
||||
}
|
||||
if got := string(m.input); got != "" {
|
||||
t.Fatalf("input = %q, want cleared after submitting alias", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exit", func(t *testing.T) {
|
||||
m := chatModel{input: []rune("/exit")}
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if cmd == nil {
|
||||
t.Fatal("exit alias should return the quit command")
|
||||
}
|
||||
m = updated.(chatModel)
|
||||
if !m.quitting {
|
||||
t.Fatal("exit alias should quit without filling /bye first")
|
||||
}
|
||||
if got := string(m.input); got != "" {
|
||||
t.Fatalf("input = %q, want cleared after submitting alias", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatSlashCommandsRunWhileModelResponds(t *testing.T) {
|
||||
m := chatModel{running: true, input: []rune("/help")}
|
||||
|
||||
@@ -1014,3 +1308,44 @@ func TestChatFileMentionSuggestionsFilterAndComplete(t *testing.T) {
|
||||
t.Fatalf("completed input = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatEnterCompletesHighlightedFileMentionWithoutSubmitting(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "alpha.md"), []byte("hi"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "target.md"), []byte("hi"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := chatModel{
|
||||
workingDir: dir,
|
||||
input: []rune("review @ after this"),
|
||||
inputCursor: len([]rune("review @")),
|
||||
inputCursorSet: true,
|
||||
}
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown})
|
||||
m = updated.(chatModel)
|
||||
if got, want := m.complete, 1; got != want {
|
||||
t.Fatalf("selected completion = %d, want %d", got, want)
|
||||
}
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if cmd != nil {
|
||||
t.Fatal("selecting a file mention should not submit the prompt")
|
||||
}
|
||||
m = updated.(chatModel)
|
||||
if got, want := string(m.input), "review @target.md after this"; got != want {
|
||||
t.Fatalf("input = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := m.inputCursor, len([]rune("review @target.md ")); got != want || !m.inputCursorSet {
|
||||
t.Fatalf("cursor = %d (set=%v), want %d after the inserted mention", got, m.inputCursorSet, want)
|
||||
}
|
||||
if len(m.entries) != 0 || len(m.messages) != 0 {
|
||||
t.Fatalf("selecting a file mention submitted the prompt: entries=%#v messages=%#v", m.entries, m.messages)
|
||||
}
|
||||
if completions := m.mentionCompletions(); completions != nil {
|
||||
t.Fatalf("mention selector remained visible after selection: %#v", completions)
|
||||
}
|
||||
}
|
||||
+162
-31
@@ -2,8 +2,11 @@ package chat
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
||||
func renderMarkdownForView(markdown string, width int) string {
|
||||
@@ -34,7 +37,7 @@ func renderMarkdownForView(markdown string, width int) string {
|
||||
}
|
||||
|
||||
if heading, ok := markdownHeading(trimmed); ok {
|
||||
rendered = append(rendered, chatHeaderStyle.Render(heading))
|
||||
rendered = append(rendered, chatHeaderStyle.Render(renderMarkdownRunes(parseMarkdownInline(heading))))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -42,9 +45,7 @@ func renderMarkdownForView(markdown string, width int) string {
|
||||
rendered = append(rendered, "")
|
||||
continue
|
||||
}
|
||||
for _, wrapped := range wrapChatText(line, width) {
|
||||
rendered = append(rendered, renderMarkdownInline(wrapped))
|
||||
}
|
||||
rendered = append(rendered, wrapMarkdownInline(line, width)...)
|
||||
}
|
||||
return strings.Join(rendered, "\n")
|
||||
}
|
||||
@@ -71,22 +72,148 @@ func markdownHeading(line string) (string, bool) {
|
||||
return strings.TrimSpace(line[level:]), true
|
||||
}
|
||||
|
||||
func renderMarkdownInline(line string) string {
|
||||
type markdownInlineStyle uint8
|
||||
|
||||
const (
|
||||
markdownPlain markdownInlineStyle = iota
|
||||
markdownStrong
|
||||
markdownCode
|
||||
)
|
||||
|
||||
type markdownInlineRune struct {
|
||||
r rune
|
||||
style markdownInlineStyle
|
||||
}
|
||||
|
||||
// wrapMarkdownInline parses a complete source line before wrapping it. That
|
||||
// keeps emphasis intact when its opening and closing delimiters land on
|
||||
// different visual lines.
|
||||
func wrapMarkdownInline(line string, width int) []string {
|
||||
return wrapInlineRunes(parseMarkdownInline(line), width)
|
||||
}
|
||||
|
||||
func wrapInlineRunes(runes []markdownInlineRune, width int) []string {
|
||||
if len(runes) == 0 {
|
||||
return []string{""}
|
||||
}
|
||||
|
||||
var rendered []string
|
||||
for len(runes) > 0 {
|
||||
hardCut, spaceCut, currentWidth := 0, 0, 0
|
||||
for i, item := range runes {
|
||||
nextWidth := currentWidth + runewidth.RuneWidth(item.r)
|
||||
if nextWidth > width {
|
||||
break
|
||||
}
|
||||
currentWidth = nextWidth
|
||||
hardCut = i + 1
|
||||
if unicode.IsSpace(item.r) && currentWidth > width/2 {
|
||||
spaceCut = i
|
||||
}
|
||||
}
|
||||
cut := hardCut
|
||||
if spaceCut > 0 {
|
||||
cut = spaceCut
|
||||
}
|
||||
if cut == 0 {
|
||||
cut = 1
|
||||
}
|
||||
|
||||
lineRunes := trimMarkdownSpace(runes[:cut])
|
||||
rendered = append(rendered, renderMarkdownRunes(lineRunes))
|
||||
runes = trimMarkdownSpace(runes[cut:])
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
|
||||
func parseMarkdownInline(line string) []markdownInlineRune {
|
||||
var out []markdownInlineRune
|
||||
for len(line) > 0 {
|
||||
if strings.HasPrefix(line, "`") {
|
||||
if end := strings.Index(line[1:], "`"); end >= 0 {
|
||||
out = appendMarkdownRunes(out, line[1:end+1], markdownCode)
|
||||
line = line[end+2:]
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (strings.HasPrefix(line, "**") || strings.HasPrefix(line, "__")) && canOpenMarkdownStrong(out) {
|
||||
delimiter := line[:2]
|
||||
if end := strings.Index(line[2:], delimiter); end >= 0 {
|
||||
out = appendMarkdownRunes(out, line[2:end+2], markdownStrong)
|
||||
line = line[end+4:]
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
r, size := utf8.DecodeRuneInString(line)
|
||||
out = append(out, markdownInlineRune{r: r, style: markdownPlain})
|
||||
line = line[size:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// canOpenMarkdownStrong keeps delimiter-like text in bare URLs and identifiers
|
||||
// literal, only treating ** / __ as strong emphasis at the common
|
||||
// whitespace- or punctuation-delimited form.
|
||||
func canOpenMarkdownStrong(out []markdownInlineRune) bool {
|
||||
if len(out) == 0 {
|
||||
return true
|
||||
}
|
||||
previous := out[len(out)-1].r
|
||||
return (unicode.IsSpace(previous) || unicode.IsPunct(previous)) && !markdownStrongInURL(out)
|
||||
}
|
||||
|
||||
func markdownStrongInURL(out []markdownInlineRune) bool {
|
||||
start := len(out)
|
||||
for start > 0 && !unicode.IsSpace(out[start-1].r) {
|
||||
start--
|
||||
}
|
||||
|
||||
var token strings.Builder
|
||||
for _, item := range out[start:] {
|
||||
token.WriteRune(item.r)
|
||||
}
|
||||
return strings.Contains(token.String(), "://")
|
||||
}
|
||||
|
||||
func appendMarkdownRunes(out []markdownInlineRune, text string, style markdownInlineStyle) []markdownInlineRune {
|
||||
for _, r := range text {
|
||||
out = append(out, markdownInlineRune{r: r, style: style})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func trimMarkdownSpace(runes []markdownInlineRune) []markdownInlineRune {
|
||||
start, end := 0, len(runes)
|
||||
for start < end && unicode.IsSpace(runes[start].r) {
|
||||
start++
|
||||
}
|
||||
for end > start && unicode.IsSpace(runes[end-1].r) {
|
||||
end--
|
||||
}
|
||||
return runes[start:end]
|
||||
}
|
||||
|
||||
func renderMarkdownRunes(runes []markdownInlineRune) string {
|
||||
var b strings.Builder
|
||||
for {
|
||||
before, rest, ok := strings.Cut(line, "`")
|
||||
b.WriteString(before)
|
||||
if !ok {
|
||||
break
|
||||
for start := 0; start < len(runes); {
|
||||
end := start + 1
|
||||
for end < len(runes) && runes[end].style == runes[start].style {
|
||||
end++
|
||||
}
|
||||
code, after, ok := strings.Cut(rest, "`")
|
||||
if !ok {
|
||||
b.WriteString("`")
|
||||
b.WriteString(rest)
|
||||
break
|
||||
var text strings.Builder
|
||||
for _, item := range runes[start:end] {
|
||||
text.WriteRune(item.r)
|
||||
}
|
||||
b.WriteString(chatInlineCodeStyle.Render(code))
|
||||
line = after
|
||||
switch runes[start].style {
|
||||
case markdownStrong:
|
||||
b.WriteString(chatStrongStyle.Render(text.String()))
|
||||
case markdownCode:
|
||||
b.WriteString(chatInlineCodeStyle.Render(text.String()))
|
||||
default:
|
||||
b.WriteString(text.String())
|
||||
}
|
||||
start = end
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -130,7 +257,7 @@ func renderMarkdownTable(lines []string, width int) ([]string, int) {
|
||||
if i < len(row) {
|
||||
cell = row[i]
|
||||
}
|
||||
naturalWidths[i] = max(naturalWidths[i], lipglossWidth(cell))
|
||||
naturalWidths[i] = max(naturalWidths[i], markdownInlineWidth(cell))
|
||||
}
|
||||
}
|
||||
widths := markdownTableColumnWidths(naturalWidths, width)
|
||||
@@ -232,19 +359,21 @@ func sumInts(values []int) int {
|
||||
}
|
||||
|
||||
func wrapMarkdownTableCell(cell string, width int) []string {
|
||||
width = max(1, width)
|
||||
var out []string
|
||||
line := strings.TrimSpace(cell)
|
||||
for lipglossWidth(line) > width {
|
||||
cut := chatDisplayWidthCut(line, width)
|
||||
out = append(out, strings.TrimSpace(line[:cut]))
|
||||
line = strings.TrimSpace(line[cut:])
|
||||
}
|
||||
out = append(out, line)
|
||||
if len(out) == 0 {
|
||||
lines := wrapInlineRunes(parseMarkdownInline(cell), max(1, width))
|
||||
if len(lines) == 0 {
|
||||
return []string{""}
|
||||
}
|
||||
return out
|
||||
return lines
|
||||
}
|
||||
|
||||
// markdownInlineWidth reports the visible width of a cell once Markdown
|
||||
// delimiters are parsed away, so columns size to rendered content.
|
||||
func markdownInlineWidth(cell string) int {
|
||||
width := 0
|
||||
for _, item := range parseMarkdownInline(cell) {
|
||||
width += runewidth.RuneWidth(item.r)
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
func looksLikeMarkdownTableRow(line string) bool {
|
||||
@@ -258,8 +387,10 @@ func isMarkdownTableSeparator(line string) bool {
|
||||
return false
|
||||
}
|
||||
for _, cell := range cells {
|
||||
cell = strings.Trim(cell, " :-")
|
||||
if cell != "" {
|
||||
cell = strings.TrimSpace(cell)
|
||||
cell = strings.TrimPrefix(cell, ":")
|
||||
cell = strings.TrimSuffix(cell, ":")
|
||||
if cell == "" || strings.Trim(cell, "-") != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
+95
-37
@@ -4,15 +4,16 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/mattn/go-runewidth"
|
||||
|
||||
coreagent "github.com/ollama/ollama/agent"
|
||||
@@ -33,6 +34,7 @@ type chatEntry struct {
|
||||
finishedAt time.Time
|
||||
tools []chatEntry
|
||||
metrics *api.Metrics
|
||||
tokenCount int
|
||||
|
||||
version int
|
||||
renderKey chatEntryRenderKey
|
||||
@@ -43,6 +45,7 @@ const (
|
||||
chatMessageIndent = " "
|
||||
chatUserMessagePrefix = ""
|
||||
maxCtrlOToolOutputRunes = 400
|
||||
maxLiveThinkingRunes = 4096
|
||||
|
||||
defaultViewWidth = 80
|
||||
defaultViewHeight = 24
|
||||
@@ -384,10 +387,17 @@ func (m *chatModel) scrollBy(lines int) {
|
||||
m.scroll = clamp(m.scroll+lines, 0, m.maxScroll())
|
||||
}
|
||||
|
||||
var chatANSISequencePattern = regexp.MustCompile(`\x1b\[[0-9;:]*[A-Za-z]`)
|
||||
|
||||
func stripChatANSI(s string) string {
|
||||
return chatANSISequencePattern.ReplaceAllString(s, "")
|
||||
s = ansi.Strip(s)
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == '\n' || r == '\t' {
|
||||
return r
|
||||
}
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
|
||||
func (m chatModel) normalizedSelectionRange() (chatSelectionPoint, chatSelectionPoint, bool) {
|
||||
@@ -669,20 +679,71 @@ func compactionSummaryStatusLine(entry chatEntry) string {
|
||||
}
|
||||
|
||||
func renderThinkingLines(entry chatEntry, width int) []string {
|
||||
if !entry.expanded || strings.TrimSpace(entry.content) == "" {
|
||||
return nil
|
||||
}
|
||||
lines := wrapChatText(thinkingStatusLine(entry), width)
|
||||
if !entry.expanded || entry.content == "" {
|
||||
return lines
|
||||
}
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, indentLines(splitRenderedBody(renderMarkdownForView(entry.content, width-2)), " ")...)
|
||||
if entry.status == "running" {
|
||||
body := styleLines(renderLiveThinkingLines(entry.content, width), chatToolOutputStyle)
|
||||
lines = append(lines, body...)
|
||||
return lines
|
||||
}
|
||||
body := styleLines(splitRenderedBody(renderMarkdownForView(stripChatANSI(entry.content), width)), chatToolOutputStyle)
|
||||
lines = append(lines, body...)
|
||||
return lines
|
||||
}
|
||||
|
||||
func thinkingStatusLine(entry chatEntry) string {
|
||||
if strings.TrimSpace(entry.label) != "" {
|
||||
return entry.label
|
||||
// renderLiveThinkingLines keeps each streaming redraw bounded. Completed
|
||||
// traces use the normal Markdown renderer when explicitly reopened, but a
|
||||
// running trace is rendered as plain text so an ever-growing document is not
|
||||
// reparsed for every delta.
|
||||
func renderLiveThinkingLines(content string, width int) []string {
|
||||
content, omitted := liveThinkingTail(content, maxLiveThinkingRunes)
|
||||
content = stripChatANSI(content)
|
||||
lines := wrapChatText(content, width)
|
||||
if omitted {
|
||||
lines = append([]string{"… earlier thinking omitted while streaming"}, lines...)
|
||||
}
|
||||
return "Thinking"
|
||||
return lines
|
||||
}
|
||||
|
||||
func liveThinkingTail(content string, limit int) (string, bool) {
|
||||
if limit <= 0 {
|
||||
return content, false
|
||||
}
|
||||
start := len(content)
|
||||
for range limit {
|
||||
if start == 0 {
|
||||
return content, false
|
||||
}
|
||||
_, size := utf8.DecodeLastRuneInString(content[:start])
|
||||
start -= size
|
||||
}
|
||||
tail := content[start:]
|
||||
if newline := strings.IndexByte(tail, '\n'); newline >= 0 {
|
||||
tail = tail[newline+1:]
|
||||
}
|
||||
return tail, true
|
||||
}
|
||||
|
||||
func thinkingStatusLine(entry chatEntry) string {
|
||||
if entry.status != "running" {
|
||||
return thoughtLabel(entry.tokenCount, entry.expanded)
|
||||
}
|
||||
|
||||
label := "Thinking"
|
||||
if strings.TrimSpace(entry.label) != "" {
|
||||
label = entry.label
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
func thoughtLabel(tokens int, expanded bool) string {
|
||||
if !expanded || tokens <= 0 {
|
||||
return "Thought"
|
||||
}
|
||||
return "Thought (" + formatTokenCount(tokens) + ")"
|
||||
}
|
||||
|
||||
func (m chatModel) thinkingLabel() string {
|
||||
@@ -696,32 +757,35 @@ func thinkingActivityLabel(tokens int) string {
|
||||
return "Thinking"
|
||||
}
|
||||
|
||||
func (m *chatModel) syncThinkingEntry() {
|
||||
if strings.TrimSpace(m.latestLiveThinking()) == "" {
|
||||
return
|
||||
}
|
||||
func (m *chatModel) syncThinkingEntry(content string) {
|
||||
idx := -1
|
||||
if len(m.entries) > 0 && m.entries[len(m.entries)-1].role == "thinking" && m.entries[len(m.entries)-1].status == "running" {
|
||||
idx = len(m.entries) - 1
|
||||
}
|
||||
if idx < 0 {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return
|
||||
}
|
||||
m.entries = append(m.entries, newChatEntry(chatEntry{role: "thinking", status: "running"}))
|
||||
idx = len(m.entries) - 1
|
||||
}
|
||||
m.entries[idx].content = m.latestLiveThinking()
|
||||
m.entries[idx].content = content
|
||||
m.entries[idx].label = m.thinkingLabel()
|
||||
m.entries[idx].status = "running"
|
||||
m.entries[idx].expanded = false
|
||||
m.entries[idx].tokenCount = m.thinkingTokens
|
||||
m.entries[idx].expanded = true
|
||||
m.markEntryDirty(idx)
|
||||
}
|
||||
|
||||
func (m chatModel) latestLiveThinking() string {
|
||||
for i := len(m.liveMessages) - 1; i >= 0; i-- {
|
||||
if m.liveMessages[i].Role == "assistant" && strings.TrimSpace(m.liveMessages[i].Thinking) != "" {
|
||||
return m.liveMessages[i].Thinking
|
||||
func (m *chatModel) applyThinkingDetails() {
|
||||
for i := range m.entries {
|
||||
entry := &m.entries[i]
|
||||
if entry.role != "thinking" || entry.status == "running" || strings.TrimSpace(entry.content) == "" || entry.expanded == m.thinkingDetailsOpen {
|
||||
continue
|
||||
}
|
||||
entry.expanded = m.thinkingDetailsOpen
|
||||
m.markEntryDirty(i)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *chatModel) finishThinkingEntry() {
|
||||
@@ -734,6 +798,8 @@ func (m *chatModel) finishThinkingEntry() {
|
||||
}
|
||||
m.entries[idx].status = "done"
|
||||
m.entries[idx].label = m.thinkingLabel()
|
||||
m.entries[idx].tokenCount = m.thinkingTokens
|
||||
m.entries[idx].expanded = m.thinkingDetailsOpen
|
||||
m.markEntryDirty(idx)
|
||||
}
|
||||
|
||||
@@ -1435,18 +1501,6 @@ func (m *chatModel) updateContextWindowTokens(tokens int) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m chatModel) responseModelName(response *api.ChatResponse) string {
|
||||
if response != nil {
|
||||
if strings.TrimSpace(response.Model) != "" {
|
||||
return response.Model
|
||||
}
|
||||
if strings.TrimSpace(response.RemoteModel) != "" {
|
||||
return response.RemoteModel
|
||||
}
|
||||
}
|
||||
return m.opts.Model
|
||||
}
|
||||
|
||||
func (m chatModel) currentWorkingDir() string {
|
||||
if strings.TrimSpace(m.workingDir) != "" {
|
||||
return m.workingDir
|
||||
@@ -1464,6 +1518,12 @@ func (m chatModel) activityLine() string {
|
||||
if !m.running && !m.compacting && m.preloadingModel == "" && m.approvalPrompt == nil {
|
||||
return ""
|
||||
}
|
||||
if m.thinking && len(m.entries) > 0 {
|
||||
entry := m.entries[len(m.entries)-1]
|
||||
if entry.role == "thinking" && entry.status == "running" {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
label := m.activityLabel()
|
||||
if label == "" {
|
||||
if m.awaitingToolStart() {
|
||||
@@ -1974,8 +2034,6 @@ func isInvisibleToolGroupingBoundary(entry chatEntry) bool {
|
||||
strings.TrimSpace(entry.label) == "" &&
|
||||
strings.TrimSpace(entry.detail) == "" &&
|
||||
entry.metrics == nil
|
||||
case "thinking":
|
||||
return !entry.expanded
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
+309
-18
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
@@ -548,6 +549,24 @@ func TestChatViewKeepsInputBoxWhileRunning(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatViewShowsActiveThinkingStatusOnce(t *testing.T) {
|
||||
m := chatModel{
|
||||
running: true,
|
||||
thinking: true,
|
||||
thinkingTokens: 42,
|
||||
width: 60,
|
||||
height: 16,
|
||||
entries: []chatEntry{
|
||||
{role: "thinking", label: "Thinking ↓ 42 tokens", status: "running", content: "streamed trace", expanded: true},
|
||||
},
|
||||
}
|
||||
|
||||
view := stripANSI(m.View())
|
||||
if count := strings.Count(view, "Thinking ↓ 42 tokens"); count != 1 {
|
||||
t.Fatalf("active thinking status appears %d times, want once:\n%s", count, view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatToolFinishedUpdatesLiveWorkingDirOnly(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
subdir := filepath.Join(root, "sub")
|
||||
@@ -738,6 +757,107 @@ func TestChatStreamingAssistantOutputHoldsLiveMarkdown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatStreamingRendersBoldBareURLAfterCompletion(t *testing.T) {
|
||||
const response = "Draft PR opened: **https://github.com/ollama/ollama/pull/17203**"
|
||||
m := chatModel{width: 80, height: 12, running: true, events: make(chan tea.Msg)}
|
||||
|
||||
updated, _ := m.Update(chatAgentMsg{event: coreagent.Event{Type: coreagent.EventMessageDelta, Content: "Draft PR opened: **https://github.com/ollama/"}})
|
||||
m = updated.(chatModel)
|
||||
if got := stripANSI(m.renderTranscript(80)); !strings.Contains(got, "**https://github.com/ollama/") {
|
||||
t.Fatalf("incomplete Markdown should remain visible while streaming: %q", got)
|
||||
}
|
||||
|
||||
updated, _ = m.Update(chatAgentMsg{event: coreagent.Event{Type: coreagent.EventMessageDelta, Content: "ollama/pull/17203**"}})
|
||||
m = updated.(chatModel)
|
||||
if got := m.entries[0].content; got != response {
|
||||
t.Fatalf("streamed content = %q, want %q", got, response)
|
||||
}
|
||||
rendered := m.renderTranscript(80)
|
||||
plain := stripANSI(rendered)
|
||||
if strings.Contains(plain, "**") {
|
||||
t.Fatalf("rendered response should not contain Markdown delimiters: %q", plain)
|
||||
}
|
||||
if !strings.Contains(plain, "Draft PR opened: https://github.com/ollama/ollama/pull/17203") {
|
||||
t.Fatalf("rendered response missing URL: %q", plain)
|
||||
}
|
||||
if !strings.Contains(rendered, chatStrongStyle.Render("https://github.com/ollama/ollama/pull/17203")) {
|
||||
t.Fatalf("URL should use the bold terminal style: %q", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownInlineWrapsStrongTextWithoutDelimiters(t *testing.T) {
|
||||
rendered := renderMarkdownForView("**alpha beta gamma delta epsilon**", 20)
|
||||
plain := stripANSI(rendered)
|
||||
if strings.Contains(plain, "**") {
|
||||
t.Fatalf("wrapped strong text should not contain Markdown delimiters: %q", plain)
|
||||
}
|
||||
for _, line := range strings.Split(rendered, "\n") {
|
||||
if got := lipgloss.Width(line); got > 20 {
|
||||
t.Fatalf("rendered line width = %d, want <= 20: %q", got, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownPreservesBareURLUnderscores(t *testing.T) {
|
||||
const url = "https://example.com/a__b__"
|
||||
if got := stripANSI(renderMarkdownForView(url, 80)); got != url {
|
||||
t.Fatalf("bare URL = %q, want %q", got, url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownStrongAfterPunctuation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
emphasis string
|
||||
}{
|
||||
{
|
||||
name: "colon",
|
||||
input: "Status: **ready**",
|
||||
want: "Status: ready",
|
||||
emphasis: "ready",
|
||||
},
|
||||
{
|
||||
name: "dash",
|
||||
input: "Note-**important**",
|
||||
want: "Note-important",
|
||||
emphasis: "important",
|
||||
},
|
||||
{
|
||||
name: "closing parenthesis",
|
||||
input: "Result) **complete**",
|
||||
want: "Result) complete",
|
||||
emphasis: "complete",
|
||||
},
|
||||
{
|
||||
name: "identifier",
|
||||
input: "value__with_delimiters__",
|
||||
want: "value__with_delimiters__",
|
||||
},
|
||||
{
|
||||
name: "URL",
|
||||
input: "https://example.com/a__b__",
|
||||
want: "https://example.com/a__b__",
|
||||
},
|
||||
{
|
||||
name: "URL punctuation",
|
||||
input: "https://example.com/a-**b**",
|
||||
want: "https://example.com/a-**b**",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
rendered := renderMarkdownForView(test.input, 80)
|
||||
if got := stripANSI(rendered); got != test.want {
|
||||
t.Fatalf("rendered = %q, want %q", got, test.want)
|
||||
}
|
||||
if test.emphasis != "" && !strings.Contains(rendered, chatStrongStyle.Render(test.emphasis)) {
|
||||
t.Fatalf("rendered output should emphasize %q: %q", test.emphasis, rendered)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMouseWheelScrollsTranscriptWhileRunning(t *testing.T) {
|
||||
m := chatModel{
|
||||
width: 80,
|
||||
@@ -1360,7 +1480,7 @@ func TestChatCompletedToolsGroupAcrossEmptyAssistantEntries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletedToolsGroupAcrossCollapsedThinking(t *testing.T) {
|
||||
func TestChatCompletedToolsPreserveCollapsedThoughts(t *testing.T) {
|
||||
entries := groupCompletedToolEntries([]chatEntry{
|
||||
newChatEntry(chatEntry{role: "tool", detail: "bash", label: `Bash("pwd")`, status: "done", content: "one"}),
|
||||
newChatEntry(chatEntry{role: "thinking", label: "Thinking", content: "choose next tool", status: "done"}),
|
||||
@@ -1369,13 +1489,13 @@ func TestChatCompletedToolsGroupAcrossCollapsedThinking(t *testing.T) {
|
||||
newChatEntry(chatEntry{role: "tool", detail: "read", label: `Read("AGENTS.md")`, status: "done", content: "instructions"}),
|
||||
})
|
||||
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("entries = %d, want one grouped tool entry: %#v", len(entries), entries)
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("entries = %d, want tool, thought, and grouped tools: %#v", len(entries), entries)
|
||||
}
|
||||
if entries[0].role != "tool_group" || len(entries[0].tools) != 3 {
|
||||
t.Fatalf("tools should group across collapsed thinking: %#v", entries[0])
|
||||
if entries[0].role != "tool" || entries[1].role != "thinking" || entries[2].role != "tool_group" || len(entries[2].tools) != 2 {
|
||||
t.Fatalf("collapsed thought should separate tool groups: %#v", entries)
|
||||
}
|
||||
if line := stripANSI(toolGroupStatusLine(entries[0])); line != "Ran 2 commands and read a file" {
|
||||
if line := stripANSI(toolGroupStatusLine(entries[2])); line != "Ran 1 command and read a file" {
|
||||
t.Fatalf("grouped tool line = %q", line)
|
||||
}
|
||||
}
|
||||
@@ -1442,29 +1562,161 @@ func TestChatCtrlOTogglesInlineOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCtrlODoesNotExpandThinking(t *testing.T) {
|
||||
func TestChatCtrlOTogglesCompletedThinkingDetails(t *testing.T) {
|
||||
m := chatModel{
|
||||
entries: []chatEntry{
|
||||
newChatEntry(chatEntry{role: "thinking", label: "Thinking", status: "done", content: "private reasoning"}),
|
||||
newChatEntry(chatEntry{role: "thinking", label: "Thinking", status: "done", content: "private reasoning", tokenCount: 12}),
|
||||
},
|
||||
}
|
||||
|
||||
if view := stripANSI(m.renderTranscript(100)); !strings.Contains(view, "Thought") || strings.Contains(view, "12 tokens") {
|
||||
t.Fatalf("collapsed thinking should hide its token count:\n%s", view)
|
||||
}
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
||||
m = updated.(chatModel)
|
||||
if m.entries[0].expanded {
|
||||
t.Fatalf("ctrl+o should not expand thinking entries: %#v", m.entries[0])
|
||||
if !m.thinkingDetailsOpen || !m.entries[0].expanded {
|
||||
t.Fatalf("ctrl+o should expand completed thinking entries: %#v", m.entries[0])
|
||||
}
|
||||
if view := stripANSI(m.renderTranscript(100)); strings.Contains(view, "private reasoning") {
|
||||
t.Fatalf("ctrl+o should not render thinking content:\n%s", view)
|
||||
if view := stripANSI(m.renderTranscript(100)); !strings.Contains(view, "Thought (12 tokens)") || !strings.Contains(view, "private reasoning") {
|
||||
t.Fatalf("ctrl+o should render completed thinking content:\n%s", view)
|
||||
}
|
||||
|
||||
m.liveMessages = []api.Message{{Role: "assistant", Thinking: "live private reasoning"}}
|
||||
m.syncThinkingEntry()
|
||||
if m.entries[1].expanded {
|
||||
t.Fatalf("live thinking should not inherit ctrl+o expansion: %#v", m.entries[1])
|
||||
m.syncThinkingEntry("live private reasoning")
|
||||
if !m.entries[1].expanded {
|
||||
t.Fatalf("live thinking should always be expanded: %#v", m.entries[1])
|
||||
}
|
||||
if view := stripANSI(m.renderTranscript(100)); strings.Contains(view, "live private reasoning") {
|
||||
t.Fatalf("ctrl+o should not render live thinking content:\n%s", view)
|
||||
if view := stripANSI(m.renderTranscript(100)); !strings.Contains(view, "live private reasoning") {
|
||||
t.Fatalf("live thinking should render while streaming:\n%s", view)
|
||||
}
|
||||
m.thinkingTokens = 9
|
||||
m.finishThinkingEntry()
|
||||
if m.entries[1].status != "done" || !m.entries[1].expanded {
|
||||
t.Fatalf("completed thinking should honor the open details mode: %#v", m.entries[1])
|
||||
}
|
||||
|
||||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
||||
m = updated.(chatModel)
|
||||
if m.thinkingDetailsOpen || m.entries[0].expanded || m.entries[1].expanded {
|
||||
t.Fatalf("ctrl+o should collapse all completed thinking details: %#v", m.entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatThinkingBodyUsesSecondaryGrey(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
entry chatEntry
|
||||
header string
|
||||
}{
|
||||
{entry: chatEntry{role: "thinking", status: "running", content: "Let me inspect the files.", expanded: true}, header: "Thinking"},
|
||||
{entry: chatEntry{role: "thinking", status: "done", content: "Let me inspect the files.", tokenCount: 15, expanded: true}, header: "Thought (15 tokens)"},
|
||||
} {
|
||||
lines := renderThinkingLines(tt.entry, 80)
|
||||
if len(lines) < 3 {
|
||||
t.Fatalf("thinking entry did not render its body: %#v", lines)
|
||||
}
|
||||
if lines[0] != tt.header {
|
||||
t.Fatalf("thinking header = %q, want unmuted %q", lines[0], tt.header)
|
||||
}
|
||||
if got, want := lines[2], chatToolOutputStyle.Render("Let me inspect the files."); got != want {
|
||||
t.Fatalf("thinking body = %q, want secondary grey %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatThinkingBodyAlignsWithStatusText(t *testing.T) {
|
||||
m := chatModel{entries: []chatEntry{
|
||||
{role: "thinking", status: "done", content: "Let me inspect the files.", tokenCount: 15, expanded: true},
|
||||
}}
|
||||
|
||||
lines := strings.Split(stripANSI(m.renderTranscript(80)), "\n")
|
||||
if len(lines) < 3 {
|
||||
t.Fatalf("thinking entry did not render its body: %#v", lines)
|
||||
}
|
||||
if got, want := lines[0], "• Thought (15 tokens)"; got != want {
|
||||
t.Fatalf("thinking header = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := lines[2], " Let me inspect the files."; got != want {
|
||||
t.Fatalf("thinking body = %q, want aligned with status text %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatLiveThinkingUsesBoundedSanitizedTail(t *testing.T) {
|
||||
longThinking := "first-visible-marker\n" + strings.Repeat("x", maxLiveThinkingRunes+100) + "\nlast-visible-marker\x1b[2J"
|
||||
m := chatModel{entries: []chatEntry{{role: "thinking", label: "Thinking ↓ 10 tokens", status: "running", content: longThinking, expanded: true}}}
|
||||
|
||||
view := stripANSI(m.renderTranscript(80))
|
||||
if strings.Contains(view, "first-visible-marker") || !strings.Contains(view, "earlier thinking omitted while streaming") || !strings.Contains(view, "last-visible-marker") {
|
||||
t.Fatalf("live thinking should render a bounded tail:\n%s", view)
|
||||
}
|
||||
if strings.Contains(view, "\x1b") || strings.Contains(view, "[2J") {
|
||||
t.Fatalf("live thinking should remove terminal control sequences: %q", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatThinkingSanitizesTerminalControlsLiveAndReopened(t *testing.T) {
|
||||
content := "safe\x1b]52;c;clipboard-payload\a visible\x1bPqdevice-control\x1b\\ done\b!\u009b31m red\a"
|
||||
tests := []struct {
|
||||
name string
|
||||
entry chatEntry
|
||||
}{
|
||||
{name: "live", entry: chatEntry{role: "thinking", status: "running", content: content, expanded: true}},
|
||||
{name: "reopened", entry: chatEntry{role: "thinking", status: "done", content: content, expanded: true}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
view := chatModel{entries: []chatEntry{tt.entry}}.renderTranscript(80)
|
||||
plain := stripANSI(view)
|
||||
if !strings.Contains(plain, "safe") || !strings.Contains(plain, "visible") || !strings.Contains(plain, "done!") || !strings.Contains(plain, "red") {
|
||||
t.Fatalf("sanitized thinking lost visible text: %q", plain)
|
||||
}
|
||||
for _, unsafe := range []string{"clipboard-payload", "device-control", "\x1b]", "\x1bP", "\a", "\b", "\u009b"} {
|
||||
if strings.Contains(view, unsafe) {
|
||||
t.Fatalf("sanitized thinking contains unsafe value %q: %q", unsafe, view)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveThinkingTailPreservesUTF8Boundary(t *testing.T) {
|
||||
content := strings.Repeat("🙂", maxLiveThinkingRunes+100)
|
||||
tail, omitted := liveThinkingTail(content, maxLiveThinkingRunes)
|
||||
if !omitted {
|
||||
t.Fatal("long thinking trace should report an omitted prefix")
|
||||
}
|
||||
if !utf8.ValidString(tail) || utf8.RuneCountInString(tail) != maxLiveThinkingRunes {
|
||||
t.Fatalf("tail should contain %d complete runes, got %d", maxLiveThinkingRunes, utf8.RuneCountInString(tail))
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRenderLiveThinkingLines(b *testing.B) {
|
||||
content := strings.Repeat("old thinking that is outside the visible tail\n", 100_000) + "visible tail"
|
||||
entry := chatEntry{role: "thinking", status: "running", content: content, expanded: true}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
renderThinkingLines(entry, 80)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatThinkingDetailsRespectNarrowAndResizedViews(t *testing.T) {
|
||||
m := chatModel{
|
||||
width: 80,
|
||||
height: 10,
|
||||
entries: []chatEntry{
|
||||
{role: "thinking", label: "Thinking ↓ 42 tokens", status: "running", content: "first delta\nsecond delta", expanded: true},
|
||||
},
|
||||
}
|
||||
|
||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 24, Height: 8})
|
||||
m = updated.(chatModel)
|
||||
transcript := stripANSI(m.renderTranscript(24))
|
||||
if !strings.Contains(transcript, "Thinking") || !strings.Contains(transcript, "second delta") {
|
||||
t.Fatalf("narrow transcript should retain the live thinking tail:\n%s", transcript)
|
||||
}
|
||||
if len(m.transcriptLines(24)) == 0 {
|
||||
t.Fatal("resized transcript should remain selectable and scrollable")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1881,7 +2133,10 @@ func TestChatToolCallRendersPrettyInvocationAndResult(t *testing.T) {
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
||||
m = updated.(chatModel)
|
||||
view := stripANSI(m.renderTranscript(100))
|
||||
if !strings.Contains(view, "**Search results for:**") || !strings.Contains(view, "https://parthsareen.com") {
|
||||
if strings.Contains(view, "**") {
|
||||
t.Fatalf("inline web output should render Markdown, not show delimiters: %q", view)
|
||||
}
|
||||
if !strings.Contains(view, "Search results for:") || !strings.Contains(view, "https://parthsareen.com") {
|
||||
t.Fatalf("inline web output missing content: %q", view)
|
||||
}
|
||||
}
|
||||
@@ -1971,3 +2226,39 @@ func TestRenderMarkdownTableWrapsLongCells(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownProseWithPipeExamplesIsNotTable(t *testing.T) {
|
||||
markdown := strings.Join([]string{
|
||||
"- **Regression confirmed** vs. `fdbe8d33`: the bare `< | open | >` remains prose.",
|
||||
"| | |",
|
||||
"- **Severity**: `< | close | >` is another inline example.",
|
||||
}, "\n")
|
||||
|
||||
rendered := renderMarkdownForView(markdown, 120)
|
||||
plain := stripANSI(rendered)
|
||||
if !strings.Contains(plain, "| | |") || !strings.Contains(plain, "the bare < | open | > remains") || !strings.Contains(plain, "< | close | > is another") {
|
||||
t.Fatalf("pipe-delimited prose rendered as a table:\n%s", plain)
|
||||
}
|
||||
if !strings.Contains(rendered, chatStrongStyle.Render("Regression confirmed")) {
|
||||
t.Fatalf("bold prose was not emphasized: %q", rendered)
|
||||
}
|
||||
if !strings.Contains(rendered, chatInlineCodeStyle.Render("fdbe8d33")) {
|
||||
t.Fatalf("inline code was not styled: %q", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownTablePreservesValidSeparator(t *testing.T) {
|
||||
markdown := strings.Join([]string{
|
||||
"| Name | State |",
|
||||
"| --- | :---: |",
|
||||
"| Ollama | Ready |",
|
||||
}, "\n")
|
||||
|
||||
plain := stripANSI(renderMarkdownForView(markdown, 80))
|
||||
if strings.Contains(plain, "---") {
|
||||
t.Fatalf("table separator should not render as prose:\n%s", plain)
|
||||
}
|
||||
if !strings.Contains(plain, "Name") || !strings.Contains(plain, "Ollama") {
|
||||
t.Fatalf("valid Markdown table was not rendered:\n%s", plain)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,9 @@ var (
|
||||
chatInlineCodeStyle = lipgloss.NewStyle().
|
||||
Bold(true)
|
||||
|
||||
chatStrongStyle = lipgloss.NewStyle().
|
||||
Bold(true)
|
||||
|
||||
chatCodeBlockStyle = lipgloss.NewStyle()
|
||||
|
||||
chatTableBorderStyle = lipgloss.NewStyle().
|
||||
|
||||
+80
-12
@@ -42,6 +42,7 @@ type menuItem struct {
|
||||
description string
|
||||
integration string
|
||||
isRunModel bool
|
||||
isOthers bool
|
||||
}
|
||||
|
||||
var runModelMenuItem = menuItem{
|
||||
@@ -50,33 +51,61 @@ var runModelMenuItem = menuItem{
|
||||
isRunModel: true,
|
||||
}
|
||||
|
||||
// launcherMenuIntegrations is intentionally short: the root ollama command is
|
||||
// a quick path to the most common launch targets. Other registered
|
||||
// integrations remain available through `ollama launch <integration>`.
|
||||
var othersMenuItem = menuItem{
|
||||
title: "More...",
|
||||
description: "Show additional integrations",
|
||||
isOthers: true,
|
||||
}
|
||||
|
||||
// launcherMenuIntegrations defines the integrations pinned to the root menu.
|
||||
// Additional visible integrations are available through More in registry order.
|
||||
var launcherMenuIntegrations = []string{"claude", "opencode", "hermes", "openclaw"}
|
||||
|
||||
type model struct {
|
||||
state *launch.LauncherState
|
||||
items []menuItem
|
||||
cursor int
|
||||
width int
|
||||
quitting bool
|
||||
selected bool
|
||||
action TUIAction
|
||||
state *launch.LauncherState
|
||||
items []menuItem
|
||||
cursor int
|
||||
showOthers bool
|
||||
width int
|
||||
quitting bool
|
||||
selected bool
|
||||
action TUIAction
|
||||
}
|
||||
|
||||
func newModel(state *launch.LauncherState) model {
|
||||
m := model{
|
||||
state: state,
|
||||
}
|
||||
m.items = buildMenuItems(state)
|
||||
m.showOthers = shouldExpandOthers(state)
|
||||
m.items = buildMenuItems(state, m.showOthers)
|
||||
m.cursor = initialCursor(state, m.items)
|
||||
return m
|
||||
}
|
||||
|
||||
func buildMenuItems(state *launch.LauncherState) []menuItem {
|
||||
func shouldExpandOthers(state *launch.LauncherState) bool {
|
||||
if state == nil {
|
||||
return false
|
||||
}
|
||||
for _, item := range otherIntegrationItems(state) {
|
||||
if item.integration == state.LastSelection {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildMenuItems(state *launch.LauncherState, showOthers bool) []menuItem {
|
||||
items := []menuItem{runModelMenuItem}
|
||||
items = append(items, launcherIntegrationItems(state)...)
|
||||
|
||||
otherItems := otherIntegrationItems(state)
|
||||
switch {
|
||||
case showOthers:
|
||||
items = append(items, otherItems...)
|
||||
case len(otherItems) > 0:
|
||||
items = append(items, othersMenuItem)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -108,6 +137,34 @@ func launcherIntegrationItems(state *launch.LauncherState) []menuItem {
|
||||
return items
|
||||
}
|
||||
|
||||
func otherIntegrationItems(state *launch.LauncherState) []menuItem {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
pinned := make(map[string]bool, len(launcherMenuIntegrations))
|
||||
for _, name := range launcherMenuIntegrations {
|
||||
pinned[name] = true
|
||||
}
|
||||
|
||||
items := make([]menuItem, 0, len(state.Integrations))
|
||||
for _, info := range launch.ListIntegrationInfos() {
|
||||
if pinned[info.Name] {
|
||||
continue
|
||||
}
|
||||
integrationState, ok := state.Integrations[info.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items = append(items, integrationMenuItem(integrationState))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func primaryMenuItemCount(state *launch.LauncherState) int {
|
||||
return 1 + len(launcherIntegrationItems(state))
|
||||
}
|
||||
|
||||
func initialCursor(state *launch.LauncherState, items []menuItem) int {
|
||||
if state == nil || state.LastSelection == "" {
|
||||
return 0
|
||||
@@ -143,12 +200,21 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
if m.showOthers && m.cursor < primaryMenuItemCount(m.state) {
|
||||
m.showOthers = false
|
||||
m.items = buildMenuItems(m.state, false)
|
||||
m.cursor = min(m.cursor, len(m.items)-1)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "down", "j":
|
||||
if m.cursor < len(m.items)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
if m.cursor < len(m.items) && m.items[m.cursor].isOthers && !m.showOthers {
|
||||
m.showOthers = true
|
||||
m.items = buildMenuItems(m.state, true)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "enter", " ":
|
||||
@@ -231,6 +297,8 @@ func (m model) renderMenuItem(index int, item menuItem) string {
|
||||
if m.cursor == index {
|
||||
style = menuSelectedItemStyle
|
||||
}
|
||||
} else if item.isOthers {
|
||||
// More immediately expands when reached, so it always uses the default style.
|
||||
} else {
|
||||
integrationState := m.state.Integrations[item.integration]
|
||||
if !integrationState.Selectable {
|
||||
|
||||
+73
-2
@@ -91,6 +91,8 @@ func integrationSequence(items []menuItem) []string {
|
||||
switch {
|
||||
case item.isRunModel:
|
||||
sequence = append(sequence, "run")
|
||||
case item.isOthers:
|
||||
sequence = append(sequence, "more")
|
||||
case item.integration != "":
|
||||
sequence = append(sequence, item.integration)
|
||||
}
|
||||
@@ -105,7 +107,7 @@ func compareStrings(got, want []string) string {
|
||||
func TestMenuRendersRootLaunchChoices(t *testing.T) {
|
||||
state := launcherTestState()
|
||||
menu := newModel(state)
|
||||
want := []string{"run", "claude", "opencode", "hermes", "openclaw"}
|
||||
want := []string{"run", "claude", "opencode", "hermes", "openclaw", "more"}
|
||||
if diff := compareStrings(integrationSequence(menu.items), want); diff != "" {
|
||||
t.Fatalf("unexpected root launch choices: %s", diff)
|
||||
}
|
||||
@@ -118,18 +120,87 @@ func TestMenuRendersRootLaunchChoices(t *testing.T) {
|
||||
"Launch OpenCode",
|
||||
"Launch Hermes Agent",
|
||||
"Launch OpenClaw",
|
||||
"More...",
|
||||
} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("expected menu view to contain %q\n%s", want, view)
|
||||
}
|
||||
}
|
||||
for _, hidden := range []string{"Launch ChatGPT", "Launch Codex", "Launch Droid", "Launch Pi", "More..."} {
|
||||
for _, hidden := range []string{"Launch ChatGPT", "Launch Codex", "Launch Droid", "Launch Pi"} {
|
||||
if strings.Contains(view, hidden) {
|
||||
t.Fatalf("expected root menu to omit %q\n%s", hidden, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuExpandsMoreOnDownNavigation(t *testing.T) {
|
||||
state := launcherTestState()
|
||||
menu := newModel(state)
|
||||
menu.cursor = findMenuCursorByIntegration(menu.items, "openclaw")
|
||||
if menu.cursor == -1 {
|
||||
t.Fatal("expected openclaw menu item")
|
||||
}
|
||||
|
||||
updated, _ := menu.Update(tea.KeyMsg{Type: tea.KeyDown})
|
||||
got := updated.(model)
|
||||
if !got.showOthers {
|
||||
t.Fatal("expected navigating down onto More to expand additional integrations")
|
||||
}
|
||||
if got.items[got.cursor].integration == "" {
|
||||
t.Fatalf("expected cursor to land on the first additional integration, got %#v", got.items[got.cursor])
|
||||
}
|
||||
if strings.Contains(got.View(), "More...") {
|
||||
t.Fatalf("expected expanded integrations to replace More\n%s", got.View())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuStartsExpandedForPreviousOverflowSelection(t *testing.T) {
|
||||
state := launcherTestState()
|
||||
overflow := otherIntegrationItems(state)
|
||||
if len(overflow) < 2 {
|
||||
t.Fatal("expected at least two additional integrations")
|
||||
}
|
||||
state.LastSelection = overflow[1].integration
|
||||
|
||||
menu := newModel(state)
|
||||
if !menu.showOthers {
|
||||
t.Fatal("expected previous additional integration selection to start expanded")
|
||||
}
|
||||
if got := menu.items[menu.cursor].integration; got != state.LastSelection {
|
||||
t.Fatalf("initial cursor integration = %q, want %q", got, state.LastSelection)
|
||||
}
|
||||
if strings.Contains(menu.View(), "More...") {
|
||||
t.Fatalf("expected expanded menu to omit More\n%s", menu.View())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuOmitsMoreWithoutAdditionalIntegrations(t *testing.T) {
|
||||
state := launcherTestState()
|
||||
for name := range state.Integrations {
|
||||
if name != "claude" && name != "opencode" && name != "hermes" && name != "openclaw" {
|
||||
delete(state.Integrations, name)
|
||||
}
|
||||
}
|
||||
state.Integrations["claude-desktop"] = launch.LauncherIntegrationState{
|
||||
Name: "claude-desktop",
|
||||
DisplayName: "Claude Desktop",
|
||||
Selectable: true,
|
||||
Changeable: true,
|
||||
}
|
||||
|
||||
menu := newModel(state)
|
||||
want := []string{"run", "claude", "opencode", "hermes", "openclaw"}
|
||||
if diff := compareStrings(integrationSequence(menu.items), want); diff != "" {
|
||||
t.Fatalf("unexpected menu without additional integrations: %s", diff)
|
||||
}
|
||||
if strings.Contains(menu.View(), "More...") {
|
||||
t.Fatalf("expected no More item without additional integrations\n%s", menu.View())
|
||||
}
|
||||
if strings.Contains(menu.View(), "Claude Desktop") {
|
||||
t.Fatalf("expected hidden integration to remain omitted\n%s", menu.View())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuEnterOnRunSelectsRun(t *testing.T) {
|
||||
menu := newModel(launcherTestState())
|
||||
updated, _ := menu.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
|
||||
+1
-1
@@ -19,5 +19,5 @@
|
||||
### Resources
|
||||
|
||||
* [Troubleshooting Guide](https://docs.ollama.com/troubleshooting)
|
||||
* [FAQ](https://docs.ollama.com/faq#faq)
|
||||
* [FAQ](https://docs.ollama.com/faq)
|
||||
* [Development guide](./development.md)
|
||||
+3
-63
@@ -16,7 +16,6 @@
|
||||
- [Generate Embeddings](#generate-embeddings)
|
||||
- [List Running Models](#list-running-models)
|
||||
- [Version](#version)
|
||||
- [Experimental: Image Generation](#image-generation-experimental)
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -59,15 +58,6 @@ Advanced parameters (optional):
|
||||
- `keep_alive`: controls how long the model will stay loaded into memory following the request (default: `5m`)
|
||||
- `context` (deprecated): the context parameter returned from a previous request to `/generate`, this can be used to keep a short conversational memory
|
||||
|
||||
Experimental image generation parameters (for image generation models only):
|
||||
|
||||
> [!WARNING]
|
||||
> These parameters are experimental and may change in future versions.
|
||||
|
||||
- `width`: width of the generated image in pixels
|
||||
- `height`: height of the generated image in pixels
|
||||
- `steps`: number of diffusion steps
|
||||
|
||||
#### Structured outputs
|
||||
|
||||
Structured outputs are supported by providing a JSON schema in the `format` parameter. The model will generate a response that matches the schema. See the [structured outputs](#request-structured-outputs) example below.
|
||||
@@ -1189,7 +1179,7 @@ Create a model from:
|
||||
- a safetensors directory; or
|
||||
- a GGUF file.
|
||||
|
||||
If you are creating a model from a safetensors directory or from a GGUF file, you must [create a blob](#create-a-blob) for each of the files and then use the file name and SHA256 digest associated with each blob in the `files` field.
|
||||
If you are creating a model from a safetensors directory or from a GGUF file, you must [push a blob](#push-a-blob) for each of the files and then use the file name and SHA256 digest associated with each blob in the `files` field.
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -1198,6 +1188,8 @@ If you are creating a model from a safetensors directory or from a GGUF file, yo
|
||||
- `files`: (optional) a dictionary of file names to SHA256 digests of blobs to create the model from
|
||||
- `adapters`: (optional) a dictionary of file names to SHA256 digests of blobs for LORA adapters
|
||||
- `template`: (optional) the prompt template for the model
|
||||
- `renderer`: (optional) the name of the renderer for the model
|
||||
- `parser`: (optional) the name of the parser for the model
|
||||
- `license`: (optional) a string or list of strings containing the license or licenses for the model
|
||||
- `system`: (optional) a string containing the system prompt for the model
|
||||
- `parameters`: (optional) a dictionary of parameters for the model (see [Modelfile](./modelfile.mdx#valid-parameters-and-values) for a list of parameters)
|
||||
@@ -1878,55 +1870,3 @@ curl http://localhost:11434/api/version
|
||||
"version": "0.5.1"
|
||||
}
|
||||
```
|
||||
|
||||
## Experimental Features
|
||||
|
||||
### Image Generation (Experimental)
|
||||
|
||||
> [!WARNING]
|
||||
> Image generation is experimental and may change in future versions.
|
||||
|
||||
Image generation is now supported through the standard `/api/generate` endpoint when using image generation models. The API automatically detects when an image generation model is being used.
|
||||
|
||||
See the [Generate a completion](#generate-a-completion) section for the full API documentation. The experimental image generation parameters (`width`, `height`, `steps`) are documented there.
|
||||
|
||||
#### Example
|
||||
|
||||
##### Request
|
||||
|
||||
```shell
|
||||
curl http://localhost:11434/api/generate -d '{
|
||||
"model": "x/z-image-turbo",
|
||||
"prompt": "a sunset over mountains",
|
||||
"width": 1024,
|
||||
"height": 768
|
||||
}'
|
||||
```
|
||||
|
||||
##### Response (streaming)
|
||||
|
||||
Progress updates during generation:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "x/z-image-turbo",
|
||||
"created_at": "2024-01-15T10:30:00.000000Z",
|
||||
"completed": 5,
|
||||
"total": 20,
|
||||
"done": false
|
||||
}
|
||||
```
|
||||
|
||||
##### Final Response
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "x/z-image-turbo",
|
||||
"created_at": "2024-01-15T10:30:15.000000Z",
|
||||
"image": "iVBORw0KGgoAAAANSUhEUg...",
|
||||
"done": true,
|
||||
"done_reason": "stop",
|
||||
"total_duration": 15000000000,
|
||||
"load_duration": 2000000000
|
||||
}
|
||||
```
|
||||
@@ -16,7 +16,7 @@ After installation, Ollama's API is served by default at:
|
||||
http://localhost:11434/api
|
||||
```
|
||||
|
||||
For running cloud models on **ollama.com**, the same API is available with the following base URL:
|
||||
For running cloud models on [ollama.com](https://ollama.com), the same API is available with the following base URL:
|
||||
|
||||
```
|
||||
https://ollama.com/api
|
||||
@@ -40,7 +40,7 @@ Ollama has official libraries for Python and JavaScript:
|
||||
- [Python](https://github.com/ollama/ollama-python)
|
||||
- [JavaScript](https://github.com/ollama/ollama-js)
|
||||
|
||||
Several community-maintained libraries are available for Ollama. For a full list, see the [Ollama GitHub repository](https://github.com/ollama/ollama?tab=readme-ov-file#libraries-1).
|
||||
Several community-maintained libraries are available for Ollama. For a full list, see the [Ollama GitHub repository](https://github.com/ollama/ollama?tab=readme-ov-file#libraries--sdks).
|
||||
|
||||
## Versioning
|
||||
|
||||
|
||||
@@ -279,73 +279,6 @@ curl -X POST http://localhost:11434/v1/chat/completions \
|
||||
- [x] `dimensions`
|
||||
- [ ] `user`
|
||||
|
||||
### `/v1/images/generations` (experimental)
|
||||
|
||||
> Note: This endpoint is experimental and may change or be removed in future versions.
|
||||
|
||||
Generate images using image generation models.
|
||||
|
||||
<CodeGroup dropdown>
|
||||
|
||||
```python images.py
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url='http://localhost:11434/v1/',
|
||||
api_key='ollama', # required but ignored
|
||||
)
|
||||
|
||||
response = client.images.generate(
|
||||
model='x/z-image-turbo',
|
||||
prompt='A cute robot learning to paint',
|
||||
size='1024x1024',
|
||||
response_format='b64_json',
|
||||
)
|
||||
print(response.data[0].b64_json[:50] + '...')
|
||||
```
|
||||
|
||||
```javascript images.js
|
||||
import OpenAI from "openai";
|
||||
|
||||
const openai = new OpenAI({
|
||||
baseURL: "http://localhost:11434/v1/",
|
||||
apiKey: "ollama", // required but ignored
|
||||
});
|
||||
|
||||
const response = await openai.images.generate({
|
||||
model: "x/z-image-turbo",
|
||||
prompt: "A cute robot learning to paint",
|
||||
size: "1024x1024",
|
||||
response_format: "b64_json",
|
||||
});
|
||||
|
||||
console.log(response.data[0].b64_json.slice(0, 50) + "...");
|
||||
```
|
||||
|
||||
```shell images.sh
|
||||
curl -X POST http://localhost:11434/v1/images/generations \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "x/z-image-turbo",
|
||||
"prompt": "A cute robot learning to paint",
|
||||
"size": "1024x1024",
|
||||
"response_format": "b64_json"
|
||||
}'
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
#### Supported request fields
|
||||
|
||||
- [x] `model`
|
||||
- [x] `prompt`
|
||||
- [x] `size` (e.g. "1024x1024")
|
||||
- [x] `response_format` (only `b64_json` supported)
|
||||
- [ ] `n`
|
||||
- [ ] `quality`
|
||||
- [ ] `style`
|
||||
- [ ] `user`
|
||||
|
||||
### `/v1/responses`
|
||||
|
||||
> Note: Added in Ollama v0.13.3
|
||||
|
||||
+24
-17
@@ -229,7 +229,7 @@ curl https://ollama.com/api/chat \
|
||||
|
||||
## Local only
|
||||
|
||||
Ollama can run in local-only mode by [disabling Ollama's cloud](./faq#how-do-i-disable-ollama-cloud) features.
|
||||
Ollama can run in local-only mode by [disabling Ollama's cloud](./faq#how-do-i-disable-ollama-cloud-features) features.
|
||||
|
||||
## Retirements
|
||||
|
||||
@@ -244,26 +244,33 @@ Ollama Cloud model retirement does not affect local models.
|
||||
|
||||
| Retirement date | Model | Recommended alternative |
|
||||
| --- | --- | --- |
|
||||
| July 15, 2026 | `deepseek-v3.1:671b` | `deepseek-v4-flash` |
|
||||
| July 15, 2026 | `deepseek-v3.2` | `deepseek-v4-flash` |
|
||||
| July 15, 2026 | `devstral-2:123b` | `mistral-large-3:675b` |
|
||||
| July 15, 2026 | `devstral-small-2:24b` | |
|
||||
| July 15, 2026 | `ministral-3:14b` | |
|
||||
| July 15, 2026 | `ministral-3:3b` | |
|
||||
| July 15, 2026 | `ministral-3:8b` | |
|
||||
| July 15, 2026 | `gemini-3-flash-preview` | `minimax-m3` |
|
||||
| July 15, 2026 | `gemma3:12b` | `gemma4:31b` |
|
||||
| July 15, 2026 | `gemma3:27b` | `gemma4:31b` |
|
||||
| July 15, 2026 | `gemma3:4b` | `gemma4:31b` |
|
||||
| July 15, 2026 | `glm-4.7` | `glm-5.2` |
|
||||
| July 15, 2026 | `glm-5` | `glm-5.2` |
|
||||
| July 15, 2026 | `minimax-m2.1` | `minimax-m3` |
|
||||
| July 15, 2026 | `qwen3-coder-next` | `qwen3.5:397b` |
|
||||
| July 15, 2026 | `qwen3-coder:480b` | `qwen3.5:397b` |
|
||||
| July 31, 2026 | `minimax-m2.5` | `minimax-m2.7` |
|
||||
| July 31, 2026 | `kimi-k2.5` | `kimi-k2.6` |
|
||||
|
||||
### Past retirements
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="July 15, 2026">
|
||||
| Model | Recommended alternative |
|
||||
| --- | --- |
|
||||
| `deepseek-v3.1:671b` | `deepseek-v4-flash` |
|
||||
| `deepseek-v3.2` | `deepseek-v4-flash` |
|
||||
| `devstral-2:123b` | `mistral-large-3:675b` |
|
||||
| `devstral-small-2:24b` | |
|
||||
| `ministral-3:14b` | |
|
||||
| `ministral-3:3b` | |
|
||||
| `ministral-3:8b` | |
|
||||
| `gemini-3-flash-preview` | `minimax-m3` |
|
||||
| `gemma3:12b` | `gemma4:31b` |
|
||||
| `gemma3:27b` | `gemma4:31b` |
|
||||
| `gemma3:4b` | `gemma4:31b` |
|
||||
| `glm-4.7` | `glm-5.2` |
|
||||
| `glm-5` | `glm-5.2` |
|
||||
| `minimax-m2.1` | `minimax-m3` |
|
||||
| `qwen3-coder-next` | `qwen3.5:397b` |
|
||||
| `qwen3-coder:480b` | `qwen3.5:397b` |
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="June 30, 2026">
|
||||
| Model | Recommended alternative |
|
||||
| --- | --- |
|
||||
|
||||
@@ -60,6 +60,26 @@
|
||||
}
|
||||
},
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/development.md",
|
||||
"destination": "/development"
|
||||
},
|
||||
{
|
||||
"source": "/api/openai-compatibility.mdx",
|
||||
"destination": "/api/openai-compatibility"
|
||||
},
|
||||
{
|
||||
"source": "/gpu.mdx",
|
||||
"destination": "/gpu"
|
||||
},
|
||||
{
|
||||
"source": "/integrations/vscode.mdx",
|
||||
"destination": "/integrations/vscode"
|
||||
},
|
||||
{
|
||||
"source": "/troubleshooting.mdx",
|
||||
"destination": "/troubleshooting"
|
||||
},
|
||||
{
|
||||
"source": "/openai",
|
||||
"destination": "/api/openai-compatibility"
|
||||
|
||||
+1
-1
@@ -11,4 +11,4 @@ Ollama JavaScript examples at [ollama-js/examples](https://github.com/ollama/oll
|
||||
|
||||
|
||||
## OpenAI compatibility examples
|
||||
Ollama OpenAI compatibility examples at [ollama/examples/openai](../docs/openai.md)
|
||||
Ollama OpenAI compatibility examples at [ollama/examples/openai](./api/openai-compatibility.mdx)
|
||||
+2
-2
@@ -160,7 +160,7 @@ docker run -d -e HTTPS_PROXY=https://my.proxy.example.com -p 11434:11434 ollama-
|
||||
|
||||
Ollama runs locally. We don't see your prompts or data when you run locally. When using cloud-hosted models, we process your prompts and responses to provide the service but do not store or log that content and never train on it. We collect basic account info and limited usage metadata to provide the service that does not include prompt or response content. We don't sell your data. You can delete your account anytime.
|
||||
|
||||
## How do I disable Ollama's cloud features?
|
||||
## How do I disable Ollama Cloud features?
|
||||
|
||||
Ollama can run in local only mode by disabling Ollama's cloud features. By turning off Ollama's cloud features, you will lose the ability to use Ollama's cloud models and web search.
|
||||
|
||||
@@ -248,7 +248,7 @@ Refer to the section [above](#how-do-i-configure-ollama-server) for how to set e
|
||||
|
||||
## How can I use Ollama in Visual Studio Code?
|
||||
|
||||
There is already a large collection of plugins available for VS Code as well as other editors that leverage Ollama. See the list of [extensions & plugins](https://github.com/ollama/ollama#extensions--plugins) at the bottom of the main repository readme.
|
||||
Install the [Ollama extension](https://marketplace.visualstudio.com/items?itemName=Ollama.ollama) to use Ollama models in VS Code Chat. See the [VS Code integration guide](./integrations/vscode.mdx) for setup and troubleshooting.
|
||||
|
||||
## How do I use Ollama with GPU acceleration in Docker?
|
||||
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ Check your compute compatibility to see if your card is supported:
|
||||
| 5.0 | GeForce GTX | `GTX 750 Ti` `GTX 750` `NVS 810` |
|
||||
| | Quadro | `K2200` `K1200` `K620` `M1200` `M520` `M5000M` `M4000M` `M3000M` `M2000M` `M1000M` `K620M` `M600M` `M500M` |
|
||||
|
||||
For building locally to support older GPUs, see [developer](./development#linux-cuda-nvidia)
|
||||
For building locally to support older GPUs, see [development](./development.md).
|
||||
|
||||
### GPU Selection
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">
|
||||
<style>@media (prefers-color-scheme: dark) { path { fill: #fff; } }</style>
|
||||
<path d="M48.8354 10.0479C48.3232 9.79199 48.1025 10.2798 47.8032 10.5278C46.7793 11.624 45.9048 12.1597 44.7622 12.0957C43.0923 12 41.666 12.5356 40.4058 13.8398C40.1377 12.2319 39.2476 11.272 37.8926 10.6558C36.4668 10.0156 35.9702 9.31982 35.356 7.72754C35.2456 7.3999 35.1353 7.06396 34.7651 7.00781C34.3633 6.94385 34.2056 7.2876 34.0479 7.57568C33.418 8.75195 33.1733 10.0479 33.1973 11.3599C33.2524 14.312 34.4736 16.6641 36.8999 18.3359C37.1758 18.5278 37.2466 18.7197 37.1597 19C36.9946 19.5757 36.7974 20.1357 36.624 20.7119C36.5137 21.0801 36.3486 21.1597 35.9624 21C32.4092 19.4878 30.0381 16.2319 27.2334 13.52C26.7764 13.1758 26.3193 12.856 25.8467 12.5518C23.8618 10.584 26.1069 8.96777 26.627 8.77588C27.1704 8.57568 26.8159 7.8877 25.0591 7.896C22.8691 7.90381 20.4507 9.06396 18.7095 9.58398C16.8501 9.22363 14.9199 9.14355 12.9033 9.37598C5.30859 10.2397 1.15674 16.4717 1.30664 27.2559C2.11768 31.9521 4.46582 35.8398 8.07373 38.8799C11.8159 42.0322 16.1255 43.5762 21.041 43.2803C24.0269 43.104 27.3516 42.6963 31.1016 39.4561C33.0396 40.1279 37.1758 40.208 38.1211 40.0078C39.6021 39.688 39.4995 38.2881 38.9639 38.0322C34.623 35.9678 35.5762 36.8081 34.71 36.1279C36.9155 33.4639 40.2402 30.6958 41.54 21.728C41.6426 21.0161 41.5557 20.5679 41.54 19.9917C41.5322 19.6396 41.6108 19.5039 42.0049 19.4639C46.6924 18.9116 49.064 15.9038 49.3315 11.2559C49.3711 10.7837 49.3237 10.2959 48.8354 10.0479ZM24.3262 37.8398C20.1196 34.4639 18.0791 33.3521 17.2358 33.3999C16.4482 33.4482 16.5898 34.3682 16.7632 34.9678C16.9443 35.5601 17.1812 35.9683 17.5117 36.4878C17.7402 36.832 17.8979 37.3442 17.2832 37.728C15.9282 38.584 13.5728 37.4399 13.4624 37.3838C7.97949 34.0879 4.48926 28.9282 4.19775 21.3677C4.1582 20.5757 4.38672 20.2959 5.15869 20.1519C11.8945 18.8799 17.165 22.0879 19.2529 25.7759C23.5381 30.104 25.335 35.1523 30.479 39.104C28.8643 39.2881 26.1699 39.3281 24.3262 37.8398ZM26.3433 24.6001C26.3433 24.248 26.6191 23.9678 26.9658 23.9678C27.3042 23.9678 27.5801 24.248 27.5801 24.6001C27.5801 24.9521 27.3042 25.2319 26.9575 25.2319C26.6108 25.2319 26.3433 24.9521 26.3433 24.6001ZM32.6064 27.8799C31.6372 28.2881 30.6289 28.3042 29.8096 27.688C28.6987 26.8555 28.6279 25.7759 28.7305 24.9199C28.8721 24.248 28.7144 23.8159 28.2495 23.4238C27.8716 23.104 27.3911 23.0161 26.8633 23.0161C26.666 23.0161 26.4849 22.9277 26.3511 22.856C25.8467 22.5762 25.9805 22.1758 26.5088 21.688C28.0996 20.7598 29.6362 21.9917 30.834 23.3281C31.6216 24.2559 32.8901 26.312 33.1104 26.9521C33.2446 27.3521 33.0713 27.6802 32.6064 27.8799Z" fill="#000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
+4
-4
@@ -4,10 +4,10 @@ title: Importing a Model
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Importing a Safetensors adapter](#Importing-a-fine-tuned-adapter-from-Safetensors-weights)
|
||||
- [Importing a Safetensors model](#Importing-a-model-from-Safetensors-weights)
|
||||
- [Importing a GGUF file](#Importing-a-GGUF-based-model-or-adapter)
|
||||
- [Sharing models on ollama.com](#Sharing-your-model-on-ollamacom)
|
||||
- [Importing a Safetensors adapter](#importing-a-fine-tuned-adapter-from-safetensors-weights)
|
||||
- [Importing a Safetensors model](#importing-a-model-from-safetensors-weights)
|
||||
- [Importing a GGUF file](#importing-a-gguf-based-model-or-adapter)
|
||||
- [Sharing models on ollama.com](#sharing-your-model-on-ollama-com)
|
||||
|
||||
## Importing a fine tuned adapter from Safetensors weights
|
||||
|
||||
|
||||
@@ -76,3 +76,15 @@ Then run:
|
||||
```
|
||||
codex --profile ollama-launch
|
||||
```
|
||||
|
||||
## Web search
|
||||
|
||||
Codex web-search requests sent through the Ollama profile are executed by
|
||||
Ollama for both local and cloud models. Sign in with `ollama signin` to use the
|
||||
web-search service.
|
||||
|
||||
To disable web search for a Codex session:
|
||||
|
||||
```shell
|
||||
codex --profile ollama-launch -c 'web_search="disabled"'
|
||||
```
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: DeepSeek Harness
|
||||
---
|
||||
|
||||
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) is an open-source coding agent.
|
||||
|
||||
<Note>DeepSeek Harness is currently a developer preview. Its upstream configuration may change between releases.</Note>
|
||||
|
||||
## Setup
|
||||
|
||||
```shell
|
||||
ollama launch dsh
|
||||
```
|
||||
|
||||
Ollama installs `@deepseek-ai/dsh` if needed. To choose a model:
|
||||
|
||||
```shell
|
||||
ollama launch dsh --model qwen3.5
|
||||
ollama launch dsh --model qwen3.5:cloud
|
||||
```
|
||||
|
||||
To configure without starting:
|
||||
|
||||
```shell
|
||||
ollama launch dsh --config
|
||||
```
|
||||
|
||||
## Web search
|
||||
|
||||
Web search is enabled automatically. It requires Ollama cloud access and a model that supports tools. Run `ollama signin` if needed.
|
||||
|
||||
## Configuration
|
||||
|
||||
Ollama stores its settings in `~/.ollama/launch/dsh/settings.yaml`. These settings load last and set the model, provider, and web search connection. Repeated launches preserve other settings in this file. Ollama does not change `~/.dsh/settings.yaml`, profiles, sessions, or credentials.
|
||||
|
||||
Launch rejects additional `--patch` arguments. Pass other Harness arguments after `--`:
|
||||
|
||||
```shell
|
||||
ollama launch dsh -- --port 3081
|
||||
```
|
||||
|
||||
## Manual install
|
||||
|
||||
DeepSeek Harness requires Node.js. To install it manually:
|
||||
|
||||
```shell
|
||||
npm install -g @deepseek-ai/dsh@latest
|
||||
```
|
||||
|
||||
Then run `ollama launch dsh`. On Windows, install Node.js for Windows.
|
||||
@@ -4,7 +4,7 @@ title: Goose
|
||||
|
||||
## Goose Desktop
|
||||
|
||||
Install [Goose](https://block.github.io/goose/docs/getting-started/installation/) Desktop.
|
||||
Install [Goose](https://goose-docs.ai/docs/getting-started/installation/) Desktop.
|
||||
|
||||
### Usage with Ollama
|
||||
1. In Goose, open **Settings** → **Configure Provider**.
|
||||
@@ -27,7 +27,7 @@ Install [Goose](https://block.github.io/goose/docs/getting-started/installation/
|
||||
|
||||
## Goose CLI
|
||||
|
||||
Install [Goose](https://block.github.io/goose/docs/getting-started/installation/) CLI
|
||||
Install [Goose](https://goose-docs.ai/docs/getting-started/installation/) CLI
|
||||
|
||||
### Usage with Ollama
|
||||
1. Run `goose configure`
|
||||
|
||||
@@ -14,7 +14,7 @@ ollama launch hermes-desktop
|
||||
|
||||
Ollama handles the setup flow automatically:
|
||||
|
||||
1. **Install** - If Hermes Desktop isn't installed, Ollama prompts to install it
|
||||
1. **Install** - If Hermes isn't installed, Ollama prompts to install the Hermes command-line agent. On first desktop launch, Hermes builds its packaged desktop app.
|
||||
2. **Model** - Pick a model from the selector
|
||||
3. **Configure** - Ollama configures Hermes Desktop to use your selected Ollama model
|
||||
4. **Launch** - Ollama opens Hermes Desktop
|
||||
@@ -26,3 +26,13 @@ ollama launch hermes-desktop --model <model>
|
||||
```
|
||||
|
||||
Run `ollama launch hermes-desktop` again to switch models later.
|
||||
|
||||
## Install Hermes Desktop directly
|
||||
|
||||
On macOS and Windows, the Hermes Desktop installer is the recommended upstream installation path. It installs the desktop app and Hermes Agent together. If you prefer the command line, `ollama launch hermes-desktop` remains the explicit Ollama-managed path and uses the same Hermes configuration, sessions, skills, and memory as the CLI.
|
||||
|
||||
To force Hermes to rebuild its packaged desktop app:
|
||||
|
||||
```bash
|
||||
ollama launch hermes-desktop -- --force-build
|
||||
```
|
||||
@@ -14,7 +14,7 @@ ollama launch hermes
|
||||
|
||||
Ollama handles everything automatically:
|
||||
|
||||
1. **Install** — If Hermes isn't installed, Ollama prompts to install it via the Nous Research install script
|
||||
1. **Install** — If Hermes isn't installed, Ollama prompts to install the Hermes command-line agent
|
||||
2. **Model** — Pick a model from the selector (local or cloud)
|
||||
3. **Onboarding** — Ollama configures the Ollama provider, points Hermes at `http://127.0.0.1:11434/v1`, and sets your model as the primary
|
||||
4. **Gateway** — Optionally connects a messaging platform (Telegram, Discord, Slack, WhatsApp, Signal, Email) and launches the Hermes chat
|
||||
@@ -45,10 +45,10 @@ hermes gateway setup
|
||||
|
||||
## Reconfigure
|
||||
|
||||
Re-run the full setup wizard at any time:
|
||||
Use Hermes's model picker to change providers or models later:
|
||||
|
||||
```bash
|
||||
hermes setup
|
||||
hermes model
|
||||
```
|
||||
|
||||
## Manual setup
|
||||
@@ -106,7 +106,7 @@ Optionally connect a messaging platform during setup:
|
||||
Connect a messaging platform? (Telegram, Discord, etc.)
|
||||
|
||||
→ Set up messaging now (recommended)
|
||||
Skip — set up later with 'hermes setup gateway'
|
||||
Skip — set up later with 'hermes gateway setup'
|
||||
```
|
||||
|
||||
### Launch
|
||||
@@ -114,4 +114,3 @@ Connect a messaging platform? (Telegram, Discord, etc.)
|
||||
```
|
||||
Launch hermes chat now? [Y/n]: Y
|
||||
```
|
||||
|
||||
@@ -17,6 +17,10 @@ Run `ollama launch` to see the latest integrations you can run from the terminal
|
||||
<Card title="OpenCode" icon="/images/launch-icons/opencode.svg" href="/integrations/opencode">
|
||||
Open-source coding agent that edits, runs, and iterates on code.
|
||||
</Card>
|
||||
|
||||
<Card title="DeepSeek Harness" icon="/images/launch-icons/deepseek-harness.svg" href="/integrations/deepseek-harness">
|
||||
DeepSeek's open-source agent harness with subagents and web search.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Connect an assistant
|
||||
|
||||
@@ -6,10 +6,12 @@ Use Ollama models in VS Code Chat with the [Ollama extension](https://marketplac
|
||||
|
||||
## Requirements
|
||||
|
||||
- [Visual Studio Code 1.120 or newer](https://code.visualstudio.com/download)
|
||||
- [Visual Studio Code 1.127 or newer](https://code.visualstudio.com/download)
|
||||
- Ollama installed and running
|
||||
- At least one local or cloud model available in Ollama
|
||||
|
||||
Earlier VS Code versions do not reliably cancel requests from language model providers.
|
||||
|
||||
Ollama 0.17.6 or newer is recommended for cloud model sign-in and richer model metadata. Older versions may still work with local models.
|
||||
|
||||
## Install the extension
|
||||
@@ -38,6 +40,12 @@ ollama signin
|
||||
|
||||
Local models do not require sign-in.
|
||||
|
||||
## Context length
|
||||
|
||||
VS Code may show a model's maximum supported context length even when Ollama allocates a smaller context at runtime.
|
||||
|
||||
For local models, open Ollama **Settings**, set the context length to at least 64k, reload the VS Code window, and resend your prompt. See [Context length](/context-length) for more information.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If Ollama models do not appear in the model picker:
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ sudo systemctl status ollama
|
||||
While AMD has contributed the `amdgpu` driver upstream to the official linux
|
||||
kernel source, the version is older and may not support all ROCm features. We
|
||||
recommend you install the latest driver from
|
||||
https://www.amd.com/en/support/linux-drivers for best support of your Radeon
|
||||
https://www.amd.com/en/support/download/linux-drivers.html for best support of your Radeon
|
||||
GPU.
|
||||
</Note>
|
||||
|
||||
|
||||
+3
-7
@@ -107,12 +107,8 @@ FROM <model name>:<tag>
|
||||
FROM llama3.2
|
||||
```
|
||||
|
||||
<Card title="Base Models" href="https://github.com/ollama/ollama#model-library">
|
||||
A list of available base models
|
||||
</Card>
|
||||
|
||||
<Card title="Base Models" href="https://ollama.com/library">
|
||||
Additional models can be found at
|
||||
<Card title="Model library" href="https://ollama.com/library">
|
||||
Browse available models
|
||||
</Card>
|
||||
|
||||
#### Build from a Safetensors model
|
||||
@@ -152,7 +148,7 @@ PARAMETER <parameter> <parametervalue>
|
||||
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------- |
|
||||
| num_ctx | Sets the size of the context window used to generate the next token. (Default: 2048) | int | num_ctx 4096 |
|
||||
| repeat_last_n | Sets how far back for the model to look back to prevent repetition. (Default: 64, 0 = disabled, -1 = num_ctx) | int | repeat_last_n 64 |
|
||||
| repeat_penalty | Sets how strongly to penalize repetitions. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 0.9) will be more lenient. (Default: 1.1) | float | repeat_penalty 1.1 |
|
||||
| repeat_penalty | Sets how strongly to penalize repetitions. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 0.9) will be more lenient. (Default: 1.0, disabled) | float | repeat_penalty 1.1 |
|
||||
| temperature | The temperature of the model. Increasing the temperature will make the model answer more creatively. (Default: 0.8) | float | temperature 0.7 |
|
||||
| seed | Sets the random number seed to use for generation. Setting this to a specific number will make the model generate the same text for the same prompt. (Default: 0) | int | seed 42 |
|
||||
| stop | Sets the stop sequences to use. When this pattern is encountered the LLM will stop generating text and return. Multiple stop patterns may be set by specifying multiple separate `stop` parameters in a modelfile. | string | stop "AI assistant:" |
|
||||
|
||||
@@ -483,6 +483,12 @@ components:
|
||||
template:
|
||||
type: string
|
||||
description: Prompt template to use for the model
|
||||
renderer:
|
||||
type: string
|
||||
description: Name of the renderer for the model
|
||||
parser:
|
||||
type: string
|
||||
description: Name of the parser for the model
|
||||
license:
|
||||
oneOf:
|
||||
- type: string
|
||||
|
||||
@@ -137,7 +137,7 @@ After upgrading, reboot and restart Ollama.
|
||||
|
||||
If you experience gibberish responses when models load across multiple AMD GPUs on Linux, see the following guide.
|
||||
|
||||
- https://rocm.docs.amd.com/projects/radeon/en/latest/docs/install/native_linux/mgpu.html#mgpu-known-issues-and-limitations
|
||||
- https://rocm.docs.amd.com/projects/radeon-ryzen/en/latest/docs/install/installrad/native_linux/mgpu.html#mgpu-known-issues-and-limitations
|
||||
|
||||
## Windows Terminal Errors
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ require (
|
||||
github.com/agnivade/levenshtein v1.1.1
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/charmbracelet/x/ansi v0.10.1
|
||||
github.com/d4l3k/go-bfloat16 v0.0.0-20211005043715-690c3bdd05f1
|
||||
github.com/dlclark/regexp2 v1.11.5
|
||||
github.com/emirpasic/gods/v2 v2.0.0-alpha
|
||||
@@ -48,7 +49,6 @@ require (
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/chewxy/hm v1.0.0 // indirect
|
||||
|
||||
+16
-3
@@ -2,8 +2,21 @@
|
||||
|
||||
This directory contains integration tests to exercise Ollama end-to-end to verify behavior
|
||||
|
||||
By default, these tests are disabled so `go test ./...` will exercise only unit tests. To run integration tests you must pass the integration tag. `go test -tags=integration ./...` Some tests require additional tags to enable to allow scoped testing to keep the duration reasonable. For example, testing a broad set of models requires `-tags=integration,models` and a longer timeout (~60m or more depending on the speed of your GPU.). To view the current set of tag combinations use `find integration -type f | xargs grep "go:build"`
|
||||
By default, these tests are disabled so `go test ./...` will exercise only unit tests. To run integration tests, pass the `integration` tag and one of the scoped tags:
|
||||
|
||||
```bash
|
||||
go test -tags=integration,fast -v -count 1 ./integration/
|
||||
go test -tags=integration,release -v -count 1 -timeout 30m ./integration/
|
||||
go test -tags=integration,library -v -count 1 -timeout 120m ./integration/
|
||||
```
|
||||
|
||||
Tags:
|
||||
|
||||
- `fast`: quick runner/model smoke coverage.
|
||||
- `release`: release regression coverage.
|
||||
- `library`: broad library coverage requiring about 2.5 TiB of disk space.
|
||||
|
||||
Scope wiring and model selections live in `integration/reg_fast_test.go`, `integration/reg_release_test.go`, and `integration/reg_library_test.go`.
|
||||
|
||||
The integration tests have 2 modes of operating.
|
||||
|
||||
@@ -21,12 +34,12 @@ harness starts the server.
|
||||
## Testing a New Model
|
||||
|
||||
When implementing new model architecture, use `OLLAMA_TEST_MODEL` to run the
|
||||
integration suite against your model.
|
||||
integration suite against your model with either the `fast` or `release` coverage.
|
||||
|
||||
```bash
|
||||
# Build the binary first
|
||||
go build .
|
||||
|
||||
# Run integration tests against it
|
||||
OLLAMA_TEST_MODEL=mymodel go test -tags integration -v -count 1 -timeout 15m ./integration/
|
||||
OLLAMA_TEST_MODEL=mymodel go test -tags=integration,fast -v -count 1 ./integration/
|
||||
```
|
||||
+48
-29
@@ -14,6 +14,13 @@ import (
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
const (
|
||||
apiTestTimeout = 4 * time.Minute
|
||||
apiInitialResponseTimeout = time.Minute
|
||||
apiOverrideInitialResponseTimeout = 2 * time.Minute
|
||||
apiStreamResponseTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
func assertBytesMatchToken(t *testing.T, label, token string, ints []int) {
|
||||
t.Helper()
|
||||
|
||||
@@ -31,10 +38,12 @@ func assertBytesMatchToken(t *testing.T, label, token string, ints []int) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGenerate(t *testing.T) {
|
||||
initialTimeout := 60 * time.Second
|
||||
streamTimeout := 30 * time.Second
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
||||
func runAPIGenerate(t *testing.T) {
|
||||
initialTimeout := apiInitialResponseTimeout
|
||||
if testModel != "" {
|
||||
initialTimeout = apiOverrideInitialResponseTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), apiTestTimeout)
|
||||
defer cancel()
|
||||
// Set up the test data
|
||||
req := api.GenerateRequest{
|
||||
@@ -45,7 +54,6 @@ func TestAPIGenerate(t *testing.T) {
|
||||
"seed": 123,
|
||||
},
|
||||
}
|
||||
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
pullOrSkip(ctx, t, client, req.Model)
|
||||
@@ -105,7 +113,7 @@ func TestAPIGenerate(t *testing.T) {
|
||||
|
||||
} // else incremental response, nothing to check right now...
|
||||
buf.Write([]byte(response.Response))
|
||||
if !stallTimer.Reset(streamTimeout) {
|
||||
if !stallTimer.Reset(apiStreamResponseTimeout) {
|
||||
return fmt.Errorf("stall was detected while streaming response, aborting")
|
||||
}
|
||||
return nil
|
||||
@@ -188,10 +196,12 @@ func TestAPIGenerate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIChat(t *testing.T) {
|
||||
initialTimeout := 60 * time.Second
|
||||
streamTimeout := 30 * time.Second
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
||||
func runAPIChat(t *testing.T) {
|
||||
initialTimeout := apiInitialResponseTimeout
|
||||
if testModel != "" {
|
||||
initialTimeout = apiOverrideInitialResponseTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), apiTestTimeout)
|
||||
defer cancel()
|
||||
// Set up the test data
|
||||
req := api.ChatRequest{
|
||||
@@ -207,7 +217,6 @@ func TestAPIChat(t *testing.T) {
|
||||
"seed": 123,
|
||||
},
|
||||
}
|
||||
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
pullOrSkip(ctx, t, client, req.Model)
|
||||
@@ -265,7 +274,7 @@ func TestAPIChat(t *testing.T) {
|
||||
}
|
||||
} // else incremental response, nothing to check right now...
|
||||
buf.Write([]byte(response.Message.Content))
|
||||
if !stallTimer.Reset(streamTimeout) {
|
||||
if !stallTimer.Reset(apiStreamResponseTimeout) {
|
||||
return fmt.Errorf("stall was detected while streaming response, aborting")
|
||||
}
|
||||
return nil
|
||||
@@ -310,11 +319,11 @@ func TestAPIChat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIListModels(t *testing.T) {
|
||||
func runAPIListModels(t *testing.T) {
|
||||
if testModel != "" {
|
||||
t.Skip("skipping metadata test with model override")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), apiTestTimeout)
|
||||
defer cancel()
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
@@ -331,44 +340,54 @@ func TestAPIListModels(t *testing.T) {
|
||||
if len(resp.Models) == 0 {
|
||||
t.Fatalf("list should not be empty")
|
||||
}
|
||||
model := resp.Models[0]
|
||||
|
||||
var model *api.ListModelResponse
|
||||
for i := range resp.Models {
|
||||
if resp.Models[i].Name == smol || resp.Models[i].Model == smol || strings.Contains(resp.Models[i].Name, smol) || strings.Contains(resp.Models[i].Model, smol) {
|
||||
model = &resp.Models[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if model == nil {
|
||||
t.Fatalf("list should include pulled model %s: %#v", smol, resp.Models)
|
||||
}
|
||||
if model.Name == "" {
|
||||
t.Errorf("first model name empty: %#v", model)
|
||||
t.Errorf("model name empty: %#v", model)
|
||||
}
|
||||
var nilTime time.Time
|
||||
if model.ModifiedAt == nilTime {
|
||||
t.Errorf("first model modified_at empty: %#v", model)
|
||||
t.Errorf("model modified_at empty: %#v", model)
|
||||
}
|
||||
if model.Size == 0 {
|
||||
t.Errorf("first model size empty: %#v", model)
|
||||
t.Errorf("model size empty: %#v", model)
|
||||
}
|
||||
if model.Digest == "" {
|
||||
t.Errorf("first model digest empty: %#v", model)
|
||||
t.Errorf("model digest empty: %#v", model)
|
||||
}
|
||||
verifyModelDetails(t, model.Details)
|
||||
}
|
||||
|
||||
func verifyModelDetails(t *testing.T, details api.ModelDetails) {
|
||||
if details.Format == "" {
|
||||
t.Errorf("first model details.format empty: %#v", details)
|
||||
t.Errorf("model details.format empty: %#v", details)
|
||||
}
|
||||
if details.Family == "" {
|
||||
t.Errorf("first model details.family empty: %#v", details)
|
||||
t.Errorf("model details.family empty: %#v", details)
|
||||
}
|
||||
if details.ParameterSize == "" {
|
||||
t.Errorf("first model details.parameter_size empty: %#v", details)
|
||||
t.Errorf("model details.parameter_size empty: %#v", details)
|
||||
}
|
||||
if details.QuantizationLevel == "" {
|
||||
t.Errorf("first model details.quantization_level empty: %#v", details)
|
||||
t.Errorf("model details.quantization_level empty: %#v", details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIShowModel(t *testing.T) {
|
||||
func runAPIShowModel(t *testing.T) {
|
||||
if testModel != "" {
|
||||
t.Skip("skipping metadata test with model override")
|
||||
}
|
||||
modelName := "llama3.2"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), apiTestTimeout)
|
||||
defer cancel()
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
@@ -405,8 +424,8 @@ func TestAPIShowModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGenerateLogprobs(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
func runAPIGenerateLogprobs(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), apiTestTimeout)
|
||||
defer cancel()
|
||||
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
@@ -518,8 +537,8 @@ func TestAPIGenerateLogprobs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIChatLogprobs(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
func runAPIChatLogprobs(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), apiTestTimeout)
|
||||
defer cancel()
|
||||
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
|
||||
+50
-61
@@ -18,12 +18,6 @@ import (
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
var defaultAudioModels = []string{
|
||||
"nemotron3:33b",
|
||||
"gemma4:e2b",
|
||||
"gemma4:e4b",
|
||||
}
|
||||
|
||||
// decodeTestAudio returns the test audio clip ("Why is the sky blue?", 16kHz mono WAV).
|
||||
func decodeTestAudio(t *testing.T) api.ImageData {
|
||||
t.Helper()
|
||||
@@ -37,61 +31,56 @@ func decodeTestAudio(t *testing.T) api.ImageData {
|
||||
// setupAudioModel pulls the model, preloads it, and skips if it doesn't support audio.
|
||||
func setupAudioModel(ctx context.Context, t *testing.T, client *api.Client, model string) {
|
||||
t.Helper()
|
||||
if testModel == "" {
|
||||
pullOrSkip(ctx, t, client, model)
|
||||
}
|
||||
pullOrSkip(ctx, t, client, model)
|
||||
skipIfModelTooLargeForVRAM(ctx, t, client, model)
|
||||
requireCapability(ctx, t, client, model, "audio")
|
||||
err := client.Generate(ctx, &api.GenerateRequest{Model: model}, func(response api.GenerateResponse) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load model %s: %s", model, err)
|
||||
}
|
||||
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: model})
|
||||
}
|
||||
|
||||
// TestAudioTranscription tests that the model can transcribe audio to text.
|
||||
func TestAudioTranscription(t *testing.T) {
|
||||
for _, model := range testModels(defaultAudioModels) {
|
||||
t.Run(model, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
|
||||
setupAudioModel(ctx, t, client, model)
|
||||
audio := decodeTestAudio(t)
|
||||
noThink := &api.ThinkValue{Value: false}
|
||||
|
||||
req := api.ChatRequest{
|
||||
Model: model,
|
||||
Think: noThink,
|
||||
Messages: []api.Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: "Transcribe the audio exactly as spoken. Output only the spoken words. Do not answer any question in the audio.",
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: "What exact words are spoken in this audio?",
|
||||
Images: []api.ImageData{audio},
|
||||
},
|
||||
},
|
||||
Stream: &stream,
|
||||
Options: map[string]any{
|
||||
"temperature": 0,
|
||||
"seed": 123,
|
||||
"num_predict": 50,
|
||||
},
|
||||
}
|
||||
|
||||
// The audio says "Why is the sky blue?" — expect key words in transcription.
|
||||
DoChat(ctx, t, client, req, []string{"sky", "blue"}, 60*time.Second, 10*time.Second)
|
||||
})
|
||||
}
|
||||
func registerAudioTranscriptionCases(models []string) {
|
||||
registerModelIntegrationCases("audio-transcription", models, runAudioTranscriptionModel)
|
||||
}
|
||||
|
||||
// TestAudioResponse tests that the model can respond to a spoken question.
|
||||
func TestAudioResponse(t *testing.T) {
|
||||
for _, model := range testModels(defaultAudioModels) {
|
||||
func runAudioTranscriptionModel(t *testing.T, model string) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
|
||||
setupAudioModel(ctx, t, client, model)
|
||||
audio := decodeTestAudio(t)
|
||||
noThink := &api.ThinkValue{Value: false}
|
||||
|
||||
req := api.ChatRequest{
|
||||
Model: model,
|
||||
Think: noThink,
|
||||
Messages: []api.Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: "Transcribe the audio exactly as spoken. Output only the spoken words. Do not answer any question in the audio.",
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: "What exact words are spoken in this audio?",
|
||||
Images: []api.ImageData{audio},
|
||||
},
|
||||
},
|
||||
Stream: &stream,
|
||||
Options: map[string]any{
|
||||
"temperature": 0,
|
||||
"seed": 123,
|
||||
"num_predict": 50,
|
||||
},
|
||||
}
|
||||
|
||||
// The audio says "Why is the sky blue?" - expect key words in transcription.
|
||||
DoChat(ctx, t, client, req, []string{"sky", "blue"}, 60*time.Second, 10*time.Second)
|
||||
}
|
||||
|
||||
// runAudioResponse tests that the model can respond to a spoken question.
|
||||
func runAudioResponse(t *testing.T, models []string) {
|
||||
for _, model := range testModels(models) {
|
||||
t.Run(model, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
@@ -128,9 +117,9 @@ func TestAudioResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIAudioTranscription tests the /v1/audio/transcriptions endpoint.
|
||||
func TestOpenAIAudioTranscription(t *testing.T) {
|
||||
for _, model := range testModels(defaultAudioModels) {
|
||||
// runOpenAIAudioTranscription tests the /v1/audio/transcriptions endpoint.
|
||||
func runOpenAIAudioTranscription(t *testing.T, models []string) {
|
||||
for _, model := range testModels(models) {
|
||||
t.Run(model, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
@@ -182,9 +171,9 @@ func TestOpenAIAudioTranscription(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIChatWithAudio tests /v1/chat/completions with input_audio content.
|
||||
func TestOpenAIChatWithAudio(t *testing.T) {
|
||||
for _, model := range testModels(defaultAudioModels) {
|
||||
// runOpenAIChatWithAudio tests /v1/chat/completions with input_audio content.
|
||||
func runOpenAIChatWithAudio(t *testing.T, models []string) {
|
||||
for _, model := range testModels(models) {
|
||||
t.Run(model, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
+17
-21
@@ -13,8 +13,8 @@ import (
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func TestBlueSky(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
func runBlueSky(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
defer cancel()
|
||||
// Set up the test data
|
||||
req := api.ChatRequest{
|
||||
@@ -34,17 +34,17 @@ func TestBlueSky(t *testing.T) {
|
||||
ChatTestHelper(ctx, t, req, blueSkyExpected)
|
||||
}
|
||||
|
||||
func TestUnicode(t *testing.T) {
|
||||
func runUnicode(t *testing.T, model string) {
|
||||
if testModel != "" {
|
||||
t.Skip("uses hardcoded model, not applicable with model override")
|
||||
}
|
||||
skipUnderMinVRAM(t, 12) // Actual model load is ~26G
|
||||
skipRegisteredMinVRAM(t, model)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
defer cancel()
|
||||
// Set up the test data
|
||||
req := api.ChatRequest{
|
||||
// DeepSeek has a Unicode tokenizer regex, making it a unicode torture test
|
||||
Model: "deepseek-coder-v2:16b-lite-instruct-q2_K", // TODO is there an ollama-engine model we can switch to and keep the coverage?
|
||||
Model: model, // TODO is there an ollama-engine model we can switch to and keep the coverage?
|
||||
Messages: []api.Message{
|
||||
{
|
||||
Role: "user",
|
||||
@@ -63,11 +63,7 @@ func TestUnicode(t *testing.T) {
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
pullOrSkip(ctx, t, client, req.Model)
|
||||
slog.Info("loading", "model", req.Model)
|
||||
err := client.Generate(ctx, &api.GenerateRequest{Model: req.Model}, func(response api.GenerateResponse) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load model %s: %s", req.Model, err)
|
||||
}
|
||||
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: req.Model})
|
||||
defer func() {
|
||||
// best effort unload once we're done with the model
|
||||
client.Generate(ctx, &api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 0}}, func(rsp api.GenerateResponse) error { return nil })
|
||||
@@ -81,15 +77,15 @@ func TestUnicode(t *testing.T) {
|
||||
}, 180*time.Second, 30*time.Second)
|
||||
}
|
||||
|
||||
func TestExtendedUnicodeOutput(t *testing.T) {
|
||||
func runExtendedUnicodeOutput(t *testing.T, model string) {
|
||||
if testModel != "" {
|
||||
t.Skip("uses hardcoded model, not applicable with model override")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
defer cancel()
|
||||
// Set up the test data
|
||||
req := api.ChatRequest{
|
||||
Model: "gemma2:2b",
|
||||
Model: model,
|
||||
Messages: []api.Message{
|
||||
{
|
||||
Role: "user",
|
||||
@@ -108,14 +104,14 @@ func TestExtendedUnicodeOutput(t *testing.T) {
|
||||
DoChat(ctx, t, client, req, []string{"😀", "😊", "😁", "😂", "😄", "😃"}, 120*time.Second, 120*time.Second)
|
||||
}
|
||||
|
||||
func TestUnicodeModelDir(t *testing.T) {
|
||||
func runUnicodeModelDir(t *testing.T) {
|
||||
// This is only useful for Windows with utf-16 characters, so skip this test for other platforms
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Unicode test only applicable to windows")
|
||||
}
|
||||
// Only works for local testing
|
||||
if os.Getenv("OLLAMA_TEST_EXISTING") != "" {
|
||||
t.Skip("TestUnicodeModelDir only works for local testing, skipping")
|
||||
t.Skip("runUnicodeModelDir only works for local testing, skipping")
|
||||
}
|
||||
|
||||
modelDir, err := os.MkdirTemp("", "ollama_埃")
|
||||
@@ -127,7 +123,7 @@ func TestUnicodeModelDir(t *testing.T) {
|
||||
|
||||
t.Setenv("OLLAMA_MODELS", modelDir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
req := api.ChatRequest{
|
||||
@@ -147,22 +143,22 @@ func TestUnicodeModelDir(t *testing.T) {
|
||||
ChatTestHelper(ctx, t, req, blueSkyExpected)
|
||||
}
|
||||
|
||||
// TestNumPredict verifies that when num_predict is set, the model generates
|
||||
// runNumPredict verifies that when num_predict is set, the model generates
|
||||
// exactly that many tokens. It uses logprobs to count the actual tokens output.
|
||||
func TestNumPredict(t *testing.T) {
|
||||
func runNumPredict(t *testing.T, model string) {
|
||||
if testModel != "" {
|
||||
t.Skip("uses hardcoded model, not applicable with model override")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
|
||||
pullOrSkip(ctx, t, client, "qwen3:0.6b")
|
||||
pullOrSkip(ctx, t, client, model)
|
||||
|
||||
req := api.GenerateRequest{
|
||||
Model: "qwen3:0.6b",
|
||||
Model: model,
|
||||
Prompt: "Write a long story.",
|
||||
Stream: &stream,
|
||||
Logprobs: true,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
//go:build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/format"
|
||||
)
|
||||
|
||||
var sweepVRAMWarning sync.Once
|
||||
|
||||
func registerChatCases(models []string) {
|
||||
registerModelIntegrationCases("chat", models, runChatModel)
|
||||
}
|
||||
|
||||
func runChatModel(t *testing.T, model string) {
|
||||
t.Helper()
|
||||
|
||||
softTimeout, hardTimeout := getTimeouts(t)
|
||||
slog.Info("Setting timeouts", "soft", softTimeout, "hard", hardTimeout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
|
||||
defer cancel()
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
|
||||
if time.Since(started) > softTimeout {
|
||||
t.Skip("skipping remaining tests to avoid excessive runtime")
|
||||
}
|
||||
skipRegisteredMinVRAM(t, model)
|
||||
requireCapability(ctx, t, client, model, "completion")
|
||||
skipIfTargetArchitecture(ctx, t, client, model)
|
||||
skipIfModelTooLargeForSweepVRAM(ctx, t, client, model)
|
||||
|
||||
initialTimeout := 120 * time.Second
|
||||
streamTimeout := 30 * time.Second
|
||||
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: model, KeepAlive: &api.Duration{Duration: 10 * time.Second}})
|
||||
defer func() {
|
||||
client.Generate(ctx, &api.GenerateRequest{Model: model, KeepAlive: &api.Duration{Duration: 0}}, func(rsp api.GenerateResponse) error { return nil })
|
||||
}()
|
||||
|
||||
gpuPercent := getGPUPercent(ctx, t, client, model)
|
||||
if gpuPercent < 80 {
|
||||
slog.Warn("Low GPU percentage - increasing timeouts", "percent", gpuPercent)
|
||||
initialTimeout = 240 * time.Second
|
||||
streamTimeout = 40 * time.Second
|
||||
}
|
||||
|
||||
req, anyResp := chatModelRequest(model)
|
||||
DoChat(ctx, t, client, req, anyResp, initialTimeout, streamTimeout)
|
||||
}
|
||||
|
||||
func chatModelRequest(model string) (api.ChatRequest, []string) {
|
||||
req := api.ChatRequest{
|
||||
Model: model,
|
||||
Messages: []api.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: blueSkyPrompt,
|
||||
},
|
||||
},
|
||||
KeepAlive: &api.Duration{Duration: 10 * time.Second},
|
||||
Options: map[string]any{
|
||||
"temperature": 0.1,
|
||||
"seed": 123,
|
||||
},
|
||||
}
|
||||
anyResp := blueSkyExpected
|
||||
|
||||
// Special cases
|
||||
if model == "duckdb-nsql" {
|
||||
anyResp = []string{"select", "from"}
|
||||
} else if model == "granite3-guardian" || model == "shieldgemma" || model == "llama-guard3" || model == "bespoke-minicheck" {
|
||||
anyResp = []string{"yes", "no", "safe", "unsafe"}
|
||||
} else if model == "openthinker" {
|
||||
anyResp = []string{"plugin", "im_sep", "components", "function call"}
|
||||
} else if model == "starcoder" || model == "starcoder2" || model == "magicoder" || model == "deepseek-coder" {
|
||||
req.Messages[0].Content = "def fibonacci():"
|
||||
anyResp = []string{"f(n)", "sequence", "n-1", "main()", "__main__", "while"}
|
||||
}
|
||||
|
||||
return req, anyResp
|
||||
}
|
||||
|
||||
func skipIfTargetArchitecture(ctx context.Context, t *testing.T, client *api.Client, model string) {
|
||||
t.Helper()
|
||||
|
||||
targetArch := os.Getenv("OLLAMA_TEST_ARCHITECTURE")
|
||||
if targetArch == "" {
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := client.Show(ctx, &api.ShowRequest{Name: model})
|
||||
if err != nil {
|
||||
t.Fatalf("unable to show model: %s", err)
|
||||
}
|
||||
arch := resp.ModelInfo["general.architecture"].(string)
|
||||
if arch != targetArch {
|
||||
t.Skip(fmt.Sprintf("Skipping %s architecture %s != %s", model, arch, targetArch))
|
||||
}
|
||||
}
|
||||
|
||||
func skipIfModelTooLargeForSweepVRAM(ctx context.Context, t *testing.T, client *api.Client, model string) {
|
||||
t.Helper()
|
||||
|
||||
s := os.Getenv("OLLAMA_MAX_VRAM")
|
||||
if s == "" {
|
||||
sweepVRAMWarning.Do(func() {
|
||||
slog.Warn("No VRAM info available, testing all models, so larger ones might timeout...")
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
maxVram, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("invalid OLLAMA_MAX_VRAM %v", err)
|
||||
}
|
||||
|
||||
resp, err := client.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list models failed %v", err)
|
||||
}
|
||||
for _, m := range resp.Models {
|
||||
if modelNameMatches(model, m.Name) && float32(m.Size)*1.2 > float32(maxVram) {
|
||||
t.Skipf("model %s is too large for available VRAM: %s > %s", model, format.HumanBytes(m.Size), format.HumanBytes(int64(maxVram)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func modelNameMatches(model, name string) bool {
|
||||
if name == model {
|
||||
return true
|
||||
}
|
||||
return !strings.Contains(model, ":") && strings.HasPrefix(name, model+":")
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
)
|
||||
|
||||
// Send multiple requests in parallel (concurrently) to a single model and ensure responses are expected
|
||||
func TestConcurrentChat(t *testing.T) {
|
||||
func runConcurrentChat(t *testing.T) {
|
||||
// Assumes all requests have the same model
|
||||
req, resp := ChatRequests()
|
||||
numParallel := int(envconfig.NumParallel() + 1)
|
||||
@@ -31,16 +31,10 @@ func TestConcurrentChat(t *testing.T) {
|
||||
defer cancel()
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
pullOrSkip(ctx, t, client, req[0].Model)
|
||||
|
||||
// Get the server running (if applicable) warm the model up with a single initial request
|
||||
slog.Info("loading", "model", req[0].Model)
|
||||
err := client.Generate(ctx,
|
||||
&api.GenerateRequest{Model: req[0].Model, KeepAlive: &api.Duration{Duration: 10 * time.Second}},
|
||||
func(response api.GenerateResponse) error { return nil },
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load model %s: %s", req[0].Model, err)
|
||||
}
|
||||
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: req[0].Model, KeepAlive: &api.Duration{Duration: 10 * time.Second}})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
r := rand.New(rand.NewSource(0))
|
||||
@@ -66,7 +60,7 @@ func TestConcurrentChat(t *testing.T) {
|
||||
|
||||
// Stress the scheduler and attempt to load more models than will fit to cause thrashing
|
||||
// This test will always load at least 2 models even on CPU based systems
|
||||
func TestMultiModelStress(t *testing.T) {
|
||||
func runMultiModelStress(t *testing.T) {
|
||||
if testModel != "" {
|
||||
t.Skip("uses hardcoded models, not applicable with model override")
|
||||
}
|
||||
@@ -85,7 +79,6 @@ func TestMultiModelStress(t *testing.T) {
|
||||
"llama3.2:1b",
|
||||
"qwen3:0.6b",
|
||||
"gemma2:2b",
|
||||
"deepseek-r1:1.5b", // qwen2 arch
|
||||
"gemma3:270m",
|
||||
}
|
||||
mediumModels := []string{
|
||||
@@ -126,12 +119,8 @@ func TestMultiModelStress(t *testing.T) {
|
||||
slog.Info("Loading models to find how many can fit in VRAM before overflowing")
|
||||
chooseModels:
|
||||
for i, model := range chosenModels {
|
||||
req := &api.GenerateRequest{Model: model} // Leave KeepAlive unset so they stay loaded until the scheduler decides to unload them
|
||||
slog.Info("loading", "model", model)
|
||||
err = client.Generate(ctx, req, func(response api.GenerateResponse) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load model %s: %s", model, err)
|
||||
}
|
||||
// Leave KeepAlive unset so they stay loaded until the scheduler decides to unload them.
|
||||
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: model})
|
||||
targetLoadCount++
|
||||
if i > 0 {
|
||||
models, err := client.ListRunning(ctx)
|
||||
|
||||
+36
-45
@@ -14,7 +14,12 @@ import (
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func TestLongInputContext(t *testing.T) {
|
||||
const (
|
||||
longInputTimeout = 2 * time.Minute
|
||||
longInputModelOverrideTimeout = 3 * time.Minute
|
||||
)
|
||||
|
||||
func runLongInputContext(t *testing.T) {
|
||||
// Setting NUM_PARALLEL to 1 ensures the allocated context is exactly what
|
||||
// we asked for and there is nothing extra that we could spill over into.
|
||||
// Context shift happens after a prompt has been admitted to a slot. Initial
|
||||
@@ -23,7 +28,11 @@ func TestLongInputContext(t *testing.T) {
|
||||
// prompt while llama-server reports it as too large to admit.
|
||||
t.Setenv("OLLAMA_NUM_PARALLEL", "1")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
timeout := longInputTimeout
|
||||
if testModel != "" {
|
||||
timeout = longInputModelOverrideTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
req := api.ChatRequest{
|
||||
Model: smol,
|
||||
@@ -79,7 +88,7 @@ func isContextLimitError(err string) bool {
|
||||
strings.Contains(err, "too long"))
|
||||
}
|
||||
|
||||
func TestContextExhaustion(t *testing.T) {
|
||||
func runContextExhaustion(t *testing.T) {
|
||||
// Setting NUM_PARALLEL to 1 ensures the allocated context is exactly what
|
||||
// we asked for and there is nothing extra that we could spill over into
|
||||
t.Setenv("OLLAMA_NUM_PARALLEL", "1")
|
||||
@@ -128,11 +137,13 @@ func containsEmoji(s string) bool {
|
||||
}
|
||||
|
||||
// Send multiple generate requests with prior context and ensure the response is coherant and expected
|
||||
func TestParallelGenerateWithHistory(t *testing.T) {
|
||||
func runParallelGenerateWithHistory(t *testing.T, modelName string) {
|
||||
if testModel != "" {
|
||||
t.Skip("uses hardcoded model, not applicable with model override")
|
||||
// The Generate API's Context field (token array continuation) is not
|
||||
// supported by all runners (e.g. MLX). Chat history works; this is
|
||||
// the only generate-specific continuation path.
|
||||
t.Skip("generate context continuation not supported by all runners")
|
||||
}
|
||||
modelName := "gpt-oss:20b"
|
||||
req, resp := GenerateRequests()
|
||||
numParallel := 2
|
||||
iterLimit := 2
|
||||
@@ -144,16 +155,10 @@ func TestParallelGenerateWithHistory(t *testing.T) {
|
||||
defer cleanup()
|
||||
initialTimeout := 120 * time.Second
|
||||
streamTimeout := 20 * time.Second
|
||||
prepareParallelHistoryModel(ctx, t, client, modelName)
|
||||
|
||||
// Get the server running (if applicable) warm the model up with a single initial request
|
||||
slog.Info("loading", "model", modelName)
|
||||
err := client.Generate(ctx,
|
||||
&api.GenerateRequest{Model: modelName, KeepAlive: &api.Duration{Duration: 10 * time.Second}},
|
||||
func(response api.GenerateResponse) error { return nil },
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load model %s: %s", modelName, err)
|
||||
}
|
||||
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: modelName, KeepAlive: &api.Duration{Duration: 10 * time.Second}})
|
||||
gpuPercent := getGPUPercent(ctx, t, client, modelName)
|
||||
if gpuPercent < 80 && gpuPercent > 50 {
|
||||
slog.Warn("Low GPU percentage - increasing timeouts", "percent", gpuPercent)
|
||||
@@ -190,7 +195,7 @@ func TestParallelGenerateWithHistory(t *testing.T) {
|
||||
}
|
||||
|
||||
// Send generate requests with prior context and ensure the response is coherant and expected
|
||||
func TestGenerateWithHistory(t *testing.T) {
|
||||
func runGenerateWithHistory(t *testing.T) {
|
||||
if testModel != "" {
|
||||
// The Generate API's Context field (token array continuation) is not
|
||||
// supported by all runners (e.g. MLX). Chat history works; this is
|
||||
@@ -212,16 +217,10 @@ func TestGenerateWithHistory(t *testing.T) {
|
||||
defer cancel()
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
pullOrSkip(ctx, t, client, req.Model)
|
||||
|
||||
// Get the server running (if applicable) warm the model up with a single initial request
|
||||
slog.Info("loading", "model", req.Model)
|
||||
err := client.Generate(ctx,
|
||||
&api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 10 * time.Second}, Options: req.Options},
|
||||
func(response api.GenerateResponse) error { return nil },
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load model %s: %s", req.Model, err)
|
||||
}
|
||||
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 10 * time.Second}, Options: req.Options})
|
||||
|
||||
req.Context = DoGenerate(ctx, t, client, req, rainbowExpected, 30*time.Second, 20*time.Second)
|
||||
|
||||
@@ -236,11 +235,7 @@ func TestGenerateWithHistory(t *testing.T) {
|
||||
}
|
||||
|
||||
// Send multiple chat requests with prior context and ensure the response is coherant and expected
|
||||
func TestParallelChatWithHistory(t *testing.T) {
|
||||
if testModel != "" {
|
||||
t.Skip("uses hardcoded model, not applicable with model override")
|
||||
}
|
||||
modelName := "gpt-oss:20b"
|
||||
func runParallelChatWithHistory(t *testing.T, modelName string) {
|
||||
req, resp := ChatRequests()
|
||||
numParallel := 2
|
||||
iterLimit := 2
|
||||
@@ -252,16 +247,10 @@ func TestParallelChatWithHistory(t *testing.T) {
|
||||
defer cleanup()
|
||||
initialTimeout := 120 * time.Second
|
||||
streamTimeout := 20 * time.Second
|
||||
prepareParallelHistoryModel(ctx, t, client, modelName)
|
||||
|
||||
// Get the server running (if applicable) warm the model up with a single initial empty request
|
||||
slog.Info("loading", "model", modelName)
|
||||
err := client.Generate(ctx,
|
||||
&api.GenerateRequest{Model: modelName, KeepAlive: &api.Duration{Duration: 10 * time.Second}},
|
||||
func(response api.GenerateResponse) error { return nil },
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load model %s: %s", modelName, err)
|
||||
}
|
||||
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: modelName, KeepAlive: &api.Duration{Duration: 10 * time.Second}})
|
||||
gpuPercent := getGPUPercent(ctx, t, client, modelName)
|
||||
if gpuPercent < 80 && gpuPercent > 50 {
|
||||
slog.Warn("Low GPU percentage - increasing timeouts", "percent", gpuPercent)
|
||||
@@ -302,8 +291,16 @@ func TestParallelChatWithHistory(t *testing.T) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func prepareParallelHistoryModel(ctx context.Context, t *testing.T, client *api.Client, modelName string) {
|
||||
t.Helper()
|
||||
skipRegisteredMinVRAM(t, modelName)
|
||||
requireCapability(ctx, t, client, modelName, "completion")
|
||||
skipIfTargetArchitecture(ctx, t, client, modelName)
|
||||
skipIfModelTooLargeForSweepVRAM(ctx, t, client, modelName)
|
||||
}
|
||||
|
||||
// Send generate requests with prior context and ensure the response is coherant and expected
|
||||
func TestChatWithHistory(t *testing.T) {
|
||||
func runChatWithHistory(t *testing.T) {
|
||||
req := api.ChatRequest{
|
||||
Model: smol,
|
||||
Stream: &stream,
|
||||
@@ -324,16 +321,10 @@ func TestChatWithHistory(t *testing.T) {
|
||||
defer cancel()
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
pullOrSkip(ctx, t, client, req.Model)
|
||||
|
||||
// Get the server running (if applicable) warm the model up with a single initial request
|
||||
slog.Info("loading", "model", req.Model)
|
||||
err := client.Generate(ctx,
|
||||
&api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 10 * time.Second}, Options: req.Options},
|
||||
func(response api.GenerateResponse) error { return nil },
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load model %s: %s", req.Model, err)
|
||||
}
|
||||
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 10 * time.Second}, Options: req.Options})
|
||||
|
||||
assistant := DoChat(ctx, t, client, req, rainbowExpected, 30*time.Second, 20*time.Second)
|
||||
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
//go:build integration && imagegen
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func TestCreateImageGen(t *testing.T) {
|
||||
skipIfRemote(t)
|
||||
skipUnderMinVRAM(t, 13)
|
||||
|
||||
// Allow overriding the model directory via env var for local testing,
|
||||
// since the model is ~33GB and may already be downloaded elsewhere.
|
||||
modelDir := os.Getenv("OLLAMA_TEST_IMAGEGEN_MODEL_DIR")
|
||||
if modelDir == "" {
|
||||
modelDir = filepath.Join(testdataModelsDir, "Z-Image-Turbo")
|
||||
downloadHFModel(t, "Tongyi-MAI/Z-Image-Turbo", modelDir)
|
||||
} else {
|
||||
t.Logf("Using existing imagegen model at %s", modelDir)
|
||||
}
|
||||
|
||||
// Verify it looks like a valid imagegen model directory
|
||||
if _, err := os.Stat(filepath.Join(modelDir, "model_index.json")); err != nil {
|
||||
t.Fatalf("model_index.json not found in %s — not a valid imagegen model directory", modelDir)
|
||||
}
|
||||
|
||||
ensureMLXLibraryPath(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
|
||||
modelName := "test-z-image-turbo-create"
|
||||
|
||||
absModelDir, err := filepath.Abs(modelDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get absolute path: %v", err)
|
||||
}
|
||||
|
||||
// Create a Modelfile pointing to the diffusers model directory
|
||||
tmpModelfile := filepath.Join(t.TempDir(), "Modelfile")
|
||||
if err := os.WriteFile(tmpModelfile, []byte("FROM "+absModelDir+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("Failed to write Modelfile: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Creating imagegen model from %s", absModelDir)
|
||||
runOllamaCreate(ctx, t, modelName, "--experimental", "-f", tmpModelfile)
|
||||
|
||||
// Verify model exists via show
|
||||
showReq := &api.ShowRequest{Name: modelName}
|
||||
showResp, err := client.Show(ctx, showReq)
|
||||
if err != nil {
|
||||
t.Fatalf("Model show failed after create: %v", err)
|
||||
}
|
||||
t.Logf("Created model details: %+v", showResp.Details)
|
||||
|
||||
// Generate an image to verify the model isn't corrupted
|
||||
t.Log("Generating test image...")
|
||||
imageBase64, err := generateImage(ctx, client, modelName, "A red circle on a white background")
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "image generation not available") {
|
||||
t.Skip("Target system does not support image generation")
|
||||
} else if strings.Contains(err.Error(), "insufficient memory for image generation") {
|
||||
t.Skip("insufficient memory for image generation")
|
||||
} else if strings.Contains(err.Error(), "ollama-mlx: no such file or directory") {
|
||||
t.Skip("unsupported architecture")
|
||||
}
|
||||
t.Fatalf("Image generation failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify we got valid image data
|
||||
imageData, err := base64.StdEncoding.DecodeString(imageBase64)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decode base64 image: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Generated image: %d bytes", len(imageData))
|
||||
|
||||
if len(imageData) < 1000 {
|
||||
t.Fatalf("Generated image suspiciously small (%d bytes), likely corrupted", len(imageData))
|
||||
}
|
||||
|
||||
// Check for PNG or JPEG magic bytes
|
||||
isPNG := len(imageData) >= 4 && imageData[0] == 0x89 && imageData[1] == 'P' && imageData[2] == 'N' && imageData[3] == 'G'
|
||||
isJPEG := len(imageData) >= 2 && imageData[0] == 0xFF && imageData[1] == 0xD8
|
||||
if !isPNG && !isJPEG {
|
||||
t.Fatalf("Generated image is neither PNG nor JPEG (first bytes: %x)", imageData[:min(8, len(imageData))])
|
||||
}
|
||||
t.Logf("Image format validated (PNG=%v, JPEG=%v)", isPNG, isJPEG)
|
||||
|
||||
// Cleanup: delete the model
|
||||
deleteReq := &api.DeleteRequest{Model: modelName}
|
||||
if err := client.Delete(ctx, deleteReq); err != nil {
|
||||
t.Logf("Warning: failed to delete test model: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
const testdataModelsDir = "testdata/models"
|
||||
|
||||
// skipIfRemote skips the test if OLLAMA_HOST points to a non-local server.
|
||||
// Safetensors/imagegen creation requires localhost since it reads model files
|
||||
// Safetensors creation requires localhost since it reads model files.
|
||||
// from disk and uses the --experimental CLI path.
|
||||
func skipIfRemote(t *testing.T) {
|
||||
t.Helper()
|
||||
@@ -43,7 +43,7 @@ func skipIfRemote(t *testing.T) {
|
||||
if ip != nil && (ip.IsLoopback() || ip.IsUnspecified()) {
|
||||
return
|
||||
}
|
||||
t.Skipf("safetensors/imagegen creation requires a local server (OLLAMA_HOST=%s)", host)
|
||||
t.Skipf("safetensors creation requires a local server (OLLAMA_HOST=%s)", host)
|
||||
}
|
||||
|
||||
// findHFCLI returns the path to the HuggingFace CLI, or "" if not found.
|
||||
@@ -136,7 +136,7 @@ func runOllamaCreate(ctx context.Context, t *testing.T, args ...string) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSafetensorsLLM(t *testing.T) {
|
||||
func runCreateSafetensorsLLM(t *testing.T) {
|
||||
if testModel != "" {
|
||||
t.Skip("exercises create pipeline with a fixed source model, not applicable with model override")
|
||||
}
|
||||
@@ -214,7 +214,7 @@ func TestCreateSafetensorsLLM(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateGGUF(t *testing.T) {
|
||||
func runCreateGGUF(t *testing.T) {
|
||||
if testModel != "" {
|
||||
t.Skip("exercises create pipeline with a fixed source model, not applicable with model override")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//go:build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func registerEmbeddingCases(models []string) {
|
||||
registerEmbeddingCasesWithFallback(models, false)
|
||||
}
|
||||
|
||||
func registerLibraryEmbeddingCases(models []string) {
|
||||
registerEmbeddingCasesWithFallback(models, true)
|
||||
}
|
||||
|
||||
func registerEmbeddingCasesWithFallback(models []string, smokeMissing bool) {
|
||||
testCases, err := loadEmbeddingTestCases()
|
||||
if err != nil {
|
||||
registerIntegrationCases(integrationCase{
|
||||
Key: "embed/testdata",
|
||||
Case: "embed",
|
||||
Model: "testdata",
|
||||
Run: func(t *testing.T) {
|
||||
t.Fatalf("failed to load embedding test data: %s", err)
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if testModel != "" {
|
||||
models = []string{testModel}
|
||||
}
|
||||
|
||||
cases := make([]integrationCase, 0, len(models))
|
||||
for _, model := range models {
|
||||
model := model
|
||||
expected, ok := embeddingExpected(testCases, model)
|
||||
if !ok {
|
||||
if smokeMissing || testModel != "" {
|
||||
cases = append(cases, embeddingSmokeCase(model))
|
||||
continue
|
||||
}
|
||||
cases = append(cases, integrationCase{
|
||||
Key: "embed/" + model,
|
||||
Case: "embed",
|
||||
Model: model,
|
||||
Run: func(t *testing.T) {
|
||||
t.Skipf("no embedding expectation for model %s", model)
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
cases = append(cases, embeddingCase(model, expected))
|
||||
}
|
||||
registerIntegrationCases(cases...)
|
||||
}
|
||||
|
||||
func embeddingSmokeCase(model string) integrationCase {
|
||||
return integrationCase{
|
||||
Key: "embed/" + model,
|
||||
Case: "embed",
|
||||
Model: model,
|
||||
Run: func(t *testing.T) {
|
||||
runEmbeddingSmokeModel(t, model)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func embeddingCase(model string, expected []float64) integrationCase {
|
||||
return integrationCase{
|
||||
Key: "embed/" + model,
|
||||
Case: "embed",
|
||||
Model: model,
|
||||
Run: func(t *testing.T) {
|
||||
runEmbeddingModel(t, model, expected)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func loadEmbeddingTestCases() (map[string][]float64, error) {
|
||||
data, err := os.ReadFile(filepath.Join("testdata", "embed.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
testCases := map[string][]float64{}
|
||||
if err := json.Unmarshal(data, &testCases); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return testCases, nil
|
||||
}
|
||||
|
||||
func embeddingExpected(testCases map[string][]float64, model string) ([]float64, bool) {
|
||||
if expected, ok := testCases[model]; ok {
|
||||
return expected, true
|
||||
}
|
||||
if !strings.Contains(model, ":") {
|
||||
expected, ok := testCases[model+":latest"]
|
||||
return expected, ok
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func runEmbeddingModel(t *testing.T, model string, expected []float64) {
|
||||
t.Helper()
|
||||
|
||||
softTimeout, hardTimeout := getTimeouts(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
|
||||
defer cancel()
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
|
||||
if time.Since(started) > softTimeout {
|
||||
t.Skip("skipping remaining tests to avoid excessive runtime")
|
||||
}
|
||||
pullOrSkip(ctx, t, client, model)
|
||||
skipIfModelTooLargeForSweepVRAM(ctx, t, client, model)
|
||||
|
||||
req := api.EmbeddingRequest{
|
||||
Model: model,
|
||||
Prompt: "why is the sky blue?",
|
||||
KeepAlive: &api.Duration{Duration: 10 * time.Second},
|
||||
Options: map[string]any{
|
||||
"temperature": 0,
|
||||
"seed": 123,
|
||||
},
|
||||
}
|
||||
resp, err := client.Embeddings(ctx, &req)
|
||||
if err != nil {
|
||||
t.Fatalf("embeddings call failed %s", err)
|
||||
}
|
||||
defer func() {
|
||||
client.Generate(ctx, &api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 0}}, func(rsp api.GenerateResponse) error { return nil })
|
||||
}()
|
||||
if len(resp.Embedding) == 0 {
|
||||
t.Errorf("zero length embedding response")
|
||||
}
|
||||
if len(expected) != len(resp.Embedding) {
|
||||
expStr := make([]string, len(resp.Embedding))
|
||||
for i, v := range resp.Embedding {
|
||||
expStr[i] = fmt.Sprintf("%0.6f", v)
|
||||
}
|
||||
// When adding new models, use this output to populate the testdata/embed.json
|
||||
fmt.Printf("expected\n%s\n", strings.Join(expStr, ", "))
|
||||
t.Fatalf("expected %d, got %d", len(expected), len(resp.Embedding))
|
||||
}
|
||||
sim := cosineSimilarity(resp.Embedding, expected)
|
||||
if sim < 0.99 {
|
||||
t.Fatalf("expected %v, got %v (similarity: %f)", expected[0:5], resp.Embedding[0:5], sim)
|
||||
}
|
||||
}
|
||||
|
||||
func runEmbeddingSmokeModel(t *testing.T, model string) {
|
||||
t.Helper()
|
||||
|
||||
softTimeout, hardTimeout := getTimeouts(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
|
||||
defer cancel()
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
|
||||
if time.Since(started) > softTimeout {
|
||||
t.Skip("skipping remaining tests to avoid excessive runtime")
|
||||
}
|
||||
requireCapability(ctx, t, client, model, "embedding")
|
||||
skipIfModelTooLargeForSweepVRAM(ctx, t, client, model)
|
||||
|
||||
req := api.EmbedRequest{
|
||||
Model: model,
|
||||
Input: []string{"cat", "kitten", "dog"},
|
||||
KeepAlive: &api.Duration{Duration: 10 * time.Second},
|
||||
}
|
||||
resp, err := embedTestHelper(ctx, client, t, req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
client.Generate(ctx, &api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 0}}, func(rsp api.GenerateResponse) error { return nil })
|
||||
}()
|
||||
if len(resp.Embeddings) != 3 {
|
||||
t.Fatalf("expected 3 embeddings, got %d", len(resp.Embeddings))
|
||||
}
|
||||
for i, embedding := range resp.Embeddings {
|
||||
if len(embedding) == 0 {
|
||||
t.Fatalf("embedding %d was empty", i)
|
||||
}
|
||||
}
|
||||
|
||||
cosRelated := cosineSimilarity(resp.Embeddings[0], resp.Embeddings[1])
|
||||
cosUnrelated := cosineSimilarity(resp.Embeddings[0], resp.Embeddings[2])
|
||||
if cosRelated <= cosUnrelated {
|
||||
t.Fatalf("expected related terms to be closer than unrelated terms: cat/kitten=%f cat/dog=%f", cosRelated, cosUnrelated)
|
||||
}
|
||||
}
|
||||
+37
-24
@@ -10,7 +10,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
@@ -61,6 +60,19 @@ func requireEmbedErrorContainsAny(t *testing.T, err error, substrings ...string)
|
||||
t.Fatalf("expected error containing one of %q, got: %v", substrings, err)
|
||||
}
|
||||
|
||||
func requireSimilarEmbedding(t *testing.T, want, got []float32) {
|
||||
t.Helper()
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected %d embedding floats, got %d", len(want), len(got))
|
||||
}
|
||||
|
||||
sim := cosineSimilarity(got, want)
|
||||
if sim < 0.999 {
|
||||
t.Fatalf("expected embedding similar to %v, got %v (similarity: %f)", want[0:5], got[0:5], sim)
|
||||
}
|
||||
}
|
||||
|
||||
func euclideanDistance[V float32 | float64](v1, v2 []V) V {
|
||||
if len(v1) != len(v2) {
|
||||
return V(math.Inf(1))
|
||||
@@ -88,13 +100,13 @@ func manhattanDistance[V float32 | float64](v1, v2 []V) V {
|
||||
return sum
|
||||
}
|
||||
|
||||
func TestEmbedCosineDistanceCorrelation(t *testing.T) {
|
||||
func runEmbedCosineDistanceCorrelation(t *testing.T, models []string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
defer cancel()
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
|
||||
for _, model := range testModels(libraryEmbedModels) {
|
||||
for _, model := range testModels(models) {
|
||||
t.Run(model, func(t *testing.T) {
|
||||
if testModel != "" {
|
||||
requireCapability(ctx, t, client, model, "embedding")
|
||||
@@ -163,7 +175,7 @@ func TestEmbedCosineDistanceCorrelation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllMiniLMEmbeddings(t *testing.T) {
|
||||
func runAllMiniLMEmbeddings(t *testing.T) {
|
||||
if testModel != "" {
|
||||
t.Skip("uses hardcoded model, not applicable with model override")
|
||||
}
|
||||
@@ -196,7 +208,7 @@ func TestAllMiniLMEmbeddings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllMiniLMEmbed(t *testing.T) {
|
||||
func runAllMiniLMEmbed(t *testing.T) {
|
||||
if testModel != "" {
|
||||
t.Skip("uses hardcoded model, not applicable with model override")
|
||||
}
|
||||
@@ -236,7 +248,7 @@ func TestAllMiniLMEmbed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllMiniLMBatchEmbed(t *testing.T) {
|
||||
func runAllMiniLMBatchEmbed(t *testing.T) {
|
||||
if testModel != "" {
|
||||
t.Skip("uses hardcoded model, not applicable with model override")
|
||||
}
|
||||
@@ -286,7 +298,7 @@ func TestAllMiniLMBatchEmbed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllMiniLMEmbedTruncate(t *testing.T) {
|
||||
func runAllMiniLMEmbedTruncate(t *testing.T) {
|
||||
if testModel != "" {
|
||||
t.Skip("uses hardcoded model, not applicable with model override")
|
||||
}
|
||||
@@ -321,9 +333,7 @@ func TestAllMiniLMEmbedTruncate(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(want.Embeddings[0], got.Embeddings[0]); diff != "" {
|
||||
t.Errorf("embedding mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
requireSimilarEmbedding(t, want.Embeddings[0], got.Embeddings[0])
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -338,9 +348,7 @@ func TestAllMiniLMEmbedTruncate(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("PromptEvalCount: want=%d got=%d", want.PromptEvalCount, got.PromptEvalCount)
|
||||
if diff := cmp.Diff(want.Embeddings[0], got.Embeddings[0]); diff != "" {
|
||||
t.Errorf("embedding mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
requireSimilarEmbedding(t, want.Embeddings[0], got.Embeddings[0])
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -356,9 +364,7 @@ func TestAllMiniLMEmbedTruncate(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("PromptEvalCount: want=%d got=%d", want.PromptEvalCount, got.PromptEvalCount)
|
||||
if diff := cmp.Diff(want.Embeddings[0], got.Embeddings[0]); diff != "" {
|
||||
t.Errorf("embedding mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
requireSimilarEmbedding(t, want.Embeddings[0], got.Embeddings[0])
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -432,7 +438,7 @@ func embedTestHelper(ctx context.Context, client *api.Client, t *testing.T, req
|
||||
return client.Embed(ctx, &req)
|
||||
}
|
||||
|
||||
func TestEmbedTruncation(t *testing.T) {
|
||||
func runEmbedTruncation(t *testing.T, models []string) {
|
||||
// Use test deadline if set, otherwise default to 2 minutes
|
||||
timeout := 2 * time.Minute
|
||||
if deadline, ok := t.Deadline(); ok {
|
||||
@@ -443,7 +449,7 @@ func TestEmbedTruncation(t *testing.T) {
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
|
||||
for _, model := range testModels(libraryEmbedModels) {
|
||||
for _, model := range testModels(models) {
|
||||
model := model
|
||||
t.Run(model, func(t *testing.T) {
|
||||
if testModel != "" {
|
||||
@@ -454,6 +460,9 @@ func TestEmbedTruncation(t *testing.T) {
|
||||
t.Skip("skipping remaining tests to avoid timeout")
|
||||
}
|
||||
|
||||
pullOrSkip(ctx, t, client, model)
|
||||
skipIfModelTooLargeForSweepVRAM(ctx, t, client, model)
|
||||
|
||||
// Give each model its own budget to account for first-time pulls/loads
|
||||
mctx, mcancel := context.WithTimeout(ctx, 3*time.Minute)
|
||||
defer mcancel()
|
||||
@@ -507,19 +516,22 @@ func TestEmbedTruncation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbedLargeInput tests that embedding models can handle large inputs that would exceed typical batch sizes.
|
||||
func TestEmbedLargeInput(t *testing.T) {
|
||||
// runEmbedLargeInput tests that embedding models can handle large inputs that would exceed typical batch sizes.
|
||||
func runEmbedLargeInput(t *testing.T, models []string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
|
||||
for _, model := range testModels(libraryEmbedModels) {
|
||||
for _, model := range testModels(models) {
|
||||
model := model
|
||||
t.Run(model, func(t *testing.T) {
|
||||
if testModel != "" {
|
||||
requireCapability(ctx, t, client, model, "embedding")
|
||||
}
|
||||
pullOrSkip(ctx, t, client, model)
|
||||
skipIfModelTooLargeForSweepVRAM(ctx, t, client, model)
|
||||
|
||||
mctx, mcancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
defer mcancel()
|
||||
|
||||
@@ -567,11 +579,11 @@ func TestEmbedLargeInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbedStatusCode tests that errors from the embedding endpoint
|
||||
// runEmbedStatusCode tests that errors from the embedding endpoint
|
||||
// properly preserve their HTTP status codes when returned to the client.
|
||||
// This test specifically checks the error handling path in EmbedHandler
|
||||
// where api.StatusError errors should maintain their original status code.
|
||||
func TestEmbedStatusCode(t *testing.T) {
|
||||
func runEmbedStatusCode(t *testing.T, models []string) {
|
||||
// Use test deadline if set, otherwise default to 2 minutes
|
||||
timeout := 2 * time.Minute
|
||||
if deadline, ok := t.Deadline(); ok {
|
||||
@@ -582,7 +594,7 @@ func TestEmbedStatusCode(t *testing.T) {
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
|
||||
for _, model := range testModels(libraryEmbedModels) {
|
||||
for _, model := range testModels(models) {
|
||||
model := model
|
||||
t.Run(model, func(t *testing.T) {
|
||||
if testModel != "" {
|
||||
@@ -598,6 +610,7 @@ func TestEmbedStatusCode(t *testing.T) {
|
||||
|
||||
// Pull the model if needed
|
||||
pullOrSkip(mctx, t, client, model)
|
||||
skipIfModelTooLargeForSweepVRAM(mctx, t, client, model)
|
||||
|
||||
t.Run("truncation error status code", func(t *testing.T) {
|
||||
truncFalse := false
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func TestImageGeneration(t *testing.T) {
|
||||
if testModel != "" {
|
||||
t.Skip("uses hardcoded models, not applicable with model override")
|
||||
}
|
||||
skipUnderMinVRAM(t, 32)
|
||||
|
||||
type testCase struct {
|
||||
imageGenModel string
|
||||
visionModel string
|
||||
prompt string
|
||||
expectedWords []string
|
||||
}
|
||||
|
||||
testCases := []testCase{
|
||||
{
|
||||
imageGenModel: "jmorgan/z-image-turbo",
|
||||
visionModel: "qwen2.5vl:3b",
|
||||
prompt: "A cartoon style llama flying like a superhero through the air with clouds in the background",
|
||||
expectedWords: []string{"llama", "flying", "cartoon", "cloud", "sky", "superhero", "air", "animal", "camelid"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("%s->%s", tc.imageGenModel, tc.visionModel), func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
|
||||
// Pull both models
|
||||
pullOrSkip(ctx, t, client, tc.imageGenModel)
|
||||
pullOrSkip(ctx, t, client, tc.visionModel)
|
||||
|
||||
// Generate the image
|
||||
t.Logf("Generating image with prompt: %s", tc.prompt)
|
||||
imageBase64, err := generateImage(ctx, client, tc.imageGenModel, tc.prompt)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "image generation not available") {
|
||||
t.Skip("Target system does not support image generation")
|
||||
} else if strings.Contains(err.Error(), "executable file not found in") { // Windows pattern, not yet supported
|
||||
t.Skip("Windows does not support image generation yet")
|
||||
} else if strings.Contains(err.Error(), "CUDA driver version is insufficient") {
|
||||
t.Skip("Driver is too old")
|
||||
} else if strings.Contains(err.Error(), "insufficient memory for image generation") {
|
||||
t.Skip("insufficient memory for image generation")
|
||||
} else if strings.Contains(err.Error(), "error while loading shared libraries: libcuda.so.1") { // AMD GPU or CPU
|
||||
t.Skip("CUDA GPU is not available")
|
||||
} else if strings.Contains(err.Error(), "ollama-mlx: no such file or directory") {
|
||||
// most likely linux arm - not supported yet
|
||||
t.Skip("unsupported architecture")
|
||||
} else if strings.Contains(err.Error(), "are available") {
|
||||
t.Skip("insufficient VRAM for image generation model")
|
||||
} else if strings.Contains(err.Error(), "failed to create server") {
|
||||
t.Skip("image generation server failed to start")
|
||||
}
|
||||
t.Fatalf("failed to generate image: %v", err)
|
||||
}
|
||||
|
||||
imageData, err := base64.StdEncoding.DecodeString(imageBase64)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode image: %v", err)
|
||||
}
|
||||
t.Logf("Generated image: %d bytes", len(imageData))
|
||||
|
||||
// Preload vision model and check GPU loading
|
||||
err = client.Generate(ctx, &api.GenerateRequest{Model: tc.visionModel}, func(response api.GenerateResponse) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load vision model: %v", err)
|
||||
}
|
||||
|
||||
// Use vision model to describe the image
|
||||
chatReq := api.ChatRequest{
|
||||
Model: tc.visionModel,
|
||||
Messages: []api.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "Describe this image in detail. What is shown? What style is it? What is the main subject doing?",
|
||||
Images: []api.ImageData{imageData},
|
||||
},
|
||||
},
|
||||
Stream: &stream,
|
||||
Options: map[string]any{
|
||||
"seed": 42,
|
||||
"temperature": 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
// Verify the vision model's response contains expected keywords
|
||||
response := DoChat(ctx, t, client, chatReq, tc.expectedWords, 240*time.Second, 30*time.Second)
|
||||
if response != nil {
|
||||
t.Logf("Vision model response: %s", response.Content)
|
||||
|
||||
// Additional detailed check for keywords
|
||||
content := strings.ToLower(response.Content)
|
||||
foundWords := []string{}
|
||||
missingWords := []string{}
|
||||
for _, word := range tc.expectedWords {
|
||||
if strings.Contains(content, word) {
|
||||
foundWords = append(foundWords, word)
|
||||
} else {
|
||||
missingWords = append(missingWords, word)
|
||||
}
|
||||
}
|
||||
t.Logf("Found keywords: %v", foundWords)
|
||||
if len(missingWords) > 0 {
|
||||
t.Logf("Missing keywords (at least one was found so test passed): %v", missingWords)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// generateImage calls the Ollama API to generate an image and returns the base64 image data
|
||||
func generateImage(ctx context.Context, client *api.Client, model, prompt string) (string, error) {
|
||||
var imageBase64 string
|
||||
|
||||
err := client.Generate(ctx, &api.GenerateRequest{
|
||||
Model: model,
|
||||
Prompt: prompt,
|
||||
}, func(resp api.GenerateResponse) error {
|
||||
if resp.Image != "" {
|
||||
imageBase64 = resp.Image
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate image: %w", err)
|
||||
}
|
||||
|
||||
if imageBase64 == "" {
|
||||
return "", fmt.Errorf("no image data in response")
|
||||
}
|
||||
|
||||
return imageBase64, nil
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
//go:build integration && library
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
// First run of this scenario on a target system will take a long time to download
|
||||
// ~1.5TB of models. Set a sufficiently large -timeout for your network speed
|
||||
func TestLibraryModelsChat(t *testing.T) {
|
||||
softTimeout, hardTimeout := getTimeouts(t)
|
||||
slog.Info("Setting timeouts", "soft", softTimeout, "hard", hardTimeout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
|
||||
defer cancel()
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
targetArch := os.Getenv("OLLAMA_TEST_ARCHITECTURE")
|
||||
|
||||
for _, model := range testModels(libraryChatModels) {
|
||||
t.Run(model, func(t *testing.T) {
|
||||
if time.Now().Sub(started) > softTimeout {
|
||||
t.Skip("skipping remaining tests to avoid excessive runtime")
|
||||
}
|
||||
pullOrSkip(ctx, t, client, model)
|
||||
if targetArch != "" {
|
||||
resp, err := client.Show(ctx, &api.ShowRequest{Name: model})
|
||||
if err != nil {
|
||||
t.Fatalf("unable to show model: %s", err)
|
||||
}
|
||||
arch := resp.ModelInfo["general.architecture"].(string)
|
||||
if arch != targetArch {
|
||||
t.Skip(fmt.Sprintf("Skipping %s architecture %s != %s", model, arch, targetArch))
|
||||
}
|
||||
}
|
||||
req := api.ChatRequest{
|
||||
Model: model,
|
||||
Messages: []api.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: blueSkyPrompt,
|
||||
},
|
||||
},
|
||||
KeepAlive: &api.Duration{Duration: 10 * time.Second},
|
||||
Options: map[string]interface{}{
|
||||
"temperature": 0.1,
|
||||
"seed": 123,
|
||||
},
|
||||
}
|
||||
anyResp := blueSkyExpected
|
||||
// Special cases
|
||||
if model == "duckdb-nsql" {
|
||||
anyResp = []string{"select", "from"}
|
||||
} else if model == "granite3-guardian" || model == "shieldgemma" || model == "llama-guard3" || model == "bespoke-minicheck" {
|
||||
anyResp = []string{"yes", "no", "safe", "unsafe"}
|
||||
} else if model == "openthinker" {
|
||||
anyResp = []string{"plugin", "im_sep", "components", "function call"}
|
||||
} else if model == "starcoder" || model == "starcoder2" || model == "magicoder" || model == "deepseek-coder" {
|
||||
req.Messages[0].Content = "def fibonacci():"
|
||||
anyResp = []string{"f(n)", "sequence", "n-1", "main()", "__main__", "while"}
|
||||
}
|
||||
DoChat(ctx, t, client, req, anyResp, 120*time.Second, 30*time.Second)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,69 +11,53 @@ import (
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func TestVisionModels(t *testing.T) {
|
||||
skipUnderMinVRAM(t, 6)
|
||||
|
||||
defaultVisionModels := []string{
|
||||
"gemma4",
|
||||
"qwen2.5vl",
|
||||
// "llama3.2-vision", // TODO: re-enable when llama.cpp supports mllama.
|
||||
"gemma3",
|
||||
"qwen3-vl:8b",
|
||||
"qwen3-vl:30b",
|
||||
"ministral-3",
|
||||
}
|
||||
|
||||
skipIfNoVisionOverride(t)
|
||||
|
||||
for _, model := range testModels(defaultVisionModels) {
|
||||
t.Run(model, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
|
||||
requireCapability(ctx, t, client, model, "vision")
|
||||
pullOrSkip(ctx, t, client, model)
|
||||
|
||||
image, err := base64.StdEncoding.DecodeString(imageEncoding)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := api.ChatRequest{
|
||||
Model: model,
|
||||
Messages: []api.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "what does the text in this image say?",
|
||||
Images: []api.ImageData{
|
||||
image,
|
||||
},
|
||||
},
|
||||
},
|
||||
Stream: &stream,
|
||||
Options: map[string]any{
|
||||
"seed": 42,
|
||||
"temperature": 0.0,
|
||||
},
|
||||
KeepAlive: &api.Duration{Duration: 10 * time.Second},
|
||||
}
|
||||
|
||||
// Preload to skip if we're less than 80% on GPU to avoid extremely slow tests
|
||||
err = client.Generate(ctx, &api.GenerateRequest{Model: req.Model}, func(response api.GenerateResponse) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load model %s: %s", req.Model, err)
|
||||
}
|
||||
skipIfNotGPULoaded(ctx, t, client, req.Model, 80)
|
||||
|
||||
// Note: sometimes it returns "the ollamas" sometimes "the ollams"
|
||||
// llava models on CPU can be quite slow to start
|
||||
DoChat(ctx, t, client, req, []string{"the ollam"}, 240*time.Second, 30*time.Second)
|
||||
})
|
||||
}
|
||||
func registerVisionTextCases(models []string) {
|
||||
registerModelIntegrationCases("vision-text", models, runVisionTextModel)
|
||||
}
|
||||
|
||||
func TestIntegrationSplitBatch(t *testing.T) {
|
||||
func runVisionTextModel(t *testing.T, model string) {
|
||||
t.Helper()
|
||||
|
||||
skipUnderMinVRAM(t, 6)
|
||||
skipKnownIntegrationFlake(t, "vision-text", model)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
|
||||
requireCapability(ctx, t, client, model, "vision")
|
||||
pullOrSkip(ctx, t, client, model)
|
||||
|
||||
image, err := base64.StdEncoding.DecodeString(imageEncoding)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := api.ChatRequest{
|
||||
Model: model,
|
||||
Messages: []api.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "what does the text in this image say?",
|
||||
Images: []api.ImageData{
|
||||
image,
|
||||
},
|
||||
},
|
||||
},
|
||||
Stream: &stream,
|
||||
Options: map[string]any{
|
||||
"seed": 42,
|
||||
"temperature": 0.0,
|
||||
},
|
||||
KeepAlive: &api.Duration{Duration: 10 * time.Second},
|
||||
}
|
||||
|
||||
// Preload to skip if we're less than 80% on GPU to avoid extremely slow tests
|
||||
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: req.Model})
|
||||
skipIfNotGPULoaded(ctx, t, client, req.Model, 80)
|
||||
DoChat(ctx, t, client, req, []string{"the ollam", "ollamas"}, 240*time.Second, 30*time.Second)
|
||||
}
|
||||
|
||||
func runIntegrationSplitBatch(t *testing.T, model string) {
|
||||
if testModel != "" {
|
||||
t.Skip("uses hardcoded model, not applicable with model override")
|
||||
}
|
||||
@@ -83,7 +67,7 @@ func TestIntegrationSplitBatch(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := api.GenerateRequest{
|
||||
Model: "gemma3:4b",
|
||||
Model: model,
|
||||
// Fill up a chunk of the batch so the image will partially spill over into the next one
|
||||
System: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed aliquet, justo in malesuada lobortis, odio ligula volutpat quam, quis faucibus ipsum magna quis sapien. Aliquam in venenatis diam, eu viverra magna. Phasellus imperdiet hendrerit volutpat. Vivamus sem ex, facilisis placerat felis non, dictum elementum est. Phasellus aliquam imperdiet lacus, eget placerat ligula sodales vel. Pellentesque nec auctor mi. Curabitur arcu nisi, faucibus eget nunc id, viverra interdum mi. Curabitur ornare ipsum ex, ac euismod ex aliquam in. Vestibulum id magna at purus accumsan fermentum. Proin scelerisque posuere nunc quis interdum. Maecenas sed mollis nisl. Etiam vitae ipsum interdum, placerat est quis, tincidunt velit. Nullam tempor nibh non lorem volutpat efficitur. Cras laoreet diam imperdiet ipsum auctor bibendum. Suspendisse ultrices urna sed metus sagittis suscipit. Quisque ullamcorper aliquam nibh ut mollis. Aenean dapibus mauris pharetra, venenatis elit ac, hendrerit odio. Cras vestibulum erat tempor, lobortis justo eu, lobortis ipsum. Nam laoreet dapibus sem. Proin vel diam ultrices, elementum ante et, ornare lectus. Proin eu accumsan nisl. Praesent ac ex vitae ipsum vulputate tristique facilisis sit amet lacus. Nullam faucibus magna a pellentesque pretium. Nunc lacinia ullamcorper sollicitudin. Donec vitae accumsan turpis, sed porttitor est. Donec porttitor mi vitae augue faucibus, vel mollis diam tincidunt.",
|
||||
Prompt: "what does the text in this image say?",
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func TestMaxQueue(t *testing.T) {
|
||||
func runMaxQueue(t *testing.T) {
|
||||
t.Skip("this test needs to be re-evaluated to use a proper embedding model")
|
||||
|
||||
if os.Getenv("OLLAMA_TEST_EXISTING") != "" {
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
//go:build integration && models
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/format"
|
||||
)
|
||||
|
||||
func TestModelsChat(t *testing.T) {
|
||||
softTimeout, hardTimeout := getTimeouts(t)
|
||||
slog.Info("Setting timeouts", "soft", softTimeout, "hard", hardTimeout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
|
||||
defer cancel()
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
|
||||
// TODO use info API eventually
|
||||
var maxVram uint64
|
||||
var err error
|
||||
if s := os.Getenv("OLLAMA_MAX_VRAM"); s != "" {
|
||||
maxVram, err = strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("invalid OLLAMA_MAX_VRAM %v", err)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("No VRAM info available, testing all models, so larger ones might timeout...")
|
||||
}
|
||||
|
||||
chatModels := append(ollamaEngineChatModels, llamaRunnerChatModels...)
|
||||
chatModels = append(chatModels, mlxEngineChatModels...)
|
||||
|
||||
for _, model := range testModels(chatModels) {
|
||||
t.Run(model, func(t *testing.T) {
|
||||
if time.Now().Sub(started) > softTimeout {
|
||||
t.Skip("skipping remaining tests to avoid excessive runtime")
|
||||
}
|
||||
pullOrSkip(ctx, t, client, model)
|
||||
if maxVram > 0 {
|
||||
resp, err := client.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list models failed %v", err)
|
||||
}
|
||||
for _, m := range resp.Models {
|
||||
if m.Name == model && float32(m.Size)*1.2 > float32(maxVram) {
|
||||
t.Skipf("model %s is too large for available VRAM: %s > %s", model, format.HumanBytes(m.Size), format.HumanBytes(int64(maxVram)))
|
||||
}
|
||||
}
|
||||
}
|
||||
initialTimeout := 120 * time.Second
|
||||
streamTimeout := 30 * time.Second
|
||||
slog.Info("loading", "model", model)
|
||||
err := client.Generate(ctx,
|
||||
&api.GenerateRequest{Model: model, KeepAlive: &api.Duration{Duration: 10 * time.Second}},
|
||||
func(response api.GenerateResponse) error { return nil },
|
||||
)
|
||||
if err != nil {
|
||||
skipIfMLXUnsupported(t, err)
|
||||
t.Fatalf("failed to load model %s: %s", model, err)
|
||||
}
|
||||
gpuPercent := getGPUPercent(ctx, t, client, model)
|
||||
if gpuPercent < 80 {
|
||||
slog.Warn("Low GPU percentage - increasing timeouts", "percent", gpuPercent)
|
||||
initialTimeout = 240 * time.Second
|
||||
streamTimeout = 40 * time.Second
|
||||
}
|
||||
|
||||
// TODO - fiddle with context size
|
||||
req := api.ChatRequest{
|
||||
Model: model,
|
||||
Messages: []api.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: blueSkyPrompt,
|
||||
},
|
||||
},
|
||||
KeepAlive: &api.Duration{Duration: 10 * time.Second},
|
||||
Options: map[string]interface{}{
|
||||
"temperature": 0,
|
||||
"seed": 123,
|
||||
},
|
||||
}
|
||||
DoChat(ctx, t, client, req, blueSkyExpected, initialTimeout, streamTimeout)
|
||||
// best effort unload once we're done with the model
|
||||
client.Generate(ctx, &api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 0}}, func(rsp api.GenerateResponse) error { return nil })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelsEmbed(t *testing.T) {
|
||||
softTimeout, hardTimeout := getTimeouts(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
|
||||
defer cancel()
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
|
||||
// TODO use info API eventually
|
||||
var maxVram uint64
|
||||
var err error
|
||||
if s := os.Getenv("OLLAMA_MAX_VRAM"); s != "" {
|
||||
maxVram, err = strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("invalid OLLAMA_MAX_VRAM %v", err)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("No VRAM info available, testing all models, so larger ones might timeout...")
|
||||
}
|
||||
|
||||
data, err := ioutil.ReadFile(filepath.Join("testdata", "embed.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open test data file: %s", err)
|
||||
}
|
||||
testCase := map[string][]float64{}
|
||||
err = json.Unmarshal(data, &testCase)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load test data: %s", err)
|
||||
}
|
||||
for model, expected := range testCase {
|
||||
if testModel != "" && model != testModel {
|
||||
continue
|
||||
}
|
||||
|
||||
t.Run(model, func(t *testing.T) {
|
||||
if time.Now().Sub(started) > softTimeout {
|
||||
t.Skip("skipping remaining tests to avoid excessive runtime")
|
||||
}
|
||||
pullOrSkip(ctx, t, client, model)
|
||||
if maxVram > 0 {
|
||||
resp, err := client.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list models failed %v", err)
|
||||
}
|
||||
for _, m := range resp.Models {
|
||||
if m.Name == model && float32(m.Size)*1.2 > float32(maxVram) {
|
||||
t.Skipf("model %s is too large for available VRAM: %s > %s", model, format.HumanBytes(m.Size), format.HumanBytes(int64(maxVram)))
|
||||
}
|
||||
}
|
||||
}
|
||||
req := api.EmbeddingRequest{
|
||||
Model: model,
|
||||
Prompt: "why is the sky blue?",
|
||||
KeepAlive: &api.Duration{Duration: 10 * time.Second},
|
||||
Options: map[string]interface{}{
|
||||
"temperature": 0,
|
||||
"seed": 123,
|
||||
},
|
||||
}
|
||||
resp, err := client.Embeddings(ctx, &req)
|
||||
if err != nil {
|
||||
t.Fatalf("embeddings call failed %s", err)
|
||||
}
|
||||
defer func() {
|
||||
// best effort unload once we're done with the model
|
||||
client.Generate(ctx, &api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 0}}, func(rsp api.GenerateResponse) error { return nil })
|
||||
}()
|
||||
if len(resp.Embedding) == 0 {
|
||||
t.Errorf("zero length embedding response")
|
||||
}
|
||||
if len(expected) != len(resp.Embedding) {
|
||||
expStr := make([]string, len(resp.Embedding))
|
||||
for i, v := range resp.Embedding {
|
||||
expStr[i] = fmt.Sprintf("%0.6f", v)
|
||||
}
|
||||
// When adding new models, use this output to populate the testdata/embed.json
|
||||
fmt.Printf("expected\n%s\n", strings.Join(expStr, ", "))
|
||||
t.Fatalf("expected %d, got %d", len(expected), len(resp.Embedding))
|
||||
}
|
||||
sim := cosineSimilarity(resp.Embedding, expected)
|
||||
if sim < 0.99 {
|
||||
t.Fatalf("expected %v, got %v (similarity: %f)", expected[0:5], resp.Embedding[0:5], sim)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
//go:build integration && perf
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log/slog"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/format"
|
||||
)
|
||||
|
||||
var (
|
||||
// Models that don't work reliably with the large context prompt in this test case
|
||||
longContextFlakes = []string{
|
||||
"granite-code:latest",
|
||||
"nemotron-mini:latest",
|
||||
"falcon:latest", // 2k model
|
||||
"falcon2:latest", // 2k model
|
||||
"minicpm-v:latest",
|
||||
"qwen:latest",
|
||||
}
|
||||
)
|
||||
|
||||
// Note: this test case can take a long time to run, particularly on models with
|
||||
// large contexts. Run with -timeout set to a large value to get reasonable coverage
|
||||
// Example usage:
|
||||
//
|
||||
// go test --tags=integration,perf -count 1 ./integration -v -timeout 90m -run TestModelsPerf 2>&1 | tee int.log
|
||||
// cat int.log | grep MODEL_PERF_HEADER | head -1| cut -f2- -d: > perf.csv
|
||||
// cat int.log | grep MODEL_PERF_DATA | cut -f2- -d: >> perf.csv
|
||||
func TestModelsPerf(t *testing.T) {
|
||||
doModelPerfTest(t, append(ollamaEngineChatModels, llamaRunnerChatModels...))
|
||||
}
|
||||
|
||||
func TestLibraryModelsPerf(t *testing.T) {
|
||||
doModelPerfTest(t, libraryChatModels)
|
||||
}
|
||||
|
||||
func doModelPerfTest(t *testing.T, chatModels []string) {
|
||||
softTimeout, hardTimeout := getTimeouts(t)
|
||||
slog.Info("Setting timeouts", "soft", softTimeout, "hard", hardTimeout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
|
||||
defer cancel()
|
||||
client, _, cleanup := InitServerConnection(ctx, t)
|
||||
defer cleanup()
|
||||
|
||||
// TODO use info API eventually
|
||||
var maxVram uint64
|
||||
var err error
|
||||
if s := os.Getenv("OLLAMA_MAX_VRAM"); s != "" {
|
||||
maxVram, err = strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("invalid OLLAMA_MAX_VRAM %v", err)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("No VRAM info available, testing all models, so larger ones might timeout...")
|
||||
}
|
||||
|
||||
data, err := ioutil.ReadFile(filepath.Join("testdata", "shakespeare.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open test data file: %s", err)
|
||||
}
|
||||
longPrompt := "summarize the following: " + string(data)
|
||||
|
||||
targetArch := os.Getenv("OLLAMA_TEST_ARCHITECTURE")
|
||||
|
||||
for _, model := range chatModels {
|
||||
if !strings.Contains(model, ":") {
|
||||
model = model + ":latest"
|
||||
}
|
||||
t.Run(model, func(t *testing.T) {
|
||||
if time.Now().Sub(started) > softTimeout {
|
||||
t.Skip("skipping remaining tests to avoid excessive runtime")
|
||||
}
|
||||
pullOrSkip(ctx, t, client, model)
|
||||
var maxContext int
|
||||
|
||||
resp, err := client.Show(ctx, &api.ShowRequest{Model: model})
|
||||
if err != nil {
|
||||
t.Fatalf("show failed: %s", err)
|
||||
}
|
||||
arch := resp.ModelInfo["general.architecture"].(string)
|
||||
maxContext = int(resp.ModelInfo[fmt.Sprintf("%s.context_length", arch)].(float64))
|
||||
if targetArch != "" && arch != targetArch {
|
||||
t.Skip(fmt.Sprintf("Skipping %s architecture %s != %s", model, arch, targetArch))
|
||||
}
|
||||
|
||||
if maxVram > 0 {
|
||||
resp, err := client.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list models failed %v", err)
|
||||
}
|
||||
for _, m := range resp.Models {
|
||||
// For these tests we want to exercise a some amount of overflow on the CPU
|
||||
if m.Name == model && float32(m.Size)*0.75 > float32(maxVram) {
|
||||
t.Skipf("model %s is too large %s for available VRAM %s", model, format.HumanBytes(m.Size), format.HumanBytes(int64(maxVram)))
|
||||
}
|
||||
}
|
||||
}
|
||||
slog.Info("scneario", "model", model, "max_context", maxContext)
|
||||
loaded := false
|
||||
defer func() {
|
||||
// best effort unload once we're done with the model
|
||||
if loaded {
|
||||
client.Generate(ctx, &api.GenerateRequest{Model: model, KeepAlive: &api.Duration{Duration: 0}}, func(rsp api.GenerateResponse) error { return nil })
|
||||
}
|
||||
}()
|
||||
|
||||
// Some models don't handle the long context data well so skip them to avoid flaky test results
|
||||
longContextFlake := false
|
||||
for _, flake := range longContextFlakes {
|
||||
if model == flake {
|
||||
longContextFlake = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// iterate through a few context sizes for coverage without excessive runtime
|
||||
var contexts []int
|
||||
keepGoing := true
|
||||
if maxContext > 16384 {
|
||||
contexts = []int{4096, 8192, 16384, maxContext}
|
||||
} else if maxContext > 8192 {
|
||||
contexts = []int{4096, 8192, maxContext}
|
||||
} else if maxContext > 4096 {
|
||||
contexts = []int{4096, maxContext}
|
||||
} else if maxContext > 0 {
|
||||
contexts = []int{maxContext}
|
||||
} else {
|
||||
t.Fatal("unknown max context size")
|
||||
}
|
||||
for _, numCtx := range contexts {
|
||||
if !keepGoing && numCtx > 8192 { // Always try up to 8k before bailing out
|
||||
break
|
||||
}
|
||||
skipLongPrompt := false
|
||||
|
||||
// Workaround bug 11172 temporarily...
|
||||
maxPrompt := longPrompt
|
||||
// If we fill the context too full with the prompt, many models
|
||||
// quickly hit context shifting and go bad.
|
||||
if len(maxPrompt) > numCtx*2 { // typically yields ~1/2 full context
|
||||
maxPrompt = maxPrompt[:numCtx*2]
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
prompt string
|
||||
anyResp []string
|
||||
}{
|
||||
{blueSkyPrompt, blueSkyExpected},
|
||||
{maxPrompt, []string{"shakespeare", "oppression", "sorrows", "gutenberg", "child", "license", "sonnet", "melancholy", "love", "sorrow", "beauty"}},
|
||||
}
|
||||
var gpuPercent int
|
||||
for _, tc := range testCases {
|
||||
if len(tc.prompt) > 100 && (longContextFlake || skipLongPrompt) {
|
||||
slog.Info("skipping long prompt", "model", model, "num_ctx", numCtx, "gpu_percent", gpuPercent)
|
||||
continue
|
||||
}
|
||||
req := api.ChatRequest{
|
||||
Model: model,
|
||||
Messages: []api.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: tc.prompt,
|
||||
},
|
||||
},
|
||||
KeepAlive: &api.Duration{Duration: 20 * time.Second}, // long enough to ensure a ps returns
|
||||
Options: map[string]interface{}{
|
||||
"temperature": 0,
|
||||
"seed": 123,
|
||||
"num_ctx": numCtx,
|
||||
},
|
||||
}
|
||||
atLeastOne := false
|
||||
var resp api.ChatResponse
|
||||
|
||||
stream := false
|
||||
req.Stream = &stream
|
||||
|
||||
// Avoid potentially getting stuck indefinitely
|
||||
limit := 5 * time.Minute
|
||||
genCtx, cancel := context.WithDeadlineCause(
|
||||
ctx,
|
||||
time.Now().Add(limit),
|
||||
fmt.Errorf("generate on model %s with ctx %d took longer than %v", model, numCtx, limit),
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
err = client.Chat(genCtx, &req, func(rsp api.ChatResponse) error {
|
||||
resp = rsp
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// Avoid excessive test runs, but don't consider a failure with massive context
|
||||
if numCtx > 16384 && strings.Contains(err.Error(), "took longer") {
|
||||
slog.Warn("max context was taking too long, skipping", "error", err)
|
||||
keepGoing = false
|
||||
skipLongPrompt = true
|
||||
continue
|
||||
}
|
||||
t.Fatalf("generate error: ctx:%d err:%s", numCtx, err)
|
||||
}
|
||||
loaded = true
|
||||
for _, expResp := range tc.anyResp {
|
||||
if strings.Contains(strings.ToLower(resp.Message.Content), expResp) {
|
||||
atLeastOne = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !atLeastOne {
|
||||
t.Fatalf("response didn't contain expected values: ctx:%d expected:%v response:%s ", numCtx, tc.anyResp, resp.Message.Content)
|
||||
}
|
||||
models, err := client.ListRunning(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("failed to list running models", "error", err)
|
||||
continue
|
||||
}
|
||||
if len(models.Models) > 1 {
|
||||
slog.Warn("multiple models loaded, may impact performance results", "loaded", models.Models)
|
||||
}
|
||||
for _, m := range models.Models {
|
||||
if m.Name == model {
|
||||
if m.SizeVRAM == 0 {
|
||||
slog.Info("Model fully loaded into CPU")
|
||||
gpuPercent = 0
|
||||
keepGoing = false
|
||||
skipLongPrompt = true
|
||||
} else if m.SizeVRAM == m.Size {
|
||||
slog.Info("Model fully loaded into GPU")
|
||||
gpuPercent = 100
|
||||
} else {
|
||||
sizeCPU := m.Size - m.SizeVRAM
|
||||
cpuPercent := math.Round(float64(sizeCPU) / float64(m.Size) * 100)
|
||||
gpuPercent = int(100 - cpuPercent)
|
||||
slog.Info("Model split between CPU/GPU", "CPU", cpuPercent, "GPU", gpuPercent)
|
||||
keepGoing = false
|
||||
|
||||
// Heuristic to avoid excessive test run time
|
||||
if gpuPercent < 90 {
|
||||
skipLongPrompt = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Round the logged prompt count for comparisons across versions/configurations which can vary slightly
|
||||
fmt.Fprintf(os.Stderr, "MODEL_PERF_HEADER:%s,%s,%s,%s,%s,%s,%s\n",
|
||||
"MODEL",
|
||||
"CONTEXT",
|
||||
"GPU PERCENT",
|
||||
"APPROX PROMPT COUNT",
|
||||
"LOAD TIME",
|
||||
"PROMPT EVAL TPS",
|
||||
"EVAL TPS",
|
||||
)
|
||||
fmt.Fprintf(os.Stderr, "MODEL_PERF_DATA:%s,%d,%d,%d,%0.2f,%0.2f,%0.2f\n",
|
||||
model,
|
||||
numCtx,
|
||||
gpuPercent,
|
||||
(resp.PromptEvalCount/10)*10,
|
||||
float64(resp.LoadDuration)/1000000000.0,
|
||||
float64(resp.PromptEvalCount)/(float64(resp.PromptEvalDuration)/1000000000.0),
|
||||
float64(resp.EvalCount)/(float64(resp.EvalDuration)/1000000000.0),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build integration && models
|
||||
//go:build integration && release
|
||||
|
||||
package integration
|
||||
|
||||
@@ -14,7 +14,11 @@ import (
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func TestQuantization(t *testing.T) {
|
||||
func runQuantization(t *testing.T) {
|
||||
if testModel != "" {
|
||||
t.Skip("exercises quantization with a fixed source model, not applicable with model override")
|
||||
}
|
||||
|
||||
sourceModels := []string{
|
||||
"qwen2.5:0.5b-instruct-fp16",
|
||||
}
|
||||
|
||||
Loaded 100 of 346 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user