Compare commits
101 Commits
pdevine/ml
...
hoyyeva/op
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a3ed0a1b4 | ||
|
|
03f9e57274 | ||
|
|
30d9100fff | ||
|
|
698e04a14b | ||
|
|
1d9537bc33 | ||
|
|
120424d832 | ||
|
|
5818001610 | ||
|
|
2cba7756c5 | ||
|
|
bf2a421727 | ||
|
|
f3cf6b75fb | ||
|
|
5dfac387a6 | ||
|
|
a99e5d9c22 | ||
|
|
0abf3aca36 | ||
|
|
ee0266462a | ||
|
|
c88fb286ec | ||
|
|
d3da29cbfc | ||
|
|
1b70bb8a10 | ||
|
|
ec29ce4ce3 | ||
|
|
4d75f5da03 | ||
|
|
798fd09bfe | ||
|
|
9330bb9120 | ||
|
|
40a1317dfd | ||
|
|
fdfe9cec98 | ||
|
|
9517864603 | ||
|
|
8e6d86dbe3 | ||
|
|
80d3744c5d | ||
|
|
2a94f03823 | ||
|
|
eb97274e5c | ||
|
|
6b5db12aa2 | ||
|
|
612f0a17d3 | ||
|
|
673726fa0e | ||
|
|
b5918f9785 | ||
|
|
d17f482d50 | ||
|
|
4e16f562c0 | ||
|
|
55308f1421 | ||
|
|
d64812eb5d | ||
|
|
f86a969f27 | ||
|
|
9fa80a1660 | ||
|
|
dde09129d1 | ||
|
|
780556c4d0 | ||
|
|
dfae363b5b | ||
|
|
30fdd229a4 | ||
|
|
e823bff873 | ||
|
|
8968740836 | ||
|
|
8c8f8f3450 | ||
|
|
82f0139587 | ||
|
|
26a58b294c | ||
|
|
34a790a2e6 | ||
|
|
4589fa2cf5 | ||
|
|
4bc2728047 | ||
|
|
49d5fd5a3e | ||
|
|
3cd2b03a5e | ||
|
|
c8e0878814 | ||
|
|
bb0c58e134 | ||
|
|
036ed1b9b5 | ||
|
|
3536ef58f6 | ||
|
|
de9673ac3f | ||
|
|
96b202d34b | ||
|
|
79865e6c5a | ||
|
|
5ab10d347a | ||
|
|
a8292dd85f | ||
|
|
cb0033598e | ||
|
|
4d14b0ff92 | ||
|
|
d9cb70c270 | ||
|
|
31f968fe1f | ||
|
|
b7bda92d52 | ||
|
|
8e54823fd3 | ||
|
|
7c8da5679e | ||
|
|
6214103e66 | ||
|
|
9e7cb9697e | ||
|
|
3824e380a8 | ||
|
|
c9b2dcfc52 | ||
|
|
b00bd1dfd4 | ||
|
|
ac83ac20c4 | ||
|
|
e7ccc129ea | ||
|
|
69ed0c2729 | ||
|
|
1cefa749aa | ||
|
|
aec2fef95d | ||
|
|
366625a831 | ||
|
|
516ebd8548 | ||
|
|
f567abc63f | ||
|
|
1adfc27f04 | ||
|
|
4a2b9f9dbc | ||
|
|
e46b67a6cc | ||
|
|
c000afe76c | ||
|
|
9d7b18f81e | ||
|
|
4f5999fd3f | ||
|
|
ac5f0dbb6a | ||
|
|
d1151e18a1 | ||
|
|
ebbce136c7 | ||
|
|
26b9f53f8e | ||
|
|
7575438366 | ||
|
|
7d7c90d702 | ||
|
|
4fda69809a | ||
|
|
c9b5da6b0c | ||
|
|
de5cb7311f | ||
|
|
95ee7fbd29 | ||
|
|
ec55536734 | ||
|
|
77491439c2 | ||
|
|
b166b36cd2 | ||
|
|
c2b0bb7a52 |
3
.github/workflows/release.yaml
vendored
@@ -27,7 +27,7 @@ jobs:
|
|||||||
echo vendorsha=$(make -f Makefile.sync print-base) | tee -a $GITHUB_OUTPUT
|
echo vendorsha=$(make -f Makefile.sync print-base) | tee -a $GITHUB_OUTPUT
|
||||||
|
|
||||||
darwin-build:
|
darwin-build:
|
||||||
runs-on: macos-14-xlarge
|
runs-on: macos-26-xlarge
|
||||||
environment: release
|
environment: release
|
||||||
needs: setup-environment
|
needs: setup-environment
|
||||||
env:
|
env:
|
||||||
@@ -424,6 +424,7 @@ jobs:
|
|||||||
lib/ollama/cuda_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
lib/ollama/cuda_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||||
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||||
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||||
|
lib/ollama/include*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||||
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
|
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
|
||||||
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
|
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
|
||||||
lib/ollama/rocm) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
|
lib/ollama/rocm) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
|
||||||
|
|||||||
9
.github/workflows/test.yaml
vendored
@@ -51,7 +51,7 @@ jobs:
|
|||||||
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
|
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
|
||||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=87'
|
flags: '-DCMAKE_CUDA_ARCHITECTURES=87'
|
||||||
- preset: ROCm
|
- preset: ROCm
|
||||||
container: rocm/dev-ubuntu-22.04:7.2
|
container: rocm/dev-ubuntu-22.04:7.2.1
|
||||||
extra-packages: rocm-libs
|
extra-packages: rocm-libs
|
||||||
flags: '-DAMDGPU_TARGETS=gfx1010 -DCMAKE_PREFIX_PATH=/opt/rocm'
|
flags: '-DAMDGPU_TARGETS=gfx1010 -DCMAKE_PREFIX_PATH=/opt/rocm'
|
||||||
- preset: Vulkan
|
- preset: Vulkan
|
||||||
@@ -64,6 +64,7 @@ jobs:
|
|||||||
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
|
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
|
||||||
extra-packages: libcudnn9-dev-cuda-13 libopenblas-dev liblapack-dev liblapacke-dev git curl
|
extra-packages: libcudnn9-dev-cuda-13 libopenblas-dev liblapack-dev liblapacke-dev git curl
|
||||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=87 -DBLAS_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu -DLAPACK_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu'
|
flags: '-DCMAKE_CUDA_ARCHITECTURES=87 -DBLAS_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu -DLAPACK_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu'
|
||||||
|
install-go: true
|
||||||
runs-on: linux
|
runs-on: linux
|
||||||
container: ${{ matrix.container }}
|
container: ${{ matrix.container }}
|
||||||
steps:
|
steps:
|
||||||
@@ -90,6 +91,12 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
env:
|
env:
|
||||||
DEBIAN_FRONTEND: noninteractive
|
DEBIAN_FRONTEND: noninteractive
|
||||||
|
- if: matrix.install-go
|
||||||
|
name: Install Go
|
||||||
|
run: |
|
||||||
|
GO_VERSION=$(awk '/^go / { print $2 }' go.mod)
|
||||||
|
curl -fsSL "https://golang.org/dl/go${GO_VERSION}.linux-$(dpkg --print-architecture).tar.gz" | tar xz -C /usr/local
|
||||||
|
echo "/usr/local/go/bin" >> $GITHUB_PATH
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: /github/home/.cache/ccache
|
path: /github/home/.cache/ccache
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -15,3 +15,4 @@ __debug_bin*
|
|||||||
llama/build
|
llama/build
|
||||||
llama/vendor
|
llama/vendor
|
||||||
/ollama
|
/ollama
|
||||||
|
integration/testdata/models/
|
||||||
|
|||||||
@@ -246,13 +246,21 @@ if(MLX_ENGINE)
|
|||||||
COMPONENT MLX)
|
COMPONENT MLX)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# Install CCCL headers for NVRTC JIT compilation at runtime.
|
# Install headers for NVRTC JIT compilation at runtime.
|
||||||
# MLX's own install rules use the default component so they get skipped by
|
# MLX's own install rules use the default component so they get skipped by
|
||||||
# --component MLX. Headers are installed alongside libmlx in OLLAMA_INSTALL_DIR.
|
# --component MLX. Headers are installed alongside libmlx in OLLAMA_INSTALL_DIR.
|
||||||
|
#
|
||||||
|
# Layout:
|
||||||
|
# ${OLLAMA_INSTALL_DIR}/include/cccl/{cuda,nv}/ — CCCL headers
|
||||||
|
# ${OLLAMA_INSTALL_DIR}/include/*.h — CUDA toolkit headers
|
||||||
|
#
|
||||||
|
# MLX's jit_module.cpp resolves CCCL via
|
||||||
|
# current_binary_dir()[.parent_path()] / "include" / "cccl"
|
||||||
# On Linux, MLX's jit_module.cpp resolves CCCL via
|
# On Linux, MLX's jit_module.cpp resolves CCCL via
|
||||||
# current_binary_dir().parent_path() / "include" / "cccl", so we create a
|
# current_binary_dir().parent_path() / "include" / "cccl", so we create a
|
||||||
# symlink from lib/ollama/include -> ${OLLAMA_RUNNER_DIR}/include
|
# symlink from lib/ollama/include -> ${OLLAMA_RUNNER_DIR}/include
|
||||||
# This will need refinement if we add multiple CUDA versions for MLX in the future.
|
# This will need refinement if we add multiple CUDA versions for MLX in the future.
|
||||||
|
# CUDA runtime headers are found via CUDA_PATH env var (set by mlxrunner).
|
||||||
if(EXISTS ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda)
|
if(EXISTS ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda)
|
||||||
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda
|
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda
|
||||||
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
|
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
|
||||||
@@ -271,6 +279,61 @@ if(MLX_ENGINE)
|
|||||||
endif()
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# Install minimal CUDA toolkit headers needed by MLX JIT kernels.
|
||||||
|
# These are the transitive closure of includes from mlx/backend/cuda/device/*.cuh.
|
||||||
|
# The Go mlxrunner sets CUDA_PATH to OLLAMA_INSTALL_DIR so MLX finds them at
|
||||||
|
# $CUDA_PATH/include/*.h via NVRTC --include-path.
|
||||||
|
if(CUDAToolkit_FOUND)
|
||||||
|
# CUDAToolkit_INCLUDE_DIRS may be a semicolon-separated list
|
||||||
|
# (e.g. ".../include;.../include/cccl"). Find the entry that
|
||||||
|
# contains the CUDA runtime headers we need.
|
||||||
|
set(_cuda_inc "")
|
||||||
|
foreach(_dir ${CUDAToolkit_INCLUDE_DIRS})
|
||||||
|
if(EXISTS "${_dir}/cuda_runtime_api.h")
|
||||||
|
set(_cuda_inc "${_dir}")
|
||||||
|
break()
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
if(NOT _cuda_inc)
|
||||||
|
message(WARNING "Could not find cuda_runtime_api.h in CUDAToolkit_INCLUDE_DIRS: ${CUDAToolkit_INCLUDE_DIRS}")
|
||||||
|
else()
|
||||||
|
set(_dst "${OLLAMA_INSTALL_DIR}/include")
|
||||||
|
set(_MLX_JIT_CUDA_HEADERS
|
||||||
|
builtin_types.h
|
||||||
|
cooperative_groups.h
|
||||||
|
cuda_bf16.h
|
||||||
|
cuda_bf16.hpp
|
||||||
|
cuda_device_runtime_api.h
|
||||||
|
cuda_fp16.h
|
||||||
|
cuda_fp16.hpp
|
||||||
|
cuda_fp8.h
|
||||||
|
cuda_fp8.hpp
|
||||||
|
cuda_runtime_api.h
|
||||||
|
device_types.h
|
||||||
|
driver_types.h
|
||||||
|
math_constants.h
|
||||||
|
surface_types.h
|
||||||
|
texture_types.h
|
||||||
|
vector_functions.h
|
||||||
|
vector_functions.hpp
|
||||||
|
vector_types.h
|
||||||
|
)
|
||||||
|
foreach(_hdr ${_MLX_JIT_CUDA_HEADERS})
|
||||||
|
install(FILES "${_cuda_inc}/${_hdr}"
|
||||||
|
DESTINATION ${_dst}
|
||||||
|
COMPONENT MLX)
|
||||||
|
endforeach()
|
||||||
|
# Subdirectory headers
|
||||||
|
install(DIRECTORY "${_cuda_inc}/cooperative_groups"
|
||||||
|
DESTINATION ${_dst}
|
||||||
|
COMPONENT MLX
|
||||||
|
FILES_MATCHING PATTERN "*.h")
|
||||||
|
install(FILES "${_cuda_inc}/crt/host_defines.h"
|
||||||
|
DESTINATION "${_dst}/crt"
|
||||||
|
COMPONENT MLX)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
# On Windows, explicitly install dl.dll (dlfcn-win32 POSIX dlopen emulation)
|
# On Windows, explicitly install dl.dll (dlfcn-win32 POSIX dlopen emulation)
|
||||||
# RUNTIME_DEPENDENCIES auto-excludes it via POST_EXCLUDE_FILES_STRICT because
|
# 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
|
# dlfcn-win32 is a known CMake target with its own install rules (which install
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
ARG FLAVOR=${TARGETARCH}
|
ARG FLAVOR=${TARGETARCH}
|
||||||
|
|
||||||
ARG ROCMVERSION=7.2
|
ARG ROCMVERSION=7.2.1
|
||||||
ARG JETPACK5VERSION=r35.4.1
|
ARG JETPACK5VERSION=r35.4.1
|
||||||
ARG JETPACK6VERSION=r36.4.0
|
ARG JETPACK6VERSION=r36.4.0
|
||||||
ARG CMAKEVERSION=3.31.2
|
ARG CMAKEVERSION=3.31.2
|
||||||
@@ -157,7 +157,7 @@ COPY CMakeLists.txt CMakePresets.json .
|
|||||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||||
COPY x/imagegen/mlx x/imagegen/mlx
|
COPY x/imagegen/mlx x/imagegen/mlx
|
||||||
COPY go.mod go.sum .
|
COPY go.mod go.sum .
|
||||||
COPY MLX_VERSION MLX_CORE_VERSION .
|
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
|
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
|
||||||
ENV PATH=/usr/local/go/bin:$PATH
|
ENV PATH=/usr/local/go/bin:$PATH
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
v0.30.6
|
|
||||||
1
MLX_C_VERSION
Normal file
@@ -0,0 +1 @@
|
|||||||
|
0726ca922fc902c4c61ef9c27d94132be418e945
|
||||||
@@ -1 +1 @@
|
|||||||
v0.5.0
|
38ad257088fb2193ad47e527cf6534a689f30943
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ type MessagesRequest struct {
|
|||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
MaxTokens int `json:"max_tokens"`
|
MaxTokens int `json:"max_tokens"`
|
||||||
Messages []MessageParam `json:"messages"`
|
Messages []MessageParam `json:"messages"`
|
||||||
System any `json:"system,omitempty"` // string or []ContentBlock
|
System any `json:"system,omitempty"` // string or []map[string]any (JSON-decoded ContentBlock)
|
||||||
Stream bool `json:"stream,omitempty"`
|
Stream bool `json:"stream,omitempty"`
|
||||||
Temperature *float64 `json:"temperature,omitempty"`
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
TopP *float64 `json:"top_p,omitempty"`
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
@@ -82,8 +82,27 @@ type MessagesRequest struct {
|
|||||||
|
|
||||||
// MessageParam represents a message in the request
|
// MessageParam represents a message in the request
|
||||||
type MessageParam struct {
|
type MessageParam struct {
|
||||||
Role string `json:"role"` // "user" or "assistant"
|
Role string `json:"role"` // "user" or "assistant"
|
||||||
Content any `json:"content"` // string or []ContentBlock
|
Content []ContentBlock `json:"content"` // always []ContentBlock; plain strings are normalized on unmarshal
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MessageParam) UnmarshalJSON(data []byte) error {
|
||||||
|
var raw struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content json.RawMessage `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.Role = raw.Role
|
||||||
|
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(raw.Content, &s); err == nil {
|
||||||
|
m.Content = []ContentBlock{{Type: "text", Text: &s}}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Unmarshal(raw.Content, &m.Content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContentBlock represents a content block in a message.
|
// ContentBlock represents a content block in a message.
|
||||||
@@ -102,9 +121,9 @@ type ContentBlock struct {
|
|||||||
Source *ImageSource `json:"source,omitempty"`
|
Source *ImageSource `json:"source,omitempty"`
|
||||||
|
|
||||||
// For tool_use and server_tool_use blocks
|
// For tool_use and server_tool_use blocks
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Input any `json:"input,omitempty"`
|
Input api.ToolCallFunctionArguments `json:"input,omitzero"`
|
||||||
|
|
||||||
// For tool_result and web_search_tool_result blocks
|
// For tool_result and web_search_tool_result blocks
|
||||||
ToolUseID string `json:"tool_use_id,omitempty"`
|
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||||
@@ -377,178 +396,145 @@ func convertMessage(msg MessageParam) ([]api.Message, error) {
|
|||||||
var messages []api.Message
|
var messages []api.Message
|
||||||
role := strings.ToLower(msg.Role)
|
role := strings.ToLower(msg.Role)
|
||||||
|
|
||||||
switch content := msg.Content.(type) {
|
var textContent strings.Builder
|
||||||
case string:
|
var images []api.ImageData
|
||||||
messages = append(messages, api.Message{Role: role, Content: content})
|
var toolCalls []api.ToolCall
|
||||||
|
var thinking string
|
||||||
|
var toolResults []api.Message
|
||||||
|
textBlocks := 0
|
||||||
|
imageBlocks := 0
|
||||||
|
toolUseBlocks := 0
|
||||||
|
toolResultBlocks := 0
|
||||||
|
serverToolUseBlocks := 0
|
||||||
|
webSearchToolResultBlocks := 0
|
||||||
|
thinkingBlocks := 0
|
||||||
|
unknownBlocks := 0
|
||||||
|
|
||||||
case []any:
|
for _, block := range msg.Content {
|
||||||
var textContent strings.Builder
|
switch block.Type {
|
||||||
var images []api.ImageData
|
case "text":
|
||||||
var toolCalls []api.ToolCall
|
textBlocks++
|
||||||
var thinking string
|
if block.Text != nil {
|
||||||
var toolResults []api.Message
|
textContent.WriteString(*block.Text)
|
||||||
textBlocks := 0
|
|
||||||
imageBlocks := 0
|
|
||||||
toolUseBlocks := 0
|
|
||||||
toolResultBlocks := 0
|
|
||||||
serverToolUseBlocks := 0
|
|
||||||
webSearchToolResultBlocks := 0
|
|
||||||
thinkingBlocks := 0
|
|
||||||
unknownBlocks := 0
|
|
||||||
|
|
||||||
for _, block := range content {
|
|
||||||
blockMap, ok := block.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
logutil.Trace("anthropic: invalid content block format", "role", role)
|
|
||||||
return nil, errors.New("invalid content block format")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
blockType, _ := blockMap["type"].(string)
|
case "image":
|
||||||
|
imageBlocks++
|
||||||
|
if block.Source == nil {
|
||||||
|
logutil.Trace("anthropic: invalid image source", "role", role)
|
||||||
|
return nil, errors.New("invalid image source")
|
||||||
|
}
|
||||||
|
|
||||||
switch blockType {
|
if block.Source.Type == "base64" {
|
||||||
case "text":
|
decoded, err := base64.StdEncoding.DecodeString(block.Source.Data)
|
||||||
textBlocks++
|
if err != nil {
|
||||||
if text, ok := blockMap["text"].(string); ok {
|
logutil.Trace("anthropic: invalid base64 image data", "role", role, "error", err)
|
||||||
textContent.WriteString(text)
|
return nil, fmt.Errorf("invalid base64 image data: %w", err)
|
||||||
}
|
}
|
||||||
|
images = append(images, decoded)
|
||||||
|
} else {
|
||||||
|
logutil.Trace("anthropic: unsupported image source type", "role", role, "source_type", block.Source.Type)
|
||||||
|
return nil, fmt.Errorf("invalid image source type: %s. Only base64 images are supported.", block.Source.Type)
|
||||||
|
}
|
||||||
|
|
||||||
case "image":
|
case "tool_use":
|
||||||
imageBlocks++
|
toolUseBlocks++
|
||||||
source, ok := blockMap["source"].(map[string]any)
|
if block.ID == "" {
|
||||||
if !ok {
|
logutil.Trace("anthropic: tool_use block missing id", "role", role)
|
||||||
logutil.Trace("anthropic: invalid image source", "role", role)
|
return nil, errors.New("tool_use block missing required 'id' field")
|
||||||
return nil, errors.New("invalid image source")
|
}
|
||||||
}
|
if block.Name == "" {
|
||||||
|
logutil.Trace("anthropic: tool_use block missing name", "role", role)
|
||||||
|
return nil, errors.New("tool_use block missing required 'name' field")
|
||||||
|
}
|
||||||
|
toolCalls = append(toolCalls, api.ToolCall{
|
||||||
|
ID: block.ID,
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: block.Name,
|
||||||
|
Arguments: block.Input,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
sourceType, _ := source["type"].(string)
|
case "tool_result":
|
||||||
if sourceType == "base64" {
|
toolResultBlocks++
|
||||||
data, _ := source["data"].(string)
|
var resultContent string
|
||||||
decoded, err := base64.StdEncoding.DecodeString(data)
|
|
||||||
if err != nil {
|
|
||||||
logutil.Trace("anthropic: invalid base64 image data", "role", role, "error", err)
|
|
||||||
return nil, fmt.Errorf("invalid base64 image data: %w", err)
|
|
||||||
}
|
|
||||||
images = append(images, decoded)
|
|
||||||
} else {
|
|
||||||
logutil.Trace("anthropic: unsupported image source type", "role", role, "source_type", sourceType)
|
|
||||||
return nil, fmt.Errorf("invalid image source type: %s. Only base64 images are supported.", sourceType)
|
|
||||||
}
|
|
||||||
// URL images would need to be fetched - skip for now
|
|
||||||
|
|
||||||
case "tool_use":
|
switch c := block.Content.(type) {
|
||||||
toolUseBlocks++
|
case string:
|
||||||
id, ok := blockMap["id"].(string)
|
resultContent = c
|
||||||
if !ok {
|
case []any:
|
||||||
logutil.Trace("anthropic: tool_use block missing id", "role", role)
|
for _, cb := range c {
|
||||||
return nil, errors.New("tool_use block missing required 'id' field")
|
if cbMap, ok := cb.(map[string]any); ok {
|
||||||
}
|
if cbMap["type"] == "text" {
|
||||||
name, ok := blockMap["name"].(string)
|
if text, ok := cbMap["text"].(string); ok {
|
||||||
if !ok {
|
resultContent += text
|
||||||
logutil.Trace("anthropic: tool_use block missing name", "role", role)
|
|
||||||
return nil, errors.New("tool_use block missing required 'name' field")
|
|
||||||
}
|
|
||||||
tc := api.ToolCall{
|
|
||||||
ID: id,
|
|
||||||
Function: api.ToolCallFunction{
|
|
||||||
Name: name,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if input, ok := blockMap["input"].(map[string]any); ok {
|
|
||||||
tc.Function.Arguments = mapToArgs(input)
|
|
||||||
}
|
|
||||||
toolCalls = append(toolCalls, tc)
|
|
||||||
|
|
||||||
case "tool_result":
|
|
||||||
toolResultBlocks++
|
|
||||||
toolUseID, _ := blockMap["tool_use_id"].(string)
|
|
||||||
var resultContent string
|
|
||||||
|
|
||||||
switch c := blockMap["content"].(type) {
|
|
||||||
case string:
|
|
||||||
resultContent = c
|
|
||||||
case []any:
|
|
||||||
for _, cb := range c {
|
|
||||||
if cbMap, ok := cb.(map[string]any); ok {
|
|
||||||
if cbMap["type"] == "text" {
|
|
||||||
if text, ok := cbMap["text"].(string); ok {
|
|
||||||
resultContent += text
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toolResults = append(toolResults, api.Message{
|
|
||||||
Role: "tool",
|
|
||||||
Content: resultContent,
|
|
||||||
ToolCallID: toolUseID,
|
|
||||||
})
|
|
||||||
|
|
||||||
case "thinking":
|
|
||||||
thinkingBlocks++
|
|
||||||
if t, ok := blockMap["thinking"].(string); ok {
|
|
||||||
thinking = t
|
|
||||||
}
|
|
||||||
|
|
||||||
case "server_tool_use":
|
|
||||||
serverToolUseBlocks++
|
|
||||||
id, _ := blockMap["id"].(string)
|
|
||||||
name, _ := blockMap["name"].(string)
|
|
||||||
tc := api.ToolCall{
|
|
||||||
ID: id,
|
|
||||||
Function: api.ToolCallFunction{
|
|
||||||
Name: name,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if input, ok := blockMap["input"].(map[string]any); ok {
|
|
||||||
tc.Function.Arguments = mapToArgs(input)
|
|
||||||
}
|
|
||||||
toolCalls = append(toolCalls, tc)
|
|
||||||
|
|
||||||
case "web_search_tool_result":
|
|
||||||
webSearchToolResultBlocks++
|
|
||||||
toolUseID, _ := blockMap["tool_use_id"].(string)
|
|
||||||
toolResults = append(toolResults, api.Message{
|
|
||||||
Role: "tool",
|
|
||||||
Content: formatWebSearchToolResultContent(blockMap["content"]),
|
|
||||||
ToolCallID: toolUseID,
|
|
||||||
})
|
|
||||||
default:
|
|
||||||
unknownBlocks++
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if textContent.Len() > 0 || len(images) > 0 || len(toolCalls) > 0 || thinking != "" {
|
toolResults = append(toolResults, api.Message{
|
||||||
m := api.Message{
|
Role: "tool",
|
||||||
Role: role,
|
Content: resultContent,
|
||||||
Content: textContent.String(),
|
ToolCallID: block.ToolUseID,
|
||||||
Images: images,
|
})
|
||||||
ToolCalls: toolCalls,
|
|
||||||
Thinking: thinking,
|
case "thinking":
|
||||||
|
thinkingBlocks++
|
||||||
|
if block.Thinking != nil {
|
||||||
|
thinking = *block.Thinking
|
||||||
}
|
}
|
||||||
messages = append(messages, m)
|
|
||||||
|
case "server_tool_use":
|
||||||
|
serverToolUseBlocks++
|
||||||
|
toolCalls = append(toolCalls, api.ToolCall{
|
||||||
|
ID: block.ID,
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: block.Name,
|
||||||
|
Arguments: block.Input,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
case "web_search_tool_result":
|
||||||
|
webSearchToolResultBlocks++
|
||||||
|
toolResults = append(toolResults, api.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: formatWebSearchToolResultContent(block.Content),
|
||||||
|
ToolCallID: block.ToolUseID,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
unknownBlocks++
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add tool results as separate messages
|
|
||||||
messages = append(messages, toolResults...)
|
|
||||||
logutil.Trace("anthropic: converted block message",
|
|
||||||
"role", role,
|
|
||||||
"blocks", len(content),
|
|
||||||
"text", textBlocks,
|
|
||||||
"image", imageBlocks,
|
|
||||||
"tool_use", toolUseBlocks,
|
|
||||||
"tool_result", toolResultBlocks,
|
|
||||||
"server_tool_use", serverToolUseBlocks,
|
|
||||||
"web_search_result", webSearchToolResultBlocks,
|
|
||||||
"thinking", thinkingBlocks,
|
|
||||||
"unknown", unknownBlocks,
|
|
||||||
"messages", TraceAPIMessages(messages),
|
|
||||||
)
|
|
||||||
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("invalid message content type: %T", content)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if textContent.Len() > 0 || len(images) > 0 || len(toolCalls) > 0 || thinking != "" {
|
||||||
|
m := api.Message{
|
||||||
|
Role: role,
|
||||||
|
Content: textContent.String(),
|
||||||
|
Images: images,
|
||||||
|
ToolCalls: toolCalls,
|
||||||
|
Thinking: thinking,
|
||||||
|
}
|
||||||
|
messages = append(messages, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tool results as separate messages
|
||||||
|
messages = append(messages, toolResults...)
|
||||||
|
logutil.Trace("anthropic: converted block message",
|
||||||
|
"role", role,
|
||||||
|
"blocks", len(msg.Content),
|
||||||
|
"text", textBlocks,
|
||||||
|
"image", imageBlocks,
|
||||||
|
"tool_use", toolUseBlocks,
|
||||||
|
"tool_result", toolResultBlocks,
|
||||||
|
"server_tool_use", serverToolUseBlocks,
|
||||||
|
"web_search_result", webSearchToolResultBlocks,
|
||||||
|
"thinking", thinkingBlocks,
|
||||||
|
"unknown", unknownBlocks,
|
||||||
|
"messages", TraceAPIMessages(messages),
|
||||||
|
)
|
||||||
|
|
||||||
return messages, nil
|
return messages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -882,7 +868,6 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
|||||||
slog.Error("failed to marshal tool arguments", "error", err, "tool_id", tc.ID)
|
slog.Error("failed to marshal tool arguments", "error", err, "tool_id", tc.ID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
events = append(events, StreamEvent{
|
events = append(events, StreamEvent{
|
||||||
Event: "content_block_start",
|
Event: "content_block_start",
|
||||||
Data: ContentBlockStartEvent{
|
Data: ContentBlockStartEvent{
|
||||||
@@ -892,7 +877,7 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
|||||||
Type: "tool_use",
|
Type: "tool_use",
|
||||||
ID: tc.ID,
|
ID: tc.ID,
|
||||||
Name: tc.Function.Name,
|
Name: tc.Function.Name,
|
||||||
Input: map[string]any{},
|
Input: api.NewToolCallFunctionArguments(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -989,15 +974,6 @@ func ptr(s string) *string {
|
|||||||
return &s
|
return &s
|
||||||
}
|
}
|
||||||
|
|
||||||
// mapToArgs converts a map to ToolCallFunctionArguments
|
|
||||||
func mapToArgs(m map[string]any) api.ToolCallFunctionArguments {
|
|
||||||
args := api.NewToolCallFunctionArguments()
|
|
||||||
for k, v := range m {
|
|
||||||
args.Set(k, v)
|
|
||||||
}
|
|
||||||
return args
|
|
||||||
}
|
|
||||||
|
|
||||||
// CountTokensRequest represents an Anthropic count_tokens request
|
// CountTokensRequest represents an Anthropic count_tokens request
|
||||||
type CountTokensRequest struct {
|
type CountTokensRequest struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
@@ -1030,17 +1006,13 @@ func estimateTokens(req CountTokensRequest) int {
|
|||||||
var totalLen int
|
var totalLen int
|
||||||
|
|
||||||
// Count system prompt
|
// Count system prompt
|
||||||
if req.System != nil {
|
totalLen += countAnyContent(req.System)
|
||||||
totalLen += countAnyContent(req.System)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count messages
|
|
||||||
for _, msg := range req.Messages {
|
for _, msg := range req.Messages {
|
||||||
// Count role (always present)
|
// Count role (always present)
|
||||||
totalLen += len(msg.Role)
|
totalLen += len(msg.Role)
|
||||||
// Count content
|
// Count content
|
||||||
contentLen := countAnyContent(msg.Content)
|
totalLen += countAnyContent(msg.Content)
|
||||||
totalLen += contentLen
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tool := range req.Tools {
|
for _, tool := range req.Tools {
|
||||||
@@ -1063,12 +1035,25 @@ func countAnyContent(content any) int {
|
|||||||
switch c := content.(type) {
|
switch c := content.(type) {
|
||||||
case string:
|
case string:
|
||||||
return len(c)
|
return len(c)
|
||||||
case []any:
|
case []ContentBlock:
|
||||||
total := 0
|
total := 0
|
||||||
for _, block := range c {
|
for _, block := range c {
|
||||||
total += countContentBlock(block)
|
total += countContentBlock(block)
|
||||||
}
|
}
|
||||||
return total
|
return total
|
||||||
|
case []any:
|
||||||
|
total := 0
|
||||||
|
for _, item := range c {
|
||||||
|
data, err := json.Marshal(item)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var block ContentBlock
|
||||||
|
if err := json.Unmarshal(data, &block); err == nil {
|
||||||
|
total += countContentBlock(block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
default:
|
default:
|
||||||
if data, err := json.Marshal(content); err == nil {
|
if data, err := json.Marshal(content); err == nil {
|
||||||
return len(data)
|
return len(data)
|
||||||
@@ -1077,38 +1062,19 @@ func countAnyContent(content any) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func countContentBlock(block any) int {
|
func countContentBlock(block ContentBlock) int {
|
||||||
blockMap, ok := block.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
if s, ok := block.(string); ok {
|
|
||||||
return len(s)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
total := 0
|
total := 0
|
||||||
blockType, _ := blockMap["type"].(string)
|
if block.Text != nil {
|
||||||
|
total += len(*block.Text)
|
||||||
if text, ok := blockMap["text"].(string); ok {
|
|
||||||
total += len(text)
|
|
||||||
}
|
}
|
||||||
|
if block.Thinking != nil {
|
||||||
if thinking, ok := blockMap["thinking"].(string); ok {
|
total += len(*block.Thinking)
|
||||||
total += len(thinking)
|
|
||||||
}
|
}
|
||||||
|
if block.Type == "tool_use" || block.Type == "tool_result" {
|
||||||
if blockType == "tool_use" {
|
if data, err := json.Marshal(block); err == nil {
|
||||||
if data, err := json.Marshal(blockMap); err == nil {
|
|
||||||
total += len(data)
|
total += len(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if blockType == "tool_result" {
|
|
||||||
if data, err := json.Marshal(blockMap); err == nil {
|
|
||||||
total += len(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,16 @@ const (
|
|||||||
testImage = `iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=`
|
testImage = `iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=`
|
||||||
)
|
)
|
||||||
|
|
||||||
// testArgs creates ToolCallFunctionArguments from a map (convenience function for tests)
|
// textContent is a convenience for constructing []ContentBlock with a single text block in tests.
|
||||||
func testArgs(m map[string]any) api.ToolCallFunctionArguments {
|
func textContent(s string) []ContentBlock {
|
||||||
|
return []ContentBlock{{Type: "text", Text: &s}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeArgs creates ToolCallFunctionArguments from key-value pairs (convenience function for tests)
|
||||||
|
func makeArgs(kvs ...any) api.ToolCallFunctionArguments {
|
||||||
args := api.NewToolCallFunctionArguments()
|
args := api.NewToolCallFunctionArguments()
|
||||||
for k, v := range m {
|
for i := 0; i < len(kvs)-1; i += 2 {
|
||||||
args.Set(k, v)
|
args.Set(kvs[i].(string), kvs[i+1])
|
||||||
}
|
}
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
@@ -29,7 +34,7 @@ func TestFromMessagesRequest_Basic(t *testing.T) {
|
|||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 1024,
|
MaxTokens: 1024,
|
||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{Role: "user", Content: "Hello"},
|
{Role: "user", Content: textContent("Hello")},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +66,7 @@ func TestFromMessagesRequest_WithSystemPrompt(t *testing.T) {
|
|||||||
MaxTokens: 1024,
|
MaxTokens: 1024,
|
||||||
System: "You are a helpful assistant.",
|
System: "You are a helpful assistant.",
|
||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{Role: "user", Content: "Hello"},
|
{Role: "user", Content: textContent("Hello")},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +93,7 @@ func TestFromMessagesRequest_WithSystemPromptArray(t *testing.T) {
|
|||||||
map[string]any{"type": "text", "text": " Be concise."},
|
map[string]any{"type": "text", "text": " Be concise."},
|
||||||
},
|
},
|
||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{Role: "user", Content: "Hello"},
|
{Role: "user", Content: textContent("Hello")},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +118,7 @@ func TestFromMessagesRequest_WithOptions(t *testing.T) {
|
|||||||
req := MessagesRequest{
|
req := MessagesRequest{
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 2048,
|
MaxTokens: 2048,
|
||||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||||
Temperature: &temp,
|
Temperature: &temp,
|
||||||
TopP: &topP,
|
TopP: &topP,
|
||||||
TopK: &topK,
|
TopK: &topK,
|
||||||
@@ -148,14 +153,14 @@ func TestFromMessagesRequest_WithImage(t *testing.T) {
|
|||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{
|
{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{"type": "text", "text": "What's in this image?"},
|
{Type: "text", Text: ptr("What's in this image?")},
|
||||||
map[string]any{
|
{
|
||||||
"type": "image",
|
Type: "image",
|
||||||
"source": map[string]any{
|
Source: &ImageSource{
|
||||||
"type": "base64",
|
Type: "base64",
|
||||||
"media_type": "image/png",
|
MediaType: "image/png",
|
||||||
"data": testImage,
|
Data: testImage,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -190,15 +195,15 @@ func TestFromMessagesRequest_WithToolUse(t *testing.T) {
|
|||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 1024,
|
MaxTokens: 1024,
|
||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{Role: "user", Content: "What's the weather in Paris?"},
|
{Role: "user", Content: textContent("What's the weather in Paris?")},
|
||||||
{
|
{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{
|
{
|
||||||
"type": "tool_use",
|
Type: "tool_use",
|
||||||
"id": "call_123",
|
ID: "call_123",
|
||||||
"name": "get_weather",
|
Name: "get_weather",
|
||||||
"input": map[string]any{"location": "Paris"},
|
Input: makeArgs("location", "Paris"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -234,11 +239,11 @@ func TestFromMessagesRequest_WithToolResult(t *testing.T) {
|
|||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{
|
{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{
|
{
|
||||||
"type": "tool_result",
|
Type: "tool_result",
|
||||||
"tool_use_id": "call_123",
|
ToolUseID: "call_123",
|
||||||
"content": "The weather in Paris is sunny, 22°C",
|
Content: "The weather in Paris is sunny, 22°C",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -270,7 +275,7 @@ func TestFromMessagesRequest_WithTools(t *testing.T) {
|
|||||||
req := MessagesRequest{
|
req := MessagesRequest{
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 1024,
|
MaxTokens: 1024,
|
||||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||||
Tools: []Tool{
|
Tools: []Tool{
|
||||||
{
|
{
|
||||||
Name: "get_weather",
|
Name: "get_weather",
|
||||||
@@ -305,7 +310,7 @@ func TestFromMessagesRequest_DropsCustomWebSearchWhenBuiltinPresent(t *testing.T
|
|||||||
req := MessagesRequest{
|
req := MessagesRequest{
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 1024,
|
MaxTokens: 1024,
|
||||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||||
Tools: []Tool{
|
Tools: []Tool{
|
||||||
{
|
{
|
||||||
Type: "web_search_20250305",
|
Type: "web_search_20250305",
|
||||||
@@ -346,7 +351,7 @@ func TestFromMessagesRequest_KeepsCustomWebSearchWhenBuiltinAbsent(t *testing.T)
|
|||||||
req := MessagesRequest{
|
req := MessagesRequest{
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 1024,
|
MaxTokens: 1024,
|
||||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||||
Tools: []Tool{
|
Tools: []Tool{
|
||||||
{
|
{
|
||||||
Type: "custom",
|
Type: "custom",
|
||||||
@@ -377,7 +382,7 @@ func TestFromMessagesRequest_WithThinking(t *testing.T) {
|
|||||||
req := MessagesRequest{
|
req := MessagesRequest{
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 1024,
|
MaxTokens: 1024,
|
||||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||||
Thinking: &ThinkingConfig{Type: "enabled", BudgetTokens: 1000},
|
Thinking: &ThinkingConfig{Type: "enabled", BudgetTokens: 1000},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,13 +404,13 @@ func TestFromMessagesRequest_ThinkingOnlyBlock(t *testing.T) {
|
|||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 1024,
|
MaxTokens: 1024,
|
||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{Role: "user", Content: "Hello"},
|
{Role: "user", Content: textContent("Hello")},
|
||||||
{
|
{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{
|
{
|
||||||
"type": "thinking",
|
Type: "thinking",
|
||||||
"thinking": "Let me think about this...",
|
Thinking: ptr("Let me think about this..."),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -434,10 +439,10 @@ func TestFromMessagesRequest_ToolUseMissingID(t *testing.T) {
|
|||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{
|
{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{
|
{
|
||||||
"type": "tool_use",
|
Type: "tool_use",
|
||||||
"name": "get_weather",
|
Name: "get_weather",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -460,10 +465,10 @@ func TestFromMessagesRequest_ToolUseMissingName(t *testing.T) {
|
|||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{
|
{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{
|
{
|
||||||
"type": "tool_use",
|
Type: "tool_use",
|
||||||
"id": "call_123",
|
ID: "call_123",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -483,7 +488,7 @@ func TestFromMessagesRequest_InvalidToolSchema(t *testing.T) {
|
|||||||
req := MessagesRequest{
|
req := MessagesRequest{
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 1024,
|
MaxTokens: 1024,
|
||||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||||
Tools: []Tool{
|
Tools: []Tool{
|
||||||
{
|
{
|
||||||
Name: "bad_tool",
|
Name: "bad_tool",
|
||||||
@@ -548,7 +553,7 @@ func TestToMessagesResponse_WithToolCalls(t *testing.T) {
|
|||||||
ID: "call_123",
|
ID: "call_123",
|
||||||
Function: api.ToolCallFunction{
|
Function: api.ToolCallFunction{
|
||||||
Name: "get_weather",
|
Name: "get_weather",
|
||||||
Arguments: testArgs(map[string]any{"location": "Paris"}),
|
Arguments: makeArgs("location", "Paris"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -760,7 +765,7 @@ func TestStreamConverter_WithToolCalls(t *testing.T) {
|
|||||||
ID: "call_123",
|
ID: "call_123",
|
||||||
Function: api.ToolCallFunction{
|
Function: api.ToolCallFunction{
|
||||||
Name: "get_weather",
|
Name: "get_weather",
|
||||||
Arguments: testArgs(map[string]any{"location": "Paris"}),
|
Arguments: makeArgs("location", "Paris"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -843,7 +848,7 @@ func TestStreamConverter_ThinkingDirectlyFollowedByToolCall(t *testing.T) {
|
|||||||
ID: "call_abc",
|
ID: "call_abc",
|
||||||
Function: api.ToolCallFunction{
|
Function: api.ToolCallFunction{
|
||||||
Name: "ask_user",
|
Name: "ask_user",
|
||||||
Arguments: testArgs(map[string]any{"question": "cats or dogs?"}),
|
Arguments: makeArgs("question", "cats or dogs?"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -965,7 +970,7 @@ func TestStreamConverter_MultipleToolCallsWithMixedValidity(t *testing.T) {
|
|||||||
ID: "call_good",
|
ID: "call_good",
|
||||||
Function: api.ToolCallFunction{
|
Function: api.ToolCallFunction{
|
||||||
Name: "good_function",
|
Name: "good_function",
|
||||||
Arguments: testArgs(map[string]any{"location": "Paris"}),
|
Arguments: makeArgs("location", "Paris"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1067,6 +1072,57 @@ func TestContentBlockJSON_EmptyFieldsPresent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestContentBlockJSON_NonToolBlocksDoNotIncludeInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
block ContentBlock
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "text block",
|
||||||
|
block: ContentBlock{
|
||||||
|
Type: "text",
|
||||||
|
Text: ptr("hello"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "thinking block",
|
||||||
|
block: ContentBlock{
|
||||||
|
Type: "thinking",
|
||||||
|
Thinking: ptr("let me think"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "image block",
|
||||||
|
block: ContentBlock{
|
||||||
|
Type: "image",
|
||||||
|
Source: &ImageSource{
|
||||||
|
Type: "base64",
|
||||||
|
MediaType: "image/png",
|
||||||
|
Data: testImage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
data, err := json.Marshal(tt.block)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := result["input"]; ok {
|
||||||
|
t.Fatalf("unexpected input field in non-tool block JSON: %s", string(data))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStreamConverter_ContentBlockStartIncludesEmptyFields(t *testing.T) {
|
func TestStreamConverter_ContentBlockStartIncludesEmptyFields(t *testing.T) {
|
||||||
t.Run("text block start includes empty text", func(t *testing.T) {
|
t.Run("text block start includes empty text", func(t *testing.T) {
|
||||||
conv := NewStreamConverter("msg_123", "test-model", 0)
|
conv := NewStreamConverter("msg_123", "test-model", 0)
|
||||||
@@ -1087,7 +1143,9 @@ func TestStreamConverter_ContentBlockStartIncludesEmptyFields(t *testing.T) {
|
|||||||
// Marshal and verify the text field is present
|
// Marshal and verify the text field is present
|
||||||
data, _ := json.Marshal(start)
|
data, _ := json.Marshal(start)
|
||||||
var result map[string]any
|
var result map[string]any
|
||||||
json.Unmarshal(data, &result)
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal content_block_start JSON: %v", err)
|
||||||
|
}
|
||||||
cb := result["content_block"].(map[string]any)
|
cb := result["content_block"].(map[string]any)
|
||||||
if _, ok := cb["text"]; !ok {
|
if _, ok := cb["text"]; !ok {
|
||||||
t.Error("content_block_start for text should include 'text' field")
|
t.Error("content_block_start for text should include 'text' field")
|
||||||
@@ -1134,13 +1192,71 @@ func TestStreamConverter_ContentBlockStartIncludesEmptyFields(t *testing.T) {
|
|||||||
t.Error("expected thinking content_block_start event")
|
t.Error("expected thinking content_block_start event")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("tool_use block start includes empty input object", func(t *testing.T) {
|
||||||
|
conv := NewStreamConverter("msg_123", "test-model", 0)
|
||||||
|
|
||||||
|
resp := api.ChatResponse{
|
||||||
|
Model: "test-model",
|
||||||
|
Message: api.Message{
|
||||||
|
Role: "assistant",
|
||||||
|
ToolCalls: []api.ToolCall{
|
||||||
|
{
|
||||||
|
ID: "call_123",
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: "get_weather",
|
||||||
|
Arguments: makeArgs("location", "Paris"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
events := conv.Process(resp)
|
||||||
|
|
||||||
|
var foundToolStart bool
|
||||||
|
for _, e := range events {
|
||||||
|
if e.Event == "content_block_start" {
|
||||||
|
if start, ok := e.Data.(ContentBlockStartEvent); ok {
|
||||||
|
if start.ContentBlock.Type == "tool_use" {
|
||||||
|
foundToolStart = true
|
||||||
|
if start.ContentBlock.Input.Len() != 0 {
|
||||||
|
t.Errorf("expected empty input object, got len=%d", start.ContentBlock.Input.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(start)
|
||||||
|
var result map[string]any
|
||||||
|
json.Unmarshal(data, &result)
|
||||||
|
cb := result["content_block"].(map[string]any)
|
||||||
|
input, ok := cb["input"]
|
||||||
|
if !ok {
|
||||||
|
t.Error("content_block_start for tool_use should include 'input' field")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inputMap, ok := input.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("input field should be an object, got %T", input)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(inputMap) != 0 {
|
||||||
|
t.Errorf("expected empty input object in content_block_start, got %v", inputMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundToolStart {
|
||||||
|
t.Error("expected tool_use content_block_start event")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEstimateTokens_SimpleMessage(t *testing.T) {
|
func TestEstimateTokens_SimpleMessage(t *testing.T) {
|
||||||
req := CountTokensRequest{
|
req := CountTokensRequest{
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{Role: "user", Content: "Hello, world!"},
|
{Role: "user", Content: textContent("Hello, world!")},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1161,7 +1277,7 @@ func TestEstimateTokens_WithSystemPrompt(t *testing.T) {
|
|||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
System: "You are a helpful assistant.",
|
System: "You are a helpful assistant.",
|
||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{Role: "user", Content: "Hello"},
|
{Role: "user", Content: textContent("Hello")},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1177,7 +1293,7 @@ func TestEstimateTokens_WithTools(t *testing.T) {
|
|||||||
req := CountTokensRequest{
|
req := CountTokensRequest{
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{Role: "user", Content: "What's the weather?"},
|
{Role: "user", Content: textContent("What's the weather?")},
|
||||||
},
|
},
|
||||||
Tools: []Tool{
|
Tools: []Tool{
|
||||||
{
|
{
|
||||||
@@ -1200,17 +1316,17 @@ func TestEstimateTokens_WithThinking(t *testing.T) {
|
|||||||
req := CountTokensRequest{
|
req := CountTokensRequest{
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{Role: "user", Content: "Hello"},
|
{Role: "user", Content: textContent("Hello")},
|
||||||
{
|
{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{
|
{
|
||||||
"type": "thinking",
|
Type: "thinking",
|
||||||
"thinking": "Let me think about this carefully...",
|
Thinking: ptr("Let me think about this carefully..."),
|
||||||
},
|
},
|
||||||
map[string]any{
|
{
|
||||||
"type": "text",
|
Type: "text",
|
||||||
"text": "Here is my response.",
|
Text: ptr("Here is my response."),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1308,12 +1424,12 @@ func TestConvertTool_RegularTool(t *testing.T) {
|
|||||||
func TestConvertMessage_ServerToolUse(t *testing.T) {
|
func TestConvertMessage_ServerToolUse(t *testing.T) {
|
||||||
msg := MessageParam{
|
msg := MessageParam{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{
|
{
|
||||||
"type": "server_tool_use",
|
Type: "server_tool_use",
|
||||||
"id": "srvtoolu_123",
|
ID: "srvtoolu_123",
|
||||||
"name": "web_search",
|
Name: "web_search",
|
||||||
"input": map[string]any{"query": "test query"},
|
Input: makeArgs("query", "test query"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1344,11 +1460,11 @@ func TestConvertMessage_ServerToolUse(t *testing.T) {
|
|||||||
func TestConvertMessage_WebSearchToolResult(t *testing.T) {
|
func TestConvertMessage_WebSearchToolResult(t *testing.T) {
|
||||||
msg := MessageParam{
|
msg := MessageParam{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{
|
{
|
||||||
"type": "web_search_tool_result",
|
Type: "web_search_tool_result",
|
||||||
"tool_use_id": "srvtoolu_123",
|
ToolUseID: "srvtoolu_123",
|
||||||
"content": []any{
|
Content: []any{
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"type": "web_search_result",
|
"type": "web_search_result",
|
||||||
"title": "Test Result",
|
"title": "Test Result",
|
||||||
@@ -1385,11 +1501,11 @@ func TestConvertMessage_WebSearchToolResult(t *testing.T) {
|
|||||||
func TestConvertMessage_WebSearchToolResultEmptyStillCreatesToolMessage(t *testing.T) {
|
func TestConvertMessage_WebSearchToolResultEmptyStillCreatesToolMessage(t *testing.T) {
|
||||||
msg := MessageParam{
|
msg := MessageParam{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{
|
{
|
||||||
"type": "web_search_tool_result",
|
Type: "web_search_tool_result",
|
||||||
"tool_use_id": "srvtoolu_empty",
|
ToolUseID: "srvtoolu_empty",
|
||||||
"content": []any{},
|
Content: []any{},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1416,11 +1532,11 @@ func TestConvertMessage_WebSearchToolResultEmptyStillCreatesToolMessage(t *testi
|
|||||||
func TestConvertMessage_WebSearchToolResultErrorStillCreatesToolMessage(t *testing.T) {
|
func TestConvertMessage_WebSearchToolResultErrorStillCreatesToolMessage(t *testing.T) {
|
||||||
msg := MessageParam{
|
msg := MessageParam{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{
|
{
|
||||||
"type": "web_search_tool_result",
|
Type: "web_search_tool_result",
|
||||||
"tool_use_id": "srvtoolu_error",
|
ToolUseID: "srvtoolu_error",
|
||||||
"content": map[string]any{
|
Content: map[string]any{
|
||||||
"type": "web_search_tool_result_error",
|
"type": "web_search_tool_result_error",
|
||||||
"error_code": "max_uses_exceeded",
|
"error_code": "max_uses_exceeded",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -436,6 +436,7 @@ type ToolProperty struct {
|
|||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Enum []any `json:"enum,omitempty"`
|
Enum []any `json:"enum,omitempty"`
|
||||||
Properties *ToolPropertiesMap `json:"properties,omitempty"`
|
Properties *ToolPropertiesMap `json:"properties,omitempty"`
|
||||||
|
Required []string `json:"required,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToTypeScriptType converts a ToolProperty to a TypeScript type string
|
// ToTypeScriptType converts a ToolProperty to a TypeScript type string
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
// currentSchemaVersion defines the current database schema version.
|
// currentSchemaVersion defines the current database schema version.
|
||||||
// Increment this when making schema changes that require migrations.
|
// Increment this when making schema changes that require migrations.
|
||||||
const currentSchemaVersion = 15
|
const currentSchemaVersion = 16
|
||||||
|
|
||||||
// database wraps the SQLite connection.
|
// database wraps the SQLite connection.
|
||||||
// SQLite handles its own locking for concurrent access:
|
// SQLite handles its own locking for concurrent access:
|
||||||
@@ -82,6 +82,7 @@ func (db *database) init() error {
|
|||||||
websearch_enabled BOOLEAN NOT NULL DEFAULT 0,
|
websearch_enabled BOOLEAN NOT NULL DEFAULT 0,
|
||||||
selected_model TEXT NOT NULL DEFAULT '',
|
selected_model TEXT NOT NULL DEFAULT '',
|
||||||
sidebar_open BOOLEAN NOT NULL DEFAULT 0,
|
sidebar_open BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
last_home_view TEXT NOT NULL DEFAULT 'launch',
|
||||||
think_enabled BOOLEAN NOT NULL DEFAULT 0,
|
think_enabled BOOLEAN NOT NULL DEFAULT 0,
|
||||||
think_level TEXT NOT NULL DEFAULT '',
|
think_level TEXT NOT NULL DEFAULT '',
|
||||||
cloud_setting_migrated BOOLEAN NOT NULL DEFAULT 0,
|
cloud_setting_migrated BOOLEAN NOT NULL DEFAULT 0,
|
||||||
@@ -264,6 +265,12 @@ func (db *database) migrate() error {
|
|||||||
return fmt.Errorf("migrate v14 to v15: %w", err)
|
return fmt.Errorf("migrate v14 to v15: %w", err)
|
||||||
}
|
}
|
||||||
version = 15
|
version = 15
|
||||||
|
case 15:
|
||||||
|
// add last_home_view column to settings table
|
||||||
|
if err := db.migrateV15ToV16(); err != nil {
|
||||||
|
return fmt.Errorf("migrate v15 to v16: %w", err)
|
||||||
|
}
|
||||||
|
version = 16
|
||||||
default:
|
default:
|
||||||
// If we have a version we don't recognize, just set it to current
|
// If we have a version we don't recognize, just set it to current
|
||||||
// This might happen during development
|
// This might happen during development
|
||||||
@@ -518,6 +525,21 @@ func (db *database) migrateV14ToV15() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// migrateV15ToV16 adds the last_home_view column to the settings table
|
||||||
|
func (db *database) migrateV15ToV16() error {
|
||||||
|
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN last_home_view TEXT NOT NULL DEFAULT 'launch'`)
|
||||||
|
if err != nil && !duplicateColumnError(err) {
|
||||||
|
return fmt.Errorf("add last_home_view column: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.conn.Exec(`UPDATE settings SET schema_version = 16`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update schema version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// cleanupOrphanedData removes orphaned records that may exist due to the foreign key bug
|
// cleanupOrphanedData removes orphaned records that may exist due to the foreign key bug
|
||||||
func (db *database) cleanupOrphanedData() error {
|
func (db *database) cleanupOrphanedData() error {
|
||||||
_, err := db.conn.Exec(`
|
_, err := db.conn.Exec(`
|
||||||
@@ -1166,9 +1188,9 @@ func (db *database) getSettings() (Settings, error) {
|
|||||||
var s Settings
|
var s Settings
|
||||||
|
|
||||||
err := db.conn.QueryRow(`
|
err := db.conn.QueryRow(`
|
||||||
SELECT expose, survey, browser, models, agent, tools, working_dir, context_length, turbo_enabled, websearch_enabled, selected_model, sidebar_open, think_enabled, think_level, auto_update_enabled
|
SELECT expose, survey, browser, models, agent, tools, working_dir, context_length, turbo_enabled, websearch_enabled, selected_model, sidebar_open, last_home_view, think_enabled, think_level, auto_update_enabled
|
||||||
FROM settings
|
FROM settings
|
||||||
`).Scan(&s.Expose, &s.Survey, &s.Browser, &s.Models, &s.Agent, &s.Tools, &s.WorkingDir, &s.ContextLength, &s.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.ThinkEnabled, &s.ThinkLevel, &s.AutoUpdateEnabled)
|
`).Scan(&s.Expose, &s.Survey, &s.Browser, &s.Models, &s.Agent, &s.Tools, &s.WorkingDir, &s.ContextLength, &s.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.LastHomeView, &s.ThinkEnabled, &s.ThinkLevel, &s.AutoUpdateEnabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Settings{}, fmt.Errorf("get settings: %w", err)
|
return Settings{}, fmt.Errorf("get settings: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1177,10 +1199,26 @@ func (db *database) getSettings() (Settings, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *database) setSettings(s Settings) error {
|
func (db *database) setSettings(s Settings) error {
|
||||||
|
lastHomeView := strings.ToLower(strings.TrimSpace(s.LastHomeView))
|
||||||
|
validLaunchView := map[string]struct{}{
|
||||||
|
"launch": {},
|
||||||
|
"openclaw": {},
|
||||||
|
"claude": {},
|
||||||
|
"codex": {},
|
||||||
|
"opencode": {},
|
||||||
|
"droid": {},
|
||||||
|
"pi": {},
|
||||||
|
}
|
||||||
|
if lastHomeView != "chat" {
|
||||||
|
if _, ok := validLaunchView[lastHomeView]; !ok {
|
||||||
|
lastHomeView = "launch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_, err := db.conn.Exec(`
|
_, err := db.conn.Exec(`
|
||||||
UPDATE settings
|
UPDATE settings
|
||||||
SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, think_enabled = ?, think_level = ?, auto_update_enabled = ?
|
SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, last_home_view = ?, think_enabled = ?, think_level = ?, auto_update_enabled = ?
|
||||||
`, s.Expose, s.Survey, s.Browser, s.Models, s.Agent, s.Tools, s.WorkingDir, s.ContextLength, s.TurboEnabled, s.WebSearchEnabled, s.SelectedModel, s.SidebarOpen, s.ThinkEnabled, s.ThinkLevel, s.AutoUpdateEnabled)
|
`, s.Expose, s.Survey, s.Browser, s.Models, s.Agent, s.Tools, s.WorkingDir, s.ContextLength, s.TurboEnabled, s.WebSearchEnabled, s.SelectedModel, s.SidebarOpen, lastHomeView, s.ThinkEnabled, s.ThinkLevel, s.AutoUpdateEnabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("set settings: %w", err)
|
return fmt.Errorf("set settings: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,45 @@ func TestMigrationV13ToV14ContextLength(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMigrationV15ToV16LastHomeViewDefaultsToLaunch(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(tmpDir, "test.db")
|
||||||
|
|
||||||
|
db, err := newDatabase(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if _, err := db.conn.Exec(`
|
||||||
|
ALTER TABLE settings DROP COLUMN last_home_view;
|
||||||
|
UPDATE settings SET schema_version = 15;
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("failed to seed v15 settings row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.migrate(); err != nil {
|
||||||
|
t.Fatalf("migration from v15 to v16 failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastHomeView string
|
||||||
|
if err := db.conn.QueryRow("SELECT last_home_view FROM settings").Scan(&lastHomeView); err != nil {
|
||||||
|
t.Fatalf("failed to read last_home_view: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastHomeView != "launch" {
|
||||||
|
t.Fatalf("expected last_home_view to default to launch after migration, got %q", lastHomeView)
|
||||||
|
}
|
||||||
|
|
||||||
|
version, err := db.getSchemaVersion()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get schema version: %v", err)
|
||||||
|
}
|
||||||
|
if version != currentSchemaVersion {
|
||||||
|
t.Fatalf("expected schema version %d, got %d", currentSchemaVersion, version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChatDeletionWithCascade(t *testing.T) {
|
func TestChatDeletionWithCascade(t *testing.T) {
|
||||||
t.Run("chat deletion cascades to related messages", func(t *testing.T) {
|
t.Run("chat deletion cascades to related messages", func(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|||||||
@@ -167,6 +167,9 @@ type Settings struct {
|
|||||||
// SidebarOpen indicates if the chat sidebar is open
|
// SidebarOpen indicates if the chat sidebar is open
|
||||||
SidebarOpen bool
|
SidebarOpen bool
|
||||||
|
|
||||||
|
// LastHomeView stores the preferred home route target ("chat" or integration name)
|
||||||
|
LastHomeView string
|
||||||
|
|
||||||
// AutoUpdateEnabled indicates if automatic updates should be downloaded
|
// AutoUpdateEnabled indicates if automatic updates should be downloaded
|
||||||
AutoUpdateEnabled bool
|
AutoUpdateEnabled bool
|
||||||
}
|
}
|
||||||
@@ -389,6 +392,10 @@ func (s *Store) Settings() (Settings, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if settings.LastHomeView == "" {
|
||||||
|
settings.LastHomeView = "launch"
|
||||||
|
}
|
||||||
|
|
||||||
return settings, nil
|
return settings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,32 @@ func TestStore(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("settings default home view is launch", func(t *testing.T) {
|
||||||
|
loaded, err := s.Settings()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded.LastHomeView != "launch" {
|
||||||
|
t.Fatalf("expected default LastHomeView to be launch, got %q", loaded.LastHomeView)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("settings empty home view falls back to launch", func(t *testing.T) {
|
||||||
|
if err := s.SetSettings(Settings{LastHomeView: ""}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := s.Settings()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded.LastHomeView != "launch" {
|
||||||
|
t.Fatalf("expected empty LastHomeView to fall back to launch, got %q", loaded.LastHomeView)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("window size", func(t *testing.T) {
|
t.Run("window size", func(t *testing.T) {
|
||||||
if err := s.SetWindowSize(1024, 768); err != nil {
|
if err := s.SetWindowSize(1024, 768); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -414,6 +414,7 @@ export class Settings {
|
|||||||
ThinkLevel: string;
|
ThinkLevel: string;
|
||||||
SelectedModel: string;
|
SelectedModel: string;
|
||||||
SidebarOpen: boolean;
|
SidebarOpen: boolean;
|
||||||
|
LastHomeView: string;
|
||||||
AutoUpdateEnabled: boolean;
|
AutoUpdateEnabled: boolean;
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
@@ -432,6 +433,7 @@ export class Settings {
|
|||||||
this.ThinkLevel = source["ThinkLevel"];
|
this.ThinkLevel = source["ThinkLevel"];
|
||||||
this.SelectedModel = source["SelectedModel"];
|
this.SelectedModel = source["SelectedModel"];
|
||||||
this.SidebarOpen = source["SidebarOpen"];
|
this.SidebarOpen = source["SidebarOpen"];
|
||||||
|
this.LastHomeView = source["LastHomeView"];
|
||||||
this.AutoUpdateEnabled = source["AutoUpdateEnabled"];
|
this.AutoUpdateEnabled = source["AutoUpdateEnabled"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -550,14 +552,12 @@ export class Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class ModelUpstreamResponse {
|
export class ModelUpstreamResponse {
|
||||||
digest?: string;
|
stale: boolean;
|
||||||
pushTime: number;
|
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.digest = source["digest"];
|
this.stale = source["stale"];
|
||||||
this.pushTime = source["pushTime"];
|
|
||||||
this.error = source["error"];
|
this.error = source["error"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
app/ui/app/public/launch-icons/claude.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- Generated by Pixelmator Pro 3.6.17 -->
|
||||||
|
<svg width="1200" height="1200" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="g314">
|
||||||
|
<path id="path147" fill="#d97757" stroke="none" d="M 233.959793 800.214905 L 468.644287 668.536987 L 472.590637 657.100647 L 468.644287 650.738403 L 457.208069 650.738403 L 417.986633 648.322144 L 283.892639 644.69812 L 167.597321 639.865845 L 54.926208 633.825623 L 26.577238 627.785339 L 3.3e-05 592.751709 L 2.73832 575.27533 L 26.577238 559.248352 L 60.724873 562.228149 L 136.187973 567.382629 L 249.422867 575.194763 L 331.570496 580.026978 L 453.261841 592.671082 L 472.590637 592.671082 L 475.328857 584.859009 L 468.724915 580.026978 L 463.570557 575.194763 L 346.389313 495.785217 L 219.543671 411.865906 L 153.100723 363.543762 L 117.181267 339.060425 L 99.060455 316.107361 L 91.248367 266.01355 L 123.865784 230.093994 L 167.677887 233.073853 L 178.872513 236.053772 L 223.248367 270.201477 L 318.040283 343.570496 L 441.825592 434.738342 L 459.946411 449.798706 L 467.194672 444.64447 L 468.080597 441.020203 L 459.946411 427.409485 L 392.617493 305.718323 L 320.778564 181.932983 L 288.80542 130.630859 L 280.348999 99.865845 C 277.369171 87.221436 275.194641 76.590698 275.194641 63.624268 L 312.322174 13.20813 L 332.8591 6.604126 L 382.389313 13.20813 L 403.248352 31.328979 L 434.013519 101.71814 L 483.865753 212.537048 L 561.181274 363.221497 L 583.812134 407.919434 L 595.892639 449.315491 L 600.40271 461.959839 L 608.214783 461.959839 L 608.214783 454.711609 L 614.577271 369.825623 L 626.335632 265.61084 L 637.771851 131.516846 L 641.718201 93.745117 L 660.402832 48.483276 L 697.530334 24.000122 L 726.52356 37.852417 L 750.362549 72 L 747.060486 94.067139 L 732.886047 186.201416 L 705.100708 330.52356 L 686.979919 427.167847 L 697.530334 427.167847 L 709.61084 415.087341 L 758.496704 350.174561 L 840.644348 247.490051 L 876.885925 206.738342 L 919.167847 161.71814 L 946.308838 140.29541 L 997.61084 140.29541 L 1035.38269 196.429626 L 1018.469849 254.416199 L 965.637634 321.422852 L 921.825562 378.201538 L 859.006714 462.765259 L 819.785278 530.41626 L 823.409424 535.812073 L 832.75177 534.92627 L 974.657776 504.724915 L 1051.328979 490.872559 L 1142.818848 475.167786 L 1184.214844 494.496582 L 1188.724854 514.147644 L 1172.456421 554.335693 L 1074.604126 578.496765 L 959.838989 601.449829 L 788.939636 641.879272 L 786.845764 643.409485 L 789.261841 646.389343 L 866.255127 653.637634 L 899.194702 655.409424 L 979.812134 655.409424 L 1129.932861 666.604187 L 1169.154419 692.537109 L 1192.671265 724.268677 L 1188.724854 748.429688 L 1128.322144 779.194641 L 1046.818848 759.865845 L 856.590759 714.604126 L 791.355774 698.335754 L 782.335693 698.335754 L 782.335693 703.731567 L 836.69812 756.885986 L 936.322205 846.845581 L 1061.073975 962.81897 L 1067.436279 991.490112 L 1051.409424 1014.120911 L 1034.496704 1011.704712 L 924.885986 929.234924 L 882.604126 892.107544 L 786.845764 811.48999 L 780.483276 811.48999 L 780.483276 819.946289 L 802.550415 852.241699 L 919.087341 1027.409424 L 925.127625 1081.127686 L 916.671204 1098.604126 L 886.469849 1109.154419 L 853.288696 1103.114136 L 785.073914 1007.355835 L 714.684631 899.516785 L 657.906067 802.872498 L 650.979858 806.81897 L 617.476624 1167.704834 L 601.771851 1186.147705 L 565.530212 1200 L 535.328857 1177.046997 L 519.302124 1139.919556 L 535.328857 1066.550537 L 554.657776 970.792053 L 570.362488 894.68457 L 584.536926 800.134277 L 592.993347 768.724976 L 592.429626 766.630859 L 585.503479 767.516968 L 514.22821 865.369263 L 405.825531 1011.865906 L 320.053711 1103.677979 L 299.516815 1111.812256 L 263.919525 1093.369263 L 267.221497 1060.429688 L 287.114136 1031.114136 L 405.825531 880.107361 L 477.422913 786.52356 L 523.651062 732.483276 L 523.328918 724.671265 L 520.590698 724.671265 L 205.288605 929.395935 L 149.154434 936.644409 L 124.993355 914.01355 L 127.973183 876.885986 L 139.409409 864.80542 L 234.201385 799.570435 L 233.879227 799.8927 Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
1
app/ui/app/public/launch-icons/codex-dark.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320"><path fill="#fff" d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
1
app/ui/app/public/launch-icons/codex.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320"><path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
8
app/ui/app/public/launch-icons/droid.svg
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
242
app/ui/app/public/launch-icons/openclaw.svg
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" width="500" height="500">
|
||||||
|
<style>
|
||||||
|
.s0 { fill: #f6f4f4 }
|
||||||
|
.s1 { fill: #0b0303 }
|
||||||
|
.s2 { fill: #ef0011 }
|
||||||
|
.s3 { fill: #f3e2e2 }
|
||||||
|
.s4 { fill: #f00212 }
|
||||||
|
.s5 { fill: #ba000d }
|
||||||
|
.s6 { fill: #faf1f1 }
|
||||||
|
.s7 { fill: #0b0100 }
|
||||||
|
.s8 { fill: #fbedee }
|
||||||
|
.s9 { fill: #faeaea }
|
||||||
|
.s10 { fill: #ab797d }
|
||||||
|
.s11 { fill: #f8eaea }
|
||||||
|
.s12 { fill: #902021 }
|
||||||
|
.s13 { fill: #f9eeee }
|
||||||
|
.s14 { fill: #f6ecec }
|
||||||
|
.s15 { fill: #080201 }
|
||||||
|
.s16 { fill: #150100 }
|
||||||
|
.s17 { fill: #f2e7e7 }
|
||||||
|
.s18 { fill: #fbe7e8 }
|
||||||
|
.s19 { fill: #060101 }
|
||||||
|
.s20 { fill: #f5e7e7 }
|
||||||
|
.s21 { fill: #fa999e }
|
||||||
|
.s22 { fill: #c46064 }
|
||||||
|
.s23 { fill: #180300 }
|
||||||
|
.s24 { fill: #f6dcdd }
|
||||||
|
.s25 { fill: #f2e6e6 }
|
||||||
|
.s26 { fill: #110200 }
|
||||||
|
.s27 { fill: #eb0011 }
|
||||||
|
.s28 { fill: #e20010 }
|
||||||
|
.s29 { fill: #ea0011 }
|
||||||
|
.s30 { fill: #760007 }
|
||||||
|
.s31 { fill: #f00514 }
|
||||||
|
.s32 { fill: #fcebeb }
|
||||||
|
.s33 { fill: #ecd6d6 }
|
||||||
|
.s34 { fill: #f5e3e3 }
|
||||||
|
.s35 { fill: #f5e4e4 }
|
||||||
|
.s36 { fill: #faf6f6 }
|
||||||
|
.s37 { fill: #e50010 }
|
||||||
|
.s38 { fill: #d5000f }
|
||||||
|
.s39 { fill: #f2e2e3 }
|
||||||
|
.s40 { fill: #ef1018 }
|
||||||
|
.s41 { fill: #f4e8e9 }
|
||||||
|
.s42 { fill: #ef0513 }
|
||||||
|
.s43 { fill: #f5e5e5 }
|
||||||
|
.s44 { fill: #f00413 }
|
||||||
|
.s45 { fill: #f4e9ea }
|
||||||
|
.s46 { fill: #ed0011 }
|
||||||
|
.s47 { fill: #e80011 }
|
||||||
|
.s48 { fill: #e60613 }
|
||||||
|
.s49 { fill: #f0d6d6 }
|
||||||
|
.s50 { fill: #fca9ac }
|
||||||
|
.s51 { fill: #9c000c }
|
||||||
|
.s52 { fill: #73393b }
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s0" d="m166.5 52.5q3.5 0 7 0 2.75 2.99 1.5 7-21.27 45.61-20.5 96 39.99 2.76 72 26.5 7.87 6.86 13.5 15.5 42.88-56.39 103.5-92.5 47.35-25.46 101-25 14.52 0.38 23.5 11.5 3.19 7.74 2 16-1.81 7.18-4.5 14-1 0-1 1-5.04 6.05-9 13-1 0-1 1 0 0.5 0 1-12.42 12.15-28.5 19-6.02 36.27-41.5 45-0.83 2.75 0 5 19.02-12.85 41.5-9 10.85-8.09 23.5-13 15.01-6.37 31-2.5 14.09 7.43 14 23.5-2.83 23.25-15.5 43-6.42 9.92-14 19-10.04 8.8-19.5 18-72.02 48.88-156.5 27-19.63 9.6-41.5 10.5-4.59 1.27-9 3 2 1 4 2 20.09-1.11 35 12 25.46 6.95 37.5 30.5 1.26 5.69-1 11-3.38 3.79-7.5 6.5 5.74 10.07 1.5 20.5-7.55 7.47-17.5 3.5-11.01-5.34-22.5-9.5-18.26 10-38.5 13-15.5 0-31 0-26.62-4.54-51-17-4.17 1.33-8 3.5-7.23 5.87-15 11-8.62 2.58-13.5-4.5-1.82 2.32-4.5 3.5-6.06 2.24-12 3.5-7.5 0-15 0-27.42-2.56-50-18.5-18-17.25-23-41.5 0-11.5 0-23 4.12-22.7 25-33 6.95-16.67 22-26.5-20.39-20.8-14.5-49.5 7.01-26.98 28.5-44.5 7.56-5.27 15-10.5-13.09-30.88-7.5-64 3.16-15.57 14.5-26.5 6.85-2.48 8 4.5-6.59 39.53 11 75.5 7.99-0.49 16-2 2.42-34.57 14.5-67.5 8.51-22.23 27.5-36z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s1" d="m113.5 401.5q0.48-5.1-1-10-0.91 0.19-1 1-2.46 1.74-5 3.5 5.65 9.54-5 13-32.21 5.55-61-10-32.89-23.11-29.5-63.5 2.96-22.67 23.5-32 7.99-19.75 27-29.5-27.65-23.7-15.5-58.5 7.33-16.82 20.5-29.5 10.79-8.14 22-15.5-16.49-37.08-5.5-76 3.19-6.13 7.5-11.5 1.48-0.89 2 1-5.69 41.09 12.5 78.5 1 1 2 2 9.97-3.24 20.5-4 2 0 4 0 0-7.5 0-15 0.99-42.22 24.5-77 6.12-7.12 14-12-4.65 13.43-10 27-11.93 37.6-9.5 77 49.38 0.7 83.5 36 2.75 4.5 5.5 9 38.99-52.24 93-88.5 45.84-29.03 100-32.5 15.69-1.56 29 6.5 5.68 7.29 3.5 16.5-10.38 33.62-43.5 45-4.39 37.33-41 45-0.79 8.63-6 15.5 1.91 1.83 4.5 2.5 22.27-17.25 50.5-14.5 12.93-9.41 28-15 36.22-8.28 31.5 28.5-15.19 51.69-62.5 77.5-65.92 35.87-138 15.5-19.67 10.42-42 10.5-8.39 2.88-17 5 3.58 6.08 10 9 20.92-1.14 36 13 22.67 5.23 34.5 25.5 3.33 7.13-3.5 11.5-3.88 1.8-8 3 7.36 8.45 6.5 19.5-4.43 5.66-11.5 3.5-12.84-5.67-26-10.5-39.4 21.02-83 10.5-18.85-5.78-36.5-14.5-13.65 4.14-23.5 14.5-9.51 3.74-11-6.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s2" d="m153.5 173.5q24.62 1.46 46 13.5 12.11 8.1 17.5 21.5 0.74 2.45 0.5 5 0.09 0.81 1 1 1.48-4.9 1-10 5.04 10.48 1.5 22-9.81 27.86-35.5 42.5-26.17 14.97-56 19.5-2.77-0.4-2 1 2.86 1.27 6 1 25.64 1.53 48.5-10 0.34 10.08 2 20 1.08 5.76 5 10 1 1.5 0 3-31.11 20.84-68.5 17.5-23.7-5.7-32.5-28.5-4.39-9.18-3.5-19 15.41 6.23 32 4.5-20.68-6.39-39-18-34.81-27.22-12.5-65.5 11.84-14.83 29-23 4.21 7.66 11.5 12.5 3 1 6 0-26.04-34.62-29-78-0.13-8.46 2-16.5 1 6.5 2 13 3.43 39.53 24.5 73 2.03 2.28 4.5 4 0.5-1.25 1-2.5-1.27-6.54-5-12 0.5-0.75 1-1.5 9.72-3.43 20-4 0.55 10.34 8 17.5 1.94 0.74 4 0.5-17.8-64.6 16.5-122 0.98-1.79 1.5 0-28.21 56.64-13.5 118 1.08 1.43 2.5 0.5 2.21-4.98 2-10.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s3" d="m454.5 97.5q-18.37-2.97-37-1.5-16.14 2.08-32 5.5 32.38-14.09 67-7.5 1.98 1.22 2 3.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s4" d="m454.5 97.5q-1.33 11.18-8.5 20-21.81 26.28-55.5 32-1.11-0.2-2 0.5 2.31 2.82 5.5 4.5 1 2 0 4-9.56 11.3-19.5 20 19.71-8.72 31-27 2.68-0.43 5 1-14.24 30.97-48 36.5-9.93 1.71-20 1.5-6.8-0.48-13 1 5.81 6.92 14 11-10.78 16.03-27 26.5 27.16-7.4 38-33.5 4.34 1.35 9 1-9.08 23.84-33 33.5-18.45 6.41-38 7 22.59 8.92 45-1 12.05-5.52 24-11 9.01-1.79 17 2.5 5.28-4.38 11-8 12.8-6.07 27-5 0 0.5 0 1-19.34 2.69-34 15.5 0.5 0.25 1 0.5 17.79-8.09 36-15 2.71-0.79 5-2 2.5-1 5-2 5.53-4.04 11-8 11.7-4.18 24-6.5 7.78-1.36 15 1.5-2.97 18.45-13.5 34-34.92 49.37-94.5 62.5-59.27 12.45-108-23-15.53-12.52-21.5-31.5-2.47-14.26 4-27-3.15 24.41 14 42-4.92-10.28-7-22-1.97-17.63 7-33 47.28-69.5 125.5-100 15.86-3.42 32-5.5 18.63-1.47 37 1.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s5" d="m86.5 112.5q-1-6.5-2-13 0.7-5.34 3.5-10-1.8 11.32-1.5 23z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s6" d="m433.5 97.5q2.22-0.39 4 1-10 13.75-27 14-0.24-2.06 0.5-4 10.3-7.78 22.5-11z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s7" d="m407.5 101.5q2.55-0.24 5 0.5-52.87 18.31-84.5 64.5-6.94 7.95-17 11-9.38-2.38-5-11 40.38-48.62 101.5-65z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s8" d="m402.5 112.5q3 0 6 0-2.56 8.8-12 7-0.22-1.58 0.5-3 2.72-2.22 5.5-4z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s9" d="m390.5 149.5q7.77 0.52 15 2-11.29 18.28-31 27 9.94-8.7 19.5-20 1-2 0-4-3.19-1.68-5.5-4.5 0.89-0.7 2-0.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s10" d="m131.5 145.5q0 7.5 0 15-2 0-4 0 1.06-1.36 3-1-0.48-7.29 1-14z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s11" d="m219.5 204.5q-1 4.5-2 9 0.24-2.55-0.5-5-5.39-13.4-17.5-21.5-21.38-12.04-46-13.5 0-2 0-4 36.7-0.86 61.5 26 3.06 4.11 4.5 9z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s12" d="m329.5 191.5q6.2-1.48 13-1-3.5 1-7 2-2.9-0.97-6-1z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s13" d="m329.5 191.5q3.1 0.03 6 1 9.55 1.31 19 3-10.84 26.1-38 33.5 16.22-10.47 27-26.5-8.19-4.08-14-11z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s14" d="m479.5 199.5q-7.22-2.86-15-1.5-12.3 2.32-24 6.5 15.6-13.11 36-11.5 3.63 2.26 3 6.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s15" d="m193.5 216.5q-12.01 1.52-22 8-2.83 1.29-5.5 3-4.79-4.57-6.5-11-5.04 2.2-9.5-1-3.47-6.4 3.5-3 4.4 0.05 8-2.5 9.22-9.73 21-16 6.3-3.24 12 1-2.9 1.22-6 1.5 2.61 5.74 4.5 12 0.75 3.97 0.5 8z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s16" d="m458.5 200.5q3.04-0.24 6 0.5-18.02 7.05-33 19-1 1-2 0 11.53-14.3 29-19.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s17" d="m178.5 202.5q6.85-0.63 4.5 6-7.6 5.09-6-4 1.08-0.82 1.5-2z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s18" d="m469.5 201.5q-2.26 13.65-14.5 22-0.47-2.11 1-4 7.08-8.82 13.5-18z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s19" d="m74.5 208.5q8.22-0.2 16 2.5 11.8 4.26 23.5 8.5 5.65-0.63 8-6 2.41 11.83-9.5 13 0.55 3.61 2 7-0.5 1-1 2-4.67-0.94-9.5-1-9.96 0.44-19.5 2.5-5.05-3.55-6.5-9.5-0.75-7.48-0.5-15-6.47 0.15-3-4z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s20" d="m429.5 212.5q-2.5 1-5 2-4 0-8 0-14.2-1.07-27 5 15.27-12.44 35-9.5 2.72 1.14 5 2.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s21" d="m219.5 204.5q0.48 5.1-1 10-0.91-0.19-1-1 1-4.5 2-9z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s22" d="m416.5 215.5q0-0.5 0-1 4 0 8 0-2.29 1.21-5 2-1.06-1.36-3-1z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s23" d="m416.5 215.5q1.94-0.36 3 1-18.21 6.91-36 15-0.5-0.25-1-0.5 14.66-12.81 34-15.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s24" d="m193.5 216.5q4.39 1.3 9 3-0.79 1.04-2 1.5-14.77-0.13-29 3.5 9.99-6.48 22-8z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s25" d="m98.5 219.5q6.09-0.98 6 5-3.04 0.24-6-0.5-1.84-2.24 0-4.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s26" d="m176.5 229.5q8.85-1.14 16 4-4.98 1.75-10 0-13.56 14.3-33 19.5-28.06 8.2-55 1 3.32-6.4 10-5.5-0.71 1.47-2 2.5 36.58 4.24 69-14 4.68-2.13 1-5 2.35-0.91 4-2.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s27" d="m231.5 238.5q1.31-0.2 2 1-3.13 28.62 15 51-16.25 6.75-27-7.5-1-1-2 0 14.73 29.34 46 18.5 1.79 0.52 0 1.5-37.63 16.82-50.5-22.5-5.1-26.48 16.5-42z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s28" d="m243.5 259.5q5.88 3.62 10.5 9 12.96 18.46 32.5 29.5-31.51-7.75-43-38.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s29" d="m203.5 266.5q1.31-0.2 2 1-2.48 22.08 12 39-6.99 1.35-14 0.5 4.59 4.08 10 7-8.71 0.28-14.5-6.5-16.98-22.76 4.5-41z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s27" d="m58.5 284.5q9.6-2.17 14.5 6 5.15 14.18-1 28-11.05-13.14-27.5-17.5 5.15-9.9 14-16.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s30" d="m129.5 288.5q2 1 4 2-3.14 0.27-6-1-0.77-1.4 2-1z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s31" d="m56.5 313.5q3.43 5.43 8 10-4.88 0.44-8 4-1.11-0.2-2 0.5 28.91 1.65 38 28.5 0.45 3.16-1 6-11.02-7.01-23-12.5-4.75-3.75-9.5-7.5 1.47 7.42 7 13 8.34 27.18 32 43 0.99 2.41-1.5 3.5-40.25 5.58-66.5-25.5-15.67-22.01-8-48 10.46-23.87 34.5-15z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s32" d="m45.5 317.5q4.03-0.25 8 0.5 2.46 4.16-2 6-6.04 2.01-9-3.5 1.26-1.85 3-3z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s33" d="m56.5 313.5q4.91 3.14 9.5 7 0.88 2.25-1.5 3-4.57-4.57-8-10z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s34" d="m198.5 319.5q-11.1 11.56-27 15.5-15.75 4.88-32 2.5 28.81-3.69 54-18.5 2.65-0.96 5 0.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s4" d="m198.5 319.5q1.44 0.68 2.5 2 2.41 8.23 6 16 1.2 2.64-0.5 5-30.65 21.41-68 18.5-25.16-6.17-32.5-30.5 6.96 4.99 15.5 6.5 8.99 0.75 18 0.5 16.25 2.38 32-2.5 15.9-3.94 27-15.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s35" d="m92.5 356.5q-9.09-26.85-38-28.5 0.89-0.7 2-0.5 25.47-4.89 35.5 19 0.75 4.98 0.5 10z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s36" d="m72.5 335.5q3.62-0.38 5 3-4.22 1.83-5-3z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s37" d="m223.5 336.5q5.59-0.48 11 1-4.04 4.16-8.5 8-5.99-3.8-2.5-9z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s38" d="m90.5 334.5q0.59-1.54 2-0.5 3.94 5.45 9 10 7 6 14 12-6.91-1.7-13-6-6.21-7.72-12-15.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s39" d="m261.5 346.5q-3.54-2.44-8-3.5-6.98-0.75-14-0.5 0.63-1.08 2-1.5 13.82-2.52 26 4-2.63 1.98-6 1.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s40" d="m239.5 342.5q7.02-0.25 14 0.5 4.46 1.06 8 3.5-5.2 2.35-10 5.5-3.88 4.65-9 7.5-9.89-3.09-9.5-13 2.36-3.63 6.5-4z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s41" d="m214.5 349.5q-21.43 15.48-48 16 22.82-5.9 43-18.5 3.64-1.12 5 2.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s42" d="m214.5 349.5q5.96 7.2 13.5 13 1 1 0 2-28.58 23.34-65.5 20.5-18.15-4.24-27.5-19.5 1.13 0.94 2.5 1.5 14.7 1.42 29-1.5 26.57-0.52 48-16z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s43" d="m302.5 373.5q-14.74-16.73-37-19-4.55 0.25-9 1 25.3-10.24 43.5 11 2.85 2.91 2.5 7z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s44" d="m302.5 373.5q0.21 2.44-2 3.5-28.69 7.6-50.5-12.5-0.06-6.71 6.5-9 4.45-0.75 9-1 22.26 2.27 37 19z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s45" d="m100.5 356.5q5.42 2.71 11 5.5-13.04 7.54-18.5 21.5-7.57-7.14-10.5-17 5.58 1.54 10 5.5 4.2 0.84 5.5-3.5 1.41-5.99 2.5-12z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s8" d="m83.5 394.5q-18.9-10.15-29.5-29-1.54-3.52-2-7 5.79 2.39 10 7 7.82 16.63 21.5 29z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s46" d="m232.5 365.5q17.6 6.19 10.5 23-10.6 10.42-25.5 11.5-25.94 3.21-49-9 36.75-1.65 64-25.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s47" d="m113.5 367.5q7.7-0.01 9.5 7-9.69 7.19-18.5 15.5-7.23 5.76-5.5-3.5 3.12-12.84 14.5-19z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s29" d="m126.5 380.5q7.88-0.4 12 6.5-8.5 7.25-17 14.5-5.62-12.55 5-21z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s48" d="m283.5 385.5q3.22 2.95 7 5.5 2.8 4.03 6 7.5 0.42 2.77-2 4-15.5-9.75-31-19.5-1.79-0.98 0-1.5 9.96 2.49 20 4z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s49" d="m283.5 385.5q8.71-1.27 11.5 7 1.22 2.9 1.5 6-3.2-3.47-6-7.5-3.78-2.55-7-5.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s50" d="m83.5 394.5q1.88-0.06 3 1.5-2.25 0.88-3-1.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s51" d="m258.5 392.5q3.51 0.41 0 2.5-2.33 1.93-5 2 2.61-2.28 5-4.5z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path fill-rule="evenodd" class="s52" d="m111.5 392.5q0.09-0.81 1-1 1.48 4.9 1 10-1-4.5-2-9z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 13 KiB |
7
app/ui/app/public/launch-icons/opencode.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512"><svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="512" height="512" fill="#131010"></rect>
|
||||||
|
<path d="M320 224V352H192V224H320Z" fill="#5A5858"></path>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z" fill="white"></path>
|
||||||
|
</svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
|
||||||
|
@media (prefers-color-scheme: dark) { :root { filter: none; } }
|
||||||
|
</style></svg>
|
||||||
|
After Width: | Height: | Size: 612 B |
9
app/ui/app/public/launch-icons/pi-dark.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800">
|
||||||
|
<rect width="800" height="800" rx="160" fill="#fff"/>
|
||||||
|
<path fill="#000" fill-rule="evenodd" d="
|
||||||
|
M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z
|
||||||
|
M282.65 282.65 V400 H400 V282.65 Z
|
||||||
|
"/>
|
||||||
|
<path fill="#000" d="M517.36 400 H634.72 V634.72 H517.36 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 389 B |
9
app/ui/app/public/launch-icons/pi.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800">
|
||||||
|
<rect width="800" height="800" rx="160" fill="#000"/>
|
||||||
|
<path fill="#fff" fill-rule="evenodd" d="
|
||||||
|
M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z
|
||||||
|
M282.65 282.65 V400 H400 V282.65 Z
|
||||||
|
"/>
|
||||||
|
<path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 389 B |
@@ -161,7 +161,7 @@ export async function getModels(query?: string): Promise<Model[]> {
|
|||||||
// Add query if it's in the registry and not already in the list
|
// Add query if it's in the registry and not already in the list
|
||||||
if (!exactMatch) {
|
if (!exactMatch) {
|
||||||
const result = await getModelUpstreamInfo(new Model({ model: query }));
|
const result = await getModelUpstreamInfo(new Model({ model: query }));
|
||||||
const existsUpstream = !!result.digest && !result.error;
|
const existsUpstream = result.exists;
|
||||||
if (existsUpstream) {
|
if (existsUpstream) {
|
||||||
filteredModels.push(new Model({ model: query }));
|
filteredModels.push(new Model({ model: query }));
|
||||||
}
|
}
|
||||||
@@ -339,7 +339,7 @@ export async function deleteChat(chatId: string): Promise<void> {
|
|||||||
// Get upstream information for model staleness checking
|
// Get upstream information for model staleness checking
|
||||||
export async function getModelUpstreamInfo(
|
export async function getModelUpstreamInfo(
|
||||||
model: Model,
|
model: Model,
|
||||||
): Promise<{ digest?: string; pushTime: number; error?: string }> {
|
): Promise<{ stale: boolean; exists: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/model/upstream`, {
|
const response = await fetch(`${API_BASE}/api/v1/model/upstream`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -353,22 +353,22 @@ export async function getModelUpstreamInfo(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`Failed to check upstream digest for ${model.model}: ${response.status}`,
|
`Failed to check upstream for ${model.model}: ${response.status}`,
|
||||||
);
|
);
|
||||||
return { pushTime: 0 };
|
return { stale: false, exists: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
console.warn(`Upstream digest check: ${data.error}`);
|
console.warn(`Upstream check: ${data.error}`);
|
||||||
return { error: data.error, pushTime: 0 };
|
return { stale: false, exists: false, error: data.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { digest: data.digest, pushTime: data.pushTime || 0 };
|
return { stale: !!data.stale, exists: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Error checking model staleness:`, error);
|
console.warn(`Error checking model staleness:`, error);
|
||||||
return { pushTime: 0 };
|
return { stale: false, exists: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -480,13 +480,15 @@ function ChatForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare attachments for submission
|
// Prepare attachments for submission, excluding unsupported images
|
||||||
const attachmentsToSend: FileAttachment[] = message.attachments.map(
|
const attachmentsToSend: FileAttachment[] = message.attachments
|
||||||
(att) => ({
|
.filter(
|
||||||
|
(att) => hasVisionCapability || !isImageFile(att.filename),
|
||||||
|
)
|
||||||
|
.map((att) => ({
|
||||||
filename: att.filename,
|
filename: att.filename,
|
||||||
data: att.data || new Uint8Array(0), // Empty data for existing files
|
data: att.data || new Uint8Array(0), // Empty data for existing files
|
||||||
}),
|
}));
|
||||||
);
|
|
||||||
|
|
||||||
const useWebSearch =
|
const useWebSearch =
|
||||||
supportsWebSearch && webSearchEnabled && !cloudDisabled;
|
supportsWebSearch && webSearchEnabled && !cloudDisabled;
|
||||||
@@ -736,10 +738,17 @@ function ChatForm({
|
|||||||
)}
|
)}
|
||||||
{(message.attachments.length > 0 || message.fileErrors.length > 0) && (
|
{(message.attachments.length > 0 || message.fileErrors.length > 0) && (
|
||||||
<div className="flex gap-2 overflow-x-auto px-3 pt pb-3 w-full scrollbar-hide">
|
<div className="flex gap-2 overflow-x-auto px-3 pt pb-3 w-full scrollbar-hide">
|
||||||
{message.attachments.map((attachment, index) => (
|
{message.attachments.map((attachment, index) => {
|
||||||
|
const isUnsupportedImage =
|
||||||
|
!hasVisionCapability && isImageFile(attachment.filename);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={attachment.id}
|
key={attachment.id}
|
||||||
className="group flex items-center gap-2 py-2 px-3 rounded-lg bg-neutral-50 dark:bg-neutral-700/50 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors flex-shrink-0"
|
className={`group flex items-center gap-2 py-2 px-3 rounded-lg transition-colors flex-shrink-0 ${
|
||||||
|
isUnsupportedImage
|
||||||
|
? "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
|
||||||
|
: "bg-neutral-50 dark:bg-neutral-700/50 hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{isImageFile(attachment.filename) ? (
|
{isImageFile(attachment.filename) ? (
|
||||||
<ImageThumbnail
|
<ImageThumbnail
|
||||||
@@ -764,9 +773,16 @@ function ChatForm({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
<span className="text-sm text-neutral-700 dark:text-neutral-300 max-w-[150px] truncate">
|
<div className="flex flex-col min-w-0">
|
||||||
{attachment.filename}
|
<span className={`text-sm max-w-36 truncate ${isUnsupportedImage ? "text-red-700 dark:text-red-300" : "text-neutral-700 dark:text-neutral-300"}`}>
|
||||||
</span>
|
{attachment.filename}
|
||||||
|
</span>
|
||||||
|
{isUnsupportedImage && (
|
||||||
|
<span className="text-xs text-red-600 dark:text-red-400 opacity-75">
|
||||||
|
This model does not support images
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeFile(index)}
|
onClick={() => removeFile(index)}
|
||||||
@@ -788,7 +804,8 @@ function ChatForm({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{message.fileErrors.map((fileError, index) => (
|
{message.fileErrors.map((fileError, index) => (
|
||||||
<div
|
<div
|
||||||
key={`error-${index}`}
|
key={`error-${index}`}
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import { getChat } from "@/api";
|
|||||||
import { Link } from "@/components/ui/link";
|
import { Link } from "@/components/ui/link";
|
||||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||||
import { ChatsResponse } from "@/gotypes";
|
import { ChatsResponse } from "@/gotypes";
|
||||||
import { CogIcon } from "@heroicons/react/24/outline";
|
import { CogIcon, RocketLaunchIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
// there's a hidden debug feature to copy a chat's data to the clipboard by
|
// there's a hidden debug feature to copy a chat's data to the clipboard by
|
||||||
// holding shift and clicking this many times within this many seconds
|
// holding shift and clicking this many times within this many seconds
|
||||||
const DEBUG_SHIFT_CLICKS_REQUIRED = 5;
|
const DEBUG_SHIFT_CLICKS_REQUIRED = 5;
|
||||||
const DEBUG_SHIFT_CLICK_WINDOW_MS = 7000; // 7 seconds
|
const DEBUG_SHIFT_CLICK_WINDOW_MS = 7000; // 7 seconds
|
||||||
|
const launchSidebarRequestedKey = "ollama.launchSidebarRequested";
|
||||||
|
|
||||||
interface ChatSidebarProps {
|
interface ChatSidebarProps {
|
||||||
currentChatId?: string;
|
currentChatId?: string;
|
||||||
@@ -267,9 +268,8 @@ export function ChatSidebar({ currentChatId }: ChatSidebarProps) {
|
|||||||
<Link
|
<Link
|
||||||
href="/c/new"
|
href="/c/new"
|
||||||
mask={{ to: "/" }}
|
mask={{ to: "/" }}
|
||||||
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 dark:text-neutral-100 ${
|
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 dark:text-neutral-100 ${currentChatId === "new" ? "bg-neutral-100 dark:bg-neutral-800" : ""
|
||||||
currentChatId === "new" ? "bg-neutral-100 dark:bg-neutral-800" : ""
|
}`}
|
||||||
}`}
|
|
||||||
draggable={false}
|
draggable={false}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -283,6 +283,23 @@ export function ChatSidebar({ currentChatId }: ChatSidebarProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
<span className="truncate">New Chat</span>
|
<span className="truncate">New Chat</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/c/$chatId"
|
||||||
|
params={{ chatId: "launch" }}
|
||||||
|
onClick={() => {
|
||||||
|
if (currentChatId !== "launch") {
|
||||||
|
sessionStorage.setItem(launchSidebarRequestedKey, "1");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 dark:text-neutral-100 cursor-pointer ${currentChatId === "launch"
|
||||||
|
? "bg-neutral-100 dark:bg-neutral-800"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
draggable={false}
|
||||||
|
>
|
||||||
|
<RocketLaunchIcon className="h-5 w-5 stroke-current" />
|
||||||
|
<span className="truncate">Launch</span>
|
||||||
|
</Link>
|
||||||
{isWindows && (
|
{isWindows && (
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
@@ -304,19 +321,18 @@ export function ChatSidebar({ currentChatId }: ChatSidebarProps) {
|
|||||||
{group.chats.map((chat) => (
|
{group.chats.map((chat) => (
|
||||||
<div
|
<div
|
||||||
key={chat.id}
|
key={chat.id}
|
||||||
className={`allow-context-menu flex items-center relative text-sm text-neutral-800 dark:text-neutral-400 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 ${
|
className={`allow-context-menu flex items-center relative text-sm text-neutral-800 dark:text-neutral-400 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 ${chat.id === currentChatId
|
||||||
chat.id === currentChatId
|
? "bg-neutral-100 text-black dark:bg-neutral-800"
|
||||||
? "bg-neutral-100 text-black dark:bg-neutral-800"
|
: ""
|
||||||
: ""
|
}`}
|
||||||
}`}
|
|
||||||
onMouseEnter={() => handleMouseEnter(chat.id)}
|
onMouseEnter={() => handleMouseEnter(chat.id)}
|
||||||
onContextMenu={(e) =>
|
onContextMenu={(e) =>
|
||||||
handleContextMenu(
|
handleContextMenu(
|
||||||
e,
|
e,
|
||||||
chat.id,
|
chat.id,
|
||||||
chat.title ||
|
chat.title ||
|
||||||
chat.userExcerpt ||
|
chat.userExcerpt ||
|
||||||
chat.createdAt.toLocaleString(),
|
chat.createdAt.toLocaleString(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface CopyButtonProps {
|
|||||||
showLabels?: boolean;
|
showLabels?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
onCopy?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CopyButton: React.FC<CopyButtonProps> = ({
|
const CopyButton: React.FC<CopyButtonProps> = ({
|
||||||
@@ -20,6 +21,7 @@ const CopyButton: React.FC<CopyButtonProps> = ({
|
|||||||
showLabels = false,
|
showLabels = false,
|
||||||
className = "",
|
className = "",
|
||||||
title = "",
|
title = "",
|
||||||
|
onCopy,
|
||||||
}) => {
|
}) => {
|
||||||
const [isCopied, setIsCopied] = useState(false);
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
|
|
||||||
@@ -48,12 +50,14 @@ const CopyButton: React.FC<CopyButtonProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsCopied(true);
|
setIsCopied(true);
|
||||||
|
onCopy?.();
|
||||||
setTimeout(() => setIsCopied(false), 2000);
|
setTimeout(() => setIsCopied(false), 2000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Clipboard API failed, falling back to plain text", error);
|
console.error("Clipboard API failed, falling back to plain text", error);
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(content);
|
await navigator.clipboard.writeText(content);
|
||||||
setIsCopied(true);
|
setIsCopied(true);
|
||||||
|
onCopy?.();
|
||||||
setTimeout(() => setIsCopied(false), 2000);
|
setTimeout(() => setIsCopied(false), 2000);
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
console.error("Fallback copy also failed:", fallbackError);
|
console.error("Fallback copy also failed:", fallbackError);
|
||||||
|
|||||||
133
app/ui/app/src/components/LaunchCommands.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useSettings } from "@/hooks/useSettings";
|
||||||
|
import CopyButton from "@/components/CopyButton";
|
||||||
|
|
||||||
|
interface LaunchCommand {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
command: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
darkIcon?: string;
|
||||||
|
iconClassName?: string;
|
||||||
|
borderless?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAUNCH_COMMANDS: LaunchCommand[] = [
|
||||||
|
{
|
||||||
|
id: "openclaw",
|
||||||
|
name: "OpenClaw",
|
||||||
|
command: "ollama launch openclaw",
|
||||||
|
description: "Personal AI with 100+ skills",
|
||||||
|
icon: "/launch-icons/openclaw.svg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude",
|
||||||
|
name: "Claude",
|
||||||
|
command: "ollama launch claude",
|
||||||
|
description: "Anthropic's coding tool with subagents",
|
||||||
|
icon: "/launch-icons/claude.svg",
|
||||||
|
iconClassName: "h-7 w-7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "codex",
|
||||||
|
name: "Codex",
|
||||||
|
command: "ollama launch codex",
|
||||||
|
description: "OpenAI's open-source coding agent",
|
||||||
|
icon: "/launch-icons/codex.svg",
|
||||||
|
darkIcon: "/launch-icons/codex-dark.svg",
|
||||||
|
iconClassName: "h-7 w-7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
command: "ollama launch opencode",
|
||||||
|
description: "Anomaly's open-source coding agent",
|
||||||
|
icon: "/launch-icons/opencode.svg",
|
||||||
|
iconClassName: "h-7 w-7 rounded",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "droid",
|
||||||
|
name: "Droid",
|
||||||
|
command: "ollama launch droid",
|
||||||
|
description: "Factory's coding agent across terminal and IDEs",
|
||||||
|
icon: "/launch-icons/droid.svg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pi",
|
||||||
|
name: "Pi",
|
||||||
|
command: "ollama launch pi",
|
||||||
|
description: "Minimal AI agent toolkit with plugin support",
|
||||||
|
icon: "/launch-icons/pi.svg",
|
||||||
|
darkIcon: "/launch-icons/pi-dark.svg",
|
||||||
|
iconClassName: "h-7 w-7",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function LaunchCommands() {
|
||||||
|
const isWindows = navigator.platform.toLowerCase().includes("win");
|
||||||
|
const { setSettings } = useSettings();
|
||||||
|
|
||||||
|
const renderCommandCard = (item: LaunchCommand) => (
|
||||||
|
<div key={item.command} className="w-full text-left">
|
||||||
|
<div className="flex items-start gap-4 sm:gap-5">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg overflow-hidden ${item.borderless ? "" : "border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-900"}`}
|
||||||
|
>
|
||||||
|
{item.darkIcon ? (
|
||||||
|
<picture>
|
||||||
|
<source srcSet={item.darkIcon} media="(prefers-color-scheme: dark)" />
|
||||||
|
<img src={item.icon} alt="" className={`${item.iconClassName ?? "h-8 w-8"} rounded-sm`} />
|
||||||
|
</picture>
|
||||||
|
) : (
|
||||||
|
<img src={item.icon} alt="" className={item.borderless ? "h-full w-full rounded-xl" : `${item.iconClassName ?? "h-8 w-8"} rounded-sm`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<p className="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2 rounded-xl border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800 px-3 py-2">
|
||||||
|
<code className="min-w-0 flex-1 truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||||
|
{item.command}
|
||||||
|
</code>
|
||||||
|
<CopyButton
|
||||||
|
content={item.command}
|
||||||
|
size="md"
|
||||||
|
title="Copy command to clipboard"
|
||||||
|
className="text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-200/60 dark:hover:bg-neutral-700/70"
|
||||||
|
onCopy={() => {
|
||||||
|
setSettings({ LastHomeView: item.id }).catch(() => { });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex h-screen w-full flex-col relative">
|
||||||
|
<section
|
||||||
|
className={`flex-1 overflow-y-auto overscroll-contain relative min-h-0 ${isWindows ? "xl:pt-4" : "xl:pt-8"}`}
|
||||||
|
>
|
||||||
|
<div className="max-w-[730px] mx-auto w-full px-4 pt-4 pb-20 sm:px-6 sm:pt-6 sm:pb-24 lg:px-8 lg:pt-8 lg:pb-28">
|
||||||
|
<h1 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
Launch
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
Copy a command and run it in your terminal.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-7">
|
||||||
|
{LAUNCH_COMMANDS.map(renderCommandCard)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -536,7 +536,7 @@ function ToolCallDisplay({
|
|||||||
let args: Record<string, unknown> | null = null;
|
let args: Record<string, unknown> | null = null;
|
||||||
try {
|
try {
|
||||||
args = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
|
args = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
|
||||||
} catch (e) {
|
} catch {
|
||||||
args = null;
|
args = null;
|
||||||
}
|
}
|
||||||
const query = args && typeof args.query === "string" ? args.query : "";
|
const query = args && typeof args.query === "string" ? args.query : "";
|
||||||
@@ -562,7 +562,7 @@ function ToolCallDisplay({
|
|||||||
let args: Record<string, unknown> | null = null;
|
let args: Record<string, unknown> | null = null;
|
||||||
try {
|
try {
|
||||||
args = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
|
args = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
|
||||||
} catch (e) {
|
} catch {
|
||||||
args = null;
|
args = null;
|
||||||
}
|
}
|
||||||
const url = args && typeof args.url === "string" ? args.url : "";
|
const url = args && typeof args.url === "string" ? args.url : "";
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default function MessageList({
|
|||||||
? String(args.url).trim()
|
? String(args.url).trim()
|
||||||
: "";
|
: "";
|
||||||
if (candidate) lastQuery = candidate;
|
if (candidate) lastQuery = candidate;
|
||||||
} catch {}
|
} catch { /* ignored */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,24 +61,7 @@ export const ModelPicker = forwardRef<
|
|||||||
try {
|
try {
|
||||||
const upstreamInfo = await getModelUpstreamInfo(model);
|
const upstreamInfo = await getModelUpstreamInfo(model);
|
||||||
|
|
||||||
// Compare local digest with upstream digest
|
if (upstreamInfo.stale) {
|
||||||
let isStale =
|
|
||||||
model.digest &&
|
|
||||||
upstreamInfo.digest &&
|
|
||||||
model.digest !== upstreamInfo.digest;
|
|
||||||
|
|
||||||
// If the model has a modified time and upstream has a push time,
|
|
||||||
// check if the model was modified after the push time - if so, it's not stale
|
|
||||||
if (isStale && model.modified_at && upstreamInfo.pushTime > 0) {
|
|
||||||
const modifiedAtTime =
|
|
||||||
new Date(model.modified_at as string | number | Date).getTime() /
|
|
||||||
1000;
|
|
||||||
if (modifiedAtTime > upstreamInfo.pushTime) {
|
|
||||||
isStale = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isStale) {
|
|
||||||
const currentStaleModels =
|
const currentStaleModels =
|
||||||
queryClient.getQueryData<Map<string, boolean>>(["staleModels"]) ||
|
queryClient.getQueryData<Map<string, boolean>>(["staleModels"]) ||
|
||||||
new Map();
|
new Map();
|
||||||
|
|||||||
@@ -273,6 +273,10 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isWindows = navigator.platform.toLowerCase().includes("win");
|
const isWindows = navigator.platform.toLowerCase().includes("win");
|
||||||
|
const handleCloseSettings = () => {
|
||||||
|
const chatId = settings.LastHomeView === "chat" ? "new" : "launch";
|
||||||
|
navigate({ to: "/c/$chatId", params: { chatId } });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex h-screen w-full flex-col select-none dark:bg-neutral-900">
|
<main className="flex h-screen w-full flex-col select-none dark:bg-neutral-900">
|
||||||
@@ -286,7 +290,7 @@ export default function Settings() {
|
|||||||
>
|
>
|
||||||
{isWindows && (
|
{isWindows && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: "/" })}
|
onClick={handleCloseSettings}
|
||||||
className="hover:bg-neutral-100 mr-3 dark:hover:bg-neutral-800 rounded-full p-1.5"
|
className="hover:bg-neutral-100 mr-3 dark:hover:bg-neutral-800 rounded-full p-1.5"
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="w-5 h-5 dark:text-white" />
|
<ArrowLeftIcon className="w-5 h-5 dark:text-white" />
|
||||||
@@ -296,7 +300,7 @@ export default function Settings() {
|
|||||||
</h1>
|
</h1>
|
||||||
{!isWindows && (
|
{!isWindows && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: "/" })}
|
onClick={handleCloseSettings}
|
||||||
className="p-1 hover:bg-neutral-100 mr-3 dark:hover:bg-neutral-800 rounded-full"
|
className="p-1 hover:bg-neutral-100 mr-3 dark:hover:bg-neutral-800 rounded-full"
|
||||||
>
|
>
|
||||||
<XMarkIcon className="w-6 h-6 dark:text-white" />
|
<XMarkIcon className="w-6 h-6 dark:text-white" />
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export const BadgeButton = forwardRef(function BadgeButton(
|
|||||||
),
|
),
|
||||||
ref: React.ForwardedRef<HTMLElement>,
|
ref: React.ForwardedRef<HTMLElement>,
|
||||||
) {
|
) {
|
||||||
let classes = clsx(
|
const classes = clsx(
|
||||||
className,
|
className,
|
||||||
"group relative inline-flex rounded-md focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500",
|
"group relative inline-flex rounded-md focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export const Button = forwardRef(function Button(
|
|||||||
{ color, outline, plain, className, children, ...props }: ButtonProps,
|
{ color, outline, plain, className, children, ...props }: ButtonProps,
|
||||||
ref: React.ForwardedRef<HTMLElement>,
|
ref: React.ForwardedRef<HTMLElement>,
|
||||||
) {
|
) {
|
||||||
let classes = clsx(
|
const classes = clsx(
|
||||||
className,
|
className,
|
||||||
styles.base,
|
styles.base,
|
||||||
outline
|
outline
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface SettingsState {
|
|||||||
webSearchEnabled: boolean;
|
webSearchEnabled: boolean;
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
|
lastHomeView: string;
|
||||||
thinkEnabled: boolean;
|
thinkEnabled: boolean;
|
||||||
thinkLevel: string;
|
thinkLevel: string;
|
||||||
}
|
}
|
||||||
@@ -21,6 +22,7 @@ type SettingsUpdate = Partial<{
|
|||||||
ThinkLevel: string;
|
ThinkLevel: string;
|
||||||
SelectedModel: string;
|
SelectedModel: string;
|
||||||
SidebarOpen: boolean;
|
SidebarOpen: boolean;
|
||||||
|
LastHomeView: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export function useSettings() {
|
export function useSettings() {
|
||||||
@@ -50,6 +52,7 @@ export function useSettings() {
|
|||||||
thinkLevel: settingsData?.settings?.ThinkLevel ?? "none",
|
thinkLevel: settingsData?.settings?.ThinkLevel ?? "none",
|
||||||
selectedModel: settingsData?.settings?.SelectedModel ?? "",
|
selectedModel: settingsData?.settings?.SelectedModel ?? "",
|
||||||
sidebarOpen: settingsData?.settings?.SidebarOpen ?? false,
|
sidebarOpen: settingsData?.settings?.SidebarOpen ?? false,
|
||||||
|
lastHomeView: settingsData?.settings?.LastHomeView ?? "launch",
|
||||||
}),
|
}),
|
||||||
[settingsData?.settings],
|
[settingsData?.settings],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,12 +4,37 @@ import Chat from "@/components/Chat";
|
|||||||
import { getChat } from "@/api";
|
import { getChat } from "@/api";
|
||||||
import { SidebarLayout } from "@/components/layout/layout";
|
import { SidebarLayout } from "@/components/layout/layout";
|
||||||
import { ChatSidebar } from "@/components/ChatSidebar";
|
import { ChatSidebar } from "@/components/ChatSidebar";
|
||||||
|
import LaunchCommands from "@/components/LaunchCommands";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useSettings } from "@/hooks/useSettings";
|
||||||
|
|
||||||
|
const launchSidebarRequestedKey = "ollama.launchSidebarRequested";
|
||||||
|
const launchSidebarSeenKey = "ollama.launchSidebarSeen";
|
||||||
|
const fallbackSessionState = new Map<string, string>();
|
||||||
|
|
||||||
|
function getSessionState() {
|
||||||
|
if (typeof sessionStorage !== "undefined") {
|
||||||
|
return sessionStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getItem(key: string) {
|
||||||
|
return fallbackSessionState.get(key) ?? null;
|
||||||
|
},
|
||||||
|
setItem(key: string, value: string) {
|
||||||
|
fallbackSessionState.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem(key: string) {
|
||||||
|
fallbackSessionState.delete(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute("/c/$chatId")({
|
export const Route = createFileRoute("/c/$chatId")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
loader: async ({ context, params }) => {
|
loader: async ({ context, params }) => {
|
||||||
// Skip loading for "new" chat
|
// Skip loading for special non-chat views
|
||||||
if (params.chatId !== "new") {
|
if (params.chatId !== "new" && params.chatId !== "launch") {
|
||||||
context.queryClient.ensureQueryData({
|
context.queryClient.ensureQueryData({
|
||||||
queryKey: ["chat", params.chatId],
|
queryKey: ["chat", params.chatId],
|
||||||
queryFn: () => getChat(params.chatId),
|
queryFn: () => getChat(params.chatId),
|
||||||
@@ -21,13 +46,70 @@ export const Route = createFileRoute("/c/$chatId")({
|
|||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { chatId } = Route.useParams();
|
const { chatId } = Route.useParams();
|
||||||
|
const { settingsData, setSettings } = useSettings();
|
||||||
|
const previousChatIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Always call hooks at the top level - use a flag to skip data when chatId is "new"
|
// Always call hooks at the top level - use a flag to skip data when chatId is a special view
|
||||||
const {
|
const {
|
||||||
data: chatData,
|
data: chatData,
|
||||||
isLoading: chatLoading,
|
isLoading: chatLoading,
|
||||||
error: chatError,
|
error: chatError,
|
||||||
} = useChat(chatId === "new" ? "" : chatId);
|
} = useChat(chatId === "new" || chatId === "launch" ? "" : chatId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settingsData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousChatId = previousChatIdRef.current;
|
||||||
|
previousChatIdRef.current = chatId;
|
||||||
|
|
||||||
|
if (chatId === "launch") {
|
||||||
|
const sessionState = getSessionState();
|
||||||
|
const shouldOpenSidebar =
|
||||||
|
previousChatId !== "launch" &&
|
||||||
|
(() => {
|
||||||
|
if (sessionState.getItem(launchSidebarRequestedKey) === "1") {
|
||||||
|
sessionState.removeItem(launchSidebarRequestedKey);
|
||||||
|
sessionState.setItem(launchSidebarSeenKey, "1");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionState.getItem(launchSidebarSeenKey) !== "1") {
|
||||||
|
sessionState.setItem(launchSidebarSeenKey, "1");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
})();
|
||||||
|
const updates: { LastHomeView?: string; SidebarOpen?: boolean } = {};
|
||||||
|
|
||||||
|
if (settingsData.LastHomeView !== "launch") {
|
||||||
|
updates.LastHomeView = "launch";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldOpenSidebar && !settingsData.SidebarOpen) {
|
||||||
|
updates.SidebarOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSettings(updates).catch(() => {
|
||||||
|
// Best effort persistence for home view preference.
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingsData.LastHomeView === "chat") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSettings({ LastHomeView: "chat" }).catch(() => {
|
||||||
|
// Best effort persistence for home view preference.
|
||||||
|
});
|
||||||
|
}, [chatId, settingsData, setSettings]);
|
||||||
|
|
||||||
// Handle "new" chat case - just use Chat component which handles everything
|
// Handle "new" chat case - just use Chat component which handles everything
|
||||||
if (chatId === "new") {
|
if (chatId === "new") {
|
||||||
@@ -38,6 +120,14 @@ function RouteComponent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (chatId === "launch") {
|
||||||
|
return (
|
||||||
|
<SidebarLayout sidebar={<ChatSidebar currentChatId={chatId} />}>
|
||||||
|
<LaunchCommands />
|
||||||
|
</SidebarLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle existing chat case
|
// Handle existing chat case
|
||||||
if (chatLoading) {
|
if (chatLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
import { getSettings } from "@/api";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
beforeLoad: () => {
|
beforeLoad: async ({ context }) => {
|
||||||
|
const settingsData = await context.queryClient.ensureQueryData({
|
||||||
|
queryKey: ["settings"],
|
||||||
|
queryFn: getSettings,
|
||||||
|
});
|
||||||
|
const chatId =
|
||||||
|
settingsData?.settings?.LastHomeView === "chat" ? "new" : "launch";
|
||||||
|
|
||||||
throw redirect({
|
throw redirect({
|
||||||
to: "/c/$chatId",
|
to: "/c/$chatId",
|
||||||
params: { chatId: "new" },
|
params: { chatId },
|
||||||
mask: {
|
mask: {
|
||||||
to: "/",
|
to: "/",
|
||||||
},
|
},
|
||||||
|
|||||||
57
app/ui/app/src/utils/clipboard.test.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import { copyTextToClipboard } from "./clipboard";
|
||||||
|
|
||||||
|
describe("copyTextToClipboard", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies via Clipboard API when available", async () => {
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.stubGlobal("navigator", {
|
||||||
|
clipboard: {
|
||||||
|
writeText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const copied = await copyTextToClipboard("ollama launch claude");
|
||||||
|
|
||||||
|
expect(copied).toBe(true);
|
||||||
|
expect(writeText).toHaveBeenCalledWith("ollama launch claude");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to execCommand when Clipboard API fails", async () => {
|
||||||
|
const writeText = vi.fn().mockRejectedValue(new Error("not allowed"));
|
||||||
|
vi.stubGlobal("navigator", {
|
||||||
|
clipboard: {
|
||||||
|
writeText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const textarea = {
|
||||||
|
value: "",
|
||||||
|
setAttribute: vi.fn(),
|
||||||
|
style: {} as Record<string, string>,
|
||||||
|
focus: vi.fn(),
|
||||||
|
select: vi.fn(),
|
||||||
|
};
|
||||||
|
const appendChild = vi.fn();
|
||||||
|
const removeChild = vi.fn();
|
||||||
|
const execCommand = vi.fn().mockReturnValue(true);
|
||||||
|
vi.stubGlobal("document", {
|
||||||
|
createElement: vi.fn().mockReturnValue(textarea),
|
||||||
|
body: {
|
||||||
|
appendChild,
|
||||||
|
removeChild,
|
||||||
|
},
|
||||||
|
execCommand,
|
||||||
|
});
|
||||||
|
|
||||||
|
const copied = await copyTextToClipboard("ollama launch openclaw");
|
||||||
|
|
||||||
|
expect(copied).toBe(true);
|
||||||
|
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||||
|
expect(appendChild).toHaveBeenCalled();
|
||||||
|
expect(removeChild).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
30
app/ui/app/src/utils/clipboard.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch (clipboardError) {
|
||||||
|
console.error(
|
||||||
|
"Clipboard API failed, falling back to execCommand",
|
||||||
|
clipboardError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.setAttribute("readonly", "true");
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.left = "-9999px";
|
||||||
|
textarea.style.opacity = "0";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
|
||||||
|
const copied = document.execCommand("copy");
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
return copied;
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.error("Fallback copy failed", fallbackError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,13 +29,15 @@ describe("fileValidation", () => {
|
|||||||
expect(result.valid).toBe(true);
|
expect(result.valid).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should reject WebP images when vision capability is disabled", () => {
|
it("should accept images regardless of vision capability", () => {
|
||||||
|
// Vision capability check is handled at the UI layer (ChatForm),
|
||||||
|
// not at validation time, so users can switch models without
|
||||||
|
// needing to re-upload files.
|
||||||
const file = createMockFile("test.webp", 1024, "image/webp");
|
const file = createMockFile("test.webp", 1024, "image/webp");
|
||||||
const result = validateFile(file, {
|
const result = validateFile(file, {
|
||||||
hasVisionCapability: false,
|
hasVisionCapability: false,
|
||||||
});
|
});
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(true);
|
||||||
expect(result.error).toBe("This model does not support images");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should accept PNG images when vision capability is enabled", () => {
|
it("should accept PNG images when vision capability is enabled", () => {
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ export function validateFile(
|
|||||||
const {
|
const {
|
||||||
maxFileSize = 10,
|
maxFileSize = 10,
|
||||||
allowedExtensions = [...TEXT_FILE_EXTENSIONS, ...IMAGE_EXTENSIONS],
|
allowedExtensions = [...TEXT_FILE_EXTENSIONS, ...IMAGE_EXTENSIONS],
|
||||||
hasVisionCapability = false,
|
|
||||||
customValidator,
|
customValidator,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
@@ -83,10 +82,6 @@ export function validateFile(
|
|||||||
return { valid: false, error: "File type not supported" };
|
return { valid: false, error: "File type not supported" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IMAGE_EXTENSIONS.includes(fileExtension) && !hasVisionCapability) {
|
|
||||||
return { valid: false, error: "This model does not support images" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// File size validation
|
// File size validation
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
return { valid: false, error: "File too large" };
|
return { valid: false, error: "File too large" };
|
||||||
|
|||||||
@@ -2,27 +2,28 @@ import { Model } from "@/gotypes";
|
|||||||
|
|
||||||
// Featured models list (in priority order)
|
// Featured models list (in priority order)
|
||||||
export const FEATURED_MODELS = [
|
export const FEATURED_MODELS = [
|
||||||
|
"kimi-k2.5:cloud",
|
||||||
|
"glm-5:cloud",
|
||||||
|
"minimax-m2.7:cloud",
|
||||||
|
"gemma4:31b-cloud",
|
||||||
|
"qwen3.5:397b-cloud",
|
||||||
"gpt-oss:120b-cloud",
|
"gpt-oss:120b-cloud",
|
||||||
"gpt-oss:20b-cloud",
|
"gpt-oss:20b-cloud",
|
||||||
"deepseek-v3.1:671b-cloud",
|
"deepseek-v3.1:671b-cloud",
|
||||||
"qwen3-coder:480b-cloud",
|
|
||||||
"qwen3-vl:235b-cloud",
|
|
||||||
"minimax-m2:cloud",
|
|
||||||
"glm-4.6:cloud",
|
|
||||||
"gpt-oss:120b",
|
"gpt-oss:120b",
|
||||||
"gpt-oss:20b",
|
"gpt-oss:20b",
|
||||||
"gemma3:27b",
|
"gemma4:31b",
|
||||||
"gemma3:12b",
|
"gemma4:26b",
|
||||||
"gemma3:4b",
|
"gemma4:e4b",
|
||||||
"gemma3:1b",
|
"gemma4:e2b",
|
||||||
"deepseek-r1:8b",
|
"deepseek-r1:8b",
|
||||||
"qwen3-coder:30b",
|
"qwen3-coder:30b",
|
||||||
"qwen3-vl:30b",
|
"qwen3-vl:30b",
|
||||||
"qwen3-vl:8b",
|
"qwen3-vl:8b",
|
||||||
"qwen3-vl:4b",
|
"qwen3-vl:4b",
|
||||||
"qwen3:30b",
|
"qwen3.5:27b",
|
||||||
"qwen3:8b",
|
"qwen3.5:9b",
|
||||||
"qwen3:4b",
|
"qwen3.5:4b",
|
||||||
];
|
];
|
||||||
|
|
||||||
function alphabeticalSort(a: Model, b: Model): number {
|
function alphabeticalSort(a: Model, b: Model): number {
|
||||||
|
|||||||
@@ -133,9 +133,8 @@ type Error struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ModelUpstreamResponse struct {
|
type ModelUpstreamResponse struct {
|
||||||
Digest string `json:"digest,omitempty"`
|
Stale bool `json:"stale"`
|
||||||
PushTime int64 `json:"pushTime"`
|
Error string `json:"error,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serializable data for the browser state
|
// Serializable data for the browser state
|
||||||
|
|||||||
37
app/ui/ui.go
@@ -32,6 +32,7 @@ import (
|
|||||||
"github.com/ollama/ollama/app/version"
|
"github.com/ollama/ollama/app/version"
|
||||||
ollamaAuth "github.com/ollama/ollama/auth"
|
ollamaAuth "github.com/ollama/ollama/auth"
|
||||||
"github.com/ollama/ollama/envconfig"
|
"github.com/ollama/ollama/envconfig"
|
||||||
|
"github.com/ollama/ollama/manifest"
|
||||||
"github.com/ollama/ollama/types/model"
|
"github.com/ollama/ollama/types/model"
|
||||||
_ "github.com/tkrajina/typescriptify-golang-structs/typescriptify"
|
_ "github.com/tkrajina/typescriptify-golang-structs/typescriptify"
|
||||||
)
|
)
|
||||||
@@ -193,7 +194,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
if CORS() {
|
if CORS() {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, User-Agent, Accept, X-Requested-With")
|
||||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
|
||||||
// Handle preflight requests
|
// Handle preflight requests
|
||||||
@@ -318,7 +319,7 @@ func (s *Server) handleError(w http.ResponseWriter, e error) {
|
|||||||
if CORS() {
|
if CORS() {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, User-Agent, Accept, X-Requested-With")
|
||||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,8 +342,18 @@ func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
|
|||||||
|
|
||||||
// httpClient returns an HTTP client that automatically adds the User-Agent header
|
// httpClient returns an HTTP client that automatically adds the User-Agent header
|
||||||
func (s *Server) httpClient() *http.Client {
|
func (s *Server) httpClient() *http.Client {
|
||||||
|
return userAgentHTTPClient(10 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// inferenceClient uses almost the same HTTP client, but without a timeout so
|
||||||
|
// long requests aren't truncated
|
||||||
|
func (s *Server) inferenceClient() *api.Client {
|
||||||
|
return api.NewClient(envconfig.Host(), userAgentHTTPClient(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func userAgentHTTPClient(timeout time.Duration) *http.Client {
|
||||||
return &http.Client{
|
return &http.Client{
|
||||||
Timeout: 10 * time.Second,
|
Timeout: timeout,
|
||||||
Transport: &userAgentTransport{
|
Transport: &userAgentTransport{
|
||||||
base: http.DefaultTransport,
|
base: http.DefaultTransport,
|
||||||
},
|
},
|
||||||
@@ -720,11 +731,7 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) error {
|
|||||||
_, cancelLoading := context.WithCancel(ctx)
|
_, cancelLoading := context.WithCancel(ctx)
|
||||||
loading := false
|
loading := false
|
||||||
|
|
||||||
c, err := api.ClientFromEnvironment()
|
c := s.inferenceClient()
|
||||||
if err != nil {
|
|
||||||
cancelLoading()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the model exists locally by trying to show it
|
// Check if the model exists locally by trying to show it
|
||||||
// TODO (jmorganca): skip this round trip and instead just act
|
// TODO (jmorganca): skip this round trip and instead just act
|
||||||
@@ -1572,9 +1579,18 @@ func (s *Server) modelUpstream(w http.ResponseWriter, r *http.Request) error {
|
|||||||
return json.NewEncoder(w).Encode(response)
|
return json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
n := model.ParseName(req.Model)
|
||||||
|
stale := true
|
||||||
|
if m, err := manifest.ParseNamedManifest(n); err == nil {
|
||||||
|
if m.Digest() == digest {
|
||||||
|
stale = false
|
||||||
|
} else if pushTime > 0 && m.FileInfo().ModTime().Unix() >= pushTime {
|
||||||
|
stale = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
response := responses.ModelUpstreamResponse{
|
response := responses.ModelUpstreamResponse{
|
||||||
Digest: digest,
|
Stale: stale,
|
||||||
PushTime: pushTime,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -1672,7 +1688,6 @@ func supportsBrowserTools(model string) bool {
|
|||||||
return strings.HasPrefix(strings.ToLower(model), "gpt-oss")
|
return strings.HasPrefix(strings.ToLower(model), "gpt-oss")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// buildChatRequest converts store.Chat to api.ChatRequest
|
// buildChatRequest converts store.Chat to api.ChatRequest
|
||||||
func (s *Server) buildChatRequest(chat *store.Chat, model string, think any, availableTools []map[string]any) (*api.ChatRequest, error) {
|
func (s *Server) buildChatRequest(chat *store.Chat, model string, think any, availableTools []map[string]any) (*api.ChatRequest, error) {
|
||||||
var msgs []api.Message
|
var msgs []api.Message
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
"github.com/ollama/ollama/app/store"
|
"github.com/ollama/ollama/app/store"
|
||||||
"github.com/ollama/ollama/app/updater"
|
"github.com/ollama/ollama/app/updater"
|
||||||
)
|
)
|
||||||
@@ -526,6 +527,33 @@ func TestUserAgentTransport(t *testing.T) {
|
|||||||
t.Logf("User-Agent transport successfully set: %s", receivedUA)
|
t.Logf("User-Agent transport successfully set: %s", receivedUA)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInferenceClientUsesUserAgent(t *testing.T) {
|
||||||
|
var gotUserAgent atomic.Value
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotUserAgent.Store(r.Header.Get("User-Agent"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
t.Setenv("OLLAMA_HOST", ts.URL)
|
||||||
|
|
||||||
|
server := &Server{}
|
||||||
|
client := server.inferenceClient()
|
||||||
|
|
||||||
|
_, err := client.Show(context.Background(), &api.ShowRequest{Model: "test"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("show request failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
receivedUA, _ := gotUserAgent.Load().(string)
|
||||||
|
expectedUA := userAgent()
|
||||||
|
|
||||||
|
if receivedUA != expectedUA {
|
||||||
|
t.Errorf("User-Agent mismatch\nExpected: %s\nReceived: %s", expectedUA, receivedUA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSupportsBrowserTools(t *testing.T) {
|
func TestSupportsBrowserTools(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
model string
|
model string
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type flagOptions struct {
|
|||||||
verbose *bool
|
verbose *bool
|
||||||
warmup *int
|
warmup *int
|
||||||
promptTokens *int
|
promptTokens *int
|
||||||
|
numCtx *int
|
||||||
}
|
}
|
||||||
|
|
||||||
type Metrics struct {
|
type Metrics struct {
|
||||||
@@ -48,6 +49,7 @@ type ModelInfo struct {
|
|||||||
Family string
|
Family string
|
||||||
SizeBytes int64
|
SizeBytes int64
|
||||||
VRAMBytes int64
|
VRAMBytes int64
|
||||||
|
NumCtx int64
|
||||||
}
|
}
|
||||||
|
|
||||||
const DefaultPrompt = `Please write a descriptive story about a llama named Alonso who grows up to be President of the Land of Llamas. Include details about Alonso's childhood, adolescent years, and how he grew up to be a political mover and shaker. Write the story with a sense of whimsy.`
|
const DefaultPrompt = `Please write a descriptive story about a llama named Alonso who grows up to be President of the Land of Llamas. Include details about Alonso's childhood, adolescent years, and how he grew up to be a political mover and shaker. Write the story with a sense of whimsy.`
|
||||||
@@ -64,9 +66,12 @@ var promptWordList = []string{
|
|||||||
"old", "stone", "bridge", "that", "crosses", "winding", "river",
|
"old", "stone", "bridge", "that", "crosses", "winding", "river",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tokensPerWord is the calibrated ratio of tokens to words for the current model.
|
||||||
|
// Initialized with a heuristic, then updated during warmup based on actual tokenization.
|
||||||
|
var tokensPerWord = 1.3
|
||||||
|
|
||||||
func generatePromptForTokenCount(targetTokens int, epoch int) string {
|
func generatePromptForTokenCount(targetTokens int, epoch int) string {
|
||||||
// ~1.3 tokens per word heuristic
|
targetWords := int(float64(targetTokens) / tokensPerWord)
|
||||||
targetWords := int(float64(targetTokens) / 1.3)
|
|
||||||
if targetWords < 1 {
|
if targetWords < 1 {
|
||||||
targetWords = 1
|
targetWords = 1
|
||||||
}
|
}
|
||||||
@@ -81,6 +86,17 @@ func generatePromptForTokenCount(targetTokens int, epoch int) string {
|
|||||||
return strings.Join(words, " ")
|
return strings.Join(words, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// calibratePromptTokens adjusts tokensPerWord based on actual tokenization from a warmup run.
|
||||||
|
func calibratePromptTokens(targetTokens, actualTokens, wordCount int) {
|
||||||
|
if actualTokens <= 0 || wordCount <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokensPerWord = float64(actualTokens) / float64(wordCount)
|
||||||
|
newWords := int(float64(targetTokens) / tokensPerWord)
|
||||||
|
fmt.Fprintf(os.Stderr, "bench: calibrated %.2f tokens/word (target=%d, got=%d, words=%d → %d)\n",
|
||||||
|
tokensPerWord, targetTokens, actualTokens, wordCount, newWords)
|
||||||
|
}
|
||||||
|
|
||||||
func buildGenerateRequest(model string, fOpt flagOptions, imgData api.ImageData, epoch int) *api.GenerateRequest {
|
func buildGenerateRequest(model string, fOpt flagOptions, imgData api.ImageData, epoch int) *api.GenerateRequest {
|
||||||
options := make(map[string]interface{})
|
options := make(map[string]interface{})
|
||||||
if *fOpt.maxTokens > 0 {
|
if *fOpt.maxTokens > 0 {
|
||||||
@@ -90,6 +106,9 @@ func buildGenerateRequest(model string, fOpt flagOptions, imgData api.ImageData,
|
|||||||
if fOpt.seed != nil && *fOpt.seed > 0 {
|
if fOpt.seed != nil && *fOpt.seed > 0 {
|
||||||
options["seed"] = *fOpt.seed
|
options["seed"] = *fOpt.seed
|
||||||
}
|
}
|
||||||
|
if fOpt.numCtx != nil && *fOpt.numCtx > 0 {
|
||||||
|
options["num_ctx"] = *fOpt.numCtx
|
||||||
|
}
|
||||||
|
|
||||||
var keepAliveDuration *api.Duration
|
var keepAliveDuration *api.Duration
|
||||||
if *fOpt.keepAlive > 0 {
|
if *fOpt.keepAlive > 0 {
|
||||||
@@ -146,7 +165,6 @@ func fetchMemoryUsage(ctx context.Context, client *api.Client, model string) (si
|
|||||||
return m.Size, m.SizeVRAM
|
return m.Size, m.SizeVRAM
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Try prefix match (model names may include :latest or tags)
|
|
||||||
for _, m := range resp.Models {
|
for _, m := range resp.Models {
|
||||||
if strings.HasPrefix(m.Name, model) || strings.HasPrefix(m.Model, model) {
|
if strings.HasPrefix(m.Name, model) || strings.HasPrefix(m.Model, model) {
|
||||||
return m.Size, m.SizeVRAM
|
return m.Size, m.SizeVRAM
|
||||||
@@ -155,6 +173,19 @@ func fetchMemoryUsage(ctx context.Context, client *api.Client, model string) (si
|
|||||||
return 0, 0
|
return 0, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fetchContextLength(ctx context.Context, client *api.Client, model string) int64 {
|
||||||
|
resp, err := client.ListRunning(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for _, m := range resp.Models {
|
||||||
|
if m.Name == model || m.Model == model || strings.HasPrefix(m.Name, model) || strings.HasPrefix(m.Model, model) {
|
||||||
|
return int64(m.ContextLength)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func outputFormatHeader(w io.Writer, format string, verbose bool) {
|
func outputFormatHeader(w io.Writer, format string, verbose bool) {
|
||||||
switch format {
|
switch format {
|
||||||
case "benchstat":
|
case "benchstat":
|
||||||
@@ -177,8 +208,12 @@ func outputModelInfo(w io.Writer, format string, info ModelInfo) {
|
|||||||
if info.SizeBytes > 0 {
|
if info.SizeBytes > 0 {
|
||||||
memStr = fmt.Sprintf(" | Size: %d | VRAM: %d", info.SizeBytes, info.VRAMBytes)
|
memStr = fmt.Sprintf(" | Size: %d | VRAM: %d", info.SizeBytes, info.VRAMBytes)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "# Model: %s | Params: %s | Quant: %s | Family: %s%s\n",
|
ctxStr := ""
|
||||||
info.Name, params, quant, family, memStr)
|
if info.NumCtx > 0 {
|
||||||
|
ctxStr = fmt.Sprintf(" | NumCtx: %d", info.NumCtx)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "# Model: %s | Params: %s | Quant: %s | Family: %s%s%s\n",
|
||||||
|
info.Name, params, quant, family, memStr, ctxStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func OutputMetrics(w io.Writer, format string, metrics []Metrics, verbose bool) {
|
func OutputMetrics(w io.Writer, format string, metrics []Metrics, verbose bool) {
|
||||||
@@ -276,21 +311,38 @@ func BenchmarkModel(fOpt flagOptions) error {
|
|||||||
req := buildGenerateRequest(model, fOpt, imgData, -(i + 1))
|
req := buildGenerateRequest(model, fOpt, imgData, -(i + 1))
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*fOpt.timeout)*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*fOpt.timeout)*time.Second)
|
||||||
|
|
||||||
|
var warmupMetrics *api.Metrics
|
||||||
err = client.Generate(ctx, req, func(resp api.GenerateResponse) error {
|
err = client.Generate(ctx, req, func(resp api.GenerateResponse) error {
|
||||||
|
if resp.Done {
|
||||||
|
warmupMetrics = &resp.Metrics
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "WARNING: Warmup %d/%d for %s failed: %v\n", i+1, *fOpt.warmup, model, err)
|
fmt.Fprintf(os.Stderr, "WARNING: Warmup %d/%d for %s failed: %v\n", i+1, *fOpt.warmup, model, err)
|
||||||
} else if *fOpt.debug {
|
} else {
|
||||||
fmt.Fprintf(os.Stderr, "Warmup %d/%d for %s complete\n", i+1, *fOpt.warmup, model)
|
if *fOpt.debug {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warmup %d/%d for %s complete\n", i+1, *fOpt.warmup, model)
|
||||||
|
}
|
||||||
|
// Calibrate prompt token count on last warmup run
|
||||||
|
if i == *fOpt.warmup-1 && *fOpt.promptTokens > 0 && warmupMetrics != nil {
|
||||||
|
prompt := generatePromptForTokenCount(*fOpt.promptTokens, -(i + 1))
|
||||||
|
wordCount := len(strings.Fields(prompt))
|
||||||
|
calibratePromptTokens(*fOpt.promptTokens, warmupMetrics.PromptEvalCount, wordCount)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch memory usage once after warmup (model is loaded and stable)
|
// Fetch memory/context info once after warmup (model is loaded and stable)
|
||||||
memCtx, memCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
memCtx, memCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
info.SizeBytes, info.VRAMBytes = fetchMemoryUsage(memCtx, client, model)
|
info.SizeBytes, info.VRAMBytes = fetchMemoryUsage(memCtx, client, model)
|
||||||
|
if fOpt.numCtx != nil && *fOpt.numCtx > 0 {
|
||||||
|
info.NumCtx = int64(*fOpt.numCtx)
|
||||||
|
} else {
|
||||||
|
info.NumCtx = fetchContextLength(memCtx, client, model)
|
||||||
|
}
|
||||||
memCancel()
|
memCancel()
|
||||||
|
|
||||||
outputModelInfo(out, *fOpt.format, info)
|
outputModelInfo(out, *fOpt.format, info)
|
||||||
@@ -479,6 +531,7 @@ func main() {
|
|||||||
debug: flag.Bool("debug", false, "Show debug information"),
|
debug: flag.Bool("debug", false, "Show debug information"),
|
||||||
warmup: flag.Int("warmup", 1, "Number of warmup requests before timing"),
|
warmup: flag.Int("warmup", 1, "Number of warmup requests before timing"),
|
||||||
promptTokens: flag.Int("prompt-tokens", 0, "Generate prompt targeting ~N tokens (0 = use -p prompt)"),
|
promptTokens: flag.Int("prompt-tokens", 0, "Generate prompt targeting ~N tokens (0 = use -p prompt)"),
|
||||||
|
numCtx: flag.Int("num-ctx", 0, "Context size (0 = server default)"),
|
||||||
}
|
}
|
||||||
|
|
||||||
flag.Usage = func() {
|
flag.Usage = func() {
|
||||||
|
|||||||
105
cmd/cmd.go
@@ -54,7 +54,6 @@ import (
|
|||||||
"github.com/ollama/ollama/types/syncmap"
|
"github.com/ollama/ollama/types/syncmap"
|
||||||
"github.com/ollama/ollama/version"
|
"github.com/ollama/ollama/version"
|
||||||
xcmd "github.com/ollama/ollama/x/cmd"
|
xcmd "github.com/ollama/ollama/x/cmd"
|
||||||
"github.com/ollama/ollama/x/create"
|
|
||||||
xcreateclient "github.com/ollama/ollama/x/create/client"
|
xcreateclient "github.com/ollama/ollama/x/create/client"
|
||||||
"github.com/ollama/ollama/x/imagegen"
|
"github.com/ollama/ollama/x/imagegen"
|
||||||
)
|
)
|
||||||
@@ -93,13 +92,7 @@ func init() {
|
|||||||
return userName, err
|
return userName, err
|
||||||
}
|
}
|
||||||
|
|
||||||
launch.DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
launch.DefaultConfirmPrompt = tui.RunConfirmWithOptions
|
||||||
ok, err := tui.RunConfirm(prompt)
|
|
||||||
if errors.Is(err, tui.ErrCancelled) {
|
|
||||||
return false, launch.ErrCancelled
|
|
||||||
}
|
|
||||||
return ok, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConnectInstructions = "If your browser did not open, navigate to:\n %s\n\n"
|
const ConnectInstructions = "If your browser did not open, navigate to:\n %s\n\n"
|
||||||
@@ -164,11 +157,13 @@ func CreateHandler(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for --experimental flag for safetensors model creation
|
// Check for --experimental flag for safetensors model creation
|
||||||
|
// This gates both safetensors LLM and imagegen model creation
|
||||||
experimental, _ := cmd.Flags().GetBool("experimental")
|
experimental, _ := cmd.Flags().GetBool("experimental")
|
||||||
if experimental {
|
if experimental {
|
||||||
if !isLocalhost() {
|
if !isLocalhost() {
|
||||||
return errors.New("remote safetensor model creation not yet supported")
|
return errors.New("remote safetensor model creation not yet supported")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Modelfile content - either from -f flag or default to "FROM ."
|
// Get Modelfile content - either from -f flag or default to "FROM ."
|
||||||
var reader io.Reader
|
var reader io.Reader
|
||||||
filename, err := getModelfileName(cmd)
|
filename, err := getModelfileName(cmd)
|
||||||
@@ -211,23 +206,12 @@ func CreateHandler(cmd *cobra.Command, args []string) error {
|
|||||||
}, p)
|
}, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Standard Modelfile + API path
|
||||||
var reader io.Reader
|
var reader io.Reader
|
||||||
|
|
||||||
filename, err := getModelfileName(cmd)
|
filename, err := getModelfileName(cmd)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
if filename == "" {
|
if filename == "" {
|
||||||
// No Modelfile found - check if current directory is an image gen model
|
|
||||||
if create.IsTensorModelDir(".") {
|
|
||||||
if !isLocalhost() {
|
|
||||||
return errors.New("remote safetensor model creation not yet supported")
|
|
||||||
}
|
|
||||||
quantize, _ := cmd.Flags().GetString("quantize")
|
|
||||||
return xcreateclient.CreateModel(xcreateclient.CreateOptions{
|
|
||||||
ModelName: modelName,
|
|
||||||
ModelDir: ".",
|
|
||||||
Quantize: quantize,
|
|
||||||
}, p)
|
|
||||||
}
|
|
||||||
reader = strings.NewReader("FROM .\n")
|
reader = strings.NewReader("FROM .\n")
|
||||||
} else {
|
} else {
|
||||||
return errModelfileNotFound
|
return errModelfileNotFound
|
||||||
@@ -695,7 +679,8 @@ func RunHandler(cmd *cobra.Command, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
opts.MultiModal = slices.Contains(info.Capabilities, model.CapabilityVision)
|
audioCapable := slices.Contains(info.Capabilities, model.CapabilityAudio)
|
||||||
|
opts.MultiModal = slices.Contains(info.Capabilities, model.CapabilityVision) || audioCapable
|
||||||
|
|
||||||
// TODO: remove the projector info and vision info checks below,
|
// TODO: remove the projector info and vision info checks below,
|
||||||
// these are left in for backwards compatibility with older servers
|
// these are left in for backwards compatibility with older servers
|
||||||
@@ -710,7 +695,7 @@ func RunHandler(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
opts.ParentModel = info.Details.ParentModel
|
applyShowResponseToRunOptions(&opts, info)
|
||||||
|
|
||||||
// Check if this is an embedding model
|
// Check if this is an embedding model
|
||||||
isEmbeddingModel := slices.Contains(info.Capabilities, model.CapabilityEmbedding)
|
isEmbeddingModel := slices.Contains(info.Capabilities, model.CapabilityEmbedding)
|
||||||
@@ -1426,23 +1411,30 @@ func PullHandler(cmd *cobra.Command, args []string) error {
|
|||||||
type generateContextKey string
|
type generateContextKey string
|
||||||
|
|
||||||
type runOptions struct {
|
type runOptions struct {
|
||||||
Model string
|
Model string
|
||||||
ParentModel string
|
ParentModel string
|
||||||
Prompt string
|
LoadedMessages []api.Message
|
||||||
Messages []api.Message
|
Prompt string
|
||||||
WordWrap bool
|
Messages []api.Message
|
||||||
Format string
|
WordWrap bool
|
||||||
System string
|
Format string
|
||||||
Images []api.ImageData
|
System string
|
||||||
Options map[string]any
|
Images []api.ImageData
|
||||||
MultiModal bool
|
Options map[string]any
|
||||||
KeepAlive *api.Duration
|
MultiModal bool
|
||||||
Think *api.ThinkValue
|
KeepAlive *api.Duration
|
||||||
HideThinking bool
|
Think *api.ThinkValue
|
||||||
ShowConnect bool
|
HideThinking bool
|
||||||
|
ShowConnect bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r runOptions) Copy() runOptions {
|
func (r runOptions) Copy() runOptions {
|
||||||
|
var loadedMessages []api.Message
|
||||||
|
if r.LoadedMessages != nil {
|
||||||
|
loadedMessages = make([]api.Message, len(r.LoadedMessages))
|
||||||
|
copy(loadedMessages, r.LoadedMessages)
|
||||||
|
}
|
||||||
|
|
||||||
var messages []api.Message
|
var messages []api.Message
|
||||||
if r.Messages != nil {
|
if r.Messages != nil {
|
||||||
messages = make([]api.Message, len(r.Messages))
|
messages = make([]api.Message, len(r.Messages))
|
||||||
@@ -1470,23 +1462,29 @@ func (r runOptions) Copy() runOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return runOptions{
|
return runOptions{
|
||||||
Model: r.Model,
|
Model: r.Model,
|
||||||
ParentModel: r.ParentModel,
|
ParentModel: r.ParentModel,
|
||||||
Prompt: r.Prompt,
|
LoadedMessages: loadedMessages,
|
||||||
Messages: messages,
|
Prompt: r.Prompt,
|
||||||
WordWrap: r.WordWrap,
|
Messages: messages,
|
||||||
Format: r.Format,
|
WordWrap: r.WordWrap,
|
||||||
System: r.System,
|
Format: r.Format,
|
||||||
Images: images,
|
System: r.System,
|
||||||
Options: opts,
|
Images: images,
|
||||||
MultiModal: r.MultiModal,
|
Options: opts,
|
||||||
KeepAlive: r.KeepAlive,
|
MultiModal: r.MultiModal,
|
||||||
Think: think,
|
KeepAlive: r.KeepAlive,
|
||||||
HideThinking: r.HideThinking,
|
Think: think,
|
||||||
ShowConnect: r.ShowConnect,
|
HideThinking: r.HideThinking,
|
||||||
|
ShowConnect: r.ShowConnect,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyShowResponseToRunOptions(opts *runOptions, info *api.ShowResponse) {
|
||||||
|
opts.ParentModel = info.Details.ParentModel
|
||||||
|
opts.LoadedMessages = slices.Clone(info.Messages)
|
||||||
|
}
|
||||||
|
|
||||||
type displayResponseState struct {
|
type displayResponseState struct {
|
||||||
lineLength int
|
lineLength int
|
||||||
wordBuffer string
|
wordBuffer string
|
||||||
@@ -1494,6 +1492,9 @@ type displayResponseState struct {
|
|||||||
|
|
||||||
func displayResponse(content string, wordWrap bool, state *displayResponseState) {
|
func displayResponse(content string, wordWrap bool, state *displayResponseState) {
|
||||||
termWidth, _, _ := term.GetSize(int(os.Stdout.Fd()))
|
termWidth, _, _ := term.GetSize(int(os.Stdout.Fd()))
|
||||||
|
if termWidth == 0 {
|
||||||
|
termWidth = 80
|
||||||
|
}
|
||||||
if wordWrap && termWidth >= 10 {
|
if wordWrap && termWidth >= 10 {
|
||||||
for _, ch := range content {
|
for _, ch := range content {
|
||||||
if state.lineLength+1 > termWidth-5 {
|
if state.lineLength+1 > termWidth-5 {
|
||||||
@@ -2065,6 +2066,10 @@ func runLauncherAction(cmd *cobra.Command, action tui.TUIAction, deps launcherDe
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return true, fmt.Errorf("launching %s: %w", action.Integration, err)
|
return true, fmt.Errorf("launching %s: %w", action.Integration, err)
|
||||||
}
|
}
|
||||||
|
// VS Code is a GUI app — exit the TUI loop after launching
|
||||||
|
if action.Integration == "vscode" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
default:
|
default:
|
||||||
return false, fmt.Errorf("unknown launcher action: %d", action.Kind)
|
return false, fmt.Errorf("unknown launcher action: %d", action.Kind)
|
||||||
|
|||||||
@@ -209,6 +209,43 @@ func TestRunLauncherAction_RunModelContinuesAfterCancellation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunLauncherAction_VSCodeExitsTUILoop(t *testing.T) {
|
||||||
|
setCmdTestHome(t, t.TempDir())
|
||||||
|
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.SetContext(context.Background())
|
||||||
|
|
||||||
|
// VS Code should exit the TUI loop (return false) after a successful launch.
|
||||||
|
continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "vscode"}, launcherDeps{
|
||||||
|
resolveRunModel: unexpectedRunModelResolution(t),
|
||||||
|
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
runModel: unexpectedModelLaunch(t),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil error, got %v", err)
|
||||||
|
}
|
||||||
|
if continueLoop {
|
||||||
|
t.Fatal("expected vscode launch to exit the TUI loop (return false)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other integrations should continue the TUI loop (return true).
|
||||||
|
continueLoop, err = runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "claude"}, launcherDeps{
|
||||||
|
resolveRunModel: unexpectedRunModelResolution(t),
|
||||||
|
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
runModel: unexpectedModelLaunch(t),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil error, got %v", err)
|
||||||
|
}
|
||||||
|
if !continueLoop {
|
||||||
|
t.Fatal("expected non-vscode integration to continue the TUI loop (return true)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunLauncherAction_IntegrationContinuesAfterCancellation(t *testing.T) {
|
func TestRunLauncherAction_IntegrationContinuesAfterCancellation(t *testing.T) {
|
||||||
setCmdTestHome(t, t.TempDir())
|
setCmdTestHome(t, t.TempDir())
|
||||||
|
|
||||||
|
|||||||
101
cmd/cmd_test.go
@@ -301,7 +301,7 @@ Weigh anchor!
|
|||||||
ParameterSize: "7B",
|
ParameterSize: "7B",
|
||||||
QuantizationLevel: "FP16",
|
QuantizationLevel: "FP16",
|
||||||
},
|
},
|
||||||
Requires: "0.14.0",
|
Requires: "0.19.0",
|
||||||
}, false, &b); err != nil {
|
}, false, &b); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -310,10 +310,17 @@ Weigh anchor!
|
|||||||
architecture test
|
architecture test
|
||||||
parameters 7B
|
parameters 7B
|
||||||
quantization FP16
|
quantization FP16
|
||||||
requires 0.14.0
|
requires 0.19.0
|
||||||
|
|
||||||
`
|
`
|
||||||
if diff := cmp.Diff(expect, b.String()); diff != "" {
|
trimLinePadding := func(s string) string {
|
||||||
|
lines := strings.Split(s, "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
lines[i] = strings.TrimRight(line, " \t\r")
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
if diff := cmp.Diff(trimLinePadding(expect), trimLinePadding(b.String())); diff != "" {
|
||||||
t.Errorf("unexpected output (-want +got):\n%s", diff)
|
t.Errorf("unexpected output (-want +got):\n%s", diff)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1648,6 +1655,24 @@ func TestNewCreateRequest(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"loaded messages are preserved when saving",
|
||||||
|
"newmodel",
|
||||||
|
runOptions{
|
||||||
|
Model: "mymodel",
|
||||||
|
ParentModel: "parentmodel",
|
||||||
|
LoadedMessages: []api.Message{{Role: "assistant", Content: "loaded"}},
|
||||||
|
Messages: []api.Message{{Role: "user", Content: "new"}},
|
||||||
|
},
|
||||||
|
&api.CreateRequest{
|
||||||
|
From: "parentmodel",
|
||||||
|
Model: "newmodel",
|
||||||
|
Messages: []api.Message{
|
||||||
|
{Role: "assistant", Content: "loaded"},
|
||||||
|
{Role: "user", Content: "new"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -1660,15 +1685,43 @@ func TestNewCreateRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyShowResponseToRunOptions(t *testing.T) {
|
||||||
|
opts := runOptions{}
|
||||||
|
info := &api.ShowResponse{
|
||||||
|
Details: api.ModelDetails{
|
||||||
|
ParentModel: "parentmodel",
|
||||||
|
},
|
||||||
|
Messages: []api.Message{
|
||||||
|
{Role: "assistant", Content: "loaded"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
applyShowResponseToRunOptions(&opts, info)
|
||||||
|
|
||||||
|
if opts.ParentModel != "parentmodel" {
|
||||||
|
t.Fatalf("ParentModel = %q, want %q", opts.ParentModel, "parentmodel")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cmp.Equal(opts.LoadedMessages, info.Messages) {
|
||||||
|
t.Fatalf("LoadedMessages = %#v, want %#v", opts.LoadedMessages, info.Messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Messages[0].Content = "modified"
|
||||||
|
if opts.LoadedMessages[0].Content == "modified" {
|
||||||
|
t.Fatal("LoadedMessages should be copied independently from ShowResponse")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunOptions_Copy(t *testing.T) {
|
func TestRunOptions_Copy(t *testing.T) {
|
||||||
// Setup test data
|
// Setup test data
|
||||||
originalKeepAlive := &api.Duration{Duration: 5 * time.Minute}
|
originalKeepAlive := &api.Duration{Duration: 5 * time.Minute}
|
||||||
originalThink := &api.ThinkValue{Value: "test reasoning"}
|
originalThink := &api.ThinkValue{Value: "test reasoning"}
|
||||||
|
|
||||||
original := runOptions{
|
original := runOptions{
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
ParentModel: "parent-model",
|
ParentModel: "parent-model",
|
||||||
Prompt: "test prompt",
|
LoadedMessages: []api.Message{{Role: "assistant", Content: "loaded hello"}},
|
||||||
|
Prompt: "test prompt",
|
||||||
Messages: []api.Message{
|
Messages: []api.Message{
|
||||||
{Role: "user", Content: "hello"},
|
{Role: "user", Content: "hello"},
|
||||||
{Role: "assistant", Content: "hi there"},
|
{Role: "assistant", Content: "hi there"},
|
||||||
@@ -1708,6 +1761,7 @@ func TestRunOptions_Copy(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"Model", copied.Model, original.Model},
|
{"Model", copied.Model, original.Model},
|
||||||
{"ParentModel", copied.ParentModel, original.ParentModel},
|
{"ParentModel", copied.ParentModel, original.ParentModel},
|
||||||
|
{"LoadedMessages", copied.LoadedMessages, original.LoadedMessages},
|
||||||
{"Prompt", copied.Prompt, original.Prompt},
|
{"Prompt", copied.Prompt, original.Prompt},
|
||||||
{"WordWrap", copied.WordWrap, original.WordWrap},
|
{"WordWrap", copied.WordWrap, original.WordWrap},
|
||||||
{"Format", copied.Format, original.Format},
|
{"Format", copied.Format, original.Format},
|
||||||
@@ -1812,13 +1866,18 @@ func TestRunOptions_Copy(t *testing.T) {
|
|||||||
func TestRunOptions_Copy_EmptySlicesAndMaps(t *testing.T) {
|
func TestRunOptions_Copy_EmptySlicesAndMaps(t *testing.T) {
|
||||||
// Test with empty slices and maps
|
// Test with empty slices and maps
|
||||||
original := runOptions{
|
original := runOptions{
|
||||||
Messages: []api.Message{},
|
LoadedMessages: []api.Message{},
|
||||||
Images: []api.ImageData{},
|
Messages: []api.Message{},
|
||||||
Options: map[string]any{},
|
Images: []api.ImageData{},
|
||||||
|
Options: map[string]any{},
|
||||||
}
|
}
|
||||||
|
|
||||||
copied := original.Copy()
|
copied := original.Copy()
|
||||||
|
|
||||||
|
if copied.LoadedMessages == nil {
|
||||||
|
t.Error("Empty LoadedMessages slice should remain empty, not nil")
|
||||||
|
}
|
||||||
|
|
||||||
if copied.Messages == nil {
|
if copied.Messages == nil {
|
||||||
t.Error("Empty Messages slice should remain empty, not nil")
|
t.Error("Empty Messages slice should remain empty, not nil")
|
||||||
}
|
}
|
||||||
@@ -1835,6 +1894,10 @@ func TestRunOptions_Copy_EmptySlicesAndMaps(t *testing.T) {
|
|||||||
t.Error("Empty Messages slice should remain empty")
|
t.Error("Empty Messages slice should remain empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(copied.LoadedMessages) != 0 {
|
||||||
|
t.Error("Empty LoadedMessages slice should remain empty")
|
||||||
|
}
|
||||||
|
|
||||||
if len(copied.Images) != 0 {
|
if len(copied.Images) != 0 {
|
||||||
t.Error("Empty Images slice should remain empty")
|
t.Error("Empty Images slice should remain empty")
|
||||||
}
|
}
|
||||||
@@ -1912,7 +1975,7 @@ func TestShowInfoImageGen(t *testing.T) {
|
|||||||
QuantizationLevel: "Q8",
|
QuantizationLevel: "Q8",
|
||||||
},
|
},
|
||||||
Capabilities: []model.Capability{model.CapabilityImage},
|
Capabilities: []model.Capability{model.CapabilityImage},
|
||||||
Requires: "0.14.0",
|
Requires: "0.19.0",
|
||||||
}, false, &b)
|
}, false, &b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -1922,7 +1985,7 @@ func TestShowInfoImageGen(t *testing.T) {
|
|||||||
" architecture ZImagePipeline \n" +
|
" architecture ZImagePipeline \n" +
|
||||||
" parameters 10.3B \n" +
|
" parameters 10.3B \n" +
|
||||||
" quantization Q8 \n" +
|
" quantization Q8 \n" +
|
||||||
" requires 0.14.0 \n" +
|
" requires 0.19.0 \n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
" Capabilities\n" +
|
" Capabilities\n" +
|
||||||
" image \n" +
|
" image \n" +
|
||||||
@@ -1980,16 +2043,20 @@ func TestRunOptions_Copy_Independence(t *testing.T) {
|
|||||||
// Test that modifications to original don't affect copy
|
// Test that modifications to original don't affect copy
|
||||||
originalThink := &api.ThinkValue{Value: "original"}
|
originalThink := &api.ThinkValue{Value: "original"}
|
||||||
original := runOptions{
|
original := runOptions{
|
||||||
Model: "original-model",
|
Model: "original-model",
|
||||||
Messages: []api.Message{{Role: "user", Content: "original"}},
|
LoadedMessages: []api.Message{{Role: "assistant", Content: "loaded"}},
|
||||||
Options: map[string]any{"key": "value"},
|
Messages: []api.Message{{Role: "user", Content: "original"}},
|
||||||
Think: originalThink,
|
Options: map[string]any{"key": "value"},
|
||||||
|
Think: originalThink,
|
||||||
}
|
}
|
||||||
|
|
||||||
copied := original.Copy()
|
copied := original.Copy()
|
||||||
|
|
||||||
// Modify original
|
// Modify original
|
||||||
original.Model = "modified-model"
|
original.Model = "modified-model"
|
||||||
|
if len(original.LoadedMessages) > 0 {
|
||||||
|
original.LoadedMessages[0].Content = "modified loaded"
|
||||||
|
}
|
||||||
if len(original.Messages) > 0 {
|
if len(original.Messages) > 0 {
|
||||||
original.Messages[0].Content = "modified"
|
original.Messages[0].Content = "modified"
|
||||||
}
|
}
|
||||||
@@ -2003,6 +2070,10 @@ func TestRunOptions_Copy_Independence(t *testing.T) {
|
|||||||
t.Error("Copy Model should not be affected by original modification")
|
t.Error("Copy Model should not be affected by original modification")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(copied.LoadedMessages) > 0 && copied.LoadedMessages[0].Content == "modified loaded" {
|
||||||
|
t.Error("Copy LoadedMessages should not be affected by original modification")
|
||||||
|
}
|
||||||
|
|
||||||
if len(copied.Messages) > 0 && copied.Messages[0].Content == "modified" {
|
if len(copied.Messages) > 0 && copied.Messages[0].Content == "modified" {
|
||||||
t.Error("Copy Messages should not be affected by original modification")
|
t.Error("Copy Messages should not be affected by original modification")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
|||||||
fmt.Fprintln(os.Stderr, "Use \"\"\" to begin a multi-line message.")
|
fmt.Fprintln(os.Stderr, "Use \"\"\" to begin a multi-line message.")
|
||||||
|
|
||||||
if opts.MultiModal {
|
if opts.MultiModal {
|
||||||
fmt.Fprintf(os.Stderr, "Use %s to include .jpg, .png, or .webp images.\n", filepath.FromSlash("/path/to/file"))
|
fmt.Fprintf(os.Stderr, "Use %s to include .jpg, .png, .webp images, or .wav audio files.\n", filepath.FromSlash("/path/to/file"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(os.Stderr, "")
|
fmt.Fprintln(os.Stderr, "")
|
||||||
@@ -214,10 +214,17 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
|||||||
}
|
}
|
||||||
origOpts := opts.Copy()
|
origOpts := opts.Copy()
|
||||||
|
|
||||||
|
client, err := api.ClientFromEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("error: couldn't connect to ollama server")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
opts.Model = args[1]
|
opts.Model = args[1]
|
||||||
opts.Messages = []api.Message{}
|
opts.Messages = []api.Message{}
|
||||||
|
opts.LoadedMessages = nil
|
||||||
fmt.Printf("Loading model '%s'\n", opts.Model)
|
fmt.Printf("Loading model '%s'\n", opts.Model)
|
||||||
opts.Think, err = inferThinkingOption(nil, &opts, thinkExplicitlySet)
|
info, err := client.Show(cmd.Context(), &api.ShowRequest{Model: opts.Model})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
fmt.Printf("Couldn't find model '%s'\n", opts.Model)
|
fmt.Printf("Couldn't find model '%s'\n", opts.Model)
|
||||||
@@ -226,6 +233,11 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
applyShowResponseToRunOptions(&opts, info)
|
||||||
|
opts.Think, err = inferThinkingOption(&info.Capabilities, &opts, thinkExplicitlySet)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := loadOrUnloadModel(cmd, &opts); err != nil {
|
if err := loadOrUnloadModel(cmd, &opts); err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
fmt.Printf("Couldn't find model '%s'\n", opts.Model)
|
fmt.Printf("Couldn't find model '%s'\n", opts.Model)
|
||||||
@@ -561,8 +573,10 @@ func NewCreateRequest(name string, opts runOptions) *api.CreateRequest {
|
|||||||
req.Parameters = opts.Options
|
req.Parameters = opts.Options
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(opts.Messages) > 0 {
|
messages := slices.Clone(opts.LoadedMessages)
|
||||||
req.Messages = opts.Messages
|
messages = append(messages, opts.Messages...)
|
||||||
|
if len(messages) > 0 {
|
||||||
|
req.Messages = messages
|
||||||
}
|
}
|
||||||
|
|
||||||
return req
|
return req
|
||||||
@@ -592,7 +606,7 @@ func extractFileNames(input string) []string {
|
|||||||
// Regex to match file paths starting with optional drive letter, / ./ \ or .\ and include escaped or unescaped spaces (\ or %20)
|
// Regex to match file paths starting with optional drive letter, / ./ \ or .\ and include escaped or unescaped spaces (\ or %20)
|
||||||
// and followed by more characters and a file extension
|
// and followed by more characters and a file extension
|
||||||
// This will capture non filename strings, but we'll check for file existence to remove mismatches
|
// This will capture non filename strings, but we'll check for file existence to remove mismatches
|
||||||
regexPattern := `(?:[a-zA-Z]:)?(?:\./|/|\\)[\S\\ ]+?\.(?i:jpg|jpeg|png|webp)\b`
|
regexPattern := `(?:[a-zA-Z]:)?(?:\./|/|\\)[\S\\ ]+?\.(?i:jpg|jpeg|png|webp|wav)\b`
|
||||||
re := regexp.MustCompile(regexPattern)
|
re := regexp.MustCompile(regexPattern)
|
||||||
|
|
||||||
return re.FindAllString(input, -1)
|
return re.FindAllString(input, -1)
|
||||||
@@ -608,10 +622,16 @@ func extractFileData(input string) (string, []api.ImageData, error) {
|
|||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
continue
|
continue
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Couldn't process image: %q\n", err)
|
fmt.Fprintf(os.Stderr, "Couldn't process file: %q\n", err)
|
||||||
return "", imgs, err
|
return "", imgs, err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "Added image '%s'\n", nfp)
|
ext := strings.ToLower(filepath.Ext(nfp))
|
||||||
|
switch ext {
|
||||||
|
case ".wav":
|
||||||
|
fmt.Fprintf(os.Stderr, "Added audio '%s'\n", nfp)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "Added image '%s'\n", nfp)
|
||||||
|
}
|
||||||
input = strings.ReplaceAll(input, "'"+nfp+"'", "")
|
input = strings.ReplaceAll(input, "'"+nfp+"'", "")
|
||||||
input = strings.ReplaceAll(input, "'"+fp+"'", "")
|
input = strings.ReplaceAll(input, "'"+fp+"'", "")
|
||||||
input = strings.ReplaceAll(input, fp, "")
|
input = strings.ReplaceAll(input, fp, "")
|
||||||
@@ -685,9 +705,9 @@ func getImageData(filePath string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
contentType := http.DetectContentType(buf)
|
contentType := http.DetectContentType(buf)
|
||||||
allowedTypes := []string{"image/jpeg", "image/jpg", "image/png", "image/webp"}
|
allowedTypes := []string{"image/jpeg", "image/jpg", "image/png", "image/webp", "audio/wave"}
|
||||||
if !slices.Contains(allowedTypes, contentType) {
|
if !slices.Contains(allowedTypes, contentType) {
|
||||||
return nil, fmt.Errorf("invalid image type: %s", contentType)
|
return nil, fmt.Errorf("invalid file type: %s", contentType)
|
||||||
}
|
}
|
||||||
|
|
||||||
info, err := file.Stat()
|
info, err := file.Stat()
|
||||||
@@ -695,8 +715,7 @@ func getImageData(filePath string) ([]byte, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the file size exceeds 100MB
|
var maxSize int64 = 100 * 1024 * 1024 // 100MB
|
||||||
var maxSize int64 = 100 * 1024 * 1024 // 100MB in bytes
|
|
||||||
if info.Size() > maxSize {
|
if info.Size() > maxSize {
|
||||||
return nil, errors.New("file size exceeds maximum limit (100MB)")
|
return nil, errors.New("file size exceeds maximum limit (100MB)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,3 +84,33 @@ func TestExtractFileDataRemovesQuotedFilepath(t *testing.T) {
|
|||||||
assert.Len(t, imgs, 1)
|
assert.Len(t, imgs, 1)
|
||||||
assert.Equal(t, cleaned, "before after")
|
assert.Equal(t, cleaned, "before after")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractFileDataWAV(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
fp := filepath.Join(dir, "sample.wav")
|
||||||
|
data := make([]byte, 600)
|
||||||
|
copy(data[:44], []byte{
|
||||||
|
'R', 'I', 'F', 'F',
|
||||||
|
0x58, 0x02, 0x00, 0x00, // file size - 8
|
||||||
|
'W', 'A', 'V', 'E',
|
||||||
|
'f', 'm', 't', ' ',
|
||||||
|
0x10, 0x00, 0x00, 0x00, // fmt chunk size
|
||||||
|
0x01, 0x00, // PCM
|
||||||
|
0x01, 0x00, // mono
|
||||||
|
0x80, 0x3e, 0x00, 0x00, // 16000 Hz
|
||||||
|
0x00, 0x7d, 0x00, 0x00, // byte rate
|
||||||
|
0x02, 0x00, // block align
|
||||||
|
0x10, 0x00, // 16-bit
|
||||||
|
'd', 'a', 't', 'a',
|
||||||
|
0x34, 0x02, 0x00, 0x00, // data size
|
||||||
|
})
|
||||||
|
if err := os.WriteFile(fp, data, 0o600); err != nil {
|
||||||
|
t.Fatalf("failed to write test audio: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
input := "before " + fp + " after"
|
||||||
|
cleaned, imgs, err := extractFileData(input)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, imgs, 1)
|
||||||
|
assert.Equal(t, "before after", cleaned)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/ollama/ollama/envconfig"
|
"github.com/ollama/ollama/envconfig"
|
||||||
@@ -15,8 +16,10 @@ type Codex struct{}
|
|||||||
|
|
||||||
func (c *Codex) String() string { return "Codex" }
|
func (c *Codex) String() string { return "Codex" }
|
||||||
|
|
||||||
|
const codexProfileName = "ollama-launch"
|
||||||
|
|
||||||
func (c *Codex) args(model string, extra []string) []string {
|
func (c *Codex) args(model string, extra []string) []string {
|
||||||
args := []string{"--oss"}
|
args := []string{"--profile", codexProfileName}
|
||||||
if model != "" {
|
if model != "" {
|
||||||
args = append(args, "-m", model)
|
args = append(args, "-m", model)
|
||||||
}
|
}
|
||||||
@@ -29,17 +32,95 @@ func (c *Codex) Run(model string, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ensureCodexConfig(); err != nil {
|
||||||
|
return fmt.Errorf("failed to configure codex: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
cmd := exec.Command("codex", c.args(model, args)...)
|
cmd := exec.Command("codex", c.args(model, args)...)
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
cmd.Env = append(os.Environ(),
|
cmd.Env = append(os.Environ(),
|
||||||
"OPENAI_BASE_URL="+envconfig.Host().String()+"/v1/",
|
|
||||||
"OPENAI_API_KEY=ollama",
|
"OPENAI_API_KEY=ollama",
|
||||||
)
|
)
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureCodexConfig writes a [profiles.ollama-launch] section to ~/.codex/config.toml
|
||||||
|
// with openai_base_url pointing to the local Ollama server.
|
||||||
|
func ensureCodexConfig() error {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
codexDir := filepath.Join(home, ".codex")
|
||||||
|
if err := os.MkdirAll(codexDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(codexDir, "config.toml")
|
||||||
|
return writeCodexProfile(configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCodexProfile ensures ~/.codex/config.toml has the ollama-launch profile
|
||||||
|
// and model provider sections with the correct base URL.
|
||||||
|
func writeCodexProfile(configPath string) error {
|
||||||
|
baseURL := envconfig.Host().String() + "/v1/"
|
||||||
|
|
||||||
|
sections := []struct {
|
||||||
|
header string
|
||||||
|
lines []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
header: fmt.Sprintf("[profiles.%s]", codexProfileName),
|
||||||
|
lines: []string{
|
||||||
|
fmt.Sprintf("openai_base_url = %q", baseURL),
|
||||||
|
`forced_login_method = "api"`,
|
||||||
|
fmt.Sprintf("model_provider = %q", codexProfileName),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: fmt.Sprintf("[model_providers.%s]", codexProfileName),
|
||||||
|
lines: []string{
|
||||||
|
`name = "Ollama"`,
|
||||||
|
fmt.Sprintf("base_url = %q", baseURL),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
content, readErr := os.ReadFile(configPath)
|
||||||
|
text := ""
|
||||||
|
if readErr == nil {
|
||||||
|
text = string(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range sections {
|
||||||
|
block := strings.Join(append([]string{s.header}, s.lines...), "\n") + "\n"
|
||||||
|
|
||||||
|
if idx := strings.Index(text, s.header); idx >= 0 {
|
||||||
|
// Replace the existing section up to the next section header.
|
||||||
|
rest := text[idx+len(s.header):]
|
||||||
|
if endIdx := strings.Index(rest, "\n["); endIdx >= 0 {
|
||||||
|
text = text[:idx] + block + rest[endIdx+1:]
|
||||||
|
} else {
|
||||||
|
text = text[:idx] + block
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Append the section.
|
||||||
|
if text != "" && !strings.HasSuffix(text, "\n") {
|
||||||
|
text += "\n"
|
||||||
|
}
|
||||||
|
if text != "" {
|
||||||
|
text += "\n"
|
||||||
|
}
|
||||||
|
text += block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(configPath, []byte(text), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
func checkCodexVersion() error {
|
func checkCodexVersion() error {
|
||||||
if _, err := exec.LookPath("codex"); err != nil {
|
if _, err := exec.LookPath("codex"); err != nil {
|
||||||
return fmt.Errorf("codex is not installed, install with: npm install -g @openai/codex")
|
return fmt.Errorf("codex is not installed, install with: npm install -g @openai/codex")
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package launch
|
package launch
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,10 +17,10 @@ func TestCodexArgs(t *testing.T) {
|
|||||||
args []string
|
args []string
|
||||||
want []string
|
want []string
|
||||||
}{
|
}{
|
||||||
{"with model", "llama3.2", nil, []string{"--oss", "-m", "llama3.2"}},
|
{"with model", "llama3.2", nil, []string{"--profile", "ollama-launch", "-m", "llama3.2"}},
|
||||||
{"empty model", "", nil, []string{"--oss"}},
|
{"empty model", "", nil, []string{"--profile", "ollama-launch"}},
|
||||||
{"with model and profile", "qwen3.5", []string{"-p", "myprofile"}, []string{"--oss", "-m", "qwen3.5", "-p", "myprofile"}},
|
{"with model and extra args", "qwen3.5", []string{"-p", "myprofile"}, []string{"--profile", "ollama-launch", "-m", "qwen3.5", "-p", "myprofile"}},
|
||||||
{"with sandbox flag", "llama3.2", []string{"--sandbox", "workspace-write"}, []string{"--oss", "-m", "llama3.2", "--sandbox", "workspace-write"}},
|
{"with sandbox flag", "llama3.2", []string{"--sandbox", "workspace-write"}, []string{"--profile", "ollama-launch", "-m", "llama3.2", "--sandbox", "workspace-write"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -29,3 +32,198 @@ func TestCodexArgs(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriteCodexProfile(t *testing.T) {
|
||||||
|
t.Run("creates new file when none exists", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.toml")
|
||||||
|
|
||||||
|
if err := writeCodexProfile(configPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(data)
|
||||||
|
if !strings.Contains(content, "[profiles.ollama-launch]") {
|
||||||
|
t.Error("missing [profiles.ollama-launch] header")
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, "openai_base_url") {
|
||||||
|
t.Error("missing openai_base_url key")
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, "/v1/") {
|
||||||
|
t.Error("missing /v1/ suffix in base URL")
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, `forced_login_method = "api"`) {
|
||||||
|
t.Error("missing forced_login_method key")
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, `model_provider = "ollama-launch"`) {
|
||||||
|
t.Error("missing model_provider key")
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, "[model_providers.ollama-launch]") {
|
||||||
|
t.Error("missing [model_providers.ollama-launch] section")
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, `name = "Ollama"`) {
|
||||||
|
t.Error("missing model provider name")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("appends profile to existing file without profile", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.toml")
|
||||||
|
existing := "[some_other_section]\nkey = \"value\"\n"
|
||||||
|
os.WriteFile(configPath, []byte(existing), 0o644)
|
||||||
|
|
||||||
|
if err := writeCodexProfile(configPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(configPath)
|
||||||
|
content := string(data)
|
||||||
|
|
||||||
|
if !strings.Contains(content, "[some_other_section]") {
|
||||||
|
t.Error("existing section was removed")
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, "[profiles.ollama-launch]") {
|
||||||
|
t.Error("missing [profiles.ollama-launch] header")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("replaces existing profile section", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.toml")
|
||||||
|
existing := "[profiles.ollama-launch]\nopenai_base_url = \"http://old:1234/v1/\"\n\n[model_providers.ollama-launch]\nname = \"Ollama\"\nbase_url = \"http://old:1234/v1/\"\n"
|
||||||
|
os.WriteFile(configPath, []byte(existing), 0o644)
|
||||||
|
|
||||||
|
if err := writeCodexProfile(configPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(configPath)
|
||||||
|
content := string(data)
|
||||||
|
|
||||||
|
if strings.Contains(content, "old:1234") {
|
||||||
|
t.Error("old URL was not replaced")
|
||||||
|
}
|
||||||
|
if strings.Count(content, "[profiles.ollama-launch]") != 1 {
|
||||||
|
t.Errorf("expected exactly one [profiles.ollama-launch] section, got %d", strings.Count(content, "[profiles.ollama-launch]"))
|
||||||
|
}
|
||||||
|
if strings.Count(content, "[model_providers.ollama-launch]") != 1 {
|
||||||
|
t.Errorf("expected exactly one [model_providers.ollama-launch] section, got %d", strings.Count(content, "[model_providers.ollama-launch]"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("replaces profile while preserving following sections", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.toml")
|
||||||
|
existing := "[profiles.ollama-launch]\nopenai_base_url = \"http://old:1234/v1/\"\n[another_section]\nfoo = \"bar\"\n"
|
||||||
|
os.WriteFile(configPath, []byte(existing), 0o644)
|
||||||
|
|
||||||
|
if err := writeCodexProfile(configPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(configPath)
|
||||||
|
content := string(data)
|
||||||
|
|
||||||
|
if strings.Contains(content, "old:1234") {
|
||||||
|
t.Error("old URL was not replaced")
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, "[another_section]") {
|
||||||
|
t.Error("following section was removed")
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, "foo = \"bar\"") {
|
||||||
|
t.Error("following section content was removed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("appends newline to file not ending with newline", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.toml")
|
||||||
|
existing := "[other]\nkey = \"val\""
|
||||||
|
os.WriteFile(configPath, []byte(existing), 0o644)
|
||||||
|
|
||||||
|
if err := writeCodexProfile(configPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(configPath)
|
||||||
|
content := string(data)
|
||||||
|
|
||||||
|
if !strings.Contains(content, "[profiles.ollama-launch]") {
|
||||||
|
t.Error("missing [profiles.ollama-launch] header")
|
||||||
|
}
|
||||||
|
// Should not have double blank lines from missing trailing newline
|
||||||
|
if strings.Contains(content, "\n\n\n") {
|
||||||
|
t.Error("unexpected triple newline in output")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("uses custom OLLAMA_HOST", func(t *testing.T) {
|
||||||
|
t.Setenv("OLLAMA_HOST", "http://myhost:9999")
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.toml")
|
||||||
|
|
||||||
|
if err := writeCodexProfile(configPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(configPath)
|
||||||
|
content := string(data)
|
||||||
|
|
||||||
|
if !strings.Contains(content, "myhost:9999/v1/") {
|
||||||
|
t.Errorf("expected custom host in URL, got:\n%s", content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureCodexConfig(t *testing.T) {
|
||||||
|
t.Run("creates .codex dir and config.toml", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
|
||||||
|
if err := ensureCodexConfig(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.toml not created: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(data)
|
||||||
|
if !strings.Contains(content, "[profiles.ollama-launch]") {
|
||||||
|
t.Error("missing [profiles.ollama-launch] header")
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, "openai_base_url") {
|
||||||
|
t.Error("missing openai_base_url key")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("is idempotent", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
|
||||||
|
if err := ensureCodexConfig(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ensureCodexConfig(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
||||||
|
data, _ := os.ReadFile(configPath)
|
||||||
|
content := string(data)
|
||||||
|
|
||||||
|
if strings.Count(content, "[profiles.ollama-launch]") != 1 {
|
||||||
|
t.Errorf("expected exactly one [profiles.ollama-launch] section after two calls, got %d", strings.Count(content, "[profiles.ollama-launch]"))
|
||||||
|
}
|
||||||
|
if strings.Count(content, "[model_providers.ollama-launch]") != 1 {
|
||||||
|
t.Errorf("expected exactly one [model_providers.ollama-launch] section after two calls, got %d", strings.Count(content, "[model_providers.ollama-launch]"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ func TestLaunchCmdYes_AutoConfirmsLaunchPromptPath(t *testing.T) {
|
|||||||
restore := OverrideIntegration("stubeditor", stub)
|
restore := OverrideIntegration("stubeditor", stub)
|
||||||
defer restore()
|
defer restore()
|
||||||
|
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
t.Fatalf("unexpected prompt with --yes: %q", prompt)
|
t.Fatalf("unexpected prompt with --yes: %q", prompt)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -393,7 +393,7 @@ func TestLaunchCmdHeadlessWithYes_AutoPullsMissingLocalModel(t *testing.T) {
|
|||||||
restore := OverrideIntegration("stubapp", stub)
|
restore := OverrideIntegration("stubapp", stub)
|
||||||
defer restore()
|
defer restore()
|
||||||
|
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
t.Fatalf("unexpected prompt with --yes in headless autopull path: %q", prompt)
|
t.Fatalf("unexpected prompt with --yes in headless autopull path: %q", prompt)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -436,7 +436,7 @@ func TestLaunchCmdHeadlessWithoutYes_ReturnsActionableConfirmError(t *testing.T)
|
|||||||
restore := OverrideIntegration("stubeditor", stub)
|
restore := OverrideIntegration("stubeditor", stub)
|
||||||
defer restore()
|
defer restore()
|
||||||
|
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
t.Fatalf("unexpected prompt in headless non-yes mode: %q", prompt)
|
t.Fatalf("unexpected prompt in headless non-yes mode: %q", prompt)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
"github.com/ollama/ollama/api"
|
"github.com/ollama/ollama/api"
|
||||||
|
"github.com/ollama/ollama/cmd/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type stubEditorRunner struct {
|
type stubEditorRunner struct {
|
||||||
@@ -290,7 +291,7 @@ func TestParseArgs(t *testing.T) {
|
|||||||
func TestIsCloudModel(t *testing.T) {
|
func TestIsCloudModel(t *testing.T) {
|
||||||
// isCloudModel now only uses Show API, so nil client always returns false
|
// isCloudModel now only uses Show API, so nil client always returns false
|
||||||
t.Run("nil client returns false", func(t *testing.T) {
|
t.Run("nil client returns false", func(t *testing.T) {
|
||||||
models := []string{"glm-5:cloud", "kimi-k2.5:cloud", "local-model"}
|
models := []string{"glm-5.1:cloud", "kimi-k2.5:cloud", "local-model"}
|
||||||
for _, model := range models {
|
for _, model := range models {
|
||||||
if isCloudModel(context.Background(), nil, model) {
|
if isCloudModel(context.Background(), nil, model) {
|
||||||
t.Errorf("isCloudModel(%q) with nil client should return false", model)
|
t.Errorf("isCloudModel(%q) with nil client should return false", model)
|
||||||
@@ -310,7 +311,7 @@ func names(items []ModelItem) []string {
|
|||||||
func TestBuildModelList_NoExistingModels(t *testing.T) {
|
func TestBuildModelList_NoExistingModels(t *testing.T) {
|
||||||
items, _, _, _ := buildModelList(nil, nil, "")
|
items, _, _, _ := buildModelList(nil, nil, "")
|
||||||
|
|
||||||
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5:cloud", "minimax-m2.7:cloud", "glm-4.7-flash", "qwen3.5"}
|
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5"}
|
||||||
if diff := cmp.Diff(want, names(items)); diff != "" {
|
if diff := cmp.Diff(want, names(items)); diff != "" {
|
||||||
t.Errorf("with no existing models, items should be recommended in order (-want +got):\n%s", diff)
|
t.Errorf("with no existing models, items should be recommended in order (-want +got):\n%s", diff)
|
||||||
}
|
}
|
||||||
@@ -338,7 +339,7 @@ func TestBuildModelList_OnlyLocalModels_CloudRecsAtBottom(t *testing.T) {
|
|||||||
got := names(items)
|
got := names(items)
|
||||||
|
|
||||||
// Recommended pinned at top (local recs first, then cloud recs when only-local), then installed non-recs
|
// Recommended pinned at top (local recs first, then cloud recs when only-local), then installed non-recs
|
||||||
want := []string{"glm-4.7-flash", "qwen3.5", "kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5:cloud", "minimax-m2.7:cloud", "llama3.2", "qwen2.5"}
|
want := []string{"gemma4", "qwen3.5", "kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "llama3.2", "qwen2.5"}
|
||||||
if diff := cmp.Diff(want, got); diff != "" {
|
if diff := cmp.Diff(want, got); diff != "" {
|
||||||
t.Errorf("recs pinned at top, local recs before cloud recs (-want +got):\n%s", diff)
|
t.Errorf("recs pinned at top, local recs before cloud recs (-want +got):\n%s", diff)
|
||||||
}
|
}
|
||||||
@@ -347,14 +348,14 @@ func TestBuildModelList_OnlyLocalModels_CloudRecsAtBottom(t *testing.T) {
|
|||||||
func TestBuildModelList_BothCloudAndLocal_RegularSort(t *testing.T) {
|
func TestBuildModelList_BothCloudAndLocal_RegularSort(t *testing.T) {
|
||||||
existing := []modelInfo{
|
existing := []modelInfo{
|
||||||
{Name: "llama3.2:latest", Remote: false},
|
{Name: "llama3.2:latest", Remote: false},
|
||||||
{Name: "glm-5:cloud", Remote: true},
|
{Name: "glm-5.1:cloud", Remote: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
items, _, _, _ := buildModelList(existing, nil, "")
|
items, _, _, _ := buildModelList(existing, nil, "")
|
||||||
got := names(items)
|
got := names(items)
|
||||||
|
|
||||||
// All recs pinned at top (cloud before local in mixed case), then non-recs
|
// All recs pinned at top (cloud before local in mixed case), then non-recs
|
||||||
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5:cloud", "minimax-m2.7:cloud", "glm-4.7-flash", "qwen3.5", "llama3.2"}
|
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5", "llama3.2"}
|
||||||
if diff := cmp.Diff(want, got); diff != "" {
|
if diff := cmp.Diff(want, got); diff != "" {
|
||||||
t.Errorf("recs pinned at top, cloud recs first in mixed case (-want +got):\n%s", diff)
|
t.Errorf("recs pinned at top, cloud recs first in mixed case (-want +got):\n%s", diff)
|
||||||
}
|
}
|
||||||
@@ -363,7 +364,7 @@ func TestBuildModelList_BothCloudAndLocal_RegularSort(t *testing.T) {
|
|||||||
func TestBuildModelList_PreCheckedFirst(t *testing.T) {
|
func TestBuildModelList_PreCheckedFirst(t *testing.T) {
|
||||||
existing := []modelInfo{
|
existing := []modelInfo{
|
||||||
{Name: "llama3.2:latest", Remote: false},
|
{Name: "llama3.2:latest", Remote: false},
|
||||||
{Name: "glm-5:cloud", Remote: true},
|
{Name: "glm-5.1:cloud", Remote: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
items, _, _, _ := buildModelList(existing, []string{"llama3.2"}, "")
|
items, _, _, _ := buildModelList(existing, []string{"llama3.2"}, "")
|
||||||
@@ -374,17 +375,50 @@ func TestBuildModelList_PreCheckedFirst(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildModelList_CurrentDefaultFirstAmongCheckedNonRec(t *testing.T) {
|
||||||
|
existing := []modelInfo{
|
||||||
|
{Name: "alpha", Remote: false},
|
||||||
|
{Name: "zebra", Remote: false},
|
||||||
|
{Name: "middle", Remote: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
// "zebra" is the current/default; all three are checked, none are recommended.
|
||||||
|
// Expected non-rec order: zebra (default), alpha, middle (alphabetical).
|
||||||
|
items, _, _, _ := buildModelList(existing, []string{"zebra", "alpha", "middle"}, "zebra")
|
||||||
|
got := names(items)
|
||||||
|
|
||||||
|
// Skip recommended items to find the non-rec portion.
|
||||||
|
var nonRec []string
|
||||||
|
for _, item := range items {
|
||||||
|
if !item.Recommended {
|
||||||
|
nonRec = append(nonRec, item.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(nonRec) < 3 {
|
||||||
|
t.Fatalf("expected 3 non-rec items, got %v", nonRec)
|
||||||
|
}
|
||||||
|
if nonRec[0] != "zebra" {
|
||||||
|
t.Errorf("current/default model should be first among checked non-rec, got %v (full: %v)", nonRec, got)
|
||||||
|
}
|
||||||
|
if nonRec[1] != "alpha" {
|
||||||
|
t.Errorf("remaining checked should be alphabetical, expected alpha second, got %v", nonRec)
|
||||||
|
}
|
||||||
|
if nonRec[2] != "middle" {
|
||||||
|
t.Errorf("remaining checked should be alphabetical, expected middle third, got %v", nonRec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildModelList_ExistingRecommendedMarked(t *testing.T) {
|
func TestBuildModelList_ExistingRecommendedMarked(t *testing.T) {
|
||||||
existing := []modelInfo{
|
existing := []modelInfo{
|
||||||
{Name: "glm-4.7-flash", Remote: false},
|
{Name: "gemma4", Remote: false},
|
||||||
{Name: "glm-5:cloud", Remote: true},
|
{Name: "glm-5.1:cloud", Remote: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
items, _, _, _ := buildModelList(existing, nil, "")
|
items, _, _, _ := buildModelList(existing, nil, "")
|
||||||
|
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
switch item.Name {
|
switch item.Name {
|
||||||
case "glm-4.7-flash", "glm-5:cloud":
|
case "gemma4", "glm-5.1:cloud":
|
||||||
if strings.HasSuffix(item.Description, "(not downloaded)") {
|
if strings.HasSuffix(item.Description, "(not downloaded)") {
|
||||||
t.Errorf("installed recommended %q should not have '(not downloaded)' suffix, got %q", item.Name, item.Description)
|
t.Errorf("installed recommended %q should not have '(not downloaded)' suffix, got %q", item.Name, item.Description)
|
||||||
}
|
}
|
||||||
@@ -402,17 +436,17 @@ func TestBuildModelList_ExistingRecommendedMarked(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildModelList_ExistingCloudModelsNotPushedToBottom(t *testing.T) {
|
func TestBuildModelList_ExistingCloudModelsNotPushedToBottom(t *testing.T) {
|
||||||
existing := []modelInfo{
|
existing := []modelInfo{
|
||||||
{Name: "glm-4.7-flash", Remote: false},
|
{Name: "gemma4", Remote: false},
|
||||||
{Name: "glm-5:cloud", Remote: true},
|
{Name: "glm-5.1:cloud", Remote: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
items, _, _, _ := buildModelList(existing, nil, "")
|
items, _, _, _ := buildModelList(existing, nil, "")
|
||||||
got := names(items)
|
got := names(items)
|
||||||
|
|
||||||
// glm-4.7-flash and glm-5:cloud are installed so they sort normally;
|
// gemma4 and glm-5.1:cloud are installed so they sort normally;
|
||||||
// kimi-k2.5:cloud, qwen3.5:cloud, and qwen3.5 are not installed so they go to the bottom
|
// kimi-k2.5:cloud, qwen3.5:cloud, and qwen3.5 are not installed so they go to the bottom
|
||||||
// All recs: cloud first in mixed case, then local, in rec order within each
|
// All recs: cloud first in mixed case, then local, in rec order within each
|
||||||
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5:cloud", "minimax-m2.7:cloud", "glm-4.7-flash", "qwen3.5"}
|
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5"}
|
||||||
if diff := cmp.Diff(want, got); diff != "" {
|
if diff := cmp.Diff(want, got); diff != "" {
|
||||||
t.Errorf("all recs, cloud first in mixed case (-want +got):\n%s", diff)
|
t.Errorf("all recs, cloud first in mixed case (-want +got):\n%s", diff)
|
||||||
}
|
}
|
||||||
@@ -430,7 +464,7 @@ func TestBuildModelList_HasRecommendedCloudModel_OnlyNonInstalledAtBottom(t *tes
|
|||||||
// kimi-k2.5:cloud is installed so it sorts normally;
|
// kimi-k2.5:cloud is installed so it sorts normally;
|
||||||
// the rest of the recommendations are not installed so they go to the bottom
|
// the rest of the recommendations are not installed so they go to the bottom
|
||||||
// All recs pinned at top (cloud first in mixed case), then non-recs
|
// All recs pinned at top (cloud first in mixed case), then non-recs
|
||||||
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5:cloud", "minimax-m2.7:cloud", "glm-4.7-flash", "qwen3.5", "llama3.2"}
|
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5", "llama3.2"}
|
||||||
if diff := cmp.Diff(want, got); diff != "" {
|
if diff := cmp.Diff(want, got); diff != "" {
|
||||||
t.Errorf("recs pinned at top, cloud first in mixed case (-want +got):\n%s", diff)
|
t.Errorf("recs pinned at top, cloud first in mixed case (-want +got):\n%s", diff)
|
||||||
}
|
}
|
||||||
@@ -452,7 +486,7 @@ func TestBuildModelList_HasRecommendedCloudModel_OnlyNonInstalledAtBottom(t *tes
|
|||||||
|
|
||||||
func TestBuildModelList_LatestTagStripped(t *testing.T) {
|
func TestBuildModelList_LatestTagStripped(t *testing.T) {
|
||||||
existing := []modelInfo{
|
existing := []modelInfo{
|
||||||
{Name: "glm-4.7-flash:latest", Remote: false},
|
{Name: "gemma4:latest", Remote: false},
|
||||||
{Name: "llama3.2:latest", Remote: false},
|
{Name: "llama3.2:latest", Remote: false},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,27 +500,27 @@ func TestBuildModelList_LatestTagStripped(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// glm-4.7-flash should not be duplicated (existing :latest matches the recommendation)
|
// gemma4 should not be duplicated (existing :latest matches the recommendation)
|
||||||
count := 0
|
count := 0
|
||||||
for _, name := range got {
|
for _, name := range got {
|
||||||
if name == "glm-4.7-flash" {
|
if name == "gemma4" {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if count != 1 {
|
if count != 1 {
|
||||||
t.Errorf("glm-4.7-flash should appear exactly once, got %d in %v", count, got)
|
t.Errorf("gemma4 should appear exactly once, got %d in %v", count, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stripped name should be in existingModels so it won't be pulled
|
// Stripped name should be in existingModels so it won't be pulled
|
||||||
if !existingModels["glm-4.7-flash"] {
|
if !existingModels["gemma4"] {
|
||||||
t.Error("glm-4.7-flash should be in existingModels")
|
t.Error("gemma4 should be in existingModels")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildModelList_ReturnsExistingAndCloudMaps(t *testing.T) {
|
func TestBuildModelList_ReturnsExistingAndCloudMaps(t *testing.T) {
|
||||||
existing := []modelInfo{
|
existing := []modelInfo{
|
||||||
{Name: "llama3.2:latest", Remote: false},
|
{Name: "llama3.2:latest", Remote: false},
|
||||||
{Name: "glm-5:cloud", Remote: true},
|
{Name: "glm-5.1:cloud", Remote: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, existingModels, cloudModels := buildModelList(existing, nil, "")
|
_, _, existingModels, cloudModels := buildModelList(existing, nil, "")
|
||||||
@@ -494,15 +528,15 @@ func TestBuildModelList_ReturnsExistingAndCloudMaps(t *testing.T) {
|
|||||||
if !existingModels["llama3.2"] {
|
if !existingModels["llama3.2"] {
|
||||||
t.Error("llama3.2 should be in existingModels")
|
t.Error("llama3.2 should be in existingModels")
|
||||||
}
|
}
|
||||||
if !existingModels["glm-5:cloud"] {
|
if !existingModels["glm-5.1:cloud"] {
|
||||||
t.Error("glm-5:cloud should be in existingModels")
|
t.Error("glm-5.1:cloud should be in existingModels")
|
||||||
}
|
}
|
||||||
if existingModels["glm-4.7-flash"] {
|
if existingModels["gemma4"] {
|
||||||
t.Error("glm-4.7-flash should not be in existingModels (it's a recommendation)")
|
t.Error("gemma4 should not be in existingModels (it's a recommendation)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cloudModels["glm-5:cloud"] {
|
if !cloudModels["glm-5.1:cloud"] {
|
||||||
t.Error("glm-5:cloud should be in cloudModels")
|
t.Error("glm-5.1:cloud should be in cloudModels")
|
||||||
}
|
}
|
||||||
if !cloudModels["kimi-k2.5:cloud"] {
|
if !cloudModels["kimi-k2.5:cloud"] {
|
||||||
t.Error("kimi-k2.5:cloud should be in cloudModels (recommended cloud)")
|
t.Error("kimi-k2.5:cloud should be in cloudModels (recommended cloud)")
|
||||||
@@ -517,7 +551,7 @@ func TestBuildModelList_ReturnsExistingAndCloudMaps(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildModelList_RecommendedFieldSet(t *testing.T) {
|
func TestBuildModelList_RecommendedFieldSet(t *testing.T) {
|
||||||
existing := []modelInfo{
|
existing := []modelInfo{
|
||||||
{Name: "glm-4.7-flash", Remote: false},
|
{Name: "gemma4", Remote: false},
|
||||||
{Name: "llama3.2:latest", Remote: false},
|
{Name: "llama3.2:latest", Remote: false},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,7 +559,7 @@ func TestBuildModelList_RecommendedFieldSet(t *testing.T) {
|
|||||||
|
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
switch item.Name {
|
switch item.Name {
|
||||||
case "glm-4.7-flash", "qwen3.5", "glm-5:cloud", "kimi-k2.5:cloud", "qwen3.5:cloud":
|
case "gemma4", "qwen3.5", "glm-5.1:cloud", "kimi-k2.5:cloud", "qwen3.5:cloud":
|
||||||
if !item.Recommended {
|
if !item.Recommended {
|
||||||
t.Errorf("%q should have Recommended=true", item.Name)
|
t.Errorf("%q should have Recommended=true", item.Name)
|
||||||
}
|
}
|
||||||
@@ -540,15 +574,15 @@ func TestBuildModelList_RecommendedFieldSet(t *testing.T) {
|
|||||||
func TestBuildModelList_MixedCase_CloudRecsFirst(t *testing.T) {
|
func TestBuildModelList_MixedCase_CloudRecsFirst(t *testing.T) {
|
||||||
existing := []modelInfo{
|
existing := []modelInfo{
|
||||||
{Name: "llama3.2:latest", Remote: false},
|
{Name: "llama3.2:latest", Remote: false},
|
||||||
{Name: "glm-5:cloud", Remote: true},
|
{Name: "glm-5.1:cloud", Remote: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
items, _, _, _ := buildModelList(existing, nil, "")
|
items, _, _, _ := buildModelList(existing, nil, "")
|
||||||
got := names(items)
|
got := names(items)
|
||||||
|
|
||||||
// Cloud recs should sort before local recs in mixed case
|
// Cloud recs should sort before local recs in mixed case
|
||||||
cloudIdx := slices.Index(got, "glm-5:cloud")
|
cloudIdx := slices.Index(got, "glm-5.1:cloud")
|
||||||
localIdx := slices.Index(got, "glm-4.7-flash")
|
localIdx := slices.Index(got, "gemma4")
|
||||||
if cloudIdx > localIdx {
|
if cloudIdx > localIdx {
|
||||||
t.Errorf("cloud recs should be before local recs in mixed case, got %v", got)
|
t.Errorf("cloud recs should be before local recs in mixed case, got %v", got)
|
||||||
}
|
}
|
||||||
@@ -563,8 +597,8 @@ func TestBuildModelList_OnlyLocal_LocalRecsFirst(t *testing.T) {
|
|||||||
got := names(items)
|
got := names(items)
|
||||||
|
|
||||||
// Local recs should sort before cloud recs in only-local case
|
// Local recs should sort before cloud recs in only-local case
|
||||||
localIdx := slices.Index(got, "glm-4.7-flash")
|
localIdx := slices.Index(got, "gemma4")
|
||||||
cloudIdx := slices.Index(got, "glm-5:cloud")
|
cloudIdx := slices.Index(got, "glm-5.1:cloud")
|
||||||
if localIdx > cloudIdx {
|
if localIdx > cloudIdx {
|
||||||
t.Errorf("local recs should be before cloud recs in only-local case, got %v", got)
|
t.Errorf("local recs should be before cloud recs in only-local case, got %v", got)
|
||||||
}
|
}
|
||||||
@@ -583,7 +617,7 @@ func TestBuildModelList_RecsAboveNonRecs(t *testing.T) {
|
|||||||
lastRecIdx := -1
|
lastRecIdx := -1
|
||||||
firstNonRecIdx := len(got)
|
firstNonRecIdx := len(got)
|
||||||
for i, name := range got {
|
for i, name := range got {
|
||||||
isRec := name == "glm-4.7-flash" || name == "qwen3.5" || name == "minimax-m2.7:cloud" || name == "glm-5:cloud" || name == "kimi-k2.5:cloud" || name == "qwen3.5:cloud"
|
isRec := name == "gemma4" || name == "qwen3.5" || name == "minimax-m2.7:cloud" || name == "glm-5.1:cloud" || name == "kimi-k2.5:cloud" || name == "qwen3.5:cloud"
|
||||||
if isRec && i > lastRecIdx {
|
if isRec && i > lastRecIdx {
|
||||||
lastRecIdx = i
|
lastRecIdx = i
|
||||||
}
|
}
|
||||||
@@ -599,7 +633,7 @@ func TestBuildModelList_RecsAboveNonRecs(t *testing.T) {
|
|||||||
func TestBuildModelList_CheckedBeforeRecs(t *testing.T) {
|
func TestBuildModelList_CheckedBeforeRecs(t *testing.T) {
|
||||||
existing := []modelInfo{
|
existing := []modelInfo{
|
||||||
{Name: "llama3.2:latest", Remote: false},
|
{Name: "llama3.2:latest", Remote: false},
|
||||||
{Name: "glm-5:cloud", Remote: true},
|
{Name: "glm-5.1:cloud", Remote: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
items, _, _, _ := buildModelList(existing, []string{"llama3.2"}, "")
|
items, _, _, _ := buildModelList(existing, []string{"llama3.2"}, "")
|
||||||
@@ -680,7 +714,7 @@ func TestLauncherClientFilterDisabledCloudModels_ChecksStatusOncePerInvocation(t
|
|||||||
apiClient: api.NewClient(u, srv.Client()),
|
apiClient: api.NewClient(u, srv.Client()),
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered := client.filterDisabledCloudModels(context.Background(), []string{"llama3.2", "glm-5:cloud", "qwen3.5:cloud"})
|
filtered := client.filterDisabledCloudModels(context.Background(), []string{"llama3.2", "glm-5.1:cloud", "qwen3.5:cloud"})
|
||||||
if diff := cmp.Diff([]string{"llama3.2"}, filtered); diff != "" {
|
if diff := cmp.Diff([]string{"llama3.2"}, filtered); diff != "" {
|
||||||
t.Fatalf("filtered models mismatch (-want +got):\n%s", diff)
|
t.Fatalf("filtered models mismatch (-want +got):\n%s", diff)
|
||||||
}
|
}
|
||||||
@@ -689,6 +723,59 @@ func TestLauncherClientFilterDisabledCloudModels_ChecksStatusOncePerInvocation(t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSavedMatchesModels(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
saved *config.IntegrationConfig
|
||||||
|
models []string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil saved",
|
||||||
|
saved: nil,
|
||||||
|
models: []string{"llama3.2"},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "identical order",
|
||||||
|
saved: &config.IntegrationConfig{Models: []string{"llama3.2", "qwen3:8b"}},
|
||||||
|
models: []string{"llama3.2", "qwen3:8b"},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different order",
|
||||||
|
saved: &config.IntegrationConfig{Models: []string{"llama3.2", "qwen3:8b"}},
|
||||||
|
models: []string{"qwen3:8b", "llama3.2"},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subset",
|
||||||
|
saved: &config.IntegrationConfig{Models: []string{"llama3.2", "qwen3:8b"}},
|
||||||
|
models: []string{"llama3.2"},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil models in saved with non-nil models",
|
||||||
|
saved: &config.IntegrationConfig{Models: nil},
|
||||||
|
models: []string{"llama3.2"},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty both",
|
||||||
|
saved: &config.IntegrationConfig{Models: nil},
|
||||||
|
models: nil,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := savedMatchesModels(tt.saved, tt.models); got != tt.want {
|
||||||
|
t.Fatalf("savedMatchesModels = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPrepareEditorIntegration_SavesOnlyAfterSuccessfulEdit(t *testing.T) {
|
func TestPrepareEditorIntegration_SavesOnlyAfterSuccessfulEdit(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
setTestHome(t, tmpDir)
|
setTestHome(t, tmpDir)
|
||||||
@@ -754,7 +841,7 @@ func TestShowOrPullWithPolicy_ModelExists(t *testing.T) {
|
|||||||
|
|
||||||
func TestShowOrPullWithPolicy_ModelNotFound_FailDoesNotPromptOrPull(t *testing.T) {
|
func TestShowOrPullWithPolicy_ModelNotFound_FailDoesNotPromptOrPull(t *testing.T) {
|
||||||
oldHook := DefaultConfirmPrompt
|
oldHook := DefaultConfirmPrompt
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
t.Fatal("confirm prompt should not be called with fail policy")
|
t.Fatal("confirm prompt should not be called with fail policy")
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -793,7 +880,7 @@ func TestShowOrPullWithPolicy_ModelNotFound_FailDoesNotPromptOrPull(t *testing.T
|
|||||||
|
|
||||||
func TestShowOrPullWithPolicy_ModelNotFound_PromptPolicyPulls(t *testing.T) {
|
func TestShowOrPullWithPolicy_ModelNotFound_PromptPolicyPulls(t *testing.T) {
|
||||||
oldHook := DefaultConfirmPrompt
|
oldHook := DefaultConfirmPrompt
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
if !strings.Contains(prompt, "missing-model") {
|
if !strings.Contains(prompt, "missing-model") {
|
||||||
t.Fatalf("expected prompt to mention missing model, got %q", prompt)
|
t.Fatalf("expected prompt to mention missing model, got %q", prompt)
|
||||||
}
|
}
|
||||||
@@ -831,7 +918,7 @@ func TestShowOrPullWithPolicy_ModelNotFound_PromptPolicyPulls(t *testing.T) {
|
|||||||
|
|
||||||
func TestShowOrPullWithPolicy_ModelNotFound_AutoPullPolicyPullsWithoutPrompt(t *testing.T) {
|
func TestShowOrPullWithPolicy_ModelNotFound_AutoPullPolicyPullsWithoutPrompt(t *testing.T) {
|
||||||
oldHook := DefaultConfirmPrompt
|
oldHook := DefaultConfirmPrompt
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
t.Fatalf("confirm prompt should not be called with auto-pull policy: %q", prompt)
|
t.Fatalf("confirm prompt should not be called with auto-pull policy: %q", prompt)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -867,7 +954,7 @@ func TestShowOrPullWithPolicy_ModelNotFound_AutoPullPolicyPullsWithoutPrompt(t *
|
|||||||
|
|
||||||
func TestShowOrPullWithPolicy_CloudModelNotFound_FailsEarlyForAllPolicies(t *testing.T) {
|
func TestShowOrPullWithPolicy_CloudModelNotFound_FailsEarlyForAllPolicies(t *testing.T) {
|
||||||
oldHook := DefaultConfirmPrompt
|
oldHook := DefaultConfirmPrompt
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
t.Fatal("confirm prompt should not be called for explicit cloud models")
|
t.Fatal("confirm prompt should not be called for explicit cloud models")
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -897,11 +984,11 @@ func TestShowOrPullWithPolicy_CloudModelNotFound_FailsEarlyForAllPolicies(t *tes
|
|||||||
u, _ := url.Parse(srv.URL)
|
u, _ := url.Parse(srv.URL)
|
||||||
client := api.NewClient(u, srv.Client())
|
client := api.NewClient(u, srv.Client())
|
||||||
|
|
||||||
err := showOrPullWithPolicy(context.Background(), client, "glm-5:cloud", policy, true)
|
err := showOrPullWithPolicy(context.Background(), client, "glm-5.1:cloud", policy, true)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("expected cloud model not-found error for policy %d", policy)
|
t.Fatalf("expected cloud model not-found error for policy %d", policy)
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), `model "glm-5:cloud" not found`) {
|
if !strings.Contains(err.Error(), `model "glm-5.1:cloud" not found`) {
|
||||||
t.Fatalf("expected not-found error for policy %d, got %v", policy, err)
|
t.Fatalf("expected not-found error for policy %d, got %v", policy, err)
|
||||||
}
|
}
|
||||||
if pullCalled {
|
if pullCalled {
|
||||||
@@ -913,7 +1000,7 @@ func TestShowOrPullWithPolicy_CloudModelNotFound_FailsEarlyForAllPolicies(t *tes
|
|||||||
|
|
||||||
func TestShowOrPullWithPolicy_CloudModelDisabled_FailsWithCloudDisabledError(t *testing.T) {
|
func TestShowOrPullWithPolicy_CloudModelDisabled_FailsWithCloudDisabledError(t *testing.T) {
|
||||||
oldHook := DefaultConfirmPrompt
|
oldHook := DefaultConfirmPrompt
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
t.Fatal("confirm prompt should not be called for explicit cloud models")
|
t.Fatal("confirm prompt should not be called for explicit cloud models")
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -943,7 +1030,7 @@ func TestShowOrPullWithPolicy_CloudModelDisabled_FailsWithCloudDisabledError(t *
|
|||||||
u, _ := url.Parse(srv.URL)
|
u, _ := url.Parse(srv.URL)
|
||||||
client := api.NewClient(u, srv.Client())
|
client := api.NewClient(u, srv.Client())
|
||||||
|
|
||||||
err := showOrPullWithPolicy(context.Background(), client, "glm-5:cloud", policy, true)
|
err := showOrPullWithPolicy(context.Background(), client, "glm-5.1:cloud", policy, true)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("expected cloud disabled error for policy %d", policy)
|
t.Fatalf("expected cloud disabled error for policy %d", policy)
|
||||||
}
|
}
|
||||||
@@ -1002,7 +1089,7 @@ func TestShowOrPull_ShowCalledWithCorrectModel(t *testing.T) {
|
|||||||
func TestShowOrPull_ModelNotFound_ConfirmYes_Pulls(t *testing.T) {
|
func TestShowOrPull_ModelNotFound_ConfirmYes_Pulls(t *testing.T) {
|
||||||
// Set up hook so confirmPrompt doesn't need a terminal
|
// Set up hook so confirmPrompt doesn't need a terminal
|
||||||
oldHook := DefaultConfirmPrompt
|
oldHook := DefaultConfirmPrompt
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
if !strings.Contains(prompt, "missing-model") {
|
if !strings.Contains(prompt, "missing-model") {
|
||||||
t.Errorf("expected prompt to contain model name, got %q", prompt)
|
t.Errorf("expected prompt to contain model name, got %q", prompt)
|
||||||
}
|
}
|
||||||
@@ -1040,7 +1127,7 @@ func TestShowOrPull_ModelNotFound_ConfirmYes_Pulls(t *testing.T) {
|
|||||||
|
|
||||||
func TestShowOrPull_ModelNotFound_ConfirmNo_Cancelled(t *testing.T) {
|
func TestShowOrPull_ModelNotFound_ConfirmNo_Cancelled(t *testing.T) {
|
||||||
oldHook := DefaultConfirmPrompt
|
oldHook := DefaultConfirmPrompt
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
return false, ErrCancelled
|
return false, ErrCancelled
|
||||||
}
|
}
|
||||||
defer func() { DefaultConfirmPrompt = oldHook }()
|
defer func() { DefaultConfirmPrompt = oldHook }()
|
||||||
@@ -1070,7 +1157,7 @@ func TestShowOrPull_ModelNotFound_ConfirmNo_Cancelled(t *testing.T) {
|
|||||||
func TestShowOrPull_CloudModel_NotFoundDoesNotPull(t *testing.T) {
|
func TestShowOrPull_CloudModel_NotFoundDoesNotPull(t *testing.T) {
|
||||||
// Confirm prompt should NOT be called for explicit cloud models
|
// Confirm prompt should NOT be called for explicit cloud models
|
||||||
oldHook := DefaultConfirmPrompt
|
oldHook := DefaultConfirmPrompt
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
t.Error("confirm prompt should not be called for cloud models")
|
t.Error("confirm prompt should not be called for cloud models")
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -1095,11 +1182,11 @@ func TestShowOrPull_CloudModel_NotFoundDoesNotPull(t *testing.T) {
|
|||||||
u, _ := url.Parse(srv.URL)
|
u, _ := url.Parse(srv.URL)
|
||||||
client := api.NewClient(u, srv.Client())
|
client := api.NewClient(u, srv.Client())
|
||||||
|
|
||||||
err := showOrPullWithPolicy(context.Background(), client, "glm-5:cloud", missingModelPromptPull, true)
|
err := showOrPullWithPolicy(context.Background(), client, "glm-5.1:cloud", missingModelPromptPull, true)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("ShowOrPull should return not-found error for cloud model")
|
t.Error("ShowOrPull should return not-found error for cloud model")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), `model "glm-5:cloud" not found`) {
|
if !strings.Contains(err.Error(), `model "glm-5.1:cloud" not found`) {
|
||||||
t.Errorf("expected cloud model not-found error, got: %v", err)
|
t.Errorf("expected cloud model not-found error, got: %v", err)
|
||||||
}
|
}
|
||||||
if pullCalled {
|
if pullCalled {
|
||||||
@@ -1110,7 +1197,7 @@ func TestShowOrPull_CloudModel_NotFoundDoesNotPull(t *testing.T) {
|
|||||||
func TestShowOrPull_CloudLegacySuffix_NotFoundDoesNotPull(t *testing.T) {
|
func TestShowOrPull_CloudLegacySuffix_NotFoundDoesNotPull(t *testing.T) {
|
||||||
// Confirm prompt should NOT be called for explicit cloud models
|
// Confirm prompt should NOT be called for explicit cloud models
|
||||||
oldHook := DefaultConfirmPrompt
|
oldHook := DefaultConfirmPrompt
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
t.Error("confirm prompt should not be called for cloud models")
|
t.Error("confirm prompt should not be called for cloud models")
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -1150,7 +1237,7 @@ func TestShowOrPull_CloudLegacySuffix_NotFoundDoesNotPull(t *testing.T) {
|
|||||||
func TestConfirmPrompt_DelegatesToHook(t *testing.T) {
|
func TestConfirmPrompt_DelegatesToHook(t *testing.T) {
|
||||||
oldHook := DefaultConfirmPrompt
|
oldHook := DefaultConfirmPrompt
|
||||||
var hookCalled bool
|
var hookCalled bool
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
hookCalled = true
|
hookCalled = true
|
||||||
if prompt != "test prompt?" {
|
if prompt != "test prompt?" {
|
||||||
t.Errorf("expected prompt %q, got %q", "test prompt?", prompt)
|
t.Errorf("expected prompt %q, got %q", "test prompt?", prompt)
|
||||||
@@ -1274,7 +1361,7 @@ func TestEnsureAuth_PreservesCancelledSignInHook(t *testing.T) {
|
|||||||
|
|
||||||
func TestEnsureAuth_DeclinedFallbackReturnsCancelled(t *testing.T) {
|
func TestEnsureAuth_DeclinedFallbackReturnsCancelled(t *testing.T) {
|
||||||
oldConfirm := DefaultConfirmPrompt
|
oldConfirm := DefaultConfirmPrompt
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
defer func() { DefaultConfirmPrompt = oldConfirm }()
|
defer func() { DefaultConfirmPrompt = oldConfirm }()
|
||||||
@@ -1551,6 +1638,31 @@ func TestIntegration_Editor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIntegration_AutoInstallable(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"openclaw", true},
|
||||||
|
{"pi", true},
|
||||||
|
{"claude", false},
|
||||||
|
{"codex", false},
|
||||||
|
{"opencode", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := false
|
||||||
|
integration, err := integrationFor(tt.name)
|
||||||
|
if err == nil {
|
||||||
|
got = integration.autoInstallable
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("integrationFor(%q).autoInstallable = %v, want %v", tt.name, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIntegrationModels(t *testing.T) {
|
func TestIntegrationModels(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
setTestHome(t, tmpDir)
|
setTestHome(t, tmpDir)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/ollama/ollama/api"
|
"github.com/ollama/ollama/api"
|
||||||
@@ -179,6 +180,7 @@ Supported integrations:
|
|||||||
opencode OpenCode
|
opencode OpenCode
|
||||||
openclaw OpenClaw (aliases: clawdbot, moltbot)
|
openclaw OpenClaw (aliases: clawdbot, moltbot)
|
||||||
pi Pi
|
pi Pi
|
||||||
|
vscode VS Code (aliases: code)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
ollama launch
|
ollama launch
|
||||||
@@ -489,15 +491,17 @@ func (c *launcherClient) launchEditorIntegration(ctx context.Context, name strin
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
models = selected
|
models = selected
|
||||||
} else if err := c.ensureModelsReady(ctx, models); err != nil {
|
} else if len(models) > 0 {
|
||||||
return err
|
if err := c.ensureModelsReady(ctx, models[:1]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(models) == 0 {
|
if len(models) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if needsConfigure || req.ModelOverride != "" {
|
if (needsConfigure || req.ModelOverride != "") && !savedMatchesModels(saved, models) {
|
||||||
if err := prepareEditorIntegration(name, runner, editor, models); err != nil {
|
if err := prepareEditorIntegration(name, runner, editor, models); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -537,24 +541,19 @@ func (c *launcherClient) selectMultiModelsForIntegration(ctx context.Context, ru
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(preChecked) > 0 {
|
|
||||||
// Keep list order stable in multi-select even when there are existing checks.
|
|
||||||
// checked/default state still comes from orderedChecked.
|
|
||||||
stableItems, _, stableErr := c.loadSelectableModels(ctx, nil, current, "no models available")
|
|
||||||
if stableErr != nil {
|
|
||||||
return nil, stableErr
|
|
||||||
}
|
|
||||||
items = stableItems
|
|
||||||
}
|
|
||||||
|
|
||||||
selected, err := DefaultMultiSelector(fmt.Sprintf("Select models for %s:", runner), items, orderedChecked)
|
selected, err := DefaultMultiSelector(fmt.Sprintf("Select models for %s:", runner), items, orderedChecked)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := c.ensureModelsReady(ctx, selected); err != nil {
|
accepted, skipped, err := c.selectReadyModelsForSave(ctx, selected)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return selected, nil
|
for _, skip := range skipped {
|
||||||
|
fmt.Fprintf(os.Stderr, "Skipped %s: %s\n", skip.model, skip.reason)
|
||||||
|
}
|
||||||
|
return accepted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *launcherClient) loadSelectableModels(ctx context.Context, preChecked []string, current, emptyMessage string) ([]ModelItem, []string, error) {
|
func (c *launcherClient) loadSelectableModels(ctx context.Context, preChecked []string, current, emptyMessage string) ([]ModelItem, []string, error) {
|
||||||
@@ -575,16 +574,7 @@ func (c *launcherClient) loadSelectableModels(ctx context.Context, preChecked []
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *launcherClient) ensureModelsReady(ctx context.Context, models []string) error {
|
func (c *launcherClient) ensureModelsReady(ctx context.Context, models []string) error {
|
||||||
var deduped []string
|
models = dedupeModelList(models)
|
||||||
seen := make(map[string]bool, len(models))
|
|
||||||
for _, model := range models {
|
|
||||||
if model == "" || seen[model] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[model] = true
|
|
||||||
deduped = append(deduped, model)
|
|
||||||
}
|
|
||||||
models = deduped
|
|
||||||
if len(models) == 0 {
|
if len(models) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -602,6 +592,56 @@ func (c *launcherClient) ensureModelsReady(ctx context.Context, models []string)
|
|||||||
return ensureAuth(ctx, c.apiClient, cloudModels, models)
|
return ensureAuth(ctx, c.apiClient, cloudModels, models)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dedupeModelList(models []string) []string {
|
||||||
|
deduped := make([]string, 0, len(models))
|
||||||
|
seen := make(map[string]bool, len(models))
|
||||||
|
for _, model := range models {
|
||||||
|
if model == "" || seen[model] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[model] = true
|
||||||
|
deduped = append(deduped, model)
|
||||||
|
}
|
||||||
|
return deduped
|
||||||
|
}
|
||||||
|
|
||||||
|
type skippedModel struct {
|
||||||
|
model string
|
||||||
|
reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *launcherClient) selectReadyModelsForSave(ctx context.Context, selected []string) ([]string, []skippedModel, error) {
|
||||||
|
selected = dedupeModelList(selected)
|
||||||
|
accepted := make([]string, 0, len(selected))
|
||||||
|
skipped := make([]skippedModel, 0, len(selected))
|
||||||
|
|
||||||
|
for _, model := range selected {
|
||||||
|
if err := c.ensureModelsReady(ctx, []string{model}); err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
skipped = append(skipped, skippedModel{
|
||||||
|
model: model,
|
||||||
|
reason: skippedModelReason(model, err),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
accepted = append(accepted, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
return accepted, skipped, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func skippedModelReason(model string, err error) string {
|
||||||
|
if errors.Is(err, ErrCancelled) {
|
||||||
|
if isCloudModelName(model) {
|
||||||
|
return "sign in was cancelled"
|
||||||
|
}
|
||||||
|
return "download was cancelled"
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
func (c *launcherClient) resolveEditorLaunchModels(ctx context.Context, saved *config.IntegrationConfig, req IntegrationLaunchRequest) ([]string, bool) {
|
func (c *launcherClient) resolveEditorLaunchModels(ctx context.Context, saved *config.IntegrationConfig, req IntegrationLaunchRequest) ([]string, bool) {
|
||||||
if req.ForceConfigure {
|
if req.ForceConfigure {
|
||||||
return editorPreCheckedModels(saved, req.ModelOverride), true
|
return editorPreCheckedModels(saved, req.ModelOverride), true
|
||||||
@@ -717,7 +757,6 @@ func (c *launcherClient) loadModelInventoryOnce(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runIntegration(runner Runner, modelName string, args []string) error {
|
func runIntegration(runner Runner, modelName string, args []string) error {
|
||||||
fmt.Fprintf(os.Stderr, "\nLaunching %s with %s...\n", runner, modelName)
|
|
||||||
return runner.Run(modelName, args)
|
return runner.Run(modelName, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,13 +840,6 @@ func cloneAliases(aliases map[string]string) map[string]string {
|
|||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
func singleModelPrechecked(current string) []string {
|
|
||||||
if current == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []string{current}
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstModel(models []string) string {
|
func firstModel(models []string) string {
|
||||||
if len(models) == 0 {
|
if len(models) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -815,6 +847,13 @@ func firstModel(models []string) string {
|
|||||||
return models[0]
|
return models[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func savedMatchesModels(saved *config.IntegrationConfig, models []string) bool {
|
||||||
|
if saved == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return slices.Equal(saved.Models, models)
|
||||||
|
}
|
||||||
|
|
||||||
func editorPreCheckedModels(saved *config.IntegrationConfig, override string) []string {
|
func editorPreCheckedModels(saved *config.IntegrationConfig, override string) []string {
|
||||||
if override == "" {
|
if override == "" {
|
||||||
if saved == nil {
|
if saved == nil {
|
||||||
|
|||||||
@@ -521,7 +521,7 @@ func TestResolveRunModel_ForcePicker_DoesNotReorderByLastModel(t *testing.T) {
|
|||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
case "/api/tags":
|
case "/api/tags":
|
||||||
fmt.Fprint(w, `{"models":[{"name":"qwen3.5"},{"name":"glm-4.7-flash"}]}`)
|
fmt.Fprint(w, `{"models":[{"name":"qwen3.5"},{"name":"gemma4"}]}`)
|
||||||
case "/api/show":
|
case "/api/show":
|
||||||
fmt.Fprint(w, `{"model":"qwen3.5"}`)
|
fmt.Fprint(w, `{"model":"qwen3.5"}`)
|
||||||
default:
|
default:
|
||||||
@@ -540,7 +540,7 @@ func TestResolveRunModel_ForcePicker_DoesNotReorderByLastModel(t *testing.T) {
|
|||||||
t.Fatal("expected selector to receive model items")
|
t.Fatal("expected selector to receive model items")
|
||||||
}
|
}
|
||||||
|
|
||||||
glmIdx := slices.Index(gotNames, "glm-4.7-flash")
|
glmIdx := slices.Index(gotNames, "gemma4")
|
||||||
qwenIdx := slices.Index(gotNames, "qwen3.5")
|
qwenIdx := slices.Index(gotNames, "qwen3.5")
|
||||||
if glmIdx == -1 || qwenIdx == -1 {
|
if glmIdx == -1 || qwenIdx == -1 {
|
||||||
t.Fatalf("expected recommended local models in selector items, got %v", gotNames)
|
t.Fatalf("expected recommended local models in selector items, got %v", gotNames)
|
||||||
@@ -618,7 +618,7 @@ func TestLaunchIntegration_EditorForceConfigure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var proceedPrompt bool
|
var proceedPrompt bool
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
if prompt == "Proceed?" {
|
if prompt == "Proceed?" {
|
||||||
proceedPrompt = true
|
proceedPrompt = true
|
||||||
}
|
}
|
||||||
@@ -668,7 +668,7 @@ func TestLaunchIntegration_EditorForceConfigure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLaunchIntegration_EditorForceConfigure_DoesNotFloatCheckedModelsInPicker(t *testing.T) {
|
func TestLaunchIntegration_EditorForceConfigure_FloatsCheckedModelsInPicker(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
setLaunchTestHome(t, tmpDir)
|
setLaunchTestHome(t, tmpDir)
|
||||||
withLauncherHooks(t)
|
withLauncherHooks(t)
|
||||||
@@ -725,8 +725,8 @@ func TestLaunchIntegration_EditorForceConfigure_DoesNotFloatCheckedModelsInPicke
|
|||||||
if len(gotItems) == 0 {
|
if len(gotItems) == 0 {
|
||||||
t.Fatal("expected multi selector to receive items")
|
t.Fatal("expected multi selector to receive items")
|
||||||
}
|
}
|
||||||
if gotItems[0] != "kimi-k2.5:cloud" {
|
if gotItems[0] != "qwen3.5:cloud" {
|
||||||
t.Fatalf("expected stable recommendation order with kimi-k2.5:cloud first, got %v", gotItems)
|
t.Fatalf("expected checked models floated to top with qwen3.5:cloud first, got %v", gotItems)
|
||||||
}
|
}
|
||||||
if len(gotPreChecked) < 2 {
|
if len(gotPreChecked) < 2 {
|
||||||
t.Fatalf("expected prechecked models to be preserved, got %v", gotPreChecked)
|
t.Fatalf("expected prechecked models to be preserved, got %v", gotPreChecked)
|
||||||
@@ -832,6 +832,403 @@ func TestLaunchIntegration_EditorCloudDisabledFallsBackToSelector(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLaunchIntegration_EditorConfigureMultiSkipsMissingLocalAndPersistsAccepted(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
withLauncherHooks(t)
|
||||||
|
|
||||||
|
binDir := t.TempDir()
|
||||||
|
writeFakeBinary(t, binDir, "droid")
|
||||||
|
t.Setenv("PATH", binDir)
|
||||||
|
|
||||||
|
editor := &launcherEditorRunner{}
|
||||||
|
withIntegrationOverride(t, "droid", editor)
|
||||||
|
|
||||||
|
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
|
||||||
|
return []string{"glm-5:cloud", "missing-local"}, nil
|
||||||
|
}
|
||||||
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
if prompt == "Proceed?" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if prompt == "Download missing-local?" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected prompt: %q", prompt)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/tags":
|
||||||
|
fmt.Fprint(w, `{"models":[{"name":"glm-5:cloud","remote_model":"glm-5"}]}`)
|
||||||
|
case "/api/status":
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprint(w, `{"error":"not found"}`)
|
||||||
|
case "/api/show":
|
||||||
|
var req apiShowRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
switch req.Model {
|
||||||
|
case "glm-5:cloud":
|
||||||
|
fmt.Fprint(w, `{"remote_model":"glm-5"}`)
|
||||||
|
case "missing-local":
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprint(w, `{"error":"model not found"}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
case "/api/me":
|
||||||
|
fmt.Fprint(w, `{"name":"test-user"}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||||
|
|
||||||
|
var launchErr error
|
||||||
|
stderr := captureStderr(t, func() {
|
||||||
|
launchErr = LaunchIntegration(context.Background(), IntegrationLaunchRequest{
|
||||||
|
Name: "droid",
|
||||||
|
ForceConfigure: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if launchErr != nil {
|
||||||
|
t.Fatalf("LaunchIntegration returned error: %v", launchErr)
|
||||||
|
}
|
||||||
|
if editor.ranModel != "glm-5:cloud" {
|
||||||
|
t.Fatalf("expected launch to use cloud primary, got %q", editor.ranModel)
|
||||||
|
}
|
||||||
|
saved, err := config.LoadIntegration("droid")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to reload saved config: %v", err)
|
||||||
|
}
|
||||||
|
if diff := compareStrings(saved.Models, []string{"glm-5:cloud"}); diff != "" {
|
||||||
|
t.Fatalf("unexpected saved models (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
if diff := compareStringSlices(editor.edited, [][]string{{"glm-5:cloud"}}); diff != "" {
|
||||||
|
t.Fatalf("unexpected edited models (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "Skipped missing-local:") {
|
||||||
|
t.Fatalf("expected skip reason in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchIntegration_EditorConfigureMultiSkipsUnauthedCloudAndPersistsAccepted(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
withLauncherHooks(t)
|
||||||
|
|
||||||
|
binDir := t.TempDir()
|
||||||
|
writeFakeBinary(t, binDir, "droid")
|
||||||
|
t.Setenv("PATH", binDir)
|
||||||
|
|
||||||
|
editor := &launcherEditorRunner{}
|
||||||
|
withIntegrationOverride(t, "droid", editor)
|
||||||
|
|
||||||
|
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
|
||||||
|
return []string{"llama3.2", "glm-5:cloud"}, nil
|
||||||
|
}
|
||||||
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
if prompt == "Proceed?" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected prompt: %q", prompt)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
DefaultSignIn = func(modelName, signInURL string) (string, error) {
|
||||||
|
return "", ErrCancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/tags":
|
||||||
|
fmt.Fprint(w, `{"models":[{"name":"llama3.2"},{"name":"glm-5:cloud","remote_model":"glm-5"}]}`)
|
||||||
|
case "/api/status":
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprint(w, `{"error":"not found"}`)
|
||||||
|
case "/api/show":
|
||||||
|
var req apiShowRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
switch req.Model {
|
||||||
|
case "llama3.2":
|
||||||
|
fmt.Fprint(w, `{"model":"llama3.2"}`)
|
||||||
|
case "glm-5:cloud":
|
||||||
|
fmt.Fprint(w, `{"remote_model":"glm-5"}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
case "/api/me":
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
fmt.Fprint(w, `{"error":"unauthorized","signin_url":"https://example.com/signin"}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||||
|
|
||||||
|
var launchErr error
|
||||||
|
stderr := captureStderr(t, func() {
|
||||||
|
launchErr = LaunchIntegration(context.Background(), IntegrationLaunchRequest{
|
||||||
|
Name: "droid",
|
||||||
|
ForceConfigure: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if launchErr != nil {
|
||||||
|
t.Fatalf("LaunchIntegration returned error: %v", launchErr)
|
||||||
|
}
|
||||||
|
if editor.ranModel != "llama3.2" {
|
||||||
|
t.Fatalf("expected launch to use local primary, got %q", editor.ranModel)
|
||||||
|
}
|
||||||
|
saved, err := config.LoadIntegration("droid")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to reload saved config: %v", err)
|
||||||
|
}
|
||||||
|
if diff := compareStrings(saved.Models, []string{"llama3.2"}); diff != "" {
|
||||||
|
t.Fatalf("unexpected saved models (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
if diff := compareStringSlices(editor.edited, [][]string{{"llama3.2"}}); diff != "" {
|
||||||
|
t.Fatalf("unexpected edited models (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "Skipped glm-5:cloud: sign in was cancelled") {
|
||||||
|
t.Fatalf("expected skip reason in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchIntegration_EditorConfigureMultiRemovesReselectedFailingModel(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
withLauncherHooks(t)
|
||||||
|
|
||||||
|
binDir := t.TempDir()
|
||||||
|
writeFakeBinary(t, binDir, "droid")
|
||||||
|
t.Setenv("PATH", binDir)
|
||||||
|
|
||||||
|
editor := &launcherEditorRunner{}
|
||||||
|
withIntegrationOverride(t, "droid", editor)
|
||||||
|
|
||||||
|
if err := config.SaveIntegration("droid", []string{"glm-5:cloud", "llama3.2"}); err != nil {
|
||||||
|
t.Fatalf("failed to seed config: %v", err)
|
||||||
|
}
|
||||||
|
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
|
||||||
|
return append([]string(nil), preChecked...), nil
|
||||||
|
}
|
||||||
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
if prompt == "Proceed?" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected prompt: %q", prompt)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
DefaultSignIn = func(modelName, signInURL string) (string, error) {
|
||||||
|
return "", ErrCancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/tags":
|
||||||
|
fmt.Fprint(w, `{"models":[{"name":"glm-5:cloud","remote_model":"glm-5"},{"name":"llama3.2"}]}`)
|
||||||
|
case "/api/status":
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprint(w, `{"error":"not found"}`)
|
||||||
|
case "/api/show":
|
||||||
|
var req apiShowRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if req.Model == "glm-5:cloud" {
|
||||||
|
fmt.Fprint(w, `{"remote_model":"glm-5"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Model == "llama3.2" {
|
||||||
|
fmt.Fprint(w, `{"model":"llama3.2"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
case "/api/me":
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
fmt.Fprint(w, `{"error":"unauthorized","signin_url":"https://example.com/signin"}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||||
|
|
||||||
|
var launchErr error
|
||||||
|
stderr := captureStderr(t, func() {
|
||||||
|
launchErr = LaunchIntegration(context.Background(), IntegrationLaunchRequest{
|
||||||
|
Name: "droid",
|
||||||
|
ForceConfigure: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if launchErr != nil {
|
||||||
|
t.Fatalf("LaunchIntegration returned error: %v", launchErr)
|
||||||
|
}
|
||||||
|
if editor.ranModel != "llama3.2" {
|
||||||
|
t.Fatalf("expected launch to use surviving model, got %q", editor.ranModel)
|
||||||
|
}
|
||||||
|
if diff := compareStringSlices(editor.edited, [][]string{{"llama3.2"}}); diff != "" {
|
||||||
|
t.Fatalf("unexpected edited models (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
saved, loadErr := config.LoadIntegration("droid")
|
||||||
|
if loadErr != nil {
|
||||||
|
t.Fatalf("failed to reload saved config: %v", loadErr)
|
||||||
|
}
|
||||||
|
if diff := compareStrings(saved.Models, []string{"llama3.2"}); diff != "" {
|
||||||
|
t.Fatalf("unexpected saved models (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "Skipped glm-5:cloud: sign in was cancelled") {
|
||||||
|
t.Fatalf("expected skip reason in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchIntegration_EditorConfigureMultiAllFailuresKeepsExistingAndSkipsLaunch(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
withLauncherHooks(t)
|
||||||
|
|
||||||
|
binDir := t.TempDir()
|
||||||
|
writeFakeBinary(t, binDir, "droid")
|
||||||
|
t.Setenv("PATH", binDir)
|
||||||
|
|
||||||
|
editor := &launcherEditorRunner{}
|
||||||
|
withIntegrationOverride(t, "droid", editor)
|
||||||
|
|
||||||
|
if err := config.SaveIntegration("droid", []string{"llama3.2"}); err != nil {
|
||||||
|
t.Fatalf("failed to seed config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
|
||||||
|
return []string{"missing-local-a", "missing-local-b"}, nil
|
||||||
|
}
|
||||||
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
if prompt == "Download missing-local-a?" || prompt == "Download missing-local-b?" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if prompt == "Proceed?" {
|
||||||
|
t.Fatal("did not expect proceed prompt when no models are accepted")
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected prompt: %q", prompt)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/tags":
|
||||||
|
fmt.Fprint(w, `{"models":[]}`)
|
||||||
|
case "/api/show":
|
||||||
|
var req apiShowRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
switch req.Model {
|
||||||
|
case "missing-local-a", "missing-local-b":
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprint(w, `{"error":"model not found"}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||||
|
|
||||||
|
var launchErr error
|
||||||
|
stderr := captureStderr(t, func() {
|
||||||
|
launchErr = LaunchIntegration(context.Background(), IntegrationLaunchRequest{
|
||||||
|
Name: "droid",
|
||||||
|
ForceConfigure: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if launchErr != nil {
|
||||||
|
t.Fatalf("LaunchIntegration returned error: %v", launchErr)
|
||||||
|
}
|
||||||
|
if editor.ranModel != "" {
|
||||||
|
t.Fatalf("expected no launch when all selected models are skipped, got %q", editor.ranModel)
|
||||||
|
}
|
||||||
|
if len(editor.edited) != 0 {
|
||||||
|
t.Fatalf("expected no editor writes when all selections fail, got %v", editor.edited)
|
||||||
|
}
|
||||||
|
saved, err := config.LoadIntegration("droid")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to reload saved config: %v", err)
|
||||||
|
}
|
||||||
|
if diff := compareStrings(saved.Models, []string{"llama3.2"}); diff != "" {
|
||||||
|
t.Fatalf("unexpected saved models (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "Skipped missing-local-a:") {
|
||||||
|
t.Fatalf("expected first skip reason in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "Skipped missing-local-b:") {
|
||||||
|
t.Fatalf("expected second skip reason in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchIntegration_ConfiguredEditorLaunchValidatesPrimaryOnly(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
withLauncherHooks(t)
|
||||||
|
|
||||||
|
binDir := t.TempDir()
|
||||||
|
writeFakeBinary(t, binDir, "droid")
|
||||||
|
t.Setenv("PATH", binDir)
|
||||||
|
|
||||||
|
editor := &launcherEditorRunner{}
|
||||||
|
withIntegrationOverride(t, "droid", editor)
|
||||||
|
|
||||||
|
if err := config.SaveIntegration("droid", []string{"llama3.2", "missing-local"}); err != nil {
|
||||||
|
t.Fatalf("failed to seed config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
t.Fatalf("did not expect prompt during normal configured launch: %q", prompt)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var missingShowCalled bool
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/show" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req apiShowRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
switch req.Model {
|
||||||
|
case "llama3.2":
|
||||||
|
fmt.Fprint(w, `{"model":"llama3.2"}`)
|
||||||
|
case "missing-local":
|
||||||
|
missingShowCalled = true
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprint(w, `{"error":"model not found"}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||||
|
|
||||||
|
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "droid"}); err != nil {
|
||||||
|
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||||
|
}
|
||||||
|
if missingShowCalled {
|
||||||
|
t.Fatal("expected configured launch to validate only the primary model")
|
||||||
|
}
|
||||||
|
if editor.ranModel != "llama3.2" {
|
||||||
|
t.Fatalf("expected launch to use saved primary model, got %q", editor.ranModel)
|
||||||
|
}
|
||||||
|
if len(editor.edited) != 0 {
|
||||||
|
t.Fatalf("expected no editor writes during normal launch, got %v", editor.edited)
|
||||||
|
}
|
||||||
|
|
||||||
|
saved, err := config.LoadIntegration("droid")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to reload saved config: %v", err)
|
||||||
|
}
|
||||||
|
if diff := compareStrings(saved.Models, []string{"llama3.2", "missing-local"}); diff != "" {
|
||||||
|
t.Fatalf("unexpected saved models (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLaunchIntegration_ConfiguredEditorLaunchSkipsReconfigure(t *testing.T) {
|
func TestLaunchIntegration_ConfiguredEditorLaunchSkipsReconfigure(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
setLaunchTestHome(t, tmpDir)
|
setLaunchTestHome(t, tmpDir)
|
||||||
@@ -848,7 +1245,7 @@ func TestLaunchIntegration_ConfiguredEditorLaunchSkipsReconfigure(t *testing.T)
|
|||||||
t.Fatalf("failed to seed config: %v", err)
|
t.Fatalf("failed to seed config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
t.Fatalf("did not expect prompt during a normal editor launch: %s", prompt)
|
t.Fatalf("did not expect prompt during a normal editor launch: %s", prompt)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -965,6 +1362,40 @@ func TestLaunchIntegration_OpenclawInstallsBeforeConfigSideEffects(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLaunchIntegration_PiInstallsBeforeConfigSideEffects(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
withLauncherHooks(t)
|
||||||
|
|
||||||
|
t.Setenv("PATH", t.TempDir())
|
||||||
|
|
||||||
|
editor := &launcherEditorRunner{}
|
||||||
|
withIntegrationOverride(t, "pi", editor)
|
||||||
|
|
||||||
|
selectorCalled := false
|
||||||
|
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
|
||||||
|
selectorCalled = true
|
||||||
|
return []string{"llama3.2"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "pi"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected launch to fail before configuration when Pi is missing")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "required dependencies are missing") {
|
||||||
|
t.Fatalf("expected install prerequisite error, got %v", err)
|
||||||
|
}
|
||||||
|
if selectorCalled {
|
||||||
|
t.Fatal("expected install check to happen before model selection")
|
||||||
|
}
|
||||||
|
if len(editor.edited) != 0 {
|
||||||
|
t.Fatalf("expected no editor writes before install succeeds, got %v", editor.edited)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(filepath.Join(tmpDir, ".pi", "agent", "models.json")); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("expected no Pi config file to be created, stat err = %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLaunchIntegration_ConfigureOnlyDoesNotRequireInstalledBinary(t *testing.T) {
|
func TestLaunchIntegration_ConfigureOnlyDoesNotRequireInstalledBinary(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
setLaunchTestHome(t, tmpDir)
|
setLaunchTestHome(t, tmpDir)
|
||||||
@@ -979,7 +1410,7 @@ func TestLaunchIntegration_ConfigureOnlyDoesNotRequireInstalledBinary(t *testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
var prompts []string
|
var prompts []string
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
prompts = append(prompts, prompt)
|
prompts = append(prompts, prompt)
|
||||||
if strings.Contains(prompt, "Launch LauncherEditor now?") {
|
if strings.Contains(prompt, "Launch LauncherEditor now?") {
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -1122,6 +1553,67 @@ func TestLaunchIntegration_ClaudeForceConfigureReprompts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLaunchIntegration_ClaudeForceConfigureMissingSelectionDoesNotSave(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
withLauncherHooks(t)
|
||||||
|
|
||||||
|
binDir := t.TempDir()
|
||||||
|
writeFakeBinary(t, binDir, "claude")
|
||||||
|
t.Setenv("PATH", binDir)
|
||||||
|
|
||||||
|
if err := config.SaveIntegration("claude", []string{"llama3.2"}); err != nil {
|
||||||
|
t.Fatalf("failed to seed config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
|
||||||
|
return "missing-model", nil
|
||||||
|
}
|
||||||
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
if prompt == "Download missing-model?" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected prompt: %q", prompt)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/tags":
|
||||||
|
fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`)
|
||||||
|
case "/api/show":
|
||||||
|
var req apiShowRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if req.Model == "missing-model" {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprint(w, `{"error":"model not found"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `{"model":%q}`, req.Model)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||||
|
|
||||||
|
err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
|
||||||
|
Name: "claude",
|
||||||
|
ForceConfigure: true,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected missing selected model to abort launch")
|
||||||
|
}
|
||||||
|
|
||||||
|
saved, loadErr := config.LoadIntegration("claude")
|
||||||
|
if loadErr != nil {
|
||||||
|
t.Fatalf("failed to reload saved config: %v", loadErr)
|
||||||
|
}
|
||||||
|
if diff := compareStrings(saved.Models, []string{"llama3.2"}); diff != "" {
|
||||||
|
t.Fatalf("unexpected saved models (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLaunchIntegration_ClaudeModelOverrideSkipsSelector(t *testing.T) {
|
func TestLaunchIntegration_ClaudeModelOverrideSkipsSelector(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
setLaunchTestHome(t, tmpDir)
|
setLaunchTestHome(t, tmpDir)
|
||||||
@@ -1139,7 +1631,7 @@ func TestLaunchIntegration_ClaudeModelOverrideSkipsSelector(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var confirmCalls int
|
var confirmCalls int
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
confirmCalls++
|
confirmCalls++
|
||||||
if !strings.Contains(prompt, "glm-4") {
|
if !strings.Contains(prompt, "glm-4") {
|
||||||
t.Fatalf("expected download prompt for override model, got %q", prompt)
|
t.Fatalf("expected download prompt for override model, got %q", prompt)
|
||||||
@@ -1203,7 +1695,7 @@ func TestLaunchIntegration_ConfigureOnlyPrompt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var prompts []string
|
var prompts []string
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
prompts = append(prompts, prompt)
|
prompts = append(prompts, prompt)
|
||||||
if strings.Contains(prompt, "Launch StubSingle now?") {
|
if strings.Contains(prompt, "Launch StubSingle now?") {
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -1253,7 +1745,7 @@ func TestLaunchIntegration_ModelOverrideHeadlessMissingFailsWithoutPrompt(t *tes
|
|||||||
withIntegrationOverride(t, "droid", runner)
|
withIntegrationOverride(t, "droid", runner)
|
||||||
|
|
||||||
confirmCalled := false
|
confirmCalled := false
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
confirmCalled = true
|
confirmCalled = true
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -1310,7 +1802,7 @@ func TestLaunchIntegration_ModelOverrideHeadlessCanOverrideMissingModelPolicy(t
|
|||||||
withIntegrationOverride(t, "droid", runner)
|
withIntegrationOverride(t, "droid", runner)
|
||||||
|
|
||||||
confirmCalled := false
|
confirmCalled := false
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
confirmCalled = true
|
confirmCalled = true
|
||||||
if !strings.Contains(prompt, "missing-model") {
|
if !strings.Contains(prompt, "missing-model") {
|
||||||
t.Fatalf("expected prompt to mention missing model, got %q", prompt)
|
t.Fatalf("expected prompt to mention missing model, got %q", prompt)
|
||||||
@@ -1368,7 +1860,7 @@ func TestLaunchIntegration_ModelOverrideInteractiveMissingPromptsAndPulls(t *tes
|
|||||||
withIntegrationOverride(t, "droid", runner)
|
withIntegrationOverride(t, "droid", runner)
|
||||||
|
|
||||||
confirmCalled := false
|
confirmCalled := false
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
confirmCalled = true
|
confirmCalled = true
|
||||||
if !strings.Contains(prompt, "missing-model") {
|
if !strings.Contains(prompt, "missing-model") {
|
||||||
t.Fatalf("expected prompt to mention missing model, got %q", prompt)
|
t.Fatalf("expected prompt to mention missing model, got %q", prompt)
|
||||||
@@ -1429,7 +1921,7 @@ func TestLaunchIntegration_HeadlessSelectorFlowFailsWithoutPrompt(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
confirmCalled := false
|
confirmCalled := false
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
confirmCalled = true
|
confirmCalled = true
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,15 +23,15 @@ import (
|
|||||||
var recommendedModels = []ModelItem{
|
var recommendedModels = []ModelItem{
|
||||||
{Name: "kimi-k2.5:cloud", Description: "Multimodal reasoning with subagents", Recommended: true},
|
{Name: "kimi-k2.5:cloud", Description: "Multimodal reasoning with subagents", Recommended: true},
|
||||||
{Name: "qwen3.5:cloud", Description: "Reasoning, coding, and agentic tool use with vision", Recommended: true},
|
{Name: "qwen3.5:cloud", Description: "Reasoning, coding, and agentic tool use with vision", Recommended: true},
|
||||||
{Name: "glm-5:cloud", Description: "Reasoning and code generation", Recommended: true},
|
{Name: "glm-5.1:cloud", Description: "Reasoning and code generation", Recommended: true},
|
||||||
{Name: "minimax-m2.7:cloud", Description: "Fast, efficient coding and real-world productivity", Recommended: true},
|
{Name: "minimax-m2.7:cloud", Description: "Fast, efficient coding and real-world productivity", Recommended: true},
|
||||||
{Name: "glm-4.7-flash", Description: "Reasoning and code generation locally", Recommended: true},
|
{Name: "gemma4", Description: "Reasoning and code generation locally", Recommended: true},
|
||||||
{Name: "qwen3.5", Description: "Reasoning, coding, and visual understanding locally", Recommended: true},
|
{Name: "qwen3.5", Description: "Reasoning, coding, and visual understanding locally", Recommended: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
var recommendedVRAM = map[string]string{
|
var recommendedVRAM = map[string]string{
|
||||||
"glm-4.7-flash": "~25GB",
|
"gemma4": "~16GB",
|
||||||
"qwen3.5": "~11GB",
|
"qwen3.5": "~11GB",
|
||||||
}
|
}
|
||||||
|
|
||||||
// cloudModelLimit holds context and output token limits for a cloud model.
|
// cloudModelLimit holds context and output token limits for a cloud model.
|
||||||
@@ -47,9 +47,11 @@ var cloudModelLimits = map[string]cloudModelLimit{
|
|||||||
"cogito-2.1:671b": {Context: 163_840, Output: 65_536},
|
"cogito-2.1:671b": {Context: 163_840, Output: 65_536},
|
||||||
"deepseek-v3.1:671b": {Context: 163_840, Output: 163_840},
|
"deepseek-v3.1:671b": {Context: 163_840, Output: 163_840},
|
||||||
"deepseek-v3.2": {Context: 163_840, Output: 65_536},
|
"deepseek-v3.2": {Context: 163_840, Output: 65_536},
|
||||||
|
"gemma4:31b": {Context: 262_144, Output: 131_072},
|
||||||
"glm-4.6": {Context: 202_752, Output: 131_072},
|
"glm-4.6": {Context: 202_752, Output: 131_072},
|
||||||
"glm-4.7": {Context: 202_752, Output: 131_072},
|
"glm-4.7": {Context: 202_752, Output: 131_072},
|
||||||
"glm-5": {Context: 202_752, Output: 131_072},
|
"glm-5": {Context: 202_752, Output: 131_072},
|
||||||
|
"glm-5.1": {Context: 202_752, Output: 131_072},
|
||||||
"gpt-oss:120b": {Context: 131_072, Output: 131_072},
|
"gpt-oss:120b": {Context: 131_072, Output: 131_072},
|
||||||
"gpt-oss:20b": {Context: 131_072, Output: 131_072},
|
"gpt-oss:20b": {Context: 131_072, Output: 131_072},
|
||||||
"kimi-k2:1t": {Context: 262_144, Output: 262_144},
|
"kimi-k2:1t": {Context: 262_144, Output: 262_144},
|
||||||
@@ -379,6 +381,17 @@ func buildModelList(existing []modelInfo, preChecked []string, current string) (
|
|||||||
}
|
}
|
||||||
return recRank[a.Name] - recRank[b.Name]
|
return recRank[a.Name] - recRank[b.Name]
|
||||||
}
|
}
|
||||||
|
// Among checked non-recommended items - put the default first
|
||||||
|
if ac && !aRec && current != "" {
|
||||||
|
aCurrent := a.Name == current
|
||||||
|
bCurrent := b.Name == current
|
||||||
|
if aCurrent != bCurrent {
|
||||||
|
if aCurrent {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
if aNew != bNew {
|
if aNew != bNew {
|
||||||
if aNew {
|
if aNew {
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -102,8 +102,6 @@ func (c *Openclaw) Run(model string, args []string) error {
|
|||||||
registerWebSearchPlugin()
|
registerWebSearchPlugin()
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "\n%sStarting your assistant — this may take a moment...%s\n\n", ansiGray, ansiReset)
|
|
||||||
|
|
||||||
// When extra args are passed through, run exactly what the user asked for
|
// When extra args are passed through, run exactly what the user asked for
|
||||||
// after setup and skip the built-in gateway+TUI convenience flow.
|
// after setup and skip the built-in gateway+TUI convenience flow.
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
@@ -118,6 +116,15 @@ func (c *Openclaw) Run(model string, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := c.runChannelSetupPreflight(bin); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Keep local pairing scopes up to date before the gateway lifecycle
|
||||||
|
// (restart/start) regardless of channel preflight branch behavior.
|
||||||
|
patchDeviceScopes()
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "\n%sStarting your assistant — this may take a moment...%s\n\n", ansiGray, ansiReset)
|
||||||
|
|
||||||
token, port := c.gatewayInfo()
|
token, port := c.gatewayInfo()
|
||||||
addr := fmt.Sprintf("localhost:%d", port)
|
addr := fmt.Sprintf("localhost:%d", port)
|
||||||
|
|
||||||
@@ -172,6 +179,94 @@ func (c *Openclaw) Run(model string, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runChannelSetupPreflight prompts users to connect a messaging channel before
|
||||||
|
// starting the built-in gateway+TUI flow. In interactive sessions, it loops
|
||||||
|
// until a channel is configured, unless the user chooses "Set up later".
|
||||||
|
func (c *Openclaw) runChannelSetupPreflight(bin string) error {
|
||||||
|
if !isInteractiveSession() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// --yes is headless; channel setup spawns an interactive picker we can't
|
||||||
|
// auto-answer, so skip it. Users can run `openclaw channels add` later.
|
||||||
|
if currentLaunchConfirmPolicy.yes {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
if c.channelsConfigured() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "\nYour assistant can message you on WhatsApp, Telegram, Discord, and more.\n\n")
|
||||||
|
ok, err := ConfirmPromptWithOptions("Connect a channel (messaging app) now?", ConfirmOptions{
|
||||||
|
YesLabel: "Yes",
|
||||||
|
NoLabel: "Set up later",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(bin, "channels", "add")
|
||||||
|
cmd.Env = openclawEnv()
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return windowsHint(fmt.Errorf("openclaw channel setup failed: %w\n\nTry running: %s channels add", err, bin))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// channelsConfigured reports whether local OpenClaw config contains at least
|
||||||
|
// one meaningfully configured channel entry.
|
||||||
|
func (c *Openclaw) channelsConfigured() bool {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range []string{
|
||||||
|
filepath.Join(home, ".openclaw", "openclaw.json"),
|
||||||
|
filepath.Join(home, ".clawdbot", "clawdbot.json"),
|
||||||
|
} {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg map[string]any
|
||||||
|
if json.Unmarshal(data, &cfg) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
channels, _ := cfg["channels"].(map[string]any)
|
||||||
|
if channels == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range channels {
|
||||||
|
if key == "defaults" || key == "modelByChannel" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry, ok := value.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for entryKey := range entry {
|
||||||
|
if entryKey != "enabled" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// gatewayInfo reads the gateway auth token and port from the OpenClaw config.
|
// gatewayInfo reads the gateway auth token and port from the OpenClaw config.
|
||||||
func (c *Openclaw) gatewayInfo() (token string, port int) {
|
func (c *Openclaw) gatewayInfo() (token string, port int) {
|
||||||
port = defaultGatewayPort
|
port = defaultGatewayPort
|
||||||
@@ -218,12 +313,9 @@ func printOpenclawReady(bin, token string, port int, firstLaunch bool) {
|
|||||||
if firstLaunch {
|
if firstLaunch {
|
||||||
fmt.Fprintf(os.Stderr, "%s Quick start:%s\n", ansiBold, ansiReset)
|
fmt.Fprintf(os.Stderr, "%s Quick start:%s\n", ansiBold, ansiReset)
|
||||||
fmt.Fprintf(os.Stderr, "%s /help see all commands%s\n", ansiGray, ansiReset)
|
fmt.Fprintf(os.Stderr, "%s /help see all commands%s\n", ansiGray, ansiReset)
|
||||||
fmt.Fprintf(os.Stderr, "%s %s configure --section channels connect WhatsApp, Telegram, etc.%s\n", ansiGray, bin, ansiReset)
|
|
||||||
fmt.Fprintf(os.Stderr, "%s %s skills browse and install skills%s\n\n", ansiGray, bin, ansiReset)
|
fmt.Fprintf(os.Stderr, "%s %s skills browse and install skills%s\n\n", ansiGray, bin, ansiReset)
|
||||||
fmt.Fprintf(os.Stderr, "%s The OpenClaw gateway is running in the background.%s\n", ansiYellow, ansiReset)
|
fmt.Fprintf(os.Stderr, "%s The OpenClaw gateway is running in the background.%s\n", ansiYellow, ansiReset)
|
||||||
fmt.Fprintf(os.Stderr, "%s Stop it with: %s gateway stop%s\n\n", ansiYellow, bin, ansiReset)
|
fmt.Fprintf(os.Stderr, "%s Stop it with: %s gateway stop%s\n\n", ansiYellow, bin, ansiReset)
|
||||||
} else {
|
|
||||||
fmt.Fprintf(os.Stderr, "%sTip: connect WhatsApp, Telegram, and more with: %s configure --section channels%s\n", ansiGray, bin, ansiReset)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,9 +404,10 @@ func (c *Openclaw) onboarded() bool {
|
|||||||
return lastRunAt != ""
|
return lastRunAt != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// patchDeviceScopes upgrades the local CLI device's paired scopes to include
|
// patchDeviceScopes upgrades the local CLI device's paired operator scopes so
|
||||||
// operator.admin. Only patches the local device, not remote ones.
|
// newer gateway auth baselines (approvedScopes) allow launch+TUI reconnects
|
||||||
// Best-effort: silently returns on any error.
|
// without forcing an interactive re-pair. Only patches the local device,
|
||||||
|
// not remote ones. Best-effort: silently returns on any error.
|
||||||
func patchDeviceScopes() {
|
func patchDeviceScopes() {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -350,9 +443,15 @@ func patchDeviceScopes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
changed := patchScopes(dev, "scopes", required)
|
changed := patchScopes(dev, "scopes", required)
|
||||||
|
if patchScopes(dev, "approvedScopes", required) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
if tokens, ok := dev["tokens"].(map[string]any); ok {
|
if tokens, ok := dev["tokens"].(map[string]any); ok {
|
||||||
for _, tok := range tokens {
|
for role, tok := range tokens {
|
||||||
if tokenMap, ok := tok.(map[string]any); ok {
|
if tokenMap, ok := tok.(map[string]any); ok {
|
||||||
|
if !isOperatorToken(role, tokenMap) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if patchScopes(tokenMap, "scopes", required) {
|
if patchScopes(tokenMap, "scopes", required) {
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
@@ -408,6 +507,14 @@ func patchScopes(obj map[string]any, key string, required []string) bool {
|
|||||||
return added
|
return added
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isOperatorToken(tokenRole string, token map[string]any) bool {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(tokenRole), "operator") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
role, _ := token["role"].(string)
|
||||||
|
return strings.EqualFold(strings.TrimSpace(role), "operator")
|
||||||
|
}
|
||||||
|
|
||||||
// canInstallDaemon reports whether the openclaw daemon can be installed as a
|
// canInstallDaemon reports whether the openclaw daemon can be installed as a
|
||||||
// background service. Returns false on Linux when systemd is absent (e.g.
|
// background service. Returns false on Linux when systemd is absent (e.g.
|
||||||
// containers) so that --install-daemon is omitted and the gateway is started
|
// containers) so that --install-daemon is omitted and the gateway is started
|
||||||
@@ -445,7 +552,7 @@ func ensureOpenclawInstalled() (string, error) {
|
|||||||
if gitErr != nil {
|
if gitErr != nil {
|
||||||
missing = append(missing, "git: https://git-scm.com/")
|
missing = append(missing, "git: https://git-scm.com/")
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("openclaw is not installed and required dependencies are missing\n\nInstall the following first:\n %s", strings.Join(missing, "\n "))
|
return "", fmt.Errorf("OpenClaw is not installed and required dependencies are missing\n\nInstall the following first:\n %s\n\nThen re-run:\n ollama launch openclaw", strings.Join(missing, "\n "))
|
||||||
}
|
}
|
||||||
|
|
||||||
ok, err := ConfirmPrompt("OpenClaw is not installed. Install with npm?")
|
ok, err := ConfirmPrompt("OpenClaw is not installed. Install with npm?")
|
||||||
@@ -678,7 +785,7 @@ func ensureWebSearchPlugin() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "%s ✓ Installed web search plugin%s\n", ansiGreen, ansiReset)
|
fmt.Fprintf(os.Stderr, "%s ✓ Installed Ollama web search %s\n", ansiGreen, ansiReset)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -64,6 +65,17 @@ func TestOpenclawRunPassthroughArgs(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oldInteractive := isInteractiveSession
|
||||||
|
isInteractiveSession = func() bool { return true }
|
||||||
|
defer func() { isInteractiveSession = oldInteractive }()
|
||||||
|
|
||||||
|
oldConfirmPrompt := DefaultConfirmPrompt
|
||||||
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
t.Fatalf("did not expect confirmation prompt during passthrough launch: %s", prompt)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||||
|
|
||||||
c := &Openclaw{}
|
c := &Openclaw{}
|
||||||
if err := c.Run("llama3.2", []string{"gateway", "--someflag"}); err != nil {
|
if err := c.Run("llama3.2", []string{"gateway", "--someflag"}); err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
@@ -82,6 +94,163 @@ func TestOpenclawRunPassthroughArgs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenclawRun_ChannelSetupHappensBeforeGatewayRestart(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("uses a POSIX shell test binary")
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
port := ln.Addr().(*net.TCPAddr).Port
|
||||||
|
|
||||||
|
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(fmt.Sprintf(`{
|
||||||
|
"wizard": {"lastRunAt": "2026-01-01T00:00:00Z"},
|
||||||
|
"gateway": {"port": %d}
|
||||||
|
}`, port)), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bin := filepath.Join(tmpDir, "openclaw")
|
||||||
|
script := fmt.Sprintf(`#!/bin/sh
|
||||||
|
printf '%%s\n' "$*" >> "$HOME/invocations.log"
|
||||||
|
if [ "$1" = "channels" ] && [ "$2" = "add" ]; then
|
||||||
|
/bin/mkdir -p "$HOME/.openclaw"
|
||||||
|
/bin/cat > "$HOME/.openclaw/openclaw.json" <<'EOF'
|
||||||
|
{"wizard":{"lastRunAt":"2026-01-01T00:00:00Z"},"gateway":{"port":%d},"channels":{"telegram":{"botToken":"configured"}}}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
`, port)
|
||||||
|
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldInteractive := isInteractiveSession
|
||||||
|
isInteractiveSession = func() bool { return true }
|
||||||
|
defer func() { isInteractiveSession = oldInteractive }()
|
||||||
|
|
||||||
|
promptCount := 0
|
||||||
|
oldConfirmPrompt := DefaultConfirmPrompt
|
||||||
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
promptCount++
|
||||||
|
if prompt != "Connect a channel (messaging app) now?" {
|
||||||
|
t.Fatalf("unexpected prompt: %q", prompt)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||||
|
|
||||||
|
c := &Openclaw{}
|
||||||
|
if err := c.Run("llama3.2", nil); err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if promptCount != 1 {
|
||||||
|
t.Fatalf("expected one channel setup prompt, got %d", promptCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(tmpDir, "invocations.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||||
|
if len(lines) < 3 {
|
||||||
|
t.Fatalf("expected at least 3 invocations (channels add, daemon restart, tui), got %v", lines)
|
||||||
|
}
|
||||||
|
if lines[0] != "channels add" {
|
||||||
|
t.Fatalf("expected first invocation to be channels setup, got %q", lines[0])
|
||||||
|
}
|
||||||
|
if lines[1] != "daemon restart" {
|
||||||
|
t.Fatalf("expected second invocation to be daemon restart, got %q", lines[1])
|
||||||
|
}
|
||||||
|
if lines[2] != "tui" {
|
||||||
|
t.Fatalf("expected third invocation to be tui, got %q", lines[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenclawRun_SetupLaterContinuesToGatewayAndTUI(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("uses a POSIX shell test binary")
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
port := ln.Addr().(*net.TCPAddr).Port
|
||||||
|
|
||||||
|
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(fmt.Sprintf(`{
|
||||||
|
"wizard": {"lastRunAt": "2026-01-01T00:00:00Z"},
|
||||||
|
"gateway": {"port": %d}
|
||||||
|
}`, port)), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bin := filepath.Join(tmpDir, "openclaw")
|
||||||
|
if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/invocations.log\"\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldInteractive := isInteractiveSession
|
||||||
|
isInteractiveSession = func() bool { return true }
|
||||||
|
defer func() { isInteractiveSession = oldInteractive }()
|
||||||
|
|
||||||
|
promptCount := 0
|
||||||
|
oldConfirmPrompt := DefaultConfirmPrompt
|
||||||
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
promptCount++
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||||
|
|
||||||
|
c := &Openclaw{}
|
||||||
|
if err := c.Run("llama3.2", nil); err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if promptCount != 1 {
|
||||||
|
t.Fatalf("expected one channel setup prompt, got %d", promptCount)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(tmpDir, "invocations.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||||
|
if len(lines) < 2 {
|
||||||
|
t.Fatalf("expected at least 2 invocations (daemon restart, tui), got %v", lines)
|
||||||
|
}
|
||||||
|
if lines[0] != "daemon restart" {
|
||||||
|
t.Fatalf("expected first invocation to be daemon restart, got %q", lines[0])
|
||||||
|
}
|
||||||
|
if lines[1] != "tui" {
|
||||||
|
t.Fatalf("expected second invocation to be tui, got %q", lines[1])
|
||||||
|
}
|
||||||
|
for _, line := range lines {
|
||||||
|
if line == "channels add" {
|
||||||
|
t.Fatalf("did not expect channels add invocation after choosing set up later, got %v", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOpenclawEdit(t *testing.T) {
|
func TestOpenclawEdit(t *testing.T) {
|
||||||
c := &Openclaw{}
|
c := &Openclaw{}
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
@@ -930,6 +1099,430 @@ func TestOpenclawOnboarded(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenclawChannelsConfigured(t *testing.T) {
|
||||||
|
c := &Openclaw{}
|
||||||
|
|
||||||
|
t.Run("returns false when no config exists", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
if c.channelsConfigured() {
|
||||||
|
t.Error("expected false when no config exists")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns false for corrupted json", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{bad`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.channelsConfigured() {
|
||||||
|
t.Error("expected false for corrupted config")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns false when channels section is missing", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{"theme":"dark"}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.channelsConfigured() {
|
||||||
|
t.Error("expected false when channels section is missing")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns false for channels defaults and modelByChannel only", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{
|
||||||
|
"channels": {
|
||||||
|
"defaults": {"dmPolicy": "pairing"},
|
||||||
|
"modelByChannel": {"telegram": "ollama/llama3.2"}
|
||||||
|
}
|
||||||
|
}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.channelsConfigured() {
|
||||||
|
t.Error("expected false for channels metadata only")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns false when channel entry only has enabled", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{
|
||||||
|
"channels": {
|
||||||
|
"telegram": {"enabled": true}
|
||||||
|
}
|
||||||
|
}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.channelsConfigured() {
|
||||||
|
t.Error("expected false when channel config only has enabled")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns true when a channel has meaningful configuration", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{
|
||||||
|
"channels": {
|
||||||
|
"telegram": {"botToken": "secret"}
|
||||||
|
}
|
||||||
|
}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.channelsConfigured() {
|
||||||
|
t.Error("expected true when channel has meaningful config")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("prefers new path over legacy", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
newDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
legacyDir := filepath.Join(tmpDir, ".clawdbot")
|
||||||
|
if err := os.MkdirAll(newDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(legacyDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(newDir, "openclaw.json"), []byte(`{"channels":{"telegram":{"enabled":true}}}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(legacyDir, "clawdbot.json"), []byte(`{"channels":{"telegram":{"botToken":"configured"}}}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.channelsConfigured() {
|
||||||
|
t.Error("expected false because new config should take precedence")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenclawChannelSetupPreflight(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("uses a POSIX shell test binary")
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &Openclaw{}
|
||||||
|
|
||||||
|
t.Run("skips in non-interactive sessions", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := filepath.Join(tmpDir, "openclaw")
|
||||||
|
if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/invocations.log\"\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldInteractive := isInteractiveSession
|
||||||
|
isInteractiveSession = func() bool { return false }
|
||||||
|
defer func() { isInteractiveSession = oldInteractive }()
|
||||||
|
|
||||||
|
oldConfirmPrompt := DefaultConfirmPrompt
|
||||||
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
t.Fatalf("did not expect prompt in non-interactive mode: %s", prompt)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||||
|
|
||||||
|
if err := c.runChannelSetupPreflight("openclaw"); err != nil {
|
||||||
|
t.Fatalf("runChannelSetupPreflight() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(tmpDir, "invocations.log")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected no command invocation in non-interactive mode, got err=%v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("already configured does not prompt or run channels add", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{"channels":{"telegram":{"botToken":"set"}}}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := filepath.Join(tmpDir, "openclaw")
|
||||||
|
if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/invocations.log\"\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldInteractive := isInteractiveSession
|
||||||
|
isInteractiveSession = func() bool { return true }
|
||||||
|
defer func() { isInteractiveSession = oldInteractive }()
|
||||||
|
|
||||||
|
oldConfirmPrompt := DefaultConfirmPrompt
|
||||||
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
t.Fatalf("did not expect prompt when already configured: %s", prompt)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||||
|
|
||||||
|
if err := c.runChannelSetupPreflight("openclaw"); err != nil {
|
||||||
|
t.Fatalf("runChannelSetupPreflight() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(tmpDir, "invocations.log")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected no channels add invocation, got err=%v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("--yes skips preflight without channels configured", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Empty config = no channels configured. Without the --yes skip, the
|
||||||
|
// preflight would prompt and (on confirm) spawn `openclaw channels add`.
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := filepath.Join(tmpDir, "openclaw")
|
||||||
|
if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/invocations.log\"\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldInteractive := isInteractiveSession
|
||||||
|
isInteractiveSession = func() bool { return true }
|
||||||
|
defer func() { isInteractiveSession = oldInteractive }()
|
||||||
|
|
||||||
|
restore := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
oldConfirmPrompt := DefaultConfirmPrompt
|
||||||
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
t.Fatalf("did not expect prompt in --yes mode: %s", prompt)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||||
|
|
||||||
|
if err := c.runChannelSetupPreflight("openclaw"); err != nil {
|
||||||
|
t.Fatalf("runChannelSetupPreflight() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(tmpDir, "invocations.log")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected no channels add invocation in --yes mode, got err=%v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("set up later prompts once and exits", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := filepath.Join(tmpDir, "openclaw")
|
||||||
|
if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/invocations.log\"\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldInteractive := isInteractiveSession
|
||||||
|
isInteractiveSession = func() bool { return true }
|
||||||
|
defer func() { isInteractiveSession = oldInteractive }()
|
||||||
|
|
||||||
|
promptCount := 0
|
||||||
|
oldConfirmPrompt := DefaultConfirmPrompt
|
||||||
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
promptCount++
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||||
|
|
||||||
|
if err := c.runChannelSetupPreflight("openclaw"); err != nil {
|
||||||
|
t.Fatalf("runChannelSetupPreflight() error = %v", err)
|
||||||
|
}
|
||||||
|
if promptCount != 1 {
|
||||||
|
t.Fatalf("expected 1 prompt, got %d", promptCount)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(tmpDir, "invocations.log")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected no channels add invocation, got err=%v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("yes runs channels add and exits after configuration", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := filepath.Join(tmpDir, "openclaw")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
printf '%s\n' "$*" >> "$HOME/invocations.log"
|
||||||
|
if [ "$1" = "channels" ] && [ "$2" = "add" ]; then
|
||||||
|
/bin/mkdir -p "$HOME/.openclaw"
|
||||||
|
/bin/cat > "$HOME/.openclaw/openclaw.json" <<'EOF'
|
||||||
|
{"channels":{"telegram":{"botToken":"configured"}}}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldInteractive := isInteractiveSession
|
||||||
|
isInteractiveSession = func() bool { return true }
|
||||||
|
defer func() { isInteractiveSession = oldInteractive }()
|
||||||
|
|
||||||
|
promptCount := 0
|
||||||
|
oldConfirmPrompt := DefaultConfirmPrompt
|
||||||
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
promptCount++
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||||
|
|
||||||
|
if err := c.runChannelSetupPreflight("openclaw"); err != nil {
|
||||||
|
t.Fatalf("runChannelSetupPreflight() error = %v", err)
|
||||||
|
}
|
||||||
|
if promptCount != 1 {
|
||||||
|
t.Fatalf("expected 1 prompt, got %d", promptCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(tmpDir, "invocations.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||||
|
if len(lines) != 1 || lines[0] != "channels add" {
|
||||||
|
t.Fatalf("expected one 'channels add' invocation, got %v", lines)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("re-prompts when channels add does not configure anything", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := filepath.Join(tmpDir, "openclaw")
|
||||||
|
if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/invocations.log\"\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldInteractive := isInteractiveSession
|
||||||
|
isInteractiveSession = func() bool { return true }
|
||||||
|
defer func() { isInteractiveSession = oldInteractive }()
|
||||||
|
|
||||||
|
promptCount := 0
|
||||||
|
oldConfirmPrompt := DefaultConfirmPrompt
|
||||||
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
promptCount++
|
||||||
|
return promptCount == 1, nil
|
||||||
|
}
|
||||||
|
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||||
|
|
||||||
|
if err := c.runChannelSetupPreflight("openclaw"); err != nil {
|
||||||
|
t.Fatalf("runChannelSetupPreflight() error = %v", err)
|
||||||
|
}
|
||||||
|
if promptCount != 2 {
|
||||||
|
t.Fatalf("expected 2 prompts, got %d", promptCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(tmpDir, "invocations.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||||
|
if len(lines) != 1 || lines[0] != "channels add" {
|
||||||
|
t.Fatalf("expected one 'channels add' invocation, got %v", lines)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns actionable error when channels add fails", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
configDir := filepath.Join(tmpDir, ".openclaw")
|
||||||
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(`{}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := filepath.Join(tmpDir, "openclaw")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
if [ "$1" = "channels" ] && [ "$2" = "add" ]; then
|
||||||
|
exit 42
|
||||||
|
fi
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldInteractive := isInteractiveSession
|
||||||
|
isInteractiveSession = func() bool { return true }
|
||||||
|
defer func() { isInteractiveSession = oldInteractive }()
|
||||||
|
|
||||||
|
oldConfirmPrompt := DefaultConfirmPrompt
|
||||||
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||||
|
|
||||||
|
err := c.runChannelSetupPreflight("openclaw")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when channels add fails")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "Try running: openclaw channels add") {
|
||||||
|
t.Fatalf("expected actionable remediation hint, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestOpenclawGatewayInfo(t *testing.T) {
|
func TestOpenclawGatewayInfo(t *testing.T) {
|
||||||
c := &Openclaw{}
|
c := &Openclaw{}
|
||||||
|
|
||||||
@@ -1036,6 +1629,133 @@ func TestOpenclawGatewayInfo(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPatchDeviceScopes(t *testing.T) {
|
||||||
|
t.Run("patches device approved scopes and operator token only", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
|
||||||
|
identityDir := filepath.Join(tmpDir, ".openclaw", "identity")
|
||||||
|
if err := os.MkdirAll(identityDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(identityDir, "device-auth.json"), []byte(`{"deviceId":"dev-1"}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
devicesDir := filepath.Join(tmpDir, ".openclaw", "devices")
|
||||||
|
if err := os.MkdirAll(devicesDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(devicesDir, "paired.json"), []byte(`{
|
||||||
|
"dev-1": {
|
||||||
|
"deviceId": "dev-1",
|
||||||
|
"scopes": ["operator.read"],
|
||||||
|
"approvedScopes": ["operator.read"],
|
||||||
|
"tokens": {
|
||||||
|
"operator": {"role":"operator","scopes":["operator.read"]},
|
||||||
|
"node": {"role":"node","scopes":["node.exec"]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
patchDeviceScopes()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(devicesDir, "paired.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var devices map[string]map[string]any
|
||||||
|
if err := json.Unmarshal(data, &devices); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
required := []string{
|
||||||
|
"operator.read",
|
||||||
|
"operator.admin",
|
||||||
|
"operator.approvals",
|
||||||
|
"operator.pairing",
|
||||||
|
}
|
||||||
|
|
||||||
|
toSet := func(v any) map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
items, _ := v.([]any)
|
||||||
|
for _, item := range items {
|
||||||
|
if s, ok := item.(string); ok {
|
||||||
|
out[s] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
assertContainsAll := func(name string, got any, want []string) {
|
||||||
|
t.Helper()
|
||||||
|
set := toSet(got)
|
||||||
|
for _, scope := range want {
|
||||||
|
if !set[scope] {
|
||||||
|
t.Fatalf("%s missing required scope %q (got=%v)", name, scope, set)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dev := devices["dev-1"]
|
||||||
|
assertContainsAll("device.scopes", dev["scopes"], required)
|
||||||
|
assertContainsAll("device.approvedScopes", dev["approvedScopes"], required)
|
||||||
|
|
||||||
|
tokens, _ := dev["tokens"].(map[string]any)
|
||||||
|
operator, _ := tokens["operator"].(map[string]any)
|
||||||
|
assertContainsAll("tokens.operator.scopes", operator["scopes"], required)
|
||||||
|
|
||||||
|
node, _ := tokens["node"].(map[string]any)
|
||||||
|
nodeScopes := toSet(node["scopes"])
|
||||||
|
if len(nodeScopes) != 1 || !nodeScopes["node.exec"] {
|
||||||
|
t.Fatalf("expected non-operator token scopes unchanged, got=%v", nodeScopes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("creates approvedScopes when missing", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
|
||||||
|
identityDir := filepath.Join(tmpDir, ".openclaw", "identity")
|
||||||
|
if err := os.MkdirAll(identityDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(identityDir, "device-auth.json"), []byte(`{"deviceId":"dev-2"}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
devicesDir := filepath.Join(tmpDir, ".openclaw", "devices")
|
||||||
|
if err := os.MkdirAll(devicesDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(devicesDir, "paired.json"), []byte(`{
|
||||||
|
"dev-2": {
|
||||||
|
"deviceId": "dev-2",
|
||||||
|
"scopes": ["operator.read"],
|
||||||
|
"tokens": {"operator":{"role":"operator","scopes":["operator.read"]}}
|
||||||
|
}
|
||||||
|
}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
patchDeviceScopes()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(devicesDir, "paired.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var devices map[string]map[string]any
|
||||||
|
if err := json.Unmarshal(data, &devices); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dev := devices["dev-2"]
|
||||||
|
if _, ok := dev["approvedScopes"]; !ok {
|
||||||
|
t.Fatal("expected approvedScopes to be created")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestPrintOpenclawReady(t *testing.T) {
|
func TestPrintOpenclawReady(t *testing.T) {
|
||||||
t.Run("includes port in URL", func(t *testing.T) {
|
t.Run("includes port in URL", func(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
@@ -1126,14 +1846,17 @@ func TestPrintOpenclawReady(t *testing.T) {
|
|||||||
buf.ReadFrom(r)
|
buf.ReadFrom(r)
|
||||||
|
|
||||||
output := buf.String()
|
output := buf.String()
|
||||||
for _, want := range []string{"/help", "channels", "skills", "gateway"} {
|
for _, want := range []string{"/help", "skills", "gateway"} {
|
||||||
if !strings.Contains(output, want) {
|
if !strings.Contains(output, want) {
|
||||||
t.Errorf("expected %q in first-launch output, got:\n%s", want, output)
|
t.Errorf("expected %q in first-launch output, got:\n%s", want, output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if strings.Contains(output, "configure --section channels") {
|
||||||
|
t.Errorf("did not expect channels configure tip in first-launch output, got:\n%s", output)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("subsequent launch shows single tip", func(t *testing.T) {
|
t.Run("subsequent launch omits quick start tips", func(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
old := os.Stderr
|
old := os.Stderr
|
||||||
r, w, _ := os.Pipe()
|
r, w, _ := os.Pipe()
|
||||||
@@ -1146,12 +1869,15 @@ func TestPrintOpenclawReady(t *testing.T) {
|
|||||||
buf.ReadFrom(r)
|
buf.ReadFrom(r)
|
||||||
|
|
||||||
output := buf.String()
|
output := buf.String()
|
||||||
if !strings.Contains(output, "Tip:") {
|
|
||||||
t.Errorf("expected single tip line, got:\n%s", output)
|
|
||||||
}
|
|
||||||
if strings.Contains(output, "Quick start") {
|
if strings.Contains(output, "Quick start") {
|
||||||
t.Errorf("should not show quick start on subsequent launch")
|
t.Errorf("should not show quick start on subsequent launch")
|
||||||
}
|
}
|
||||||
|
if strings.Contains(output, "browse skills with") {
|
||||||
|
t.Errorf("should not show repeated skills tip on subsequent launch")
|
||||||
|
}
|
||||||
|
if strings.Contains(output, "configure --section channels") {
|
||||||
|
t.Errorf("did not expect channels configure tip on subsequent launch, got:\n%s", output)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +1,108 @@
|
|||||||
package launch
|
package launch
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||||
"github.com/ollama/ollama/envconfig"
|
"github.com/ollama/ollama/envconfig"
|
||||||
|
modeltype "github.com/ollama/ollama/types/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OpenCode implements Runner and Editor for OpenCode integration
|
// OpenCode implements Runner and Editor for OpenCode integration.
|
||||||
type OpenCode struct{}
|
// Config is passed via OPENCODE_CONFIG_CONTENT env var at launch time
|
||||||
|
// instead of writing to opencode's config files.
|
||||||
|
type OpenCode struct {
|
||||||
|
configContent string // JSON config built by Edit, passed to Run via env var
|
||||||
|
}
|
||||||
|
|
||||||
func (o *OpenCode) String() string { return "OpenCode" }
|
func (o *OpenCode) String() string { return "OpenCode" }
|
||||||
|
|
||||||
|
// findOpenCode returns the opencode binary path, checking PATH first then the
|
||||||
|
// curl installer location (~/.opencode/bin) which may not be on PATH yet.
|
||||||
|
func findOpenCode() (string, bool) {
|
||||||
|
if p, err := exec.LookPath("opencode"); err == nil {
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
name := "opencode"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
name = "opencode.exe"
|
||||||
|
}
|
||||||
|
fallback := filepath.Join(home, ".opencode", "bin", name)
|
||||||
|
if _, err := os.Stat(fallback); err == nil {
|
||||||
|
return fallback, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
func (o *OpenCode) Run(model string, args []string) error {
|
func (o *OpenCode) Run(model string, args []string) error {
|
||||||
if _, err := exec.LookPath("opencode"); err != nil {
|
opencodePath, ok := findOpenCode()
|
||||||
|
if !ok {
|
||||||
return fmt.Errorf("opencode is not installed, install from https://opencode.ai")
|
return fmt.Errorf("opencode is not installed, install from https://opencode.ai")
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("opencode", args...)
|
cmd := exec.Command(opencodePath, args...)
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
cmd.Env = os.Environ()
|
||||||
|
if content := o.resolveContent(model); content != "" {
|
||||||
|
cmd.Env = append(cmd.Env, "OPENCODE_CONFIG_CONTENT="+content)
|
||||||
|
}
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveContent returns the inline config to send via OPENCODE_CONFIG_CONTENT.
|
||||||
|
// Returns content built by Edit if available, otherwise builds from model.json
|
||||||
|
// with the requested model as primary (e.g. re-launch with saved config).
|
||||||
|
func (o *OpenCode) resolveContent(model string) string {
|
||||||
|
if o.configContent != "" {
|
||||||
|
return o.configContent
|
||||||
|
}
|
||||||
|
models := readModelJSONModels()
|
||||||
|
if !slices.Contains(models, model) {
|
||||||
|
models = append([]string{model}, models...)
|
||||||
|
}
|
||||||
|
content, err := buildInlineConfig(model, models)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
func (o *OpenCode) Paths() []string {
|
func (o *OpenCode) Paths() []string {
|
||||||
home, err := os.UserHomeDir()
|
sp, err := openCodeStatePath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var paths []string
|
|
||||||
p := filepath.Join(home, ".config", "opencode", "opencode.json")
|
|
||||||
if _, err := os.Stat(p); err == nil {
|
|
||||||
paths = append(paths, p)
|
|
||||||
}
|
|
||||||
sp := filepath.Join(home, ".local", "state", "opencode", "model.json")
|
|
||||||
if _, err := os.Stat(sp); err == nil {
|
if _, err := os.Stat(sp); err == nil {
|
||||||
paths = append(paths, sp)
|
return []string{sp}
|
||||||
}
|
}
|
||||||
return paths
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openCodeStatePath returns the path to opencode's model state file.
|
||||||
|
// TODO: this hardcodes the Linux/macOS XDG path. On Windows, opencode stores
|
||||||
|
// state under %LOCALAPPDATA% (or similar) — verify and branch on runtime.GOOS.
|
||||||
|
func openCodeStatePath() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".local", "state", "opencode", "model.json"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *OpenCode) Edit(modelList []string) error {
|
func (o *OpenCode) Edit(modelList []string) error {
|
||||||
@@ -54,109 +110,17 @@ func (o *OpenCode) Edit(modelList []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
home, err := os.UserHomeDir()
|
content, err := buildInlineConfig(modelList[0], modelList)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
o.configContent = content
|
||||||
|
|
||||||
configPath := filepath.Join(home, ".config", "opencode", "opencode.json")
|
// Write model state file so models appear in OpenCode's model picker
|
||||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
statePath, err := openCodeStatePath()
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
config := make(map[string]any)
|
|
||||||
if data, err := os.ReadFile(configPath); err == nil {
|
|
||||||
_ = json.Unmarshal(data, &config) // Ignore parse errors; treat missing/corrupt files as empty
|
|
||||||
}
|
|
||||||
|
|
||||||
config["$schema"] = "https://opencode.ai/config.json"
|
|
||||||
|
|
||||||
provider, ok := config["provider"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
provider = make(map[string]any)
|
|
||||||
}
|
|
||||||
|
|
||||||
ollama, ok := provider["ollama"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
ollama = map[string]any{
|
|
||||||
"npm": "@ai-sdk/openai-compatible",
|
|
||||||
"name": "Ollama",
|
|
||||||
"options": map[string]any{
|
|
||||||
"baseURL": envconfig.Host().String() + "/v1",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migrate legacy provider name
|
|
||||||
if name, _ := ollama["name"].(string); name == "Ollama (local)" {
|
|
||||||
ollama["name"] = "Ollama"
|
|
||||||
}
|
|
||||||
|
|
||||||
models, ok := ollama["models"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
models = make(map[string]any)
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedSet := make(map[string]bool)
|
|
||||||
for _, m := range modelList {
|
|
||||||
selectedSet[m] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, cfg := range models {
|
|
||||||
if cfgMap, ok := cfg.(map[string]any); ok {
|
|
||||||
if isOllamaModel(cfgMap) && !selectedSet[name] {
|
|
||||||
delete(models, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, model := range modelList {
|
|
||||||
if existing, ok := models[model].(map[string]any); ok {
|
|
||||||
// migrate existing models without _launch marker
|
|
||||||
if isOllamaModel(existing) {
|
|
||||||
existing["_launch"] = true
|
|
||||||
if name, ok := existing["name"].(string); ok {
|
|
||||||
existing["name"] = strings.TrimSuffix(name, " [Ollama]")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if isCloudModelName(model) {
|
|
||||||
if l, ok := lookupCloudModelLimit(model); ok {
|
|
||||||
existing["limit"] = map[string]any{
|
|
||||||
"context": l.Context,
|
|
||||||
"output": l.Output,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
entry := map[string]any{
|
|
||||||
"name": model,
|
|
||||||
"_launch": true,
|
|
||||||
}
|
|
||||||
if isCloudModelName(model) {
|
|
||||||
if l, ok := lookupCloudModelLimit(model); ok {
|
|
||||||
entry["limit"] = map[string]any{
|
|
||||||
"context": l.Context,
|
|
||||||
"output": l.Output,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
models[model] = entry
|
|
||||||
}
|
|
||||||
|
|
||||||
ollama["models"] = models
|
|
||||||
provider["ollama"] = ollama
|
|
||||||
config["provider"] = provider
|
|
||||||
|
|
||||||
configData, err := json.MarshalIndent(config, "", " ")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := fileutil.WriteWithBackup(configPath, configData); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
statePath := filepath.Join(home, ".local", "state", "opencode", "model.json")
|
|
||||||
if err := os.MkdirAll(filepath.Dir(statePath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(statePath), 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -208,33 +172,127 @@ func (o *OpenCode) Edit(modelList []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (o *OpenCode) Models() []string {
|
func (o *OpenCode) Models() []string {
|
||||||
home, err := os.UserHomeDir()
|
return nil
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
config, err := fileutil.ReadJSON(filepath.Join(home, ".config", "opencode", "opencode.json"))
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
provider, _ := config["provider"].(map[string]any)
|
|
||||||
ollama, _ := provider["ollama"].(map[string]any)
|
|
||||||
models, _ := ollama["models"].(map[string]any)
|
|
||||||
if len(models) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
keys := slices.Collect(maps.Keys(models))
|
|
||||||
slices.Sort(keys)
|
|
||||||
return keys
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// isOllamaModel reports whether a model config entry is managed by us
|
// buildInlineConfig produces the JSON string for OPENCODE_CONFIG_CONTENT.
|
||||||
func isOllamaModel(cfg map[string]any) bool {
|
// primary is the model to launch with, models is the full list of available models.
|
||||||
if v, ok := cfg["_launch"].(bool); ok && v {
|
func buildInlineConfig(primary string, models []string) (string, error) {
|
||||||
return true
|
if primary == "" || len(models) == 0 {
|
||||||
|
return "", fmt.Errorf("buildInlineConfig: primary and models are required")
|
||||||
}
|
}
|
||||||
// previously used [Ollama] as a suffix for the model managed by ollama launch
|
config := map[string]any{
|
||||||
if name, ok := cfg["name"].(string); ok {
|
"$schema": "https://opencode.ai/config.json",
|
||||||
return strings.HasSuffix(name, "[Ollama]")
|
"provider": map[string]any{
|
||||||
|
"ollama": map[string]any{
|
||||||
|
"npm": "@ai-sdk/openai-compatible",
|
||||||
|
"name": "Ollama",
|
||||||
|
"options": map[string]any{
|
||||||
|
"baseURL": envconfig.Host().String() + "/v1",
|
||||||
|
},
|
||||||
|
"models": buildModelEntries(models),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"model": "ollama/" + primary,
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(config)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readModelJSONModels reads ollama model IDs from the opencode model.json state file
|
||||||
|
func readModelJSONModels() []string {
|
||||||
|
statePath, err := openCodeStatePath()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(statePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var state map[string]any
|
||||||
|
if err := json.Unmarshal(data, &state); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
recent, _ := state["recent"].([]any)
|
||||||
|
var models []string
|
||||||
|
for _, entry := range recent {
|
||||||
|
e, ok := entry.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e["providerID"] != "ollama" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if id, ok := e["modelID"].(string); ok && id != "" {
|
||||||
|
models = append(models, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return models
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildModelEntries(modelList []string) map[string]any {
|
||||||
|
client := api.NewClient(envconfig.Host(), http.DefaultClient)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
models := make(map[string]any)
|
||||||
|
for _, model := range modelList {
|
||||||
|
entry := map[string]any{
|
||||||
|
"name": model,
|
||||||
|
}
|
||||||
|
if isCloudModelName(model) {
|
||||||
|
if l, ok := lookupCloudModelLimit(model); ok {
|
||||||
|
entry["limit"] = map[string]any{
|
||||||
|
"context": l.Context,
|
||||||
|
"output": l.Output,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyOpenCodeReasoning(ctx, client, model, entry)
|
||||||
|
models[model] = entry
|
||||||
|
}
|
||||||
|
return models
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyOpenCodeReasoning detects thinking capability and sets reasoning config
|
||||||
|
// on the model entry. When the model supports thinking, it sets "reasoning": true
|
||||||
|
// and configures variants for the OpenCode TUI:
|
||||||
|
// - GPT-OSS: supports variable effort levels (low/medium/high) and defaults to
|
||||||
|
// medium via options. Thinking cannot be turned off.
|
||||||
|
// - Other models: only support on/off. Disables built-in low/medium/high variants
|
||||||
|
// and adds a "none" variant so users can toggle thinking off via Ctrl+T.
|
||||||
|
//
|
||||||
|
// When the model does not support thinking, no reasoning config is set.
|
||||||
|
func applyOpenCodeReasoning(ctx context.Context, client *api.Client, modelName string, entry map[string]any) {
|
||||||
|
resp, err := client.Show(ctx, &api.ShowRequest{Model: modelName})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(resp.Capabilities, modeltype.CapabilityThinking) {
|
||||||
|
entry["reasoning"] = true
|
||||||
|
|
||||||
|
if strings.Contains(modelName, "gpt-oss") {
|
||||||
|
// GPT-OSS models support variable thinking effort levels
|
||||||
|
// and cannot turn thinking off. Keep the built-in
|
||||||
|
// low/medium/high variants as-is and default to medium.
|
||||||
|
options, ok := entry["options"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
options = make(map[string]any)
|
||||||
|
}
|
||||||
|
options["reasoningEffort"] = "medium"
|
||||||
|
entry["options"] = options
|
||||||
|
} else {
|
||||||
|
// Most models only support thinking on or off.
|
||||||
|
// Disable the built-in low/medium/high variants and add none.
|
||||||
|
entry["variants"] = map[string]any{
|
||||||
|
"none": map[string]any{"reasoningEffort": "none"},
|
||||||
|
"low": map[string]any{"disabled": true},
|
||||||
|
"medium": map[string]any{"disabled": true},
|
||||||
|
"high": map[string]any{"disabled": true},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|||||||
137
cmd/launch/pi.go
@@ -20,20 +20,151 @@ import (
|
|||||||
// Pi implements Runner and Editor for Pi (Pi Coding Agent) integration
|
// Pi implements Runner and Editor for Pi (Pi Coding Agent) integration
|
||||||
type Pi struct{}
|
type Pi struct{}
|
||||||
|
|
||||||
|
const (
|
||||||
|
piNpmPackage = "@mariozechner/pi-coding-agent"
|
||||||
|
piWebSearchSource = "npm:@ollama/pi-web-search"
|
||||||
|
piWebSearchPkg = "@ollama/pi-web-search"
|
||||||
|
)
|
||||||
|
|
||||||
func (p *Pi) String() string { return "Pi" }
|
func (p *Pi) String() string { return "Pi" }
|
||||||
|
|
||||||
func (p *Pi) Run(model string, args []string) error {
|
func (p *Pi) Run(model string, args []string) error {
|
||||||
if _, err := exec.LookPath("pi"); err != nil {
|
fmt.Fprintf(os.Stderr, "\n%sPreparing Pi...%s\n", ansiGray, ansiReset)
|
||||||
return fmt.Errorf("pi is not installed, install with: npm install -g @mariozechner/pi-coding-agent")
|
if err := ensureNpmInstalled(); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("pi", args...)
|
fmt.Fprintf(os.Stderr, "%sChecking Pi installation...%s\n", ansiGray, ansiReset)
|
||||||
|
bin, err := ensurePiInstalled()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ensurePiWebSearchPackage(bin)
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "\n%sLaunching Pi...%s\n\n", ansiGray, ansiReset)
|
||||||
|
|
||||||
|
cmd := exec.Command(bin, args...)
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureNpmInstalled() error {
|
||||||
|
if _, err := exec.LookPath("npm"); err != nil {
|
||||||
|
return fmt.Errorf("npm (Node.js) is required to launch pi\n\nInstall it first:\n https://nodejs.org/\n\nThen re-run:\n ollama launch pi")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensurePiInstalled() (string, error) {
|
||||||
|
if _, err := exec.LookPath("pi"); err == nil {
|
||||||
|
return "pi", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("npm"); err != nil {
|
||||||
|
return "", fmt.Errorf("pi is not installed and required dependencies are missing\n\nInstall the following first:\n npm (Node.js): https://nodejs.org/\n\nThen re-run:\n ollama launch pi")
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := ConfirmPrompt("Pi is not installed. Install with npm?")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("pi installation cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "\nInstalling Pi...\n")
|
||||||
|
cmd := exec.Command("npm", "install", "-g", piNpmPackage+"@latest")
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to install pi: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("pi"); err != nil {
|
||||||
|
return "", fmt.Errorf("pi was installed but the binary was not found on PATH\n\nYou may need to restart your shell")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "%sPi installed successfully%s\n\n", ansiGreen, ansiReset)
|
||||||
|
return "pi", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensurePiWebSearchPackage(bin string) {
|
||||||
|
if !shouldManagePiWebSearch() {
|
||||||
|
fmt.Fprintf(os.Stderr, "%sCloud is disabled; skipping %s setup.%s\n", ansiGray, piWebSearchPkg, ansiReset)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "%sChecking Pi web search package...%s\n", ansiGray, ansiReset)
|
||||||
|
|
||||||
|
installed, err := piPackageInstalled(bin, piWebSearchSource)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s Warning: could not check %s installation: %v%s\n", ansiYellow, piWebSearchPkg, err, ansiReset)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !installed {
|
||||||
|
fmt.Fprintf(os.Stderr, "%sInstalling %s...%s\n", ansiGray, piWebSearchPkg, ansiReset)
|
||||||
|
cmd := exec.Command(bin, "install", piWebSearchSource)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s Warning: could not install %s: %v%s\n", ansiYellow, piWebSearchPkg, err, ansiReset)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "%s ✓ Installed %s%s\n", ansiGreen, piWebSearchPkg, ansiReset)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "%sUpdating %s...%s\n", ansiGray, piWebSearchPkg, ansiReset)
|
||||||
|
cmd := exec.Command(bin, "update", piWebSearchSource)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s Warning: could not update %s: %v%s\n", ansiYellow, piWebSearchPkg, err, ansiReset)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "%s ✓ Updated %s%s\n", ansiGreen, piWebSearchPkg, ansiReset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldManagePiWebSearch() bool {
|
||||||
|
client, err := api.ClientFromEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
disabled, known := cloudStatusDisabled(context.Background(), client)
|
||||||
|
if known && disabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func piPackageInstalled(bin, source string) (bool, error) {
|
||||||
|
cmd := exec.Command(bin, "list")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
msg := strings.TrimSpace(string(out))
|
||||||
|
if msg == "" {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("%w: %s", err, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trimmed, source) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Pi) Paths() []string {
|
func (p *Pi) Paths() []string {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/ollama/ollama/api"
|
"github.com/ollama/ollama/api"
|
||||||
@@ -33,6 +35,341 @@ func TestPiIntegration(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPiRun_InstallAndWebSearchLifecycle(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("uses POSIX shell test binaries")
|
||||||
|
}
|
||||||
|
|
||||||
|
writeScript := func(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedPiScript := func(t *testing.T, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
piPath := filepath.Join(dir, "pi")
|
||||||
|
listPath := filepath.Join(dir, "pi-list.txt")
|
||||||
|
piScript := fmt.Sprintf(`#!/bin/sh
|
||||||
|
echo "$@" >> %q
|
||||||
|
if [ "$1" = "list" ]; then
|
||||||
|
if [ -f %q ]; then
|
||||||
|
/bin/cat %q
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "$1" = "update" ] && [ "$PI_FAIL_UPDATE" = "1" ]; then
|
||||||
|
echo "update failed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$1" = "install" ] && [ "$PI_FAIL_INSTALL" = "1" ]; then
|
||||||
|
echo "install failed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
`, filepath.Join(dir, "pi.log"), listPath, listPath)
|
||||||
|
writeScript(t, piPath, piScript)
|
||||||
|
}
|
||||||
|
|
||||||
|
seedNpmNoop := func(t *testing.T, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
writeScript(t, filepath.Join(dir, "npm"), "#!/bin/sh\nexit 0\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
setCloudStatus := func(t *testing.T, disabled bool) {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/status" {
|
||||||
|
fmt.Fprintf(w, `{"cloud":{"disabled":%t,"source":"config"}}`, disabled)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("pi missing + user accepts install", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
setCloudStatus(t, false)
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n npm:@ollama/pi-web-search\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
npmScript := fmt.Sprintf(`#!/bin/sh
|
||||||
|
echo "$@" >> %q
|
||||||
|
if [ "$1" = "install" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then
|
||||||
|
/bin/cat > %q <<'EOS'
|
||||||
|
#!/bin/sh
|
||||||
|
echo "$@" >> %q
|
||||||
|
if [ "$1" = "list" ]; then
|
||||||
|
if [ -f %q ]; then
|
||||||
|
/bin/cat %q
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
EOS
|
||||||
|
/bin/chmod +x %q
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
`, filepath.Join(tmpDir, "npm.log"), piNpmPackage+"@latest", filepath.Join(tmpDir, "pi"), filepath.Join(tmpDir, "pi.log"), filepath.Join(tmpDir, "pi-list.txt"), filepath.Join(tmpDir, "pi-list.txt"), filepath.Join(tmpDir, "pi"))
|
||||||
|
writeScript(t, filepath.Join(tmpDir, "npm"), npmScript)
|
||||||
|
|
||||||
|
withConfirm(t, func(prompt string) (bool, error) {
|
||||||
|
if strings.Contains(prompt, "Pi is not installed.") {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
p := &Pi{}
|
||||||
|
if err := p.Run("ignored", []string{"--version"}); err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
npmCalls, err := os.ReadFile(filepath.Join(tmpDir, "npm.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(npmCalls), "install -g "+piNpmPackage+"@latest") {
|
||||||
|
t.Fatalf("expected npm install call, got:\n%s", npmCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := string(piCalls)
|
||||||
|
if !strings.Contains(got, "list\n") {
|
||||||
|
t.Fatalf("expected pi list call, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "update "+piWebSearchSource+"\n") {
|
||||||
|
t.Fatalf("expected pi update call, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "--version\n") {
|
||||||
|
t.Fatalf("expected final pi launch call, got:\n%s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pi missing + user declines install", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
setCloudStatus(t, false)
|
||||||
|
writeScript(t, filepath.Join(tmpDir, "npm"), "#!/bin/sh\nexit 0\n")
|
||||||
|
|
||||||
|
withConfirm(t, func(prompt string) (bool, error) {
|
||||||
|
if strings.Contains(prompt, "Pi is not installed.") {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
p := &Pi{}
|
||||||
|
err := p.Run("ignored", nil)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "pi installation cancelled") {
|
||||||
|
t.Fatalf("expected install cancellation error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pi installed + web search missing auto-installs", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
setCloudStatus(t, false)
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seedPiScript(t, tmpDir)
|
||||||
|
seedNpmNoop(t, tmpDir)
|
||||||
|
withConfirm(t, func(prompt string) (bool, error) {
|
||||||
|
t.Fatalf("did not expect confirmation prompt, got %q", prompt)
|
||||||
|
return false, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
p := &Pi{}
|
||||||
|
if err := p.Run("ignored", []string{"session"}); err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := string(piCalls)
|
||||||
|
if !strings.Contains(got, "list\n") {
|
||||||
|
t.Fatalf("expected pi list call, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "install "+piWebSearchSource+"\n") {
|
||||||
|
t.Fatalf("expected pi install call, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "update "+piWebSearchSource+"\n") {
|
||||||
|
t.Fatalf("did not expect pi update call when package missing, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "session\n") {
|
||||||
|
t.Fatalf("expected final pi launch call, got:\n%s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pi installed + web search present updates every launch", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
setCloudStatus(t, false)
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n "+piWebSearchSource+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seedPiScript(t, tmpDir)
|
||||||
|
seedNpmNoop(t, tmpDir)
|
||||||
|
|
||||||
|
p := &Pi{}
|
||||||
|
if err := p.Run("ignored", []string{"doctor"}); err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := string(piCalls)
|
||||||
|
if !strings.Contains(got, "update "+piWebSearchSource+"\n") {
|
||||||
|
t.Fatalf("expected pi update call, got:\n%s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("web search update failure warns and continues", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
setCloudStatus(t, false)
|
||||||
|
t.Setenv("PI_FAIL_UPDATE", "1")
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n "+piWebSearchSource+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seedPiScript(t, tmpDir)
|
||||||
|
seedNpmNoop(t, tmpDir)
|
||||||
|
|
||||||
|
p := &Pi{}
|
||||||
|
stderr := captureStderr(t, func() {
|
||||||
|
if err := p.Run("ignored", []string{"session"}); err != nil {
|
||||||
|
t.Fatalf("Run() should continue after web search update failure, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if !strings.Contains(stderr, "Warning: could not update "+piWebSearchPkg) {
|
||||||
|
t.Fatalf("expected update warning, got:\n%s", stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(piCalls), "session\n") {
|
||||||
|
t.Fatalf("expected final pi launch call, got:\n%s", piCalls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("web search install failure warns and continues", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
setCloudStatus(t, false)
|
||||||
|
t.Setenv("PI_FAIL_INSTALL", "1")
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seedPiScript(t, tmpDir)
|
||||||
|
seedNpmNoop(t, tmpDir)
|
||||||
|
withConfirm(t, func(prompt string) (bool, error) {
|
||||||
|
t.Fatalf("did not expect confirmation prompt, got %q", prompt)
|
||||||
|
return false, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
p := &Pi{}
|
||||||
|
stderr := captureStderr(t, func() {
|
||||||
|
if err := p.Run("ignored", []string{"session"}); err != nil {
|
||||||
|
t.Fatalf("Run() should continue after web search install failure, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if !strings.Contains(stderr, "Warning: could not install "+piWebSearchPkg) {
|
||||||
|
t.Fatalf("expected install warning, got:\n%s", stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(piCalls), "session\n") {
|
||||||
|
t.Fatalf("expected final pi launch call, got:\n%s", piCalls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cloud disabled skips web search package management", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
setCloudStatus(t, true)
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seedPiScript(t, tmpDir)
|
||||||
|
seedNpmNoop(t, tmpDir)
|
||||||
|
|
||||||
|
p := &Pi{}
|
||||||
|
stderr := captureStderr(t, func() {
|
||||||
|
if err := p.Run("ignored", []string{"session"}); err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if !strings.Contains(stderr, "Cloud is disabled; skipping "+piWebSearchPkg+" setup.") {
|
||||||
|
t.Fatalf("expected cloud-disabled skip message, got:\n%s", stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := string(piCalls)
|
||||||
|
if strings.Contains(got, "list\n") || strings.Contains(got, "install "+piWebSearchSource+"\n") || strings.Contains(got, "update "+piWebSearchSource+"\n") {
|
||||||
|
t.Fatalf("did not expect web search package management calls, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "session\n") {
|
||||||
|
t.Fatalf("expected final pi launch call, got:\n%s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing npm returns error before pi flow", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
setCloudStatus(t, false)
|
||||||
|
seedPiScript(t, tmpDir)
|
||||||
|
|
||||||
|
p := &Pi{}
|
||||||
|
err := p.Run("ignored", []string{"session"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "npm (Node.js) is required to launch pi") {
|
||||||
|
t.Fatalf("expected missing npm error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, statErr := os.Stat(filepath.Join(tmpDir, "pi.log")); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("expected pi not to run when npm is missing, stat err = %v", statErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestPiPaths(t *testing.T) {
|
func TestPiPaths(t *testing.T) {
|
||||||
pi := &Pi{}
|
pi := &Pi{}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ type IntegrationInfo struct {
|
|||||||
Description string
|
Description string
|
||||||
}
|
}
|
||||||
|
|
||||||
var launcherIntegrationOrder = []string{"opencode", "droid", "pi", "cline"}
|
var launcherIntegrationOrder = []string{"opencode", "droid", "pi"}
|
||||||
|
|
||||||
var integrationSpecs = []*IntegrationSpec{
|
var integrationSpecs = []*IntegrationSpec{
|
||||||
{
|
{
|
||||||
@@ -52,6 +52,7 @@ var integrationSpecs = []*IntegrationSpec{
|
|||||||
Name: "cline",
|
Name: "cline",
|
||||||
Runner: &Cline{},
|
Runner: &Cline{},
|
||||||
Description: "Autonomous coding agent with parallel execution",
|
Description: "Autonomous coding agent with parallel execution",
|
||||||
|
Hidden: true,
|
||||||
Install: IntegrationInstallSpec{
|
Install: IntegrationInstallSpec{
|
||||||
CheckInstalled: func() bool {
|
CheckInstalled: func() bool {
|
||||||
_, err := exec.LookPath("cline")
|
_, err := exec.LookPath("cline")
|
||||||
@@ -91,8 +92,8 @@ var integrationSpecs = []*IntegrationSpec{
|
|||||||
Description: "Anomaly's open-source coding agent",
|
Description: "Anomaly's open-source coding agent",
|
||||||
Install: IntegrationInstallSpec{
|
Install: IntegrationInstallSpec{
|
||||||
CheckInstalled: func() bool {
|
CheckInstalled: func() bool {
|
||||||
_, err := exec.LookPath("opencode")
|
_, ok := findOpenCode()
|
||||||
return err == nil
|
return ok
|
||||||
},
|
},
|
||||||
URL: "https://opencode.ai",
|
URL: "https://opencode.ai",
|
||||||
},
|
},
|
||||||
@@ -128,7 +129,24 @@ var integrationSpecs = []*IntegrationSpec{
|
|||||||
_, err := exec.LookPath("pi")
|
_, err := exec.LookPath("pi")
|
||||||
return err == nil
|
return err == nil
|
||||||
},
|
},
|
||||||
Command: []string{"npm", "install", "-g", "@mariozechner/pi-coding-agent"},
|
EnsureInstalled: func() error {
|
||||||
|
_, err := ensurePiInstalled()
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
Command: []string{"npm", "install", "-g", "@mariozechner/pi-coding-agent@latest"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "vscode",
|
||||||
|
Runner: &VSCode{},
|
||||||
|
Aliases: []string{"code"},
|
||||||
|
Description: "Microsoft's open-source AI code editor",
|
||||||
|
Hidden: true,
|
||||||
|
Install: IntegrationInstallSpec{
|
||||||
|
CheckInstalled: func() bool {
|
||||||
|
return (&VSCode{}).findBinary() != ""
|
||||||
|
},
|
||||||
|
URL: "https://code.visualstudio.com",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) {
|
|||||||
binary: "opencode",
|
binary: "opencode",
|
||||||
runner: &OpenCode{},
|
runner: &OpenCode{},
|
||||||
checkPath: func(home string) string {
|
checkPath: func(home string) string {
|
||||||
return filepath.Join(home, ".config", "opencode", "opencode.json")
|
return filepath.Join(home, ".local", "state", "opencode", "model.json")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -54,6 +54,9 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) {
|
|||||||
|
|
||||||
binDir := t.TempDir()
|
binDir := t.TempDir()
|
||||||
writeFakeBinary(t, binDir, tt.binary)
|
writeFakeBinary(t, binDir, tt.binary)
|
||||||
|
if tt.name == "pi" {
|
||||||
|
writeFakeBinary(t, binDir, "npm")
|
||||||
|
}
|
||||||
t.Setenv("PATH", binDir)
|
t.Setenv("PATH", binDir)
|
||||||
|
|
||||||
configPath := tt.checkPath(home)
|
configPath := tt.checkPath(home)
|
||||||
|
|||||||
@@ -25,7 +25,13 @@ var errCancelled = ErrCancelled
|
|||||||
|
|
||||||
// DefaultConfirmPrompt provides a TUI-based confirmation prompt.
|
// DefaultConfirmPrompt provides a TUI-based confirmation prompt.
|
||||||
// When set, ConfirmPrompt delegates to it instead of using raw terminal I/O.
|
// When set, ConfirmPrompt delegates to it instead of using raw terminal I/O.
|
||||||
var DefaultConfirmPrompt func(prompt string) (bool, error)
|
var DefaultConfirmPrompt func(prompt string, options ConfirmOptions) (bool, error)
|
||||||
|
|
||||||
|
// ConfirmOptions customizes labels for confirmation prompts.
|
||||||
|
type ConfirmOptions struct {
|
||||||
|
YesLabel string
|
||||||
|
NoLabel string
|
||||||
|
}
|
||||||
|
|
||||||
// SingleSelector is a function type for single item selection.
|
// SingleSelector is a function type for single item selection.
|
||||||
// current is the name of the previously selected item to highlight; empty means no pre-selection.
|
// current is the name of the previously selected item to highlight; empty means no pre-selection.
|
||||||
@@ -65,6 +71,12 @@ func withLaunchConfirmPolicy(policy launchConfirmPolicy) func() {
|
|||||||
// Behavior is controlled by currentLaunchConfirmPolicy, typically scoped by
|
// Behavior is controlled by currentLaunchConfirmPolicy, typically scoped by
|
||||||
// withLaunchConfirmPolicy in LaunchCmd (e.g. auto-approve with --yes).
|
// withLaunchConfirmPolicy in LaunchCmd (e.g. auto-approve with --yes).
|
||||||
func ConfirmPrompt(prompt string) (bool, error) {
|
func ConfirmPrompt(prompt string) (bool, error) {
|
||||||
|
return ConfirmPromptWithOptions(prompt, ConfirmOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmPromptWithOptions is the shared confirmation gate for launch flows
|
||||||
|
// that need custom yes/no labels in interactive UIs.
|
||||||
|
func ConfirmPromptWithOptions(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
if currentLaunchConfirmPolicy.yes {
|
if currentLaunchConfirmPolicy.yes {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -73,7 +85,7 @@ func ConfirmPrompt(prompt string) (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if DefaultConfirmPrompt != nil {
|
if DefaultConfirmPrompt != nil {
|
||||||
return DefaultConfirmPrompt(prompt)
|
return DefaultConfirmPrompt(prompt, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
fd := int(os.Stdin.Fd())
|
fd := int(os.Stdin.Fd())
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func TestWithLaunchConfirmPolicy_ScopesAndRestores(t *testing.T) {
|
|||||||
|
|
||||||
currentLaunchConfirmPolicy = launchConfirmPolicy{}
|
currentLaunchConfirmPolicy = launchConfirmPolicy{}
|
||||||
var hookCalls int
|
var hookCalls int
|
||||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
hookCalls++
|
hookCalls++
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -74,3 +74,39 @@ func TestWithLaunchConfirmPolicy_ScopesAndRestores(t *testing.T) {
|
|||||||
t.Fatalf("expected one hook call after restore, got %d", hookCalls)
|
t.Fatalf("expected one hook call after restore, got %d", hookCalls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfirmPromptWithOptions_DelegatesToOptionsHook(t *testing.T) {
|
||||||
|
oldPolicy := currentLaunchConfirmPolicy
|
||||||
|
oldHook := DefaultConfirmPrompt
|
||||||
|
t.Cleanup(func() {
|
||||||
|
currentLaunchConfirmPolicy = oldPolicy
|
||||||
|
DefaultConfirmPrompt = oldHook
|
||||||
|
})
|
||||||
|
|
||||||
|
currentLaunchConfirmPolicy = launchConfirmPolicy{}
|
||||||
|
called := false
|
||||||
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
called = true
|
||||||
|
if prompt != "Connect now?" {
|
||||||
|
t.Fatalf("unexpected prompt: %q", prompt)
|
||||||
|
}
|
||||||
|
if options.YesLabel != "Yes" || options.NoLabel != "Set up later" {
|
||||||
|
t.Fatalf("unexpected options: %+v", options)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := ConfirmPromptWithOptions("Connect now?", ConfirmOptions{
|
||||||
|
YesLabel: "Yes",
|
||||||
|
NoLabel: "Set up later",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConfirmPromptWithOptions() error = %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected confirm to return true")
|
||||||
|
}
|
||||||
|
if !called {
|
||||||
|
t.Fatal("expected options hook to be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
591
cmd/launch/vscode.go
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
package launch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
|
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||||
|
"github.com/ollama/ollama/envconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VSCode implements Runner and Editor for Visual Studio Code integration.
|
||||||
|
type VSCode struct{}
|
||||||
|
|
||||||
|
func (v *VSCode) String() string { return "Visual Studio Code" }
|
||||||
|
|
||||||
|
// findBinary returns the path/command to launch VS Code, or "" if not found.
|
||||||
|
// It checks platform-specific locations only.
|
||||||
|
func (v *VSCode) findBinary() string {
|
||||||
|
var candidates []string
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
candidates = []string{
|
||||||
|
"/Applications/Visual Studio Code.app",
|
||||||
|
}
|
||||||
|
case "windows":
|
||||||
|
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
|
||||||
|
candidates = append(candidates, filepath.Join(localAppData, "Programs", "Microsoft VS Code", "bin", "code.cmd"))
|
||||||
|
}
|
||||||
|
default: // linux
|
||||||
|
candidates = []string{
|
||||||
|
"/usr/bin/code",
|
||||||
|
"/snap/bin/code",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range candidates {
|
||||||
|
if _, err := os.Stat(c); err == nil {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunning reports whether VS Code is currently running.
|
||||||
|
// Each platform uses a pattern specific enough to avoid matching Cursor or
|
||||||
|
// other VS Code forks.
|
||||||
|
func (v *VSCode) IsRunning() bool {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
out, err := exec.Command("pgrep", "-f", "Visual Studio Code.app/Contents/MacOS/Code").Output()
|
||||||
|
return err == nil && len(out) > 0
|
||||||
|
case "windows":
|
||||||
|
// Match VS Code by executable path to avoid matching Cursor or other forks.
|
||||||
|
out, err := exec.Command("powershell", "-NoProfile", "-Command",
|
||||||
|
`Get-Process Code -ErrorAction SilentlyContinue | Where-Object { $_.Path -like '*Microsoft VS Code*' } | Select-Object -First 1`).Output()
|
||||||
|
return err == nil && len(strings.TrimSpace(string(out))) > 0
|
||||||
|
default:
|
||||||
|
// Match VS Code specifically by its install path to avoid matching
|
||||||
|
// Cursor (/cursor/) or other forks.
|
||||||
|
for _, pattern := range []string{"/usr/share/code/", "/snap/code/"} {
|
||||||
|
out, err := exec.Command("pgrep", "-f", pattern).Output()
|
||||||
|
if err == nil && len(out) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quit gracefully quits VS Code and waits for it to exit so that it flushes
|
||||||
|
// its in-memory state back to the database.
|
||||||
|
func (v *VSCode) Quit() {
|
||||||
|
if !v.IsRunning() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
_ = exec.Command("osascript", "-e", `quit app "Visual Studio Code"`).Run()
|
||||||
|
case "windows":
|
||||||
|
// Kill VS Code by executable path to avoid killing Cursor or other forks.
|
||||||
|
_ = exec.Command("powershell", "-NoProfile", "-Command",
|
||||||
|
`Get-Process Code -ErrorAction SilentlyContinue | Where-Object { $_.Path -like '*Microsoft VS Code*' } | Stop-Process -Force`).Run()
|
||||||
|
default:
|
||||||
|
for _, pattern := range []string{"/usr/share/code/", "/snap/code/"} {
|
||||||
|
_ = exec.Command("pkill", "-f", pattern).Run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Wait for the process to fully exit and flush its state to disk
|
||||||
|
// TODO(hoyyeva): update spinner to use bubble tea
|
||||||
|
spinnerFrames := []string{"|", "/", "-", "\\"}
|
||||||
|
frame := 0
|
||||||
|
fmt.Fprintf(os.Stderr, "\033[90mRestarting VS Code... %s\033[0m", spinnerFrames[0])
|
||||||
|
|
||||||
|
ticker := time.NewTicker(200 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range 150 { // 150 ticks × 200ms = 30s timeout
|
||||||
|
<-ticker.C
|
||||||
|
frame++
|
||||||
|
fmt.Fprintf(os.Stderr, "\r\033[90mRestarting VS Code... %s\033[0m", spinnerFrames[frame%len(spinnerFrames)])
|
||||||
|
|
||||||
|
if frame%5 == 0 { // check every ~1s
|
||||||
|
if !v.IsRunning() {
|
||||||
|
fmt.Fprintf(os.Stderr, "\r\033[K")
|
||||||
|
// Give VS Code a moment to finish writing its state DB
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "\r\033[K")
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
minCopilotChatVersion = "0.41.0"
|
||||||
|
minVSCodeVersion = "1.113"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (v *VSCode) Run(model string, args []string) error {
|
||||||
|
v.checkVSCodeVersion()
|
||||||
|
v.checkCopilotChatVersion()
|
||||||
|
|
||||||
|
// Get all configured models (saved by the launcher framework before Run is called)
|
||||||
|
models := []string{model}
|
||||||
|
if cfg, err := loadStoredIntegrationConfig("vscode"); err == nil && len(cfg.Models) > 0 {
|
||||||
|
models = cfg.Models
|
||||||
|
}
|
||||||
|
|
||||||
|
// VS Code discovers models from ollama ls. Cloud models that pass Show
|
||||||
|
// (the server knows about them) but aren't in ls need to be pulled to
|
||||||
|
// register them so VS Code can find them.
|
||||||
|
if client, err := api.ClientFromEnvironment(); err == nil {
|
||||||
|
v.ensureModelsRegistered(context.Background(), client, models)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn if the default model doesn't support tool calling
|
||||||
|
if client, err := api.ClientFromEnvironment(); err == nil {
|
||||||
|
if resp, err := client.Show(context.Background(), &api.ShowRequest{Model: models[0]}); err == nil {
|
||||||
|
hasTools := false
|
||||||
|
for _, c := range resp.Capabilities {
|
||||||
|
if c == "tools" {
|
||||||
|
hasTools = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasTools {
|
||||||
|
fmt.Fprintf(os.Stderr, "Note: %s does not support tool calling and may not appear in the Copilot Chat model picker.\n", models[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
v.printModelAccessTip()
|
||||||
|
|
||||||
|
if v.IsRunning() {
|
||||||
|
restart, err := ConfirmPrompt("Restart VS Code?")
|
||||||
|
if err != nil {
|
||||||
|
restart = false
|
||||||
|
}
|
||||||
|
if restart {
|
||||||
|
v.Quit()
|
||||||
|
if err := v.ShowInModelPicker(models); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s Warning: could not update VS Code model picker: %v%s\n", ansiYellow, err, ansiReset)
|
||||||
|
}
|
||||||
|
v.FocusVSCode()
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "\nTo get the latest model configuration, restart VS Code when you're ready.\n")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := v.ShowInModelPicker(models); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s Warning: could not update VS Code model picker: %v%s\n", ansiYellow, err, ansiReset)
|
||||||
|
}
|
||||||
|
v.FocusVSCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureModelsRegistered pulls models that the server knows about (Show succeeds)
|
||||||
|
// but aren't in ollama ls yet. This is needed for cloud models so that VS Code
|
||||||
|
// can discover them from the Ollama API.
|
||||||
|
func (v *VSCode) ensureModelsRegistered(ctx context.Context, client *api.Client, models []string) {
|
||||||
|
listed, err := client.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
registered := make(map[string]bool, len(listed.Models))
|
||||||
|
for _, m := range listed.Models {
|
||||||
|
registered[m.Name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, model := range models {
|
||||||
|
if registered[model] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Also check without :latest suffix
|
||||||
|
if !strings.Contains(model, ":") && registered[model+":latest"] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := pullModel(ctx, client, model, false); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s Warning: could not register model %s: %v%s\n", ansiYellow, model, err, ansiReset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FocusVSCode brings VS Code to the foreground.
|
||||||
|
func (v *VSCode) FocusVSCode() {
|
||||||
|
binary := v.findBinary()
|
||||||
|
if binary == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "darwin" && strings.HasSuffix(binary, ".app") {
|
||||||
|
_ = exec.Command("open", "-a", binary).Run()
|
||||||
|
} else {
|
||||||
|
_ = exec.Command(binary).Start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printModelAccessTip shows instructions for finding Ollama models in VS Code.
|
||||||
|
func (v *VSCode) printModelAccessTip() {
|
||||||
|
fmt.Fprintf(os.Stderr, "\nTip: To use Ollama models, open Copilot Chat and click the model picker.\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " If you don't see your models, click \"Other models\" to find them.\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VSCode) Paths() []string {
|
||||||
|
if p := v.chatLanguageModelsPath(); fileExists(p) {
|
||||||
|
return []string{p}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VSCode) Edit(models []string) error {
|
||||||
|
if len(models) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write chatLanguageModels.json with Ollama vendor entry
|
||||||
|
clmPath := v.chatLanguageModelsPath()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(clmPath), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []map[string]any
|
||||||
|
if data, err := os.ReadFile(clmPath); err == nil {
|
||||||
|
_ = json.Unmarshal(data, &entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any existing Ollama entries, preserve others
|
||||||
|
filtered := make([]map[string]any, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if vendor, _ := entry["vendor"].(string); vendor != "ollama" {
|
||||||
|
filtered = append(filtered, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new Ollama entry
|
||||||
|
filtered = append(filtered, map[string]any{
|
||||||
|
"vendor": "ollama",
|
||||||
|
"name": "Ollama",
|
||||||
|
"url": envconfig.Host().String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(filtered, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := fileutil.WriteWithBackup(clmPath, data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up legacy settings from older Ollama integrations
|
||||||
|
v.updateSettings()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VSCode) Models() []string {
|
||||||
|
if !v.hasOllamaVendor() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cfg, err := loadStoredIntegrationConfig("vscode"); err == nil {
|
||||||
|
return cfg.Models
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasOllamaVendor checks if chatLanguageModels.json contains an Ollama vendor entry.
|
||||||
|
func (v *VSCode) hasOllamaVendor() bool {
|
||||||
|
data, err := os.ReadFile(v.chatLanguageModelsPath())
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []map[string]any
|
||||||
|
if err := json.Unmarshal(data, &entries); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if vendor, _ := entry["vendor"].(string); vendor == "ollama" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VSCode) chatLanguageModelsPath() string {
|
||||||
|
return v.vscodePath("chatLanguageModels.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VSCode) settingsPath() string {
|
||||||
|
return v.vscodePath("settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateSettings cleans up legacy settings from older Ollama integrations.
|
||||||
|
func (v *VSCode) updateSettings() {
|
||||||
|
settingsPath := v.settingsPath()
|
||||||
|
data, err := os.ReadFile(settingsPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings map[string]any
|
||||||
|
if err := json.Unmarshal(data, &settings); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
for _, key := range []string{"github.copilot.chat.byok.ollamaEndpoint", "ollama.launch.configured"} {
|
||||||
|
if _, ok := settings[key]; ok {
|
||||||
|
delete(settings, key)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !changed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := json.MarshalIndent(settings, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = fileutil.WriteWithBackup(settingsPath, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VSCode) statePath() string {
|
||||||
|
return v.vscodePath("globalStorage", "state.vscdb")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowInModelPicker ensures the given models are visible in VS Code's Copilot
|
||||||
|
// Chat model picker. It sets the configured models to true in the picker
|
||||||
|
// preferences so they appear in the dropdown. Models use the VS Code identifier
|
||||||
|
// format "ollama/Ollama/<name>".
|
||||||
|
func (v *VSCode) ShowInModelPicker(models []string) error {
|
||||||
|
if len(models) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := v.statePath()
|
||||||
|
needsCreate := !fileExists(dbPath)
|
||||||
|
if needsCreate {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("creating state directory: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite3", dbPath+"?_busy_timeout=5000")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("opening state database: %w", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Create the table if this is a fresh DB. Schema must match what VS Code creates.
|
||||||
|
if needsCreate {
|
||||||
|
if _, err := db.Exec("CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"); err != nil {
|
||||||
|
return fmt.Errorf("initializing state database: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read existing preferences
|
||||||
|
prefs := make(map[string]bool)
|
||||||
|
var prefsJSON string
|
||||||
|
if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = 'chatModelPickerPreferences'").Scan(&prefsJSON); err == nil {
|
||||||
|
_ = json.Unmarshal([]byte(prefsJSON), &prefs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build name→ID map from VS Code's cached model list.
|
||||||
|
// VS Code uses numeric IDs like "ollama/Ollama/4", not "ollama/Ollama/kimi-k2.5:cloud".
|
||||||
|
nameToID := make(map[string]string)
|
||||||
|
var cacheJSON string
|
||||||
|
if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = 'chat.cachedLanguageModels.v2'").Scan(&cacheJSON); err == nil {
|
||||||
|
var cached []map[string]any
|
||||||
|
if json.Unmarshal([]byte(cacheJSON), &cached) == nil {
|
||||||
|
for _, entry := range cached {
|
||||||
|
meta, _ := entry["metadata"].(map[string]any)
|
||||||
|
if meta == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if vendor, _ := meta["vendor"].(string); vendor == "ollama" {
|
||||||
|
name, _ := meta["name"].(string)
|
||||||
|
id, _ := entry["identifier"].(string)
|
||||||
|
if name != "" && id != "" {
|
||||||
|
nameToID[name] = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ollama config is authoritative: always show configured models,
|
||||||
|
// hide Ollama models that are no longer in the config.
|
||||||
|
configuredIDs := make(map[string]bool)
|
||||||
|
for _, m := range models {
|
||||||
|
for _, id := range v.modelVSCodeIDs(m, nameToID) {
|
||||||
|
prefs[id] = true
|
||||||
|
configuredIDs[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id := range prefs {
|
||||||
|
if strings.HasPrefix(id, "ollama/") && !configuredIDs[id] {
|
||||||
|
prefs[id] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(prefs)
|
||||||
|
if _, err = db.Exec("INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('chatModelPickerPreferences', ?)", string(data)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelVSCodeIDs returns all possible VS Code picker IDs for a model name.
|
||||||
|
func (v *VSCode) modelVSCodeIDs(model string, nameToID map[string]string) []string {
|
||||||
|
var ids []string
|
||||||
|
if id, ok := nameToID[model]; ok {
|
||||||
|
ids = append(ids, id)
|
||||||
|
} else if !strings.Contains(model, ":") {
|
||||||
|
if id, ok := nameToID[model+":latest"]; ok {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids = append(ids, "ollama/Ollama/"+model)
|
||||||
|
if !strings.Contains(model, ":") {
|
||||||
|
ids = append(ids, "ollama/Ollama/"+model+":latest")
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VSCode) vscodePath(parts ...string) string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
var base string
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
base = filepath.Join(home, "Library", "Application Support", "Code", "User")
|
||||||
|
case "windows":
|
||||||
|
base = filepath.Join(os.Getenv("APPDATA"), "Code", "User")
|
||||||
|
default:
|
||||||
|
base = filepath.Join(home, ".config", "Code", "User")
|
||||||
|
}
|
||||||
|
return filepath.Join(append([]string{base}, parts...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkVSCodeVersion warns if VS Code is older than minVSCodeVersion.
|
||||||
|
func (v *VSCode) checkVSCodeVersion() {
|
||||||
|
codeCLI := v.findCodeCLI()
|
||||||
|
if codeCLI == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := exec.Command(codeCLI, "--version").Output()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// "code --version" outputs: version\ncommit\narch
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||||
|
if len(lines) == 0 || lines[0] == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
version := strings.TrimSpace(lines[0])
|
||||||
|
|
||||||
|
if compareVersions(version, minVSCodeVersion) < 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n%sWarning: VS Code version (%s) is older than the recommended version (%s)%s\n", ansiYellow, version, minVSCodeVersion, ansiReset)
|
||||||
|
fmt.Fprintf(os.Stderr, "Please update VS Code to the latest version.\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkCopilotChatVersion warns if the GitHub Copilot Chat extension is
|
||||||
|
// missing or older than minCopilotChatVersion.
|
||||||
|
func (v *VSCode) checkCopilotChatVersion() {
|
||||||
|
codeCLI := v.findCodeCLI()
|
||||||
|
if codeCLI == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := exec.Command(codeCLI, "--list-extensions", "--show-versions").Output()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
installed, version := parseCopilotChatVersion(string(out))
|
||||||
|
if !installed {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n%sWarning: GitHub Copilot Chat extension is not installed%s\n", ansiYellow, ansiReset)
|
||||||
|
fmt.Fprintf(os.Stderr, "Install it in VS Code: Extensions → search \"GitHub Copilot Chat\" → Install\n\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if compareVersions(version, minCopilotChatVersion) < 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n%sWarning: GitHub Copilot Chat extension version (%s) is older than the recommended version (%s)%s\n", ansiYellow, version, minCopilotChatVersion, ansiReset)
|
||||||
|
fmt.Fprintf(os.Stderr, "Please update it in VS Code: Extensions → search \"GitHub Copilot Chat\" → Update\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findCodeCLI returns the path to the VS Code CLI for querying extensions.
|
||||||
|
// On macOS, findBinary may return an .app bundle which can't run --list-extensions,
|
||||||
|
// so this resolves to the actual CLI binary inside the bundle.
|
||||||
|
func (v *VSCode) findCodeCLI() string {
|
||||||
|
binary := v.findBinary()
|
||||||
|
if binary == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "darwin" && strings.HasSuffix(binary, ".app") {
|
||||||
|
bundleCLI := binary + "/Contents/Resources/app/bin/code"
|
||||||
|
if _, err := os.Stat(bundleCLI); err == nil {
|
||||||
|
return bundleCLI
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return binary
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCopilotChatVersion extracts the version of the GitHub Copilot Chat
|
||||||
|
// extension from "code --list-extensions --show-versions" output.
|
||||||
|
func parseCopilotChatVersion(output string) (installed bool, version string) {
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
// Format: github.copilot-chat@0.40.1
|
||||||
|
if !strings.HasPrefix(strings.ToLower(line), "github.copilot-chat@") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "@", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return true, strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareVersions compares two dot-separated version strings.
|
||||||
|
// Returns -1 if a < b, 0 if a == b, 1 if a > b.
|
||||||
|
func compareVersions(a, b string) int {
|
||||||
|
aParts := strings.Split(a, ".")
|
||||||
|
bParts := strings.Split(b, ".")
|
||||||
|
|
||||||
|
maxLen := len(aParts)
|
||||||
|
if len(bParts) > maxLen {
|
||||||
|
maxLen = len(bParts)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range maxLen {
|
||||||
|
var aNum, bNum int
|
||||||
|
if i < len(aParts) {
|
||||||
|
aNum, _ = strconv.Atoi(aParts[i])
|
||||||
|
}
|
||||||
|
if i < len(bParts) {
|
||||||
|
bNum, _ = strconv.Atoi(bParts[i])
|
||||||
|
}
|
||||||
|
if aNum < bNum {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if aNum > bNum {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
486
cmd/launch/vscode_test.go
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
package launch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVSCodeIntegration(t *testing.T) {
|
||||||
|
v := &VSCode{}
|
||||||
|
|
||||||
|
t.Run("String", func(t *testing.T) {
|
||||||
|
if got := v.String(); got != "Visual Studio Code" {
|
||||||
|
t.Errorf("String() = %q, want %q", got, "Visual Studio Code")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("implements Runner", func(t *testing.T) {
|
||||||
|
var _ Runner = v
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("implements Editor", func(t *testing.T) {
|
||||||
|
var _ Editor = v
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVSCodeEdit(t *testing.T) {
|
||||||
|
v := &VSCode{}
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "")
|
||||||
|
clmPath := testVSCodePath(t, tmpDir, "chatLanguageModels.json")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setup string // initial chatLanguageModels.json content, empty means no file
|
||||||
|
models []string
|
||||||
|
validate func(t *testing.T, data []byte)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "fresh install",
|
||||||
|
models: []string{"llama3.2"},
|
||||||
|
validate: func(t *testing.T, data []byte) {
|
||||||
|
assertOllamaVendorConfigured(t, data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "preserve other vendor entries",
|
||||||
|
setup: `[{"vendor": "azure", "name": "Azure", "url": "https://example.com"}]`,
|
||||||
|
models: []string{"llama3.2"},
|
||||||
|
validate: func(t *testing.T, data []byte) {
|
||||||
|
var entries []map[string]any
|
||||||
|
json.Unmarshal(data, &entries)
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Errorf("expected 2 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
// Check Azure entry preserved
|
||||||
|
found := false
|
||||||
|
for _, e := range entries {
|
||||||
|
if v, _ := e["vendor"].(string); v == "azure" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("azure vendor entry was not preserved")
|
||||||
|
}
|
||||||
|
assertOllamaVendorConfigured(t, data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update existing ollama entry",
|
||||||
|
setup: `[{"vendor": "ollama", "name": "Ollama", "url": "http://old:11434"}]`,
|
||||||
|
models: []string{"llama3.2"},
|
||||||
|
validate: func(t *testing.T, data []byte) {
|
||||||
|
assertOllamaVendorConfigured(t, data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty models is no-op",
|
||||||
|
setup: `[{"vendor": "azure", "name": "Azure"}]`,
|
||||||
|
models: []string{},
|
||||||
|
validate: func(t *testing.T, data []byte) {
|
||||||
|
if string(data) != `[{"vendor": "azure", "name": "Azure"}]` {
|
||||||
|
t.Error("empty models should not modify file")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "corrupted JSON treated as empty",
|
||||||
|
setup: `{corrupted json`,
|
||||||
|
models: []string{"llama3.2"},
|
||||||
|
validate: func(t *testing.T, data []byte) {
|
||||||
|
var entries []map[string]any
|
||||||
|
if err := json.Unmarshal(data, &entries); err != nil {
|
||||||
|
t.Errorf("result is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
os.RemoveAll(filepath.Dir(clmPath))
|
||||||
|
|
||||||
|
if tt.setup != "" {
|
||||||
|
os.MkdirAll(filepath.Dir(clmPath), 0o755)
|
||||||
|
os.WriteFile(clmPath, []byte(tt.setup), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := v.Edit(tt.models); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := os.ReadFile(clmPath)
|
||||||
|
tt.validate(t, data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVSCodeEditCleansUpOldSettings(t *testing.T) {
|
||||||
|
v := &VSCode{}
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "")
|
||||||
|
settingsPath := testVSCodePath(t, tmpDir, "settings.json")
|
||||||
|
|
||||||
|
// Create settings.json with old byok setting
|
||||||
|
os.MkdirAll(filepath.Dir(settingsPath), 0o755)
|
||||||
|
os.WriteFile(settingsPath, []byte(`{"github.copilot.chat.byok.ollamaEndpoint": "http://old:11434", "ollama.launch.configured": true, "editor.fontSize": 14}`), 0o644)
|
||||||
|
|
||||||
|
if err := v.Edit([]string{"llama3.2"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify old settings were removed
|
||||||
|
data, err := os.ReadFile(settingsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings map[string]any
|
||||||
|
json.Unmarshal(data, &settings)
|
||||||
|
if _, ok := settings["github.copilot.chat.byok.ollamaEndpoint"]; ok {
|
||||||
|
t.Error("github.copilot.chat.byok.ollamaEndpoint should have been removed")
|
||||||
|
}
|
||||||
|
if _, ok := settings["ollama.launch.configured"]; ok {
|
||||||
|
t.Error("ollama.launch.configured should have been removed")
|
||||||
|
}
|
||||||
|
if settings["editor.fontSize"] != float64(14) {
|
||||||
|
t.Error("editor.fontSize should have been preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVSCodePaths(t *testing.T) {
|
||||||
|
v := &VSCode{}
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "")
|
||||||
|
clmPath := testVSCodePath(t, tmpDir, "chatLanguageModels.json")
|
||||||
|
|
||||||
|
t.Run("no file returns nil", func(t *testing.T) {
|
||||||
|
os.Remove(clmPath)
|
||||||
|
if paths := v.Paths(); paths != nil {
|
||||||
|
t.Errorf("expected nil, got %v", paths)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("existing file returns path", func(t *testing.T) {
|
||||||
|
os.MkdirAll(filepath.Dir(clmPath), 0o755)
|
||||||
|
os.WriteFile(clmPath, []byte(`[]`), 0o644)
|
||||||
|
|
||||||
|
if paths := v.Paths(); len(paths) != 1 {
|
||||||
|
t.Errorf("expected 1 path, got %d", len(paths))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// testVSCodePath returns the expected VS Code config path for the given file in tests.
|
||||||
|
func testVSCodePath(t *testing.T, tmpDir, filename string) string {
|
||||||
|
t.Helper()
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
return filepath.Join(tmpDir, "Library", "Application Support", "Code", "User", filename)
|
||||||
|
case "windows":
|
||||||
|
t.Setenv("APPDATA", tmpDir)
|
||||||
|
return filepath.Join(tmpDir, "Code", "User", filename)
|
||||||
|
default:
|
||||||
|
return filepath.Join(tmpDir, ".config", "Code", "User", filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertOllamaVendorConfigured(t *testing.T, data []byte) {
|
||||||
|
t.Helper()
|
||||||
|
var entries []map[string]any
|
||||||
|
if err := json.Unmarshal(data, &entries); err != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if vendor, _ := entry["vendor"].(string); vendor == "ollama" {
|
||||||
|
if name, _ := entry["name"].(string); name != "Ollama" {
|
||||||
|
t.Errorf("expected name \"Ollama\", got %q", name)
|
||||||
|
}
|
||||||
|
if url, _ := entry["url"].(string); url == "" {
|
||||||
|
t.Error("url not set")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Error("no ollama vendor entry found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShowInModelPicker(t *testing.T) {
|
||||||
|
v := &VSCode{}
|
||||||
|
|
||||||
|
// helper to create a state DB with optional seed data
|
||||||
|
setupDB := func(t *testing.T, tmpDir string, seedPrefs map[string]bool, seedCache []map[string]any) string {
|
||||||
|
t.Helper()
|
||||||
|
dbDir := filepath.Join(tmpDir, "globalStorage")
|
||||||
|
os.MkdirAll(dbDir, 0o755)
|
||||||
|
dbPath := filepath.Join(dbDir, "state.vscdb")
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite3", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if _, err := db.Exec("CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if seedPrefs != nil {
|
||||||
|
data, _ := json.Marshal(seedPrefs)
|
||||||
|
db.Exec("INSERT INTO ItemTable (key, value) VALUES ('chatModelPickerPreferences', ?)", string(data))
|
||||||
|
}
|
||||||
|
if seedCache != nil {
|
||||||
|
data, _ := json.Marshal(seedCache)
|
||||||
|
db.Exec("INSERT INTO ItemTable (key, value) VALUES ('chat.cachedLanguageModels.v2', ?)", string(data))
|
||||||
|
}
|
||||||
|
return dbPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper to read prefs back from DB
|
||||||
|
readPrefs := func(t *testing.T, dbPath string) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
db, err := sql.Open("sqlite3", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
var raw string
|
||||||
|
if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = 'chatModelPickerPreferences'").Scan(&raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
prefs := make(map[string]bool)
|
||||||
|
json.Unmarshal([]byte(raw), &prefs)
|
||||||
|
return prefs
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("fresh DB creates table and shows models", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "")
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Setenv("APPDATA", tmpDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := v.ShowInModelPicker([]string{"llama3.2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := testVSCodePath(t, tmpDir, filepath.Join("globalStorage", "state.vscdb"))
|
||||||
|
prefs := readPrefs(t, dbPath)
|
||||||
|
if !prefs["ollama/Ollama/llama3.2"] {
|
||||||
|
t.Error("expected llama3.2 to be shown")
|
||||||
|
}
|
||||||
|
if !prefs["ollama/Ollama/llama3.2:latest"] {
|
||||||
|
t.Error("expected llama3.2:latest to be shown")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("configured models are shown", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "")
|
||||||
|
dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), nil, nil)
|
||||||
|
|
||||||
|
err := v.ShowInModelPicker([]string{"llama3.2", "qwen3:8b"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs := readPrefs(t, dbPath)
|
||||||
|
if !prefs["ollama/Ollama/llama3.2"] {
|
||||||
|
t.Error("expected llama3.2 to be shown")
|
||||||
|
}
|
||||||
|
if !prefs["ollama/Ollama/qwen3:8b"] {
|
||||||
|
t.Error("expected qwen3:8b to be shown")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("removed models are hidden", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "")
|
||||||
|
dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), map[string]bool{
|
||||||
|
"ollama/Ollama/llama3.2": true,
|
||||||
|
"ollama/Ollama/llama3.2:latest": true,
|
||||||
|
"ollama/Ollama/mistral": true,
|
||||||
|
"ollama/Ollama/mistral:latest": true,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
// Only configure llama3.2 — mistral should get hidden
|
||||||
|
err := v.ShowInModelPicker([]string{"llama3.2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs := readPrefs(t, dbPath)
|
||||||
|
if !prefs["ollama/Ollama/llama3.2"] {
|
||||||
|
t.Error("expected llama3.2 to stay shown")
|
||||||
|
}
|
||||||
|
if prefs["ollama/Ollama/mistral"] {
|
||||||
|
t.Error("expected mistral to be hidden")
|
||||||
|
}
|
||||||
|
if prefs["ollama/Ollama/mistral:latest"] {
|
||||||
|
t.Error("expected mistral:latest to be hidden")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-ollama prefs are preserved", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "")
|
||||||
|
dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), map[string]bool{
|
||||||
|
"copilot/gpt-4o": true,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
err := v.ShowInModelPicker([]string{"llama3.2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs := readPrefs(t, dbPath)
|
||||||
|
if !prefs["copilot/gpt-4o"] {
|
||||||
|
t.Error("expected copilot/gpt-4o to stay shown")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("uses cached numeric IDs when available", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "")
|
||||||
|
cache := []map[string]any{
|
||||||
|
{
|
||||||
|
"identifier": "ollama/Ollama/4",
|
||||||
|
"metadata": map[string]any{"vendor": "ollama", "name": "llama3.2"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), nil, cache)
|
||||||
|
|
||||||
|
err := v.ShowInModelPicker([]string{"llama3.2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs := readPrefs(t, dbPath)
|
||||||
|
if !prefs["ollama/Ollama/4"] {
|
||||||
|
t.Error("expected numeric ID ollama/Ollama/4 to be shown")
|
||||||
|
}
|
||||||
|
// Name-based fallback should also be set
|
||||||
|
if !prefs["ollama/Ollama/llama3.2"] {
|
||||||
|
t.Error("expected name-based ID to also be shown")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty models is no-op", func(t *testing.T) {
|
||||||
|
err := v.ShowInModelPicker([]string{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("previously hidden model is re-shown when configured", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "")
|
||||||
|
dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), map[string]bool{
|
||||||
|
"ollama/Ollama/llama3.2": false,
|
||||||
|
"ollama/Ollama/llama3.2:latest": false,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
// Ollama config is authoritative — should override the hidden state
|
||||||
|
err := v.ShowInModelPicker([]string{"llama3.2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs := readPrefs(t, dbPath)
|
||||||
|
if !prefs["ollama/Ollama/llama3.2"] {
|
||||||
|
t.Error("expected llama3.2 to be re-shown")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCopilotChatVersion(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
output string
|
||||||
|
wantInstalled bool
|
||||||
|
wantVersion string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "found among other extensions",
|
||||||
|
output: "ms-python.python@2024.1.1\ngithub.copilot-chat@0.40.1\ngithub.copilot@1.200.0\n",
|
||||||
|
wantInstalled: true,
|
||||||
|
wantVersion: "0.40.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only extension",
|
||||||
|
output: "GitHub.copilot-chat@0.41.0\n",
|
||||||
|
wantInstalled: true,
|
||||||
|
wantVersion: "0.41.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not installed",
|
||||||
|
output: "ms-python.python@2024.1.1\ngithub.copilot@1.200.0\n",
|
||||||
|
wantInstalled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty output",
|
||||||
|
output: "",
|
||||||
|
wantInstalled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "case insensitive match",
|
||||||
|
output: "GitHub.Copilot-Chat@0.39.0\n",
|
||||||
|
wantInstalled: true,
|
||||||
|
wantVersion: "0.39.0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
installed, version := parseCopilotChatVersion(tt.output)
|
||||||
|
if installed != tt.wantInstalled {
|
||||||
|
t.Errorf("installed = %v, want %v", installed, tt.wantInstalled)
|
||||||
|
}
|
||||||
|
if installed && version != tt.wantVersion {
|
||||||
|
t.Errorf("version = %q, want %q", version, tt.wantVersion)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareVersions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
a, b string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"0.40.1", "0.40.1", 0},
|
||||||
|
{"0.40.2", "0.40.1", 1},
|
||||||
|
{"0.40.0", "0.40.1", -1},
|
||||||
|
{"0.41.0", "0.40.1", 1},
|
||||||
|
{"0.39.9", "0.40.1", -1},
|
||||||
|
{"1.0.0", "0.40.1", 1},
|
||||||
|
{"0.40", "0.40.1", -1},
|
||||||
|
{"0.40.1.1", "0.40.1", 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.a+"_vs_"+tt.b, func(t *testing.T) {
|
||||||
|
got := compareVersions(tt.a, tt.b)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("compareVersions(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/ollama/ollama/cmd/launch"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -18,12 +19,16 @@ var (
|
|||||||
|
|
||||||
type confirmModel struct {
|
type confirmModel struct {
|
||||||
prompt string
|
prompt string
|
||||||
|
yesLabel string
|
||||||
|
noLabel string
|
||||||
yes bool
|
yes bool
|
||||||
confirmed bool
|
confirmed bool
|
||||||
cancelled bool
|
cancelled bool
|
||||||
width int
|
width int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConfirmOptions = launch.ConfirmOptions
|
||||||
|
|
||||||
func (m confirmModel) Init() tea.Cmd {
|
func (m confirmModel) Init() tea.Cmd {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -40,22 +45,16 @@ func (m confirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "ctrl+c", "esc", "n":
|
case "ctrl+c", "esc":
|
||||||
m.cancelled = true
|
m.cancelled = true
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case "y":
|
|
||||||
m.yes = true
|
|
||||||
m.confirmed = true
|
|
||||||
return m, tea.Quit
|
|
||||||
case "enter":
|
case "enter":
|
||||||
m.confirmed = true
|
m.confirmed = true
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case "left", "h":
|
case "left":
|
||||||
m.yes = true
|
m.yes = true
|
||||||
case "right", "l":
|
case "right":
|
||||||
m.yes = false
|
m.yes = false
|
||||||
case "tab":
|
|
||||||
m.yes = !m.yes
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,12 +67,20 @@ func (m confirmModel) View() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var yesBtn, noBtn string
|
var yesBtn, noBtn string
|
||||||
|
yesLabel := m.yesLabel
|
||||||
|
if yesLabel == "" {
|
||||||
|
yesLabel = "Yes"
|
||||||
|
}
|
||||||
|
noLabel := m.noLabel
|
||||||
|
if noLabel == "" {
|
||||||
|
noLabel = "No"
|
||||||
|
}
|
||||||
if m.yes {
|
if m.yes {
|
||||||
yesBtn = confirmActiveStyle.Render(" Yes ")
|
yesBtn = confirmActiveStyle.Render(" " + yesLabel + " ")
|
||||||
noBtn = confirmInactiveStyle.Render(" No ")
|
noBtn = confirmInactiveStyle.Render(" " + noLabel + " ")
|
||||||
} else {
|
} else {
|
||||||
yesBtn = confirmInactiveStyle.Render(" Yes ")
|
yesBtn = confirmInactiveStyle.Render(" " + yesLabel + " ")
|
||||||
noBtn = confirmActiveStyle.Render(" No ")
|
noBtn = confirmActiveStyle.Render(" " + noLabel + " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
s := selectorTitleStyle.Render(m.prompt) + "\n\n"
|
s := selectorTitleStyle.Render(m.prompt) + "\n\n"
|
||||||
@@ -89,9 +96,26 @@ func (m confirmModel) View() string {
|
|||||||
// RunConfirm shows a bubbletea yes/no confirmation prompt.
|
// RunConfirm shows a bubbletea yes/no confirmation prompt.
|
||||||
// Returns true if the user confirmed, false if cancelled.
|
// Returns true if the user confirmed, false if cancelled.
|
||||||
func RunConfirm(prompt string) (bool, error) {
|
func RunConfirm(prompt string) (bool, error) {
|
||||||
|
return RunConfirmWithOptions(prompt, ConfirmOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunConfirmWithOptions shows a bubbletea yes/no confirmation prompt with
|
||||||
|
// optional custom button labels.
|
||||||
|
func RunConfirmWithOptions(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
yesLabel := options.YesLabel
|
||||||
|
if yesLabel == "" {
|
||||||
|
yesLabel = "Yes"
|
||||||
|
}
|
||||||
|
noLabel := options.NoLabel
|
||||||
|
if noLabel == "" {
|
||||||
|
noLabel = "No"
|
||||||
|
}
|
||||||
|
|
||||||
m := confirmModel{
|
m := confirmModel{
|
||||||
prompt: prompt,
|
prompt: prompt,
|
||||||
yes: true, // default to yes
|
yesLabel: yesLabel,
|
||||||
|
noLabel: noLabel,
|
||||||
|
yes: true, // default to yes
|
||||||
}
|
}
|
||||||
|
|
||||||
p := tea.NewProgram(m)
|
p := tea.NewProgram(m)
|
||||||
|
|||||||
@@ -33,6 +33,22 @@ func TestConfirmModel_View_ContainsButtons(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfirmModel_View_ContainsCustomButtons(t *testing.T) {
|
||||||
|
m := confirmModel{
|
||||||
|
prompt: "Connect a messaging app now?",
|
||||||
|
yesLabel: "Yes",
|
||||||
|
noLabel: "Set up later",
|
||||||
|
yes: true,
|
||||||
|
}
|
||||||
|
got := m.View()
|
||||||
|
if !strings.Contains(got, "Yes") {
|
||||||
|
t.Error("should contain custom yes button")
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "Set up later") {
|
||||||
|
t.Error("should contain custom no button")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfirmModel_View_ContainsHelp(t *testing.T) {
|
func TestConfirmModel_View_ContainsHelp(t *testing.T) {
|
||||||
m := confirmModel{prompt: "Download?", yes: true}
|
m := confirmModel{prompt: "Download?", yes: true}
|
||||||
got := m.View()
|
got := m.View()
|
||||||
@@ -109,30 +125,33 @@ func TestConfirmModel_CtrlCCancels(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfirmModel_NCancels(t *testing.T) {
|
func TestConfirmModel_NDoesNothing(t *testing.T) {
|
||||||
m := confirmModel{prompt: "Download?", yes: true}
|
m := confirmModel{prompt: "Download?", yes: true}
|
||||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
|
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
|
||||||
fm := updated.(confirmModel)
|
fm := updated.(confirmModel)
|
||||||
if !fm.cancelled {
|
if fm.cancelled {
|
||||||
t.Error("'n' should set cancelled=true")
|
t.Error("'n' should not cancel")
|
||||||
}
|
}
|
||||||
if cmd == nil {
|
if fm.confirmed {
|
||||||
t.Error("'n' should return tea.Quit")
|
t.Error("'n' should not confirm")
|
||||||
|
}
|
||||||
|
if cmd != nil {
|
||||||
|
t.Error("'n' should not quit")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfirmModel_YConfirmsYes(t *testing.T) {
|
func TestConfirmModel_YDoesNothing(t *testing.T) {
|
||||||
m := confirmModel{prompt: "Download?", yes: false}
|
m := confirmModel{prompt: "Download?", yes: false}
|
||||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}})
|
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}})
|
||||||
fm := updated.(confirmModel)
|
fm := updated.(confirmModel)
|
||||||
if !fm.confirmed {
|
if fm.confirmed {
|
||||||
t.Error("'y' should set confirmed=true")
|
t.Error("'y' should not confirm")
|
||||||
}
|
}
|
||||||
if !fm.yes {
|
if fm.yes {
|
||||||
t.Error("'y' should set yes=true")
|
t.Error("'y' should not change selection")
|
||||||
}
|
}
|
||||||
if cmd == nil {
|
if cmd != nil {
|
||||||
t.Error("'y' should return tea.Quit")
|
t.Error("'y' should not quit")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,36 +159,33 @@ func TestConfirmModel_ArrowKeysNavigate(t *testing.T) {
|
|||||||
m := confirmModel{prompt: "Download?", yes: true}
|
m := confirmModel{prompt: "Download?", yes: true}
|
||||||
|
|
||||||
// Right moves to No
|
// Right moves to No
|
||||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}})
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight})
|
||||||
fm := updated.(confirmModel)
|
fm := updated.(confirmModel)
|
||||||
if fm.yes {
|
if fm.yes {
|
||||||
t.Error("right/l should move to No")
|
t.Error("right should move to No")
|
||||||
}
|
}
|
||||||
if fm.confirmed || fm.cancelled {
|
if fm.confirmed || fm.cancelled {
|
||||||
t.Error("navigation should not confirm or cancel")
|
t.Error("navigation should not confirm or cancel")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Left moves back to Yes
|
// Left moves back to Yes
|
||||||
updated, _ = fm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}})
|
updated, _ = fm.Update(tea.KeyMsg{Type: tea.KeyLeft})
|
||||||
fm = updated.(confirmModel)
|
fm = updated.(confirmModel)
|
||||||
if !fm.yes {
|
if !fm.yes {
|
||||||
t.Error("left/h should move to Yes")
|
t.Error("left should move to Yes")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfirmModel_TabToggles(t *testing.T) {
|
func TestConfirmModel_TabDoesNothing(t *testing.T) {
|
||||||
m := confirmModel{prompt: "Download?", yes: true}
|
m := confirmModel{prompt: "Download?", yes: true}
|
||||||
|
|
||||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab})
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab})
|
||||||
fm := updated.(confirmModel)
|
fm := updated.(confirmModel)
|
||||||
if fm.yes {
|
|
||||||
t.Error("tab should toggle from Yes to No")
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, _ = fm.Update(tea.KeyMsg{Type: tea.KeyTab})
|
|
||||||
fm = updated.(confirmModel)
|
|
||||||
if !fm.yes {
|
if !fm.yes {
|
||||||
t.Error("tab should toggle from No to Yes")
|
t.Error("tab should not change selection")
|
||||||
|
}
|
||||||
|
if fm.confirmed || fm.cancelled {
|
||||||
|
t.Error("tab should not confirm or cancel")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -56,7 +55,7 @@ var (
|
|||||||
const maxSelectorItems = 10
|
const maxSelectorItems = 10
|
||||||
|
|
||||||
// ErrCancelled is returned when the user cancels the selection.
|
// ErrCancelled is returned when the user cancels the selection.
|
||||||
var ErrCancelled = errors.New("cancelled")
|
var ErrCancelled = launch.ErrCancelled
|
||||||
|
|
||||||
type SelectItem struct {
|
type SelectItem struct {
|
||||||
Name string
|
Name string
|
||||||
@@ -242,6 +241,10 @@ func (m selectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.cancelled = true
|
m.cancelled = true
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
|
||||||
|
case tea.KeyLeft:
|
||||||
|
m.cancelled = true
|
||||||
|
return m, tea.Quit
|
||||||
|
|
||||||
case tea.KeyEnter:
|
case tea.KeyEnter:
|
||||||
filtered := m.filteredItems()
|
filtered := m.filteredItems()
|
||||||
if len(filtered) > 0 && m.cursor < len(filtered) {
|
if len(filtered) > 0 && m.cursor < len(filtered) {
|
||||||
@@ -354,7 +357,7 @@ func (m selectorModel) renderContent() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.WriteString("\n")
|
s.WriteString("\n")
|
||||||
help := "↑/↓ navigate • enter select • esc cancel"
|
help := "↑/↓ navigate • enter select • ← back"
|
||||||
if m.helpText != "" {
|
if m.helpText != "" {
|
||||||
help = m.helpText
|
help = m.helpText
|
||||||
}
|
}
|
||||||
@@ -608,6 +611,10 @@ func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.cancelled = true
|
m.cancelled = true
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
|
||||||
|
case tea.KeyLeft:
|
||||||
|
m.cancelled = true
|
||||||
|
return m, tea.Quit
|
||||||
|
|
||||||
case tea.KeyTab:
|
case tea.KeyTab:
|
||||||
m.multi = !m.multi
|
m.multi = !m.multi
|
||||||
|
|
||||||
@@ -809,17 +816,21 @@ func (m multiSelectorModel) View() string {
|
|||||||
|
|
||||||
s.WriteString("\n")
|
s.WriteString("\n")
|
||||||
|
|
||||||
|
count := m.selectedCount()
|
||||||
if !m.multi {
|
if !m.multi {
|
||||||
s.WriteString(selectorHelpStyle.Render("↑/↓ navigate • enter select • tab add multiple • esc cancel"))
|
if count > 0 {
|
||||||
|
s.WriteString(sectionHeaderStyle.Render(fmt.Sprintf("%d models selected - press tab to edit", count)))
|
||||||
|
s.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
s.WriteString(selectorHelpStyle.Render("↑/↓ navigate • enter select • tab add multiple • ← back"))
|
||||||
} else {
|
} else {
|
||||||
count := m.selectedCount()
|
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
s.WriteString(selectorDescStyle.Render(" Select at least one model."))
|
s.WriteString(sectionHeaderStyle.Render("Select at least one model."))
|
||||||
} else {
|
} else {
|
||||||
s.WriteString(selectorDescStyle.Render(fmt.Sprintf(" %d selected - press enter to continue", count)))
|
s.WriteString(sectionHeaderStyle.Render(fmt.Sprintf("%d models selected - press enter to continue", count)))
|
||||||
}
|
}
|
||||||
s.WriteString("\n\n")
|
s.WriteString("\n\n")
|
||||||
s.WriteString(selectorHelpStyle.Render("↑/↓ navigate • space toggle • tab select single • enter confirm • esc cancel"))
|
s.WriteString(selectorHelpStyle.Render("↑/↓ navigate • space toggle • tab select single • enter confirm • ← back"))
|
||||||
}
|
}
|
||||||
|
|
||||||
result := s.String()
|
result := s.String()
|
||||||
|
|||||||
@@ -782,6 +782,9 @@ func TestMulti_MultiModeHelpText(t *testing.T) {
|
|||||||
if !strings.Contains(content, "tab select single") {
|
if !strings.Contains(content, "tab select single") {
|
||||||
t.Error("multi mode should show 'tab select single' in help")
|
t.Error("multi mode should show 'tab select single' in help")
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(content, "← back") {
|
||||||
|
t.Error("multi mode should show '← back' in help")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- preChecked initialization order ---
|
// --- preChecked initialization order ---
|
||||||
@@ -868,6 +871,46 @@ func TestMulti_UncheckingTopDefaultFallsBackToNearestCheckedBelow(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Left arrow back navigation ---
|
||||||
|
|
||||||
|
func TestSelectorLeftArrowCancelsWhenNoFilter(t *testing.T) {
|
||||||
|
m := selectorModelWithCurrent("Pick:", items("a", "b", "c"), "")
|
||||||
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft})
|
||||||
|
got := updated.(selectorModel)
|
||||||
|
if !got.cancelled {
|
||||||
|
t.Error("left arrow with empty filter should cancel (go back)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectorLeftArrowCancelsWhenFiltering(t *testing.T) {
|
||||||
|
m := selectorModelWithCurrent("Pick:", items("a", "b", "c"), "")
|
||||||
|
m.filter = "a"
|
||||||
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft})
|
||||||
|
got := updated.(selectorModel)
|
||||||
|
if !got.cancelled {
|
||||||
|
t.Error("left arrow with active filter should still cancel (go back)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiSelectorLeftArrowCancelsWhenNoFilter(t *testing.T) {
|
||||||
|
m := newMultiSelectorModel("Pick:", items("a", "b", "c"), nil)
|
||||||
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft})
|
||||||
|
got := updated.(multiSelectorModel)
|
||||||
|
if !got.cancelled {
|
||||||
|
t.Error("left arrow with empty filter should cancel (go back)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiSelectorLeftArrowCancelsWhenFiltering(t *testing.T) {
|
||||||
|
m := newMultiSelectorModel("Pick:", items("a", "b", "c"), nil)
|
||||||
|
m.filter = "a"
|
||||||
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft})
|
||||||
|
got := updated.(multiSelectorModel)
|
||||||
|
if !got.cancelled {
|
||||||
|
t.Error("left arrow with active filter should still cancel (go back)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Key message helpers for testing
|
// Key message helpers for testing
|
||||||
|
|
||||||
type keyType = int
|
type keyType = int
|
||||||
|
|||||||
@@ -47,18 +47,18 @@ type menuItem struct {
|
|||||||
|
|
||||||
var mainMenuItems = []menuItem{
|
var mainMenuItems = []menuItem{
|
||||||
{
|
{
|
||||||
title: "Run a model",
|
title: "Chat with a model",
|
||||||
description: "Start an interactive chat with a model",
|
description: "Start an interactive chat with a model",
|
||||||
isRunModel: true,
|
isRunModel: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
integration: "openclaw",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
integration: "claude",
|
integration: "claude",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
integration: "codex",
|
integration: "opencode",
|
||||||
},
|
|
||||||
{
|
|
||||||
integration: "openclaw",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,9 +136,9 @@ func integrationMenuItem(state launch.LauncherIntegrationState) menuItem {
|
|||||||
|
|
||||||
func otherIntegrationItems(state *launch.LauncherState) []menuItem {
|
func otherIntegrationItems(state *launch.LauncherState) []menuItem {
|
||||||
pinned := map[string]bool{
|
pinned := map[string]bool{
|
||||||
"claude": true,
|
|
||||||
"codex": true,
|
|
||||||
"openclaw": true,
|
"openclaw": true,
|
||||||
|
"claude": true,
|
||||||
|
"opencode": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
var items []menuItem
|
var items []menuItem
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ func launcherTestState() *launch.LauncherState {
|
|||||||
Changeable: true,
|
Changeable: true,
|
||||||
AutoInstallable: true,
|
AutoInstallable: true,
|
||||||
},
|
},
|
||||||
|
"opencode": {
|
||||||
|
Name: "opencode",
|
||||||
|
DisplayName: "OpenCode",
|
||||||
|
Description: "Anomaly's open-source coding agent",
|
||||||
|
Selectable: true,
|
||||||
|
Changeable: true,
|
||||||
|
},
|
||||||
"droid": {
|
"droid": {
|
||||||
Name: "droid",
|
Name: "droid",
|
||||||
DisplayName: "Droid",
|
DisplayName: "Droid",
|
||||||
@@ -54,13 +61,25 @@ func launcherTestState() *launch.LauncherState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func findMenuCursorByIntegration(items []menuItem, name string) int {
|
||||||
|
for i, item := range items {
|
||||||
|
if item.integration == name {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
func TestMenuRendersPinnedItemsAndMore(t *testing.T) {
|
func TestMenuRendersPinnedItemsAndMore(t *testing.T) {
|
||||||
view := newModel(launcherTestState()).View()
|
view := newModel(launcherTestState()).View()
|
||||||
for _, want := range []string{"Run a model", "Launch Claude Code", "Launch Codex", "Launch OpenClaw", "More..."} {
|
for _, want := range []string{"Chat with a model", "Launch OpenClaw", "Launch Claude Code", "Launch OpenCode", "More..."} {
|
||||||
if !strings.Contains(view, want) {
|
if !strings.Contains(view, want) {
|
||||||
t.Fatalf("expected menu view to contain %q\n%s", want, view)
|
t.Fatalf("expected menu view to contain %q\n%s", want, view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if strings.Contains(view, "Launch Codex") {
|
||||||
|
t.Fatalf("expected Codex to be under More, not pinned\n%s", view)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMenuExpandsOthersFromLastSelection(t *testing.T) {
|
func TestMenuExpandsOthersFromLastSelection(t *testing.T) {
|
||||||
@@ -102,7 +121,10 @@ func TestMenuRightOnRunSelectsChangeRun(t *testing.T) {
|
|||||||
|
|
||||||
func TestMenuEnterOnIntegrationSelectsLaunch(t *testing.T) {
|
func TestMenuEnterOnIntegrationSelectsLaunch(t *testing.T) {
|
||||||
menu := newModel(launcherTestState())
|
menu := newModel(launcherTestState())
|
||||||
menu.cursor = 1
|
menu.cursor = findMenuCursorByIntegration(menu.items, "claude")
|
||||||
|
if menu.cursor == -1 {
|
||||||
|
t.Fatal("expected claude menu item")
|
||||||
|
}
|
||||||
updated, _ := menu.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
updated, _ := menu.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
got := updated.(model)
|
got := updated.(model)
|
||||||
want := TUIAction{Kind: TUIActionLaunchIntegration, Integration: "claude"}
|
want := TUIAction{Kind: TUIActionLaunchIntegration, Integration: "claude"}
|
||||||
@@ -113,7 +135,10 @@ func TestMenuEnterOnIntegrationSelectsLaunch(t *testing.T) {
|
|||||||
|
|
||||||
func TestMenuRightOnIntegrationSelectsConfigure(t *testing.T) {
|
func TestMenuRightOnIntegrationSelectsConfigure(t *testing.T) {
|
||||||
menu := newModel(launcherTestState())
|
menu := newModel(launcherTestState())
|
||||||
menu.cursor = 1
|
menu.cursor = findMenuCursorByIntegration(menu.items, "claude")
|
||||||
|
if menu.cursor == -1 {
|
||||||
|
t.Fatal("expected claude menu item")
|
||||||
|
}
|
||||||
updated, _ := menu.Update(tea.KeyMsg{Type: tea.KeyRight})
|
updated, _ := menu.Update(tea.KeyMsg{Type: tea.KeyRight})
|
||||||
got := updated.(model)
|
got := updated.(model)
|
||||||
want := TUIAction{Kind: TUIActionLaunchIntegration, Integration: "claude", ForceConfigure: true}
|
want := TUIAction{Kind: TUIActionLaunchIntegration, Integration: "claude", ForceConfigure: true}
|
||||||
@@ -130,7 +155,10 @@ func TestMenuIgnoresDisabledActions(t *testing.T) {
|
|||||||
state.Integrations["claude"] = claude
|
state.Integrations["claude"] = claude
|
||||||
|
|
||||||
menu := newModel(state)
|
menu := newModel(state)
|
||||||
menu.cursor = 1
|
menu.cursor = findMenuCursorByIntegration(menu.items, "claude")
|
||||||
|
if menu.cursor == -1 {
|
||||||
|
t.Fatal("expected claude menu item")
|
||||||
|
}
|
||||||
|
|
||||||
updatedEnter, _ := menu.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
updatedEnter, _ := menu.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
if updatedEnter.(model).selected {
|
if updatedEnter.(model).selected {
|
||||||
@@ -150,7 +178,10 @@ func TestMenuShowsCurrentModelSuffixes(t *testing.T) {
|
|||||||
t.Fatalf("expected run row to show current model suffix\n%s", runView)
|
t.Fatalf("expected run row to show current model suffix\n%s", runView)
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.cursor = 1
|
menu.cursor = findMenuCursorByIntegration(menu.items, "claude")
|
||||||
|
if menu.cursor == -1 {
|
||||||
|
t.Fatal("expected claude menu item")
|
||||||
|
}
|
||||||
integrationView := menu.View()
|
integrationView := menu.View()
|
||||||
if !strings.Contains(integrationView, "(glm-5:cloud)") {
|
if !strings.Contains(integrationView, "(glm-5:cloud)") {
|
||||||
t.Fatalf("expected integration row to show current model suffix\n%s", integrationView)
|
t.Fatalf("expected integration row to show current model suffix\n%s", integrationView)
|
||||||
@@ -166,8 +197,12 @@ func TestMenuShowsInstallStatusAndHint(t *testing.T) {
|
|||||||
codex.InstallHint = "Install from https://example.com/codex"
|
codex.InstallHint = "Install from https://example.com/codex"
|
||||||
state.Integrations["codex"] = codex
|
state.Integrations["codex"] = codex
|
||||||
|
|
||||||
|
state.LastSelection = "codex"
|
||||||
menu := newModel(state)
|
menu := newModel(state)
|
||||||
menu.cursor = 2
|
menu.cursor = findMenuCursorByIntegration(menu.items, "codex")
|
||||||
|
if menu.cursor == -1 {
|
||||||
|
t.Fatal("expected codex menu item in overflow section")
|
||||||
|
}
|
||||||
view := menu.View()
|
view := menu.View()
|
||||||
if !strings.Contains(view, "(not installed)") {
|
if !strings.Contains(view, "(not installed)") {
|
||||||
t.Fatalf("expected not-installed marker\n%s", view)
|
t.Fatalf("expected not-installed marker\n%s", view)
|
||||||
|
|||||||
@@ -290,6 +290,8 @@ func LoadModelMetadata(fsys fs.FS) (ModelKV, *Tokenizer, error) {
|
|||||||
conv = &gemma3Model{Architecture: p.Architectures[0]}
|
conv = &gemma3Model{Architecture: p.Architectures[0]}
|
||||||
case "Gemma3nForConditionalGeneration":
|
case "Gemma3nForConditionalGeneration":
|
||||||
conv = &gemma3nModel{}
|
conv = &gemma3nModel{}
|
||||||
|
case "Gemma4ForCausalLM", "Gemma4ForConditionalGeneration":
|
||||||
|
conv = &gemma4Model{Architecture: p.Architectures[0]}
|
||||||
case "Phi3ForCausalLM":
|
case "Phi3ForCausalLM":
|
||||||
conv = &phi3Model{}
|
conv = &phi3Model{}
|
||||||
case "Qwen2ForCausalLM":
|
case "Qwen2ForCausalLM":
|
||||||
|
|||||||
574
convert/convert_gemma4.go
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
package convert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/fs/ggml"
|
||||||
|
)
|
||||||
|
|
||||||
|
type gemma4Model struct {
|
||||||
|
gemmaModel
|
||||||
|
Architecture string
|
||||||
|
TextModel struct {
|
||||||
|
HiddenSize uint32 `json:"hidden_size"`
|
||||||
|
NumHiddenLayers uint32 `json:"num_hidden_layers"`
|
||||||
|
IntermediateSize uint32 `json:"intermediate_size"`
|
||||||
|
NumAttentionHeads uint32 `json:"num_attention_heads"`
|
||||||
|
NumKeyValueHeads uint32 `json:"num_key_value_heads"`
|
||||||
|
HeadDim uint32 `json:"head_dim"`
|
||||||
|
GlobalHeadDim uint32 `json:"global_head_dim"`
|
||||||
|
VocabSize uint32 `json:"vocab_size"`
|
||||||
|
RMSNormEps float32 `json:"rms_norm_eps"`
|
||||||
|
MaxPositionEmbeddings uint32 `json:"max_position_embeddings"`
|
||||||
|
SlidingWindow uint32 `json:"sliding_window"`
|
||||||
|
SlidingWindowPattern *int32 `json:"_sliding_window_pattern"`
|
||||||
|
LayerTypes []string `json:"layer_types"`
|
||||||
|
FinalLogitSoftcapping float32 `json:"final_logit_softcapping"`
|
||||||
|
EnableMoeBlock bool `json:"enable_moe_block"`
|
||||||
|
NumExperts *uint32 `json:"num_experts"`
|
||||||
|
TopKExperts *uint32 `json:"top_k_experts"`
|
||||||
|
ExpertIntermediateSize *uint32 `json:"moe_intermediate_size"`
|
||||||
|
HiddenSizePerLayerInput *uint32 `json:"hidden_size_per_layer_input"`
|
||||||
|
NumKVSharedLayers uint32 `json:"num_kv_shared_layers"`
|
||||||
|
AttentionKEqV bool `json:"attention_k_eq_v"`
|
||||||
|
NumGlobalKeyValueHeads *uint32 `json:"num_global_key_value_heads"`
|
||||||
|
QueryPreAttnScalar *uint32 `json:"query_pre_attn_scalar"`
|
||||||
|
UseDoubleWideMLP bool `json:"use_double_wide_mlp"`
|
||||||
|
RopeParameters map[string]*struct {
|
||||||
|
RopeTheta float32 `json:"rope_theta"`
|
||||||
|
PartialRotaryFactor *float32 `json:"partial_rotary_factor"`
|
||||||
|
} `json:"rope_parameters"`
|
||||||
|
} `json:"text_config"`
|
||||||
|
|
||||||
|
VisionModel struct {
|
||||||
|
HiddenSize uint32 `json:"hidden_size"`
|
||||||
|
NumHiddenLayers uint32 `json:"num_hidden_layers"`
|
||||||
|
NumAttentionHeads uint32 `json:"num_attention_heads"`
|
||||||
|
IntermediateSize uint32 `json:"intermediate_size"`
|
||||||
|
PatchSize uint32 `json:"patch_size"`
|
||||||
|
NumChannels uint32 `json:"num_channels"`
|
||||||
|
PoolingKernelSize uint32 `json:"pooling_kernel_size"`
|
||||||
|
LayerNormEps float32 `json:"layer_norm_eps"`
|
||||||
|
} `json:"vision_config"`
|
||||||
|
|
||||||
|
AudioModel *struct {
|
||||||
|
HiddenSize uint32 `json:"hidden_size"`
|
||||||
|
OutputProjDims uint32 `json:"output_proj_dims"`
|
||||||
|
NumHiddenLayers uint32 `json:"num_hidden_layers"`
|
||||||
|
NumAttentionHeads uint32 `json:"num_attention_heads"`
|
||||||
|
ConvKernelSize uint32 `json:"conv_kernel_size"`
|
||||||
|
RMSNormEps float32 `json:"rms_norm_eps"`
|
||||||
|
} `json:"audio_config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *gemma4Model) KV(t *Tokenizer) KV {
|
||||||
|
kv := p.ModelParameters.KV(t)
|
||||||
|
kv["general.architecture"] = "gemma4"
|
||||||
|
kv["tokenizer.ggml.model"] = "llama"
|
||||||
|
kv["tokenizer.ggml.pre"] = "gemma4"
|
||||||
|
|
||||||
|
tc := p.TextModel
|
||||||
|
|
||||||
|
kv["gemma4.block_count"] = tc.NumHiddenLayers
|
||||||
|
kv["gemma4.embedding_length"] = tc.HiddenSize
|
||||||
|
|
||||||
|
// Per-layer FFN width: when use_double_wide_mlp is set, KV-shared layers get 2x FFN width.
|
||||||
|
if tc.UseDoubleWideMLP && tc.NumKVSharedLayers > 0 {
|
||||||
|
firstShared := int(tc.NumHiddenLayers) - int(tc.NumKVSharedLayers)
|
||||||
|
ffnWidths := make([]int32, tc.NumHiddenLayers)
|
||||||
|
for i := range ffnWidths {
|
||||||
|
if i >= firstShared {
|
||||||
|
ffnWidths[i] = int32(tc.IntermediateSize * 2)
|
||||||
|
} else {
|
||||||
|
ffnWidths[i] = int32(tc.IntermediateSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kv["gemma4.feed_forward_length"] = ffnWidths
|
||||||
|
} else {
|
||||||
|
kv["gemma4.feed_forward_length"] = tc.IntermediateSize
|
||||||
|
}
|
||||||
|
kv["gemma4.context_length"] = tc.MaxPositionEmbeddings
|
||||||
|
kv["gemma4.attention.head_count"] = tc.NumAttentionHeads
|
||||||
|
// Per-layer KV head count array: SWA layers use NumKeyValueHeads, global layers use NumGlobalKeyValueHeads
|
||||||
|
if tc.NumGlobalKeyValueHeads != nil && *tc.NumGlobalKeyValueHeads != tc.NumKeyValueHeads && len(tc.LayerTypes) > 0 {
|
||||||
|
kvHeads := make([]int32, len(tc.LayerTypes))
|
||||||
|
for i, lt := range tc.LayerTypes {
|
||||||
|
if lt == "sliding_attention" {
|
||||||
|
kvHeads[i] = int32(tc.NumKeyValueHeads)
|
||||||
|
} else {
|
||||||
|
kvHeads[i] = int32(*tc.NumGlobalKeyValueHeads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kv["gemma4.attention.head_count_kv"] = kvHeads
|
||||||
|
} else {
|
||||||
|
kv["gemma4.attention.head_count_kv"] = tc.NumKeyValueHeads
|
||||||
|
}
|
||||||
|
// key_length = global head dim, key_length_swa = local (SWA) head dim
|
||||||
|
kv["gemma4.attention.key_length"] = tc.GlobalHeadDim
|
||||||
|
kv["gemma4.attention.value_length"] = tc.GlobalHeadDim
|
||||||
|
kv["gemma4.attention.key_length_swa"] = tc.HeadDim
|
||||||
|
kv["gemma4.attention.value_length_swa"] = tc.HeadDim
|
||||||
|
kv["gemma4.attention.layer_norm_rms_epsilon"] = tc.RMSNormEps
|
||||||
|
kv["gemma4.attention.sliding_window"] = tc.SlidingWindow
|
||||||
|
|
||||||
|
// Sliding window pattern from layer_types
|
||||||
|
if len(tc.LayerTypes) > 0 {
|
||||||
|
kv["gemma4.attention.sliding_window_pattern"] = slices.Collect(func(yield func(bool) bool) {
|
||||||
|
for _, lt := range tc.LayerTypes {
|
||||||
|
if !yield(lt == "sliding_attention") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
kv["gemma4.attention.shared_kv_layers"] = tc.NumKVSharedLayers
|
||||||
|
|
||||||
|
// RoPE: dimension_count is the full global head dim (freq_factors handle partial rotation)
|
||||||
|
if rp, ok := tc.RopeParameters["full_attention"]; ok && rp != nil {
|
||||||
|
kv["gemma4.rope.freq_base"] = rp.RopeTheta
|
||||||
|
kv["gemma4.rope.dimension_count"] = tc.GlobalHeadDim
|
||||||
|
}
|
||||||
|
if rp, ok := tc.RopeParameters["sliding_attention"]; ok && rp != nil {
|
||||||
|
kv["gemma4.rope.freq_base_swa"] = rp.RopeTheta
|
||||||
|
kv["gemma4.rope.dimension_count_swa"] = tc.HeadDim
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.FinalLogitSoftcapping > 0 {
|
||||||
|
kv["gemma4.final_logit_softcapping"] = tc.FinalLogitSoftcapping
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoE
|
||||||
|
if tc.EnableMoeBlock && tc.NumExperts != nil {
|
||||||
|
kv["gemma4.expert_count"] = *tc.NumExperts
|
||||||
|
if tc.TopKExperts != nil {
|
||||||
|
kv["gemma4.expert_used_count"] = *tc.TopKExperts
|
||||||
|
}
|
||||||
|
if tc.ExpertIntermediateSize != nil {
|
||||||
|
kv["gemma4.expert_feed_forward_length"] = *tc.ExpertIntermediateSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PLE — always emit, even when 0
|
||||||
|
pleSize := uint32(0)
|
||||||
|
if tc.HiddenSizePerLayerInput != nil {
|
||||||
|
pleSize = *tc.HiddenSizePerLayerInput
|
||||||
|
}
|
||||||
|
kv["gemma4.embedding_length_per_layer_input"] = pleSize
|
||||||
|
|
||||||
|
// Vision model KV metadata
|
||||||
|
vc := p.VisionModel
|
||||||
|
if vc.NumHiddenLayers > 0 {
|
||||||
|
kv["gemma4.vision.block_count"] = vc.NumHiddenLayers
|
||||||
|
kv["gemma4.vision.embedding_length"] = vc.HiddenSize
|
||||||
|
kv["gemma4.vision.attention.head_count"] = vc.NumAttentionHeads
|
||||||
|
kv["gemma4.vision.feed_forward_length"] = vc.IntermediateSize
|
||||||
|
kv["gemma4.vision.patch_size"] = vc.PatchSize
|
||||||
|
numCh := vc.NumChannels
|
||||||
|
if numCh == 0 {
|
||||||
|
numCh = 3
|
||||||
|
}
|
||||||
|
kv["gemma4.vision.num_channels"] = numCh
|
||||||
|
nMerge := vc.PoolingKernelSize
|
||||||
|
if nMerge == 0 {
|
||||||
|
nMerge = 3
|
||||||
|
}
|
||||||
|
kv["gemma4.vision.projector.scale_factor"] = nMerge
|
||||||
|
eps := vc.LayerNormEps
|
||||||
|
if eps == 0 {
|
||||||
|
eps = 1e-6
|
||||||
|
}
|
||||||
|
kv["gemma4.vision.attention.layer_norm_epsilon"] = eps
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio model KV metadata
|
||||||
|
if p.AudioModel != nil && p.AudioModel.NumHiddenLayers > 0 {
|
||||||
|
ac := p.AudioModel
|
||||||
|
kv["gemma4.audio.block_count"] = ac.NumHiddenLayers
|
||||||
|
kv["gemma4.audio.embedding_length"] = ac.HiddenSize
|
||||||
|
kv["gemma4.audio.feed_forward_length"] = ac.HiddenSize * 4
|
||||||
|
kv["gemma4.audio.attention.head_count"] = ac.NumAttentionHeads
|
||||||
|
eps := ac.RMSNormEps
|
||||||
|
if eps == 0 {
|
||||||
|
eps = 1e-6
|
||||||
|
}
|
||||||
|
kv["gemma4.audio.attention.layer_norm_epsilon"] = eps
|
||||||
|
if ac.ConvKernelSize > 0 {
|
||||||
|
kv["gemma4.audio.conv_kernel_size"] = ac.ConvKernelSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return kv
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *gemma4Model) Tensors(ts []Tensor) []*ggml.Tensor {
|
||||||
|
// First pass: collect vision clamp scalar values into a packed tensor.
|
||||||
|
// Layout: per vision layer (0..N-1), 7 linears (q,k,v,out,gate,up,down) × 4 values (inMin,inMax,outMin,outMax).
|
||||||
|
// Then 4 values for the projector (mm.input_projection).
|
||||||
|
clampSuffixes := []string{".input_min", ".input_max", ".output_min", ".output_max"}
|
||||||
|
clampMap := make(map[string]float32)
|
||||||
|
for _, t := range ts {
|
||||||
|
name := t.Name()
|
||||||
|
for _, sfx := range clampSuffixes {
|
||||||
|
if strings.HasSuffix(name, sfx) && (strings.Contains(name, "vision_tower") || strings.Contains(name, "embed_vision")) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
t.WriteTo(&buf)
|
||||||
|
data := buf.Bytes()
|
||||||
|
if len(data) >= 4 {
|
||||||
|
clampMap[name] = math.Float32frombits(uint32(data[0]) | uint32(data[1])<<8 | uint32(data[2])<<16 | uint32(data[3])<<24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []*ggml.Tensor
|
||||||
|
for _, t := range ts {
|
||||||
|
name := t.Name()
|
||||||
|
|
||||||
|
// Skip embedding_post_projection_norm — used as weightless RMS norm in inference
|
||||||
|
if strings.Contains(name, "embedding_post_projection_norm") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vision tensor renaming: match published mmproj GGUF names
|
||||||
|
if strings.HasPrefix(name, "v.blk.") {
|
||||||
|
name = strings.Replace(name, ".attn_norm.", ".ln1.", 1)
|
||||||
|
name = strings.Replace(name, ".ffn_norm.", ".ln2.", 1)
|
||||||
|
name = strings.Replace(name, ".attn_output.", ".attn_out.", 1)
|
||||||
|
name = strings.Replace(name, ".post_attention_norm.", ".attn_post_norm.", 1)
|
||||||
|
name = strings.Replace(name, ".post_ffw_norm.", ".ffn_post_norm.", 1)
|
||||||
|
name = strings.Replace(name, ".layer_output_scale.", ".out_scale.", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// per_dim_scale: apply softplus to weight data and add .weight suffix.
|
||||||
|
if strings.HasPrefix(name, "a.blk.") && strings.HasSuffix(name, "per_dim_scale") {
|
||||||
|
name = name + ".weight"
|
||||||
|
t.SetRepacker(softplusRepacker)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depthwise conv1d: squeeze middle dimension [C, 1, K] → [C, K].
|
||||||
|
if strings.HasPrefix(name, "a.blk.") && strings.Contains(name, "conv_dw") && strings.HasSuffix(name, ".weight") {
|
||||||
|
t.SetRepacker(squeezeMiddleDim)
|
||||||
|
}
|
||||||
|
|
||||||
|
shape := t.Shape()
|
||||||
|
|
||||||
|
// Convert scalar tensors (input_min/max, output_min/max) to 1D
|
||||||
|
if len(shape) == 0 {
|
||||||
|
shape = []uint64{1}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depthwise conv1d shape: safetensors [C, 1, K] → GGUF ne[K, C].
|
||||||
|
// Shape array here maps to GGUF ne[] directly, but safetensors reader
|
||||||
|
// stores shape in PyTorch order [C, 1, K] which the GGUF writer inverts.
|
||||||
|
// Published GGUF has ne[0]=K, ne[1]=C → shape array must be [K, C].
|
||||||
|
if strings.HasPrefix(name, "a.blk.") && strings.Contains(name, "conv_dw") && strings.HasSuffix(name, ".weight") && len(shape) == 3 {
|
||||||
|
shape = []uint64{shape[0], shape[2]}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoE expert weights: no transpose needed. Safetensors stores [experts, out, in]
|
||||||
|
// which the framework reverses to GGUF ne=[in, out, experts], matching ggml_mul_mat_id.
|
||||||
|
// (transposeExperts was incorrectly swapping dims — removed)
|
||||||
|
|
||||||
|
// Audio conv weights are forced to F32 via tensorBase.Kind() in reader.go
|
||||||
|
// (im2col doesn't support BF16). No kindOverride needed — the Kind() method
|
||||||
|
// controls both the GGUF header type AND the WriteTo data encoding path.
|
||||||
|
var kindOverride *uint32
|
||||||
|
|
||||||
|
// Vision patch embedding: reshape from [n_embd, ksize_sq_c] to [n_embd, 3, patch_size, patch_size]
|
||||||
|
// Must be stored as F16 (not BF16) because the Conv2D im2col kernel requires F16/F32.
|
||||||
|
if strings.Contains(name, "v.patch_embd.weight") && len(shape) == 2 {
|
||||||
|
nEmbd := shape[0]
|
||||||
|
patchSize := uint64(p.VisionModel.PatchSize)
|
||||||
|
if patchSize == 0 {
|
||||||
|
patchSize = 16
|
||||||
|
}
|
||||||
|
numCh := uint64(p.VisionModel.NumChannels)
|
||||||
|
if numCh == 0 {
|
||||||
|
numCh = 3
|
||||||
|
}
|
||||||
|
t.SetRepacker(p.reshapePatchEmbed)
|
||||||
|
shape = []uint64{nEmbd, numCh, patchSize, patchSize}
|
||||||
|
f16Kind := uint32(1) // tensorKindFP16
|
||||||
|
kindOverride = &f16Kind
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vision position embedding: keep 3D [2, maxPos, nEmbd] — matching published mmproj format.
|
||||||
|
// The framework reverses shape to GGUF ne=[nEmbd, maxPos, 2]. No data repacking needed.
|
||||||
|
|
||||||
|
kind := t.Kind()
|
||||||
|
if kindOverride != nil {
|
||||||
|
kind = *kindOverride
|
||||||
|
}
|
||||||
|
out = append(out, &ggml.Tensor{
|
||||||
|
Name: name,
|
||||||
|
Kind: kind,
|
||||||
|
Shape: shape,
|
||||||
|
WriterTo: t,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a single global rope_freqs.weight for proportional RoPE on global attention layers.
|
||||||
|
// This matches the published GGUF format: one global tensor shared by all layers.
|
||||||
|
// Global layers use partial_rotary_factor (0.25) — only rotate that fraction of dims.
|
||||||
|
// Dimensions beyond the rotated portion get freq_factor=1e30 (effectively no rotation).
|
||||||
|
tc := p.TextModel
|
||||||
|
if tc.GlobalHeadDim > 0 {
|
||||||
|
globalFreqsSize := tc.GlobalHeadDim / 2 // freq_factors are per dimension pair
|
||||||
|
|
||||||
|
// Compute number of rotated pairs for global layers
|
||||||
|
partialRotaryFactor := float32(0.25) // default
|
||||||
|
if rp, ok := tc.RopeParameters["full_attention"]; ok && rp != nil && rp.PartialRotaryFactor != nil {
|
||||||
|
partialRotaryFactor = *rp.PartialRotaryFactor
|
||||||
|
}
|
||||||
|
nRotFull := int(float32(tc.GlobalHeadDim) * partialRotaryFactor / 2)
|
||||||
|
|
||||||
|
freqs := make(ropeFactor, globalFreqsSize)
|
||||||
|
for j := range freqs {
|
||||||
|
if j < nRotFull {
|
||||||
|
freqs[j] = 1.0
|
||||||
|
} else {
|
||||||
|
freqs[j] = 1e30 // effectively disable rotation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, &ggml.Tensor{
|
||||||
|
Name: "rope_freqs.weight",
|
||||||
|
Kind: 0, // F32
|
||||||
|
Shape: []uint64{uint64(len(freqs))},
|
||||||
|
WriterTo: freqs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit packed vision clamp data as a single F32 tensor.
|
||||||
|
// Layout: numLayers × 7 linears (q,k,v,out,gate,up,down) × 4 floats (inMin,inMax,outMin,outMax)
|
||||||
|
// then 4 floats for the projector. Total = (numLayers*7 + 1) * 4 floats.
|
||||||
|
if len(clampMap) > 0 {
|
||||||
|
numLayers := int(p.VisionModel.NumHiddenLayers)
|
||||||
|
linearNames := []string{"attn_q", "attn_k", "attn_v", "attn_out", "ffn_gate", "ffn_up", "ffn_down"}
|
||||||
|
suffixes := []string{".input_min", ".input_max", ".output_min", ".output_max"}
|
||||||
|
|
||||||
|
totalFloats := (numLayers*len(linearNames) + 1) * 4 // +1 for projector
|
||||||
|
clampData := make([]float32, totalFloats)
|
||||||
|
|
||||||
|
for layer := range numLayers {
|
||||||
|
for li, ln := range linearNames {
|
||||||
|
for si, sfx := range suffixes {
|
||||||
|
sfxMap := map[string]string{"attn_q": "q_proj", "attn_k": "k_proj", "attn_v": "v_proj", "attn_out": "o_proj", "ffn_gate": "gate_proj", "ffn_up": "up_proj", "ffn_down": "down_proj"}
|
||||||
|
for origName, val := range clampMap {
|
||||||
|
if strings.Contains(origName, fmt.Sprintf("layers.%d.", layer)) && strings.HasSuffix(origName, sfx) && strings.Contains(origName, sfxMap[ln]) {
|
||||||
|
idx := (layer*len(linearNames)+li)*4 + si
|
||||||
|
clampData[idx] = val
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Projector clamp values
|
||||||
|
projIdx := numLayers * len(linearNames) * 4
|
||||||
|
for si, sfx := range suffixes {
|
||||||
|
for origName, val := range clampMap {
|
||||||
|
if strings.Contains(origName, "input_projection") && strings.HasSuffix(origName, sfx) {
|
||||||
|
clampData[projIdx+si] = val
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
binary.Write(&buf, binary.LittleEndian, clampData)
|
||||||
|
out = append(out, &ggml.Tensor{
|
||||||
|
Name: "v.clamp_data",
|
||||||
|
Kind: 0, // F32
|
||||||
|
Shape: []uint64{uint64(totalFloats)},
|
||||||
|
WriterTo: &buf,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// reshapePatchEmbed reshapes the vision patch embedding from HF layout [n_embd, ksize*ksize*channels]
|
||||||
|
// to GGUF layout [n_embd, channels, patch_size, patch_size].
|
||||||
|
func (*gemma4Model) reshapePatchEmbed(_ string, data []float32, shape []uint64) ([]float32, error) {
|
||||||
|
if len(shape) != 2 {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
nEmbd := int(shape[0])
|
||||||
|
ksqC := int(shape[1])
|
||||||
|
nChannels := 3
|
||||||
|
patchSize := int(math.Sqrt(float64(ksqC / nChannels)))
|
||||||
|
|
||||||
|
// HF layout: [n_embd, patch_size * patch_size * channels] (row-major)
|
||||||
|
// Need: [n_embd, channels, patch_size, patch_size]
|
||||||
|
result := make([]float32, len(data))
|
||||||
|
for e := range nEmbd {
|
||||||
|
for c := range nChannels {
|
||||||
|
for h := range patchSize {
|
||||||
|
for w := range patchSize {
|
||||||
|
srcIdx := e*ksqC + h*patchSize*nChannels + w*nChannels + c
|
||||||
|
dstIdx := e*nChannels*patchSize*patchSize + c*patchSize*patchSize + h*patchSize + w
|
||||||
|
result[dstIdx] = data[srcIdx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shape[0] = uint64(nEmbd)
|
||||||
|
shape[1] = uint64(nChannels * patchSize * patchSize)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// softplusRepacker applies softplus (ln(1 + exp(x))) to tensor data.
|
||||||
|
// Used for per_dim_scale tensors which the published GGUF stores pre-activated.
|
||||||
|
func softplusRepacker(_ string, data []float32, shape []uint64) ([]float32, error) {
|
||||||
|
result := make([]float32, len(data))
|
||||||
|
for i, x := range data {
|
||||||
|
result[i] = float32(math.Log(1 + math.Exp(float64(x))))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// squeezeMiddleDim squeezes the middle dimension from [C, 1, K] → [C, K] for depthwise conv1d weights.
|
||||||
|
// Data layout stays the same since the middle dim is 1 — just a shape change.
|
||||||
|
func squeezeMiddleDim(_ string, data []float32, _ []uint64) ([]float32, error) {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *gemma4Model) Replacements() []string {
|
||||||
|
return []string{
|
||||||
|
// ClippableLinear wraps nn.Linear — strip .linear. from weight path
|
||||||
|
".linear.weight", ".weight",
|
||||||
|
".linear.bias", ".bias",
|
||||||
|
|
||||||
|
// Audio SSCP (Sub-Sample Convolution Projection)
|
||||||
|
"model.audio_tower.subsample_conv_projection.conv_0.conv", "a.conv1d.0",
|
||||||
|
"model.audio_tower.subsample_conv_projection.conv_0.norm", "a.conv1d.0.norm",
|
||||||
|
"model.audio_tower.subsample_conv_projection.conv_1.conv", "a.conv1d.1",
|
||||||
|
"model.audio_tower.subsample_conv_projection.conv_1.norm", "a.conv1d.1.norm",
|
||||||
|
"model.audio_tower.subsample_conv_projection.layer0.conv", "a.conv1d.0",
|
||||||
|
"model.audio_tower.subsample_conv_projection.layer0.norm", "a.conv1d.0.norm",
|
||||||
|
"model.audio_tower.subsample_conv_projection.layer1.conv", "a.conv1d.1",
|
||||||
|
"model.audio_tower.subsample_conv_projection.layer1.norm", "a.conv1d.1.norm",
|
||||||
|
"model.audio_tower.subsample_conv_projection.input_proj_linear", "a.pre_encode.out",
|
||||||
|
|
||||||
|
// Audio conformer blocks
|
||||||
|
"model.audio_tower.conformer", "a.blk",
|
||||||
|
"model.audio_tower.layers", "a.blk",
|
||||||
|
|
||||||
|
// Audio conformer attention
|
||||||
|
"attention.attn.relative_position_embedding.pos_proj", "linear_pos",
|
||||||
|
"self_attn.relative_k_proj", "linear_pos",
|
||||||
|
"attention.attn.per_dim_key_scale", "per_dim_k_scale",
|
||||||
|
"attention.attn.per_dim_scale", "per_dim_scale",
|
||||||
|
"self_attn.per_dim_scale", "per_dim_scale",
|
||||||
|
"attention.attn.q_proj", "attn_q",
|
||||||
|
"attention.attn.k_proj", "attn_k",
|
||||||
|
"attention.attn.v_proj", "attn_v",
|
||||||
|
"attention.pre_attn_norm", "ln1",
|
||||||
|
"attention.post_norm", "ln2",
|
||||||
|
"attention.post", "attn_out",
|
||||||
|
"self_attn.post", "attn_out",
|
||||||
|
"norm_pre_attn", "ln1",
|
||||||
|
"norm_post_attn", "ln2",
|
||||||
|
|
||||||
|
// Audio conformer feedforward
|
||||||
|
"ffw_layer_start.pre_layer_norm", "ffn_norm",
|
||||||
|
"ffw_layer_start.post_layer_norm", "ffn_post_norm",
|
||||||
|
"ffw_layer_start.ffw_layer_1", "ffn_up",
|
||||||
|
"ffw_layer_start.ffw_layer_2", "ffn_down",
|
||||||
|
"ffw_layer_end.pre_layer_norm", "ffn_norm_1",
|
||||||
|
"ffw_layer_end.post_layer_norm", "ffn_post_norm_1",
|
||||||
|
"ffw_layer_end.ffw_layer_1", "ffn_up_1",
|
||||||
|
"ffw_layer_end.ffw_layer_2", "ffn_down_1",
|
||||||
|
"feed_forward1.pre_layer_norm", "ffn_norm",
|
||||||
|
"feed_forward1.post_layer_norm", "ffn_post_norm",
|
||||||
|
"feed_forward1.ffw_layer_1", "ffn_up",
|
||||||
|
"feed_forward1.ffw_layer_2", "ffn_down",
|
||||||
|
"feed_forward2.pre_layer_norm", "ffn_norm_1",
|
||||||
|
"feed_forward2.post_layer_norm", "ffn_post_norm_1",
|
||||||
|
"feed_forward2.ffw_layer_1", "ffn_up_1",
|
||||||
|
"feed_forward2.ffw_layer_2", "ffn_down_1",
|
||||||
|
|
||||||
|
// Audio conformer lightweight conv1d
|
||||||
|
"lconv1d.depthwise_conv1d", "conv_dw",
|
||||||
|
"lconv1d.pre_layer_norm", "conv_norm",
|
||||||
|
"lconv1d.conv_norm", "norm_conv",
|
||||||
|
"lconv1d.linear_start", "conv_pw1",
|
||||||
|
"lconv1d.linear_end", "conv_pw2",
|
||||||
|
|
||||||
|
// Audio block final norm
|
||||||
|
"norm_out", "layer_pre_norm",
|
||||||
|
|
||||||
|
// Audio embedder and output projection
|
||||||
|
"model.embed_audio.embedding_projection", "mm.a.input_projection",
|
||||||
|
"model.audio_tower.output_proj", "mm.a.fc",
|
||||||
|
|
||||||
|
// Vision encoder
|
||||||
|
"model.vision_tower.encoder.layers", "v.blk",
|
||||||
|
"model.vision_tower.patch_embedder.input_proj", "v.patch_embd",
|
||||||
|
"model.vision_tower.patch_embedder.position_embedding_table", "v.position_embd.weight",
|
||||||
|
"model.vision_tower.std_bias", "v.std_bias",
|
||||||
|
"model.vision_tower.std_scale", "v.std_scale",
|
||||||
|
|
||||||
|
// Vision multimodal projector
|
||||||
|
"model.embed_vision.embedding_projection", "mm.input_projection",
|
||||||
|
|
||||||
|
// Text model
|
||||||
|
"model.language_model.embed_tokens_per_layer", "per_layer_token_embd",
|
||||||
|
"model.language_model.embed_tokens", "token_embd",
|
||||||
|
"model.language_model.per_layer_model_projection", "per_layer_model_proj",
|
||||||
|
"model.language_model.per_layer_projection_norm", "per_layer_proj_norm",
|
||||||
|
"model.language_model.norm", "output_norm",
|
||||||
|
"model.language_model.layers", "blk",
|
||||||
|
|
||||||
|
// Shared attention replacements (work for both text and vision tensors)
|
||||||
|
"input_layernorm", "attn_norm",
|
||||||
|
"self_attn.q_proj", "attn_q",
|
||||||
|
"self_attn.q_norm", "attn_q_norm",
|
||||||
|
"self_attn.k_proj", "attn_k",
|
||||||
|
"self_attn.k_norm", "attn_k_norm",
|
||||||
|
"self_attn.v_proj", "attn_v",
|
||||||
|
"self_attn.o_proj", "attn_output",
|
||||||
|
"mlp.gate_proj", "ffn_gate",
|
||||||
|
"mlp.down_proj", "ffn_down",
|
||||||
|
"mlp.up_proj", "ffn_up",
|
||||||
|
|
||||||
|
// Post norms
|
||||||
|
"post_attention_layernorm", "post_attention_norm",
|
||||||
|
"pre_feedforward_layernorm_2", "pre_ffw_norm_2",
|
||||||
|
"pre_feedforward_layernorm", "ffn_norm",
|
||||||
|
"post_feedforward_layernorm_1", "post_ffw_norm_1",
|
||||||
|
"post_feedforward_layernorm_2", "post_ffw_norm_2",
|
||||||
|
"post_feedforward_layernorm", "post_ffw_norm",
|
||||||
|
|
||||||
|
// PLE
|
||||||
|
"per_layer_input_gate", "inp_gate",
|
||||||
|
"per_layer_projection", "proj",
|
||||||
|
"post_per_layer_input_norm", "post_norm",
|
||||||
|
|
||||||
|
// MoE
|
||||||
|
"router.proj", "ffn_gate_inp",
|
||||||
|
"router.scale", "ffn_gate_inp.scale",
|
||||||
|
"router.per_expert_scale.weight", "ffn_down_exps.scale",
|
||||||
|
"router.per_expert_scale", "ffn_down_exps.scale",
|
||||||
|
"experts.gate_up_proj.weight", "ffn_gate_up_exps.weight",
|
||||||
|
"experts.gate_up_proj", "ffn_gate_up_exps.weight",
|
||||||
|
"experts.down_proj.weight", "ffn_down_exps.weight",
|
||||||
|
"experts.down_proj", "ffn_down_exps.weight",
|
||||||
|
"moe.gate_proj", "ffn_gate_exps.weight",
|
||||||
|
"moe.up_proj", "ffn_up_exps.weight",
|
||||||
|
"moe.gate_up_proj.weight", "ffn_gate_up_exps.weight",
|
||||||
|
"moe.gate_up_proj", "ffn_gate_up_exps.weight",
|
||||||
|
"moe.down_proj", "ffn_down_exps.weight",
|
||||||
|
"moe.per_expert_scale.weight", "ffn_down_exps.scale",
|
||||||
|
"moe.per_expert_scale", "ffn_down_exps.scale",
|
||||||
|
|
||||||
|
// Layer scalar
|
||||||
|
"layer_scalar", "layer_output_scale.weight",
|
||||||
|
}
|
||||||
|
}
|
||||||
318
convert/convert_gemma4_test.go
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
package convert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGemma4AudioReplacements(t *testing.T) {
|
||||||
|
p := gemma4Model{}
|
||||||
|
r := strings.NewReplacer(p.Replacements()...)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
// SSCP convolution blocks
|
||||||
|
{
|
||||||
|
"sscp conv0 weight",
|
||||||
|
"model.audio_tower.subsample_conv_projection.conv_0.conv.weight",
|
||||||
|
"a.conv1d.0.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sscp conv0 norm",
|
||||||
|
"model.audio_tower.subsample_conv_projection.conv_0.norm.weight",
|
||||||
|
"a.conv1d.0.norm.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sscp conv1 weight",
|
||||||
|
"model.audio_tower.subsample_conv_projection.conv_1.conv.weight",
|
||||||
|
"a.conv1d.1.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sscp input proj weight",
|
||||||
|
"model.audio_tower.subsample_conv_projection.input_proj_linear.weight",
|
||||||
|
"a.pre_encode.out.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sscp input proj bias",
|
||||||
|
"model.audio_tower.subsample_conv_projection.input_proj_linear.bias",
|
||||||
|
"a.pre_encode.out.bias",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sscp layer0 conv weight (new naming)",
|
||||||
|
"model.audio_tower.subsample_conv_projection.layer0.conv.weight",
|
||||||
|
"a.conv1d.0.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sscp layer1 norm weight (new naming)",
|
||||||
|
"model.audio_tower.subsample_conv_projection.layer1.norm.weight",
|
||||||
|
"a.conv1d.1.norm.weight",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Conformer attention
|
||||||
|
{
|
||||||
|
"attn q weight",
|
||||||
|
"model.audio_tower.conformer.0.attention.attn.q_proj.linear.weight",
|
||||||
|
"a.blk.0.attn_q.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attn k weight",
|
||||||
|
"model.audio_tower.conformer.5.attention.attn.k_proj.linear.weight",
|
||||||
|
"a.blk.5.attn_k.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attn v clamp input_min",
|
||||||
|
"model.audio_tower.conformer.0.attention.attn.v_proj.input_min",
|
||||||
|
"a.blk.0.attn_v.input_min",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attn out weight (ClippableLinear)",
|
||||||
|
"model.audio_tower.conformer.0.attention.post.linear.weight",
|
||||||
|
"a.blk.0.attn_out.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attn out clamp output_max",
|
||||||
|
"model.audio_tower.conformer.0.attention.post.output_max",
|
||||||
|
"a.blk.0.attn_out.output_max",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attn pre norm",
|
||||||
|
"model.audio_tower.conformer.0.attention.pre_attn_norm.weight",
|
||||||
|
"a.blk.0.ln1.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attn post norm",
|
||||||
|
"model.audio_tower.conformer.0.attention.post_norm.weight",
|
||||||
|
"a.blk.0.ln2.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"linear pos",
|
||||||
|
"model.audio_tower.conformer.0.attention.attn.relative_position_embedding.pos_proj.weight",
|
||||||
|
"a.blk.0.linear_pos.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"per dim scale",
|
||||||
|
"model.audio_tower.conformer.0.attention.attn.per_dim_scale",
|
||||||
|
"a.blk.0.per_dim_scale",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"per dim key scale",
|
||||||
|
"model.audio_tower.conformer.0.attention.attn.per_dim_key_scale",
|
||||||
|
"a.blk.0.per_dim_k_scale",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attn relative k proj (new naming)",
|
||||||
|
"model.audio_tower.layers.0.self_attn.relative_k_proj.weight",
|
||||||
|
"a.blk.0.linear_pos.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attn pre norm (new naming)",
|
||||||
|
"model.audio_tower.layers.0.norm_pre_attn.weight",
|
||||||
|
"a.blk.0.ln1.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attn post norm (new naming)",
|
||||||
|
"model.audio_tower.layers.0.norm_post_attn.weight",
|
||||||
|
"a.blk.0.ln2.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attn out clamp output_max (new naming)",
|
||||||
|
"model.audio_tower.layers.0.self_attn.post.output_max",
|
||||||
|
"a.blk.0.attn_out.output_max",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"per dim scale (new naming)",
|
||||||
|
"model.audio_tower.layers.0.self_attn.per_dim_scale",
|
||||||
|
"a.blk.0.per_dim_scale",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Conformer feedforward start
|
||||||
|
{
|
||||||
|
"ffn up weight",
|
||||||
|
"model.audio_tower.conformer.0.ffw_layer_start.ffw_layer_1.linear.weight",
|
||||||
|
"a.blk.0.ffn_up.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ffn down weight",
|
||||||
|
"model.audio_tower.conformer.0.ffw_layer_start.ffw_layer_2.linear.weight",
|
||||||
|
"a.blk.0.ffn_down.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ffn norm",
|
||||||
|
"model.audio_tower.conformer.0.ffw_layer_start.pre_layer_norm.weight",
|
||||||
|
"a.blk.0.ffn_norm.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ffn post norm",
|
||||||
|
"model.audio_tower.conformer.0.ffw_layer_start.post_layer_norm.weight",
|
||||||
|
"a.blk.0.ffn_post_norm.weight",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Conformer feedforward end
|
||||||
|
{
|
||||||
|
"ffn up 1 weight",
|
||||||
|
"model.audio_tower.conformer.0.ffw_layer_end.ffw_layer_1.linear.weight",
|
||||||
|
"a.blk.0.ffn_up_1.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ffn down 1 weight",
|
||||||
|
"model.audio_tower.conformer.0.ffw_layer_end.ffw_layer_2.linear.weight",
|
||||||
|
"a.blk.0.ffn_down_1.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ffn norm 1",
|
||||||
|
"model.audio_tower.conformer.0.ffw_layer_end.pre_layer_norm.weight",
|
||||||
|
"a.blk.0.ffn_norm_1.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ffn post norm 1",
|
||||||
|
"model.audio_tower.conformer.0.ffw_layer_end.post_layer_norm.weight",
|
||||||
|
"a.blk.0.ffn_post_norm_1.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ffn up output_max (new naming)",
|
||||||
|
"model.audio_tower.layers.10.feed_forward1.ffw_layer_1.output_max",
|
||||||
|
"a.blk.10.ffn_up.output_max",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ffn down output_min (new naming)",
|
||||||
|
"model.audio_tower.layers.0.feed_forward1.ffw_layer_2.output_min",
|
||||||
|
"a.blk.0.ffn_down.output_min",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ffn up 1 input_max (new naming)",
|
||||||
|
"model.audio_tower.layers.0.feed_forward2.ffw_layer_1.input_max",
|
||||||
|
"a.blk.0.ffn_up_1.input_max",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ffn norm 1 (new naming)",
|
||||||
|
"model.audio_tower.layers.0.feed_forward2.pre_layer_norm.weight",
|
||||||
|
"a.blk.0.ffn_norm_1.weight",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Conformer lightweight conv1d
|
||||||
|
{
|
||||||
|
"conv dw weight",
|
||||||
|
"model.audio_tower.conformer.0.lconv1d.depthwise_conv1d.weight",
|
||||||
|
"a.blk.0.conv_dw.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conv norm (pre_layer_norm)",
|
||||||
|
"model.audio_tower.conformer.0.lconv1d.pre_layer_norm.weight",
|
||||||
|
"a.blk.0.conv_norm.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm conv (conv_norm)",
|
||||||
|
"model.audio_tower.conformer.0.lconv1d.conv_norm.weight",
|
||||||
|
"a.blk.0.norm_conv.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conv pw1 weight",
|
||||||
|
"model.audio_tower.conformer.0.lconv1d.linear_start.linear.weight",
|
||||||
|
"a.blk.0.conv_pw1.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conv pw2 weight",
|
||||||
|
"model.audio_tower.conformer.0.lconv1d.linear_end.linear.weight",
|
||||||
|
"a.blk.0.conv_pw2.weight",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Audio embedder
|
||||||
|
{
|
||||||
|
"audio embedder projection weight",
|
||||||
|
"model.embed_audio.embedding_projection.linear.weight",
|
||||||
|
"mm.a.input_projection.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"audio embedder projection bias",
|
||||||
|
"model.embed_audio.embedding_projection.linear.bias",
|
||||||
|
"mm.a.input_projection.bias",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Audio output projection
|
||||||
|
{
|
||||||
|
"audio output proj weight",
|
||||||
|
"model.audio_tower.output_proj.weight",
|
||||||
|
"mm.a.fc.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"audio output proj bias",
|
||||||
|
"model.audio_tower.output_proj.bias",
|
||||||
|
"mm.a.fc.bias",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Verify vision tensors still work
|
||||||
|
{
|
||||||
|
"vision q weight",
|
||||||
|
"model.vision_tower.encoder.layers.0.self_attn.q_proj.linear.weight",
|
||||||
|
"v.blk.0.attn_q.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vision std bias",
|
||||||
|
"model.vision_tower.std_bias",
|
||||||
|
"v.std_bias",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vision std scale",
|
||||||
|
"model.vision_tower.std_scale",
|
||||||
|
"v.std_scale",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vision patch embd",
|
||||||
|
"model.vision_tower.patch_embedder.input_proj.weight",
|
||||||
|
"v.patch_embd.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vision projector",
|
||||||
|
"model.embed_vision.embedding_projection.linear.weight",
|
||||||
|
"mm.input_projection.weight",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Verify text tensors still work
|
||||||
|
{
|
||||||
|
"text attn q",
|
||||||
|
"model.language_model.layers.0.self_attn.q_proj.weight",
|
||||||
|
"blk.0.attn_q.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text token embd",
|
||||||
|
"model.language_model.embed_tokens.weight",
|
||||||
|
"token_embd.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text moe gate up fused",
|
||||||
|
"model.language_model.layers.0.experts.gate_up_proj",
|
||||||
|
"blk.0.ffn_gate_up_exps.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text moe down",
|
||||||
|
"model.language_model.layers.0.experts.down_proj",
|
||||||
|
"blk.0.ffn_down_exps.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text moe down with weight suffix",
|
||||||
|
"model.language_model.layers.0.experts.down_proj.weight",
|
||||||
|
"blk.0.ffn_down_exps.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text moe per expert scale",
|
||||||
|
"model.language_model.layers.0.router.per_expert_scale",
|
||||||
|
"blk.0.ffn_down_exps.scale",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text moe per expert scale with weight suffix",
|
||||||
|
"model.language_model.layers.0.router.per_expert_scale.weight",
|
||||||
|
"blk.0.ffn_down_exps.scale",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := r.Replace(tt.in); got != tt.want {
|
||||||
|
t.Errorf("Replace(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -205,8 +205,8 @@ func TestConvertInvalidDatatype(t *testing.T) {
|
|||||||
generateSafetensorTestData(t, tempDir, td)
|
generateSafetensorTestData(t, tempDir, td)
|
||||||
|
|
||||||
err = ConvertModel(os.DirFS(tempDir), f)
|
err = ConvertModel(os.DirFS(tempDir), f)
|
||||||
if err == nil || err.Error() != "unsupported safetensors model" {
|
if err == nil || !strings.Contains(err.Error(), "unknown data type") {
|
||||||
t.Errorf("expected error but didn't get one")
|
t.Errorf("expected 'unknown data type' error but got: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,11 @@ func (t tensorBase) Kind() uint32 {
|
|||||||
strings.HasSuffix(t.name, ".bias") ||
|
strings.HasSuffix(t.name, ".bias") ||
|
||||||
strings.HasSuffix(t.name, ".shortconv.conv.weight") ||
|
strings.HasSuffix(t.name, ".shortconv.conv.weight") ||
|
||||||
strings.HasSuffix(t.name, ".ssm_conv1d.weight") || // SSM conv kernel must be F32 for Metal
|
strings.HasSuffix(t.name, ".ssm_conv1d.weight") || // SSM conv kernel must be F32 for Metal
|
||||||
|
strings.HasPrefix(t.name, "a.conv1d.") || // audio SSCP conv weights must be F32 for im2col
|
||||||
|
strings.Contains(t.name, ".conv_dw.") || // audio depthwise conv weights must be F32
|
||||||
t.name == "token_types.weight" ||
|
t.name == "token_types.weight" ||
|
||||||
t.name == "v.positional_embedding_vlm" ||
|
t.name == "v.positional_embedding_vlm" ||
|
||||||
|
t.name == "v.position_embd.weight" ||
|
||||||
t.name == "v.tile_position_embd.weight" ||
|
t.name == "v.tile_position_embd.weight" ||
|
||||||
t.name == "v.pre_tile_position_embd.weight" ||
|
t.name == "v.pre_tile_position_embd.weight" ||
|
||||||
t.name == "v.post_tile_position_embd.weight" ||
|
t.name == "v.post_tile_position_embd.weight" ||
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -53,9 +52,10 @@ func parseSafetensors(fsys fs.FS, replacer *strings.Replacer, ps ...string) ([]T
|
|||||||
|
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
if value := headers[key]; value.Type != "" {
|
if value := headers[key]; value.Type != "" {
|
||||||
// bitsandbytes quantized models are unsupported
|
// Scalar tensors (e.g. clipped linear min/max) are 0-dim in safetensors.
|
||||||
|
// Promote them to 1-dim so they can be stored in GGUF.
|
||||||
if len(value.Shape) == 0 {
|
if len(value.Shape) == 0 {
|
||||||
return nil, errors.New("unsupported safetensors model")
|
value.Shape = []uint64{1}
|
||||||
}
|
}
|
||||||
ggufName := replacer.Replace(key)
|
ggufName := replacer.Replace(key)
|
||||||
if _, ok := names[ggufName]; ok {
|
if _, ok := names[ggufName]; ok {
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func GPUDevices(ctx context.Context, runners []ml.FilteredRunnerDiscovery) []ml.
|
|||||||
}
|
}
|
||||||
var dirs []string
|
var dirs []string
|
||||||
if dir != "" {
|
if dir != "" {
|
||||||
if requested != "" && filepath.Base(dir) != requested {
|
if requested != "" && !strings.HasPrefix(requested, "mlx_") && filepath.Base(dir) != requested {
|
||||||
slog.Debug("skipping available library at user's request", "requested", requested, "libDir", dir)
|
slog.Debug("skipping available library at user's request", "requested", requested, "libDir", dir)
|
||||||
continue
|
continue
|
||||||
} else if jetpack != "" && filepath.Base(dir) != "cuda_"+jetpack {
|
} else if jetpack != "" && filepath.Base(dir) != "cuda_"+jetpack {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Configure and launch external applications to use Ollama models. This provides a
|
|||||||
- **OpenCode** - Open-source coding assistant
|
- **OpenCode** - Open-source coding assistant
|
||||||
- **Claude Code** - Anthropic's agentic coding tool
|
- **Claude Code** - Anthropic's agentic coding tool
|
||||||
- **Codex** - OpenAI's coding assistant
|
- **Codex** - OpenAI's coding assistant
|
||||||
|
- **VS Code** - Microsoft's IDE with built-in AI chat
|
||||||
- **Droid** - Factory's AI coding agent
|
- **Droid** - Factory's AI coding agent
|
||||||
|
|
||||||
#### Examples
|
#### Examples
|
||||||
|
|||||||
@@ -110,7 +110,8 @@
|
|||||||
"group": "Assistants",
|
"group": "Assistants",
|
||||||
"expanded": true,
|
"expanded": true,
|
||||||
"pages": [
|
"pages": [
|
||||||
"/integrations/openclaw"
|
"/integrations/openclaw",
|
||||||
|
"/integrations/hermes"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -127,6 +128,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"group": "IDEs & Editors",
|
"group": "IDEs & Editors",
|
||||||
|
"expanded": true,
|
||||||
"pages": [
|
"pages": [
|
||||||
"/integrations/cline",
|
"/integrations/cline",
|
||||||
"/integrations/jetbrains",
|
"/integrations/jetbrains",
|
||||||
|
|||||||
BIN
docs/images/local.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/images/vscode-add-ollama.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 56 KiB |
BIN
docs/images/vscode-other-models.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
docs/images/vscode-unhide.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
docs/images/vscode.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
@@ -96,6 +96,18 @@ The `/loop` command runs a prompt or slash command on a recurring schedule insid
|
|||||||
/loop 1h Remind me to review the deploy status
|
/loop 1h Remind me to review the deploy status
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Telegram
|
||||||
|
|
||||||
|
Chat with Claude Code from Telegram by connecting a bot to your session. Install the [Telegram plugin](https://github.com/anthropics/claude-plugins-official), create a bot via [@BotFather](https://t.me/BotFather), then launch with the channel flag:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ollama launch claude -- --channels plugin:telegram@claude-plugins-official
|
||||||
|
```
|
||||||
|
|
||||||
|
Claude Code will prompt for permission on most actions. To allow the bot to work autonomously, configure [permission rules](https://code.claude.com/docs/en/permissions) or pass `--dangerously-skip-permissions` in isolated environments.
|
||||||
|
|
||||||
|
See the [plugin README](https://github.com/anthropics/claude-plugins-official/tree/main/external_plugins/telegram) for full setup instructions including pairing and access control.
|
||||||
|
|
||||||
## Manual setup
|
## Manual setup
|
||||||
|
|
||||||
Claude Code connects to Ollama using the Anthropic-compatible API.
|
Claude Code connects to Ollama using the Anthropic-compatible API.
|
||||||
|
|||||||
@@ -35,36 +35,39 @@ To use `codex` with Ollama, use the `--oss` flag:
|
|||||||
codex --oss
|
codex --oss
|
||||||
```
|
```
|
||||||
|
|
||||||
### Changing Models
|
To use a specific model, pass the `-m` flag:
|
||||||
|
|
||||||
By default, codex will use the local `gpt-oss:20b` model. However, you can specify a different model with the `-m` flag:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
codex --oss -m gpt-oss:120b
|
codex --oss -m gpt-oss:120b
|
||||||
```
|
```
|
||||||
|
|
||||||
### Cloud Models
|
To use a cloud model:
|
||||||
|
|
||||||
```
|
```
|
||||||
codex --oss -m gpt-oss:120b-cloud
|
codex --oss -m gpt-oss:120b-cloud
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Profile-based setup
|
||||||
|
|
||||||
## Connecting to ollama.com
|
For a persistent configuration, add an Ollama provider and profiles to `~/.codex/config.toml`:
|
||||||
|
|
||||||
|
|
||||||
Create an [API key](https://ollama.com/settings/keys) from ollama.com and export it as `OLLAMA_API_KEY`.
|
|
||||||
|
|
||||||
To use ollama.com directly, edit your `~/.codex/config.toml` file to point to ollama.com.
|
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
model = "gpt-oss:120b"
|
[model_providers.ollama-launch]
|
||||||
model_provider = "ollama"
|
|
||||||
|
|
||||||
[model_providers.ollama]
|
|
||||||
name = "Ollama"
|
name = "Ollama"
|
||||||
base_url = "https://ollama.com/v1"
|
base_url = "http://localhost:11434/v1"
|
||||||
env_key = "OLLAMA_API_KEY"
|
|
||||||
|
[profiles.ollama-launch]
|
||||||
|
model = "gpt-oss:120b"
|
||||||
|
model_provider = "ollama-launch"
|
||||||
|
|
||||||
|
[profiles.ollama-cloud]
|
||||||
|
model = "gpt-oss:120b-cloud"
|
||||||
|
model_provider = "ollama-launch"
|
||||||
```
|
```
|
||||||
|
|
||||||
Run `codex` in a new terminal to load the new settings.
|
Then run:
|
||||||
|
|
||||||
|
```
|
||||||
|
codex --profile ollama-launch
|
||||||
|
codex --profile ollama-cloud
|
||||||
|
```
|
||||||
|
|||||||
111
docs/integrations/hermes.mdx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
title: Hermes Agent
|
||||||
|
---
|
||||||
|
|
||||||
|
Hermes Agent is a self-improving AI agent built by Nous Research. It features automatic skill creation, cross-session memory, and connects messaging platforms (Telegram, Discord, Slack, WhatsApp, Signal, Email) to models through a unified gateway.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### Pull a model
|
||||||
|
|
||||||
|
Before running the setup wizard, make sure you have a model available. Hermes will auto-detect models downloaded through Ollama.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ollama pull kimi-k2.5:cloud
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Recommended models](#recommended-models) for more options.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set up
|
||||||
|
|
||||||
|
After installation, Hermes launches the setup wizard automatically. Choose **Quick setup**:
|
||||||
|
|
||||||
|
```
|
||||||
|
How would you like to set up Hermes?
|
||||||
|
|
||||||
|
→ Quick setup — provider, model & messaging (recommended)
|
||||||
|
Full setup — configure everything
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connect to Ollama
|
||||||
|
|
||||||
|
1. Select **More providers...**
|
||||||
|
2. Select **Custom endpoint (enter URL manually)**
|
||||||
|
3. Set the API base URL to the Ollama OpenAI-compatible endpoint:
|
||||||
|
|
||||||
|
```
|
||||||
|
API base URL [e.g. https://api.example.com/v1]: http://127.0.0.1:11434/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Leave the API key blank (not required for local Ollama):
|
||||||
|
|
||||||
|
```
|
||||||
|
API key [optional]:
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Hermes auto-detects downloaded models, confirm the one you want:
|
||||||
|
|
||||||
|
```
|
||||||
|
Verified endpoint via http://127.0.0.1:11434/v1/models (1 model(s) visible)
|
||||||
|
Detected model: kimi-k2.5:cloud
|
||||||
|
Use this model? [Y/n]:
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Leave context length blank to auto-detect:
|
||||||
|
|
||||||
|
```
|
||||||
|
Context length in tokens [leave blank for auto-detect]:
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connect messaging
|
||||||
|
|
||||||
|
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'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Launch
|
||||||
|
|
||||||
|
```
|
||||||
|
Launch hermes chat now? [Y/n]: Y
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommended models
|
||||||
|
|
||||||
|
**Cloud models**:
|
||||||
|
|
||||||
|
- `kimi-k2.5:cloud` — Multimodal reasoning with subagents
|
||||||
|
- `qwen3.5:cloud` — Reasoning, coding, and agentic tool use with vision
|
||||||
|
- `glm-5.1:cloud` — Reasoning and code generation
|
||||||
|
- `minimax-m2.7:cloud` — Fast, efficient coding and real-world productivity
|
||||||
|
|
||||||
|
**Local models:**
|
||||||
|
|
||||||
|
- `gemma4` — Reasoning and code generation locally (~16 GB VRAM)
|
||||||
|
- `qwen3.5` — Reasoning, coding, and visual understanding locally (~11 GB VRAM)
|
||||||
|
|
||||||
|
More models at [ollama.com/search](https://ollama.com/models).
|
||||||
|
|
||||||
|
## Configure later
|
||||||
|
|
||||||
|
Re-run the setup wizard at any time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes setup
|
||||||
|
```
|
||||||
|
|
||||||
|
To configure just messaging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes setup gateway
|
||||||
|
```
|
||||||
@@ -20,6 +20,7 @@ Coding assistants that can read, modify, and execute code in your projects.
|
|||||||
AI assistants that help with everyday tasks.
|
AI assistants that help with everyday tasks.
|
||||||
|
|
||||||
- [OpenClaw](/integrations/openclaw)
|
- [OpenClaw](/integrations/openclaw)
|
||||||
|
- [Hermes Agent](/integrations/hermes)
|
||||||
|
|
||||||
## IDEs & Editors
|
## IDEs & Editors
|
||||||
|
|
||||||
|
|||||||
@@ -59,12 +59,14 @@ If the gateway is already running, it restarts automatically to pick up the new
|
|||||||
**Cloud models**:
|
**Cloud models**:
|
||||||
|
|
||||||
- `kimi-k2.5:cloud` — Multimodal reasoning with subagents
|
- `kimi-k2.5:cloud` — Multimodal reasoning with subagents
|
||||||
|
- `qwen3.5:cloud` — Reasoning, coding, and agentic tool use with vision
|
||||||
|
- `glm-5.1:cloud` — Reasoning and code generation
|
||||||
- `minimax-m2.7:cloud` — Fast, efficient coding and real-world productivity
|
- `minimax-m2.7:cloud` — Fast, efficient coding and real-world productivity
|
||||||
- `glm-5:cloud` — Reasoning and code generation
|
|
||||||
|
|
||||||
**Local models:**
|
**Local models:**
|
||||||
|
|
||||||
- `glm-4.7-flash` — Reasoning and code generation locally (~25 GB VRAM)
|
- `gemma4` — Reasoning and code generation locally (~16 GB VRAM)
|
||||||
|
- `qwen3.5` — Reasoning, coding, and visual understanding locally (~11 GB VRAM)
|
||||||
|
|
||||||
More models at [ollama.com/search](https://ollama.com/search?c=cloud).
|
More models at [ollama.com/search](https://ollama.com/search?c=cloud).
|
||||||
|
|
||||||
|
|||||||
@@ -28,79 +28,4 @@ To configure without launching:
|
|||||||
ollama launch opencode --config
|
ollama launch opencode --config
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual setup
|
<Note>`ollama launch opencode` passes its configuration to OpenCode inline via the `OPENCODE_CONFIG_CONTENT` environment variable. OpenCode deep-merges its config sources on startup, so anything you declare in `~/.config/opencode/opencode.json` is still respected and available inside OpenCode. Models declared only in `opencode.json` won't appear in `ollama launch`'s model-selection menu.</Note>
|
||||||
|
|
||||||
Add a configuration block to `~/.config/opencode/opencode.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"$schema": "https://opencode.ai/config.json",
|
|
||||||
"provider": {
|
|
||||||
"ollama": {
|
|
||||||
"npm": "@ai-sdk/openai-compatible",
|
|
||||||
"name": "Ollama",
|
|
||||||
"options": {
|
|
||||||
"baseURL": "http://localhost:11434/v1"
|
|
||||||
},
|
|
||||||
"models": {
|
|
||||||
"qwen3-coder": {
|
|
||||||
"name": "qwen3-coder"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cloud Models
|
|
||||||
|
|
||||||
`glm-4.7:cloud` is the recommended model for use with OpenCode.
|
|
||||||
|
|
||||||
Add the cloud configuration to `~/.config/opencode/opencode.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"$schema": "https://opencode.ai/config.json",
|
|
||||||
"provider": {
|
|
||||||
"ollama": {
|
|
||||||
"npm": "@ai-sdk/openai-compatible",
|
|
||||||
"name": "Ollama",
|
|
||||||
"options": {
|
|
||||||
"baseURL": "http://localhost:11434/v1"
|
|
||||||
},
|
|
||||||
"models": {
|
|
||||||
"glm-4.7:cloud": {
|
|
||||||
"name": "glm-4.7:cloud"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Connecting to ollama.com
|
|
||||||
|
|
||||||
1. Create an [API key](https://ollama.com/settings/keys) from ollama.com and export it as `OLLAMA_API_KEY`.
|
|
||||||
2. Update `~/.config/opencode/opencode.json` to point to ollama.com:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"$schema": "https://opencode.ai/config.json",
|
|
||||||
"provider": {
|
|
||||||
"ollama": {
|
|
||||||
"npm": "@ai-sdk/openai-compatible",
|
|
||||||
"name": "Ollama Cloud",
|
|
||||||
"options": {
|
|
||||||
"baseURL": "https://ollama.com/v1"
|
|
||||||
},
|
|
||||||
"models": {
|
|
||||||
"glm-4.7:cloud": {
|
|
||||||
"name": "glm-4.7:cloud"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Run `opencode` in a new terminal to load the new settings.
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
title: Pi
|
title: Pi
|
||||||
---
|
---
|
||||||
|
|
||||||
Pi is a minimal AI agent toolkit with plugin support.
|
Pi is a minimal and extensible coding agent.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -20,13 +20,65 @@ npm install -g @mariozechner/pi-coding-agent
|
|||||||
ollama launch pi
|
ollama launch pi
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This installs Pi, configures Ollama as a provider including web tools, and drops you into an interactive session.
|
||||||
|
|
||||||
To configure without launching:
|
To configure without launching:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
ollama launch pi --config
|
ollama launch pi --config
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual setup
|
### Run directly with a model
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ollama launch pi --model qwen3.5:cloud
|
||||||
|
```
|
||||||
|
|
||||||
|
Cloud models are also available at [ollama.com](https://ollama.com/search?c=cloud).
|
||||||
|
|
||||||
|
## Extensions
|
||||||
|
|
||||||
|
Pi ships with four core tools: `read`, `write`, `edit`, and `bash`. All other capabilities are added through its extension system.
|
||||||
|
|
||||||
|
On-demand capability packages invoked via `/skill:name` commands.
|
||||||
|
|
||||||
|
Install from npm or git:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi install npm:@foo/some-tools
|
||||||
|
pi install git:github.com/user/repo@v1
|
||||||
|
```
|
||||||
|
|
||||||
|
See all packages at [pi.dev](https://pi.dev/packages)
|
||||||
|
|
||||||
|
### Web search
|
||||||
|
|
||||||
|
Pi can use web search and fetch tools via the `@ollama/pi-web-search` package.
|
||||||
|
|
||||||
|
When launching Pi through Ollama, package install/update is managed automatically.
|
||||||
|
To install manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi install npm:@ollama/pi-web-search
|
||||||
|
```
|
||||||
|
|
||||||
|
### Autoresearch with `pi-autoresearch`
|
||||||
|
|
||||||
|
[pi-autoresearch](https://github.com/davebcn87/pi-autoresearch) brings autonomous experiment loops to Pi. Inspired by Karpathy's autoresearch, it turns any measurable metric into an optimization target: test speed, bundle size, build time, model training loss, Lighthouse scores.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi install https://github.com/davebcn87/pi-autoresearch
|
||||||
|
```
|
||||||
|
|
||||||
|
Tell Pi what to optimize. It runs experiments, benchmarks each one, keeps improvements, reverts regressions, and repeats — all autonomously. A built-in dashboard tracks every run with confidence scoring to distinguish real gains from benchmark noise.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/autoresearch optimize unit test runtime
|
||||||
|
```
|
||||||
|
|
||||||
|
Each kept experiment is automatically committed. Each failed one is reverted. When you're done, Pi can group improvements into independent branches for clean review and merge.
|
||||||
|
|
||||||
|
## Manual setup
|
||||||
|
|
||||||
Add a configuration block to `~/.pi/agent/models.json`:
|
Add a configuration block to `~/.pi/agent/models.json`:
|
||||||
|
|
||||||
|
|||||||