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
|
||||
|
||||
darwin-build:
|
||||
runs-on: macos-14-xlarge
|
||||
runs-on: macos-26-xlarge
|
||||
environment: release
|
||||
needs: setup-environment
|
||||
env:
|
||||
@@ -424,6 +424,7 @@ jobs:
|
||||
lib/ollama/cuda_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/include*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
|
||||
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
|
||||
lib/ollama/rocm) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
|
||||
|
||||
9
.github/workflows/test.yaml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=87'
|
||||
- preset: ROCm
|
||||
container: rocm/dev-ubuntu-22.04:7.2
|
||||
container: rocm/dev-ubuntu-22.04:7.2.1
|
||||
extra-packages: rocm-libs
|
||||
flags: '-DAMDGPU_TARGETS=gfx1010 -DCMAKE_PREFIX_PATH=/opt/rocm'
|
||||
- preset: Vulkan
|
||||
@@ -64,6 +64,7 @@ jobs:
|
||||
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
|
||||
extra-packages: libcudnn9-dev-cuda-13 libopenblas-dev liblapack-dev liblapacke-dev git curl
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=87 -DBLAS_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu -DLAPACK_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu'
|
||||
install-go: true
|
||||
runs-on: linux
|
||||
container: ${{ matrix.container }}
|
||||
steps:
|
||||
@@ -90,6 +91,12 @@ jobs:
|
||||
fi
|
||||
env:
|
||||
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
|
||||
with:
|
||||
path: /github/home/.cache/ccache
|
||||
|
||||
1
.gitignore
vendored
@@ -15,3 +15,4 @@ __debug_bin*
|
||||
llama/build
|
||||
llama/vendor
|
||||
/ollama
|
||||
integration/testdata/models/
|
||||
|
||||
@@ -246,13 +246,21 @@ if(MLX_ENGINE)
|
||||
COMPONENT MLX)
|
||||
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
|
||||
# --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
|
||||
# current_binary_dir().parent_path() / "include" / "cccl", so we create a
|
||||
# symlink from lib/ollama/include -> ${OLLAMA_RUNNER_DIR}/include
|
||||
# This will need refinement if we add multiple CUDA versions for MLX in the future.
|
||||
# current_binary_dir().parent_path() / "include" / "cccl", so we create a
|
||||
# symlink from lib/ollama/include -> ${OLLAMA_RUNNER_DIR}/include
|
||||
# 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)
|
||||
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
|
||||
@@ -271,6 +279,61 @@ if(MLX_ENGINE)
|
||||
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)
|
||||
# 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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
ARG FLAVOR=${TARGETARCH}
|
||||
|
||||
ARG ROCMVERSION=7.2
|
||||
ARG ROCMVERSION=7.2.1
|
||||
ARG JETPACK5VERSION=r35.4.1
|
||||
ARG JETPACK6VERSION=r36.4.0
|
||||
ARG CMAKEVERSION=3.31.2
|
||||
@@ -157,7 +157,7 @@ COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
COPY x/imagegen/mlx x/imagegen/mlx
|
||||
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
|
||||
ENV PATH=/usr/local/go/bin:$PATH
|
||||
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"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
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"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
@@ -82,8 +82,27 @@ type MessagesRequest struct {
|
||||
|
||||
// MessageParam represents a message in the request
|
||||
type MessageParam struct {
|
||||
Role string `json:"role"` // "user" or "assistant"
|
||||
Content any `json:"content"` // string or []ContentBlock
|
||||
Role string `json:"role"` // "user" or "assistant"
|
||||
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.
|
||||
@@ -102,9 +121,9 @@ type ContentBlock struct {
|
||||
Source *ImageSource `json:"source,omitempty"`
|
||||
|
||||
// For tool_use and server_tool_use blocks
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input any `json:"input,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input api.ToolCallFunctionArguments `json:"input,omitzero"`
|
||||
|
||||
// For tool_result and web_search_tool_result blocks
|
||||
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||
@@ -377,178 +396,145 @@ func convertMessage(msg MessageParam) ([]api.Message, error) {
|
||||
var messages []api.Message
|
||||
role := strings.ToLower(msg.Role)
|
||||
|
||||
switch content := msg.Content.(type) {
|
||||
case string:
|
||||
messages = append(messages, api.Message{Role: role, Content: content})
|
||||
var textContent strings.Builder
|
||||
var images []api.ImageData
|
||||
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:
|
||||
var textContent strings.Builder
|
||||
var images []api.ImageData
|
||||
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
|
||||
|
||||
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")
|
||||
for _, block := range msg.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
textBlocks++
|
||||
if block.Text != nil {
|
||||
textContent.WriteString(*block.Text)
|
||||
}
|
||||
|
||||
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 {
|
||||
case "text":
|
||||
textBlocks++
|
||||
if text, ok := blockMap["text"].(string); ok {
|
||||
textContent.WriteString(text)
|
||||
if block.Source.Type == "base64" {
|
||||
decoded, err := base64.StdEncoding.DecodeString(block.Source.Data)
|
||||
if err != nil {
|
||||
logutil.Trace("anthropic: invalid base64 image data", "role", role, "error", err)
|
||||
return nil, fmt.Errorf("invalid base64 image data: %w", err)
|
||||
}
|
||||
images = append(images, decoded)
|
||||
} else {
|
||||
logutil.Trace("anthropic: unsupported image source type", "role", role, "source_type", block.Source.Type)
|
||||
return nil, fmt.Errorf("invalid image source type: %s. Only base64 images are supported.", block.Source.Type)
|
||||
}
|
||||
|
||||
case "image":
|
||||
imageBlocks++
|
||||
source, ok := blockMap["source"].(map[string]any)
|
||||
if !ok {
|
||||
logutil.Trace("anthropic: invalid image source", "role", role)
|
||||
return nil, errors.New("invalid image source")
|
||||
}
|
||||
case "tool_use":
|
||||
toolUseBlocks++
|
||||
if block.ID == "" {
|
||||
logutil.Trace("anthropic: tool_use block missing id", "role", role)
|
||||
return nil, errors.New("tool_use block missing required 'id' field")
|
||||
}
|
||||
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)
|
||||
if sourceType == "base64" {
|
||||
data, _ := source["data"].(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_result":
|
||||
toolResultBlocks++
|
||||
var resultContent string
|
||||
|
||||
case "tool_use":
|
||||
toolUseBlocks++
|
||||
id, ok := blockMap["id"].(string)
|
||||
if !ok {
|
||||
logutil.Trace("anthropic: tool_use block missing id", "role", role)
|
||||
return nil, errors.New("tool_use block missing required 'id' field")
|
||||
}
|
||||
name, ok := blockMap["name"].(string)
|
||||
if !ok {
|
||||
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
|
||||
}
|
||||
switch c := block.Content.(type) {
|
||||
case string:
|
||||
resultContent = c
|
||||
case []any:
|
||||
for _, cb := range c {
|
||||
if cbMap, ok := cb.(map[string]any); ok {
|
||||
if cbMap["type"] == "text" {
|
||||
if text, ok := cbMap["text"].(string); ok {
|
||||
resultContent += text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toolResults = append(toolResults, api.Message{
|
||||
Role: "tool",
|
||||
Content: resultContent,
|
||||
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 != "" {
|
||||
m := api.Message{
|
||||
Role: role,
|
||||
Content: textContent.String(),
|
||||
Images: images,
|
||||
ToolCalls: toolCalls,
|
||||
Thinking: thinking,
|
||||
toolResults = append(toolResults, api.Message{
|
||||
Role: "tool",
|
||||
Content: resultContent,
|
||||
ToolCallID: block.ToolUseID,
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
continue
|
||||
}
|
||||
|
||||
events = append(events, StreamEvent{
|
||||
Event: "content_block_start",
|
||||
Data: ContentBlockStartEvent{
|
||||
@@ -892,7 +877,7 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
||||
Type: "tool_use",
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Input: map[string]any{},
|
||||
Input: api.NewToolCallFunctionArguments(),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -989,15 +974,6 @@ func ptr(s string) *string {
|
||||
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
|
||||
type CountTokensRequest struct {
|
||||
Model string `json:"model"`
|
||||
@@ -1030,17 +1006,13 @@ func estimateTokens(req CountTokensRequest) int {
|
||||
var totalLen int
|
||||
|
||||
// Count system prompt
|
||||
if req.System != nil {
|
||||
totalLen += countAnyContent(req.System)
|
||||
}
|
||||
totalLen += countAnyContent(req.System)
|
||||
|
||||
// Count messages
|
||||
for _, msg := range req.Messages {
|
||||
// Count role (always present)
|
||||
totalLen += len(msg.Role)
|
||||
// Count content
|
||||
contentLen := countAnyContent(msg.Content)
|
||||
totalLen += contentLen
|
||||
totalLen += countAnyContent(msg.Content)
|
||||
}
|
||||
|
||||
for _, tool := range req.Tools {
|
||||
@@ -1063,12 +1035,25 @@ func countAnyContent(content any) int {
|
||||
switch c := content.(type) {
|
||||
case string:
|
||||
return len(c)
|
||||
case []any:
|
||||
case []ContentBlock:
|
||||
total := 0
|
||||
for _, block := range c {
|
||||
total += countContentBlock(block)
|
||||
}
|
||||
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:
|
||||
if data, err := json.Marshal(content); err == nil {
|
||||
return len(data)
|
||||
@@ -1077,38 +1062,19 @@ func countAnyContent(content any) int {
|
||||
}
|
||||
}
|
||||
|
||||
func countContentBlock(block any) int {
|
||||
blockMap, ok := block.(map[string]any)
|
||||
if !ok {
|
||||
if s, ok := block.(string); ok {
|
||||
return len(s)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func countContentBlock(block ContentBlock) int {
|
||||
total := 0
|
||||
blockType, _ := blockMap["type"].(string)
|
||||
|
||||
if text, ok := blockMap["text"].(string); ok {
|
||||
total += len(text)
|
||||
if block.Text != nil {
|
||||
total += len(*block.Text)
|
||||
}
|
||||
|
||||
if thinking, ok := blockMap["thinking"].(string); ok {
|
||||
total += len(thinking)
|
||||
if block.Thinking != nil {
|
||||
total += len(*block.Thinking)
|
||||
}
|
||||
|
||||
if blockType == "tool_use" {
|
||||
if data, err := json.Marshal(blockMap); err == nil {
|
||||
if block.Type == "tool_use" || block.Type == "tool_result" {
|
||||
if data, err := json.Marshal(block); err == nil {
|
||||
total += len(data)
|
||||
}
|
||||
}
|
||||
|
||||
if blockType == "tool_result" {
|
||||
if data, err := json.Marshal(blockMap); err == nil {
|
||||
total += len(data)
|
||||
}
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,16 @@ const (
|
||||
testImage = `iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=`
|
||||
)
|
||||
|
||||
// testArgs creates ToolCallFunctionArguments from a map (convenience function for tests)
|
||||
func testArgs(m map[string]any) api.ToolCallFunctionArguments {
|
||||
// textContent is a convenience for constructing []ContentBlock with a single text block in tests.
|
||||
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()
|
||||
for k, v := range m {
|
||||
args.Set(k, v)
|
||||
for i := 0; i < len(kvs)-1; i += 2 {
|
||||
args.Set(kvs[i].(string), kvs[i+1])
|
||||
}
|
||||
return args
|
||||
}
|
||||
@@ -29,7 +34,7 @@ func TestFromMessagesRequest_Basic(t *testing.T) {
|
||||
Model: "test-model",
|
||||
MaxTokens: 1024,
|
||||
Messages: []MessageParam{
|
||||
{Role: "user", Content: "Hello"},
|
||||
{Role: "user", Content: textContent("Hello")},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -61,7 +66,7 @@ func TestFromMessagesRequest_WithSystemPrompt(t *testing.T) {
|
||||
MaxTokens: 1024,
|
||||
System: "You are a helpful assistant.",
|
||||
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."},
|
||||
},
|
||||
Messages: []MessageParam{
|
||||
{Role: "user", Content: "Hello"},
|
||||
{Role: "user", Content: textContent("Hello")},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -113,7 +118,7 @@ func TestFromMessagesRequest_WithOptions(t *testing.T) {
|
||||
req := MessagesRequest{
|
||||
Model: "test-model",
|
||||
MaxTokens: 2048,
|
||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
||||
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||
Temperature: &temp,
|
||||
TopP: &topP,
|
||||
TopK: &topK,
|
||||
@@ -148,14 +153,14 @@ func TestFromMessagesRequest_WithImage(t *testing.T) {
|
||||
Messages: []MessageParam{
|
||||
{
|
||||
Role: "user",
|
||||
Content: []any{
|
||||
map[string]any{"type": "text", "text": "What's in this image?"},
|
||||
map[string]any{
|
||||
"type": "image",
|
||||
"source": map[string]any{
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": testImage,
|
||||
Content: []ContentBlock{
|
||||
{Type: "text", Text: ptr("What's in this image?")},
|
||||
{
|
||||
Type: "image",
|
||||
Source: &ImageSource{
|
||||
Type: "base64",
|
||||
MediaType: "image/png",
|
||||
Data: testImage,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -190,15 +195,15 @@ func TestFromMessagesRequest_WithToolUse(t *testing.T) {
|
||||
Model: "test-model",
|
||||
MaxTokens: 1024,
|
||||
Messages: []MessageParam{
|
||||
{Role: "user", Content: "What's the weather in Paris?"},
|
||||
{Role: "user", Content: textContent("What's the weather in Paris?")},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "tool_use",
|
||||
"id": "call_123",
|
||||
"name": "get_weather",
|
||||
"input": map[string]any{"location": "Paris"},
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "tool_use",
|
||||
ID: "call_123",
|
||||
Name: "get_weather",
|
||||
Input: makeArgs("location", "Paris"),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -234,11 +239,11 @@ func TestFromMessagesRequest_WithToolResult(t *testing.T) {
|
||||
Messages: []MessageParam{
|
||||
{
|
||||
Role: "user",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "call_123",
|
||||
"content": "The weather in Paris is sunny, 22°C",
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "tool_result",
|
||||
ToolUseID: "call_123",
|
||||
Content: "The weather in Paris is sunny, 22°C",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -270,7 +275,7 @@ func TestFromMessagesRequest_WithTools(t *testing.T) {
|
||||
req := MessagesRequest{
|
||||
Model: "test-model",
|
||||
MaxTokens: 1024,
|
||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
||||
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||
Tools: []Tool{
|
||||
{
|
||||
Name: "get_weather",
|
||||
@@ -305,7 +310,7 @@ func TestFromMessagesRequest_DropsCustomWebSearchWhenBuiltinPresent(t *testing.T
|
||||
req := MessagesRequest{
|
||||
Model: "test-model",
|
||||
MaxTokens: 1024,
|
||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
||||
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||
Tools: []Tool{
|
||||
{
|
||||
Type: "web_search_20250305",
|
||||
@@ -346,7 +351,7 @@ func TestFromMessagesRequest_KeepsCustomWebSearchWhenBuiltinAbsent(t *testing.T)
|
||||
req := MessagesRequest{
|
||||
Model: "test-model",
|
||||
MaxTokens: 1024,
|
||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
||||
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||
Tools: []Tool{
|
||||
{
|
||||
Type: "custom",
|
||||
@@ -377,7 +382,7 @@ func TestFromMessagesRequest_WithThinking(t *testing.T) {
|
||||
req := MessagesRequest{
|
||||
Model: "test-model",
|
||||
MaxTokens: 1024,
|
||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
||||
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||
Thinking: &ThinkingConfig{Type: "enabled", BudgetTokens: 1000},
|
||||
}
|
||||
|
||||
@@ -399,13 +404,13 @@ func TestFromMessagesRequest_ThinkingOnlyBlock(t *testing.T) {
|
||||
Model: "test-model",
|
||||
MaxTokens: 1024,
|
||||
Messages: []MessageParam{
|
||||
{Role: "user", Content: "Hello"},
|
||||
{Role: "user", Content: textContent("Hello")},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "thinking",
|
||||
"thinking": "Let me think about this...",
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "thinking",
|
||||
Thinking: ptr("Let me think about this..."),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -434,10 +439,10 @@ func TestFromMessagesRequest_ToolUseMissingID(t *testing.T) {
|
||||
Messages: []MessageParam{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "tool_use",
|
||||
"name": "get_weather",
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "tool_use",
|
||||
Name: "get_weather",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -460,10 +465,10 @@ func TestFromMessagesRequest_ToolUseMissingName(t *testing.T) {
|
||||
Messages: []MessageParam{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "tool_use",
|
||||
"id": "call_123",
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "tool_use",
|
||||
ID: "call_123",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -483,7 +488,7 @@ func TestFromMessagesRequest_InvalidToolSchema(t *testing.T) {
|
||||
req := MessagesRequest{
|
||||
Model: "test-model",
|
||||
MaxTokens: 1024,
|
||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
||||
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||
Tools: []Tool{
|
||||
{
|
||||
Name: "bad_tool",
|
||||
@@ -548,7 +553,7 @@ func TestToMessagesResponse_WithToolCalls(t *testing.T) {
|
||||
ID: "call_123",
|
||||
Function: api.ToolCallFunction{
|
||||
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",
|
||||
Function: api.ToolCallFunction{
|
||||
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",
|
||||
Function: api.ToolCallFunction{
|
||||
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",
|
||||
Function: api.ToolCallFunction{
|
||||
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) {
|
||||
t.Run("text block start includes empty text", func(t *testing.T) {
|
||||
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
|
||||
data, _ := json.Marshal(start)
|
||||
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)
|
||||
if _, ok := cb["text"]; !ok {
|
||||
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.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) {
|
||||
req := CountTokensRequest{
|
||||
Model: "test-model",
|
||||
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",
|
||||
System: "You are a helpful assistant.",
|
||||
Messages: []MessageParam{
|
||||
{Role: "user", Content: "Hello"},
|
||||
{Role: "user", Content: textContent("Hello")},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1177,7 +1293,7 @@ func TestEstimateTokens_WithTools(t *testing.T) {
|
||||
req := CountTokensRequest{
|
||||
Model: "test-model",
|
||||
Messages: []MessageParam{
|
||||
{Role: "user", Content: "What's the weather?"},
|
||||
{Role: "user", Content: textContent("What's the weather?")},
|
||||
},
|
||||
Tools: []Tool{
|
||||
{
|
||||
@@ -1200,17 +1316,17 @@ func TestEstimateTokens_WithThinking(t *testing.T) {
|
||||
req := CountTokensRequest{
|
||||
Model: "test-model",
|
||||
Messages: []MessageParam{
|
||||
{Role: "user", Content: "Hello"},
|
||||
{Role: "user", Content: textContent("Hello")},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "thinking",
|
||||
"thinking": "Let me think about this carefully...",
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "thinking",
|
||||
Thinking: ptr("Let me think about this carefully..."),
|
||||
},
|
||||
map[string]any{
|
||||
"type": "text",
|
||||
"text": "Here is my response.",
|
||||
{
|
||||
Type: "text",
|
||||
Text: ptr("Here is my response."),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1308,12 +1424,12 @@ func TestConvertTool_RegularTool(t *testing.T) {
|
||||
func TestConvertMessage_ServerToolUse(t *testing.T) {
|
||||
msg := MessageParam{
|
||||
Role: "assistant",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "server_tool_use",
|
||||
"id": "srvtoolu_123",
|
||||
"name": "web_search",
|
||||
"input": map[string]any{"query": "test query"},
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "server_tool_use",
|
||||
ID: "srvtoolu_123",
|
||||
Name: "web_search",
|
||||
Input: makeArgs("query", "test query"),
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1344,11 +1460,11 @@ func TestConvertMessage_ServerToolUse(t *testing.T) {
|
||||
func TestConvertMessage_WebSearchToolResult(t *testing.T) {
|
||||
msg := MessageParam{
|
||||
Role: "user",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": "srvtoolu_123",
|
||||
"content": []any{
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "web_search_tool_result",
|
||||
ToolUseID: "srvtoolu_123",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "web_search_result",
|
||||
"title": "Test Result",
|
||||
@@ -1385,11 +1501,11 @@ func TestConvertMessage_WebSearchToolResult(t *testing.T) {
|
||||
func TestConvertMessage_WebSearchToolResultEmptyStillCreatesToolMessage(t *testing.T) {
|
||||
msg := MessageParam{
|
||||
Role: "user",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": "srvtoolu_empty",
|
||||
"content": []any{},
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "web_search_tool_result",
|
||||
ToolUseID: "srvtoolu_empty",
|
||||
Content: []any{},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1416,11 +1532,11 @@ func TestConvertMessage_WebSearchToolResultEmptyStillCreatesToolMessage(t *testi
|
||||
func TestConvertMessage_WebSearchToolResultErrorStillCreatesToolMessage(t *testing.T) {
|
||||
msg := MessageParam{
|
||||
Role: "user",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": "srvtoolu_error",
|
||||
"content": map[string]any{
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "web_search_tool_result",
|
||||
ToolUseID: "srvtoolu_error",
|
||||
Content: map[string]any{
|
||||
"type": "web_search_tool_result_error",
|
||||
"error_code": "max_uses_exceeded",
|
||||
},
|
||||
|
||||
@@ -436,6 +436,7 @@ type ToolProperty struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Enum []any `json:"enum,omitempty"`
|
||||
Properties *ToolPropertiesMap `json:"properties,omitempty"`
|
||||
Required []string `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
// ToTypeScriptType converts a ToolProperty to a TypeScript type string
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
// currentSchemaVersion defines the current database schema version.
|
||||
// Increment this when making schema changes that require migrations.
|
||||
const currentSchemaVersion = 15
|
||||
const currentSchemaVersion = 16
|
||||
|
||||
// database wraps the SQLite connection.
|
||||
// SQLite handles its own locking for concurrent access:
|
||||
@@ -82,6 +82,7 @@ func (db *database) init() error {
|
||||
websearch_enabled BOOLEAN NOT NULL DEFAULT 0,
|
||||
selected_model TEXT NOT NULL DEFAULT '',
|
||||
sidebar_open BOOLEAN NOT NULL DEFAULT 0,
|
||||
last_home_view TEXT NOT NULL DEFAULT 'launch',
|
||||
think_enabled BOOLEAN NOT NULL DEFAULT 0,
|
||||
think_level TEXT NOT NULL DEFAULT '',
|
||||
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)
|
||||
}
|
||||
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:
|
||||
// If we have a version we don't recognize, just set it to current
|
||||
// This might happen during development
|
||||
@@ -518,6 +525,21 @@ func (db *database) migrateV14ToV15() error {
|
||||
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
|
||||
func (db *database) cleanupOrphanedData() error {
|
||||
_, err := db.conn.Exec(`
|
||||
@@ -1166,9 +1188,9 @@ func (db *database) getSettings() (Settings, error) {
|
||||
var s Settings
|
||||
|
||||
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
|
||||
`).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 {
|
||||
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 {
|
||||
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(`
|
||||
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 = ?
|
||||
`, 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)
|
||||
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, lastHomeView, s.ThinkEnabled, s.ThinkLevel, s.AutoUpdateEnabled)
|
||||
if err != nil {
|
||||
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) {
|
||||
t.Run("chat deletion cascades to related messages", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
@@ -167,6 +167,9 @@ type Settings struct {
|
||||
// SidebarOpen indicates if the chat sidebar is open
|
||||
SidebarOpen bool
|
||||
|
||||
// LastHomeView stores the preferred home route target ("chat" or integration name)
|
||||
LastHomeView string
|
||||
|
||||
// AutoUpdateEnabled indicates if automatic updates should be downloaded
|
||||
AutoUpdateEnabled bool
|
||||
}
|
||||
@@ -389,6 +392,10 @@ func (s *Store) Settings() (Settings, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if settings.LastHomeView == "" {
|
||||
settings.LastHomeView = "launch"
|
||||
}
|
||||
|
||||
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) {
|
||||
if err := s.SetWindowSize(1024, 768); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -414,6 +414,7 @@ export class Settings {
|
||||
ThinkLevel: string;
|
||||
SelectedModel: string;
|
||||
SidebarOpen: boolean;
|
||||
LastHomeView: string;
|
||||
AutoUpdateEnabled: boolean;
|
||||
|
||||
constructor(source: any = {}) {
|
||||
@@ -432,6 +433,7 @@ export class Settings {
|
||||
this.ThinkLevel = source["ThinkLevel"];
|
||||
this.SelectedModel = source["SelectedModel"];
|
||||
this.SidebarOpen = source["SidebarOpen"];
|
||||
this.LastHomeView = source["LastHomeView"];
|
||||
this.AutoUpdateEnabled = source["AutoUpdateEnabled"];
|
||||
}
|
||||
}
|
||||
@@ -550,14 +552,12 @@ export class Error {
|
||||
}
|
||||
}
|
||||
export class ModelUpstreamResponse {
|
||||
digest?: string;
|
||||
pushTime: number;
|
||||
stale: boolean;
|
||||
error?: string;
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.digest = source["digest"];
|
||||
this.pushTime = source["pushTime"];
|
||||
this.stale = source["stale"];
|
||||
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
|
||||
if (!exactMatch) {
|
||||
const result = await getModelUpstreamInfo(new Model({ model: query }));
|
||||
const existsUpstream = !!result.digest && !result.error;
|
||||
const existsUpstream = result.exists;
|
||||
if (existsUpstream) {
|
||||
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
|
||||
export async function getModelUpstreamInfo(
|
||||
model: Model,
|
||||
): Promise<{ digest?: string; pushTime: number; error?: string }> {
|
||||
): Promise<{ stale: boolean; exists: boolean; error?: string }> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/model/upstream`, {
|
||||
method: "POST",
|
||||
@@ -353,22 +353,22 @@ export async function getModelUpstreamInfo(
|
||||
|
||||
if (!response.ok) {
|
||||
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();
|
||||
|
||||
if (data.error) {
|
||||
console.warn(`Upstream digest check: ${data.error}`);
|
||||
return { error: data.error, pushTime: 0 };
|
||||
console.warn(`Upstream check: ${data.error}`);
|
||||
return { stale: false, exists: false, error: data.error };
|
||||
}
|
||||
|
||||
return { digest: data.digest, pushTime: data.pushTime || 0 };
|
||||
return { stale: !!data.stale, exists: true };
|
||||
} catch (error) {
|
||||
console.warn(`Error checking model staleness:`, error);
|
||||
return { pushTime: 0 };
|
||||
return { stale: false, exists: false };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -480,13 +480,15 @@ function ChatForm({
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare attachments for submission
|
||||
const attachmentsToSend: FileAttachment[] = message.attachments.map(
|
||||
(att) => ({
|
||||
// Prepare attachments for submission, excluding unsupported images
|
||||
const attachmentsToSend: FileAttachment[] = message.attachments
|
||||
.filter(
|
||||
(att) => hasVisionCapability || !isImageFile(att.filename),
|
||||
)
|
||||
.map((att) => ({
|
||||
filename: att.filename,
|
||||
data: att.data || new Uint8Array(0), // Empty data for existing files
|
||||
}),
|
||||
);
|
||||
}));
|
||||
|
||||
const useWebSearch =
|
||||
supportsWebSearch && webSearchEnabled && !cloudDisabled;
|
||||
@@ -736,10 +738,17 @@ function ChatForm({
|
||||
)}
|
||||
{(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">
|
||||
{message.attachments.map((attachment, index) => (
|
||||
{message.attachments.map((attachment, index) => {
|
||||
const isUnsupportedImage =
|
||||
!hasVisionCapability && isImageFile(attachment.filename);
|
||||
return (
|
||||
<div
|
||||
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) ? (
|
||||
<ImageThumbnail
|
||||
@@ -764,9 +773,16 @@ function ChatForm({
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300 max-w-[150px] truncate">
|
||||
{attachment.filename}
|
||||
</span>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className={`text-sm max-w-36 truncate ${isUnsupportedImage ? "text-red-700 dark:text-red-300" : "text-neutral-700 dark:text-neutral-300"}`}>
|
||||
{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
|
||||
type="button"
|
||||
onClick={() => removeFile(index)}
|
||||
@@ -788,7 +804,8 @@ function ChatForm({
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{message.fileErrors.map((fileError, index) => (
|
||||
<div
|
||||
key={`error-${index}`}
|
||||
|
||||
@@ -6,12 +6,13 @@ import { getChat } from "@/api";
|
||||
import { Link } from "@/components/ui/link";
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
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
|
||||
// holding shift and clicking this many times within this many seconds
|
||||
const DEBUG_SHIFT_CLICKS_REQUIRED = 5;
|
||||
const DEBUG_SHIFT_CLICK_WINDOW_MS = 7000; // 7 seconds
|
||||
const launchSidebarRequestedKey = "ollama.launchSidebarRequested";
|
||||
|
||||
interface ChatSidebarProps {
|
||||
currentChatId?: string;
|
||||
@@ -267,9 +268,8 @@ export function ChatSidebar({ currentChatId }: ChatSidebarProps) {
|
||||
<Link
|
||||
href="/c/new"
|
||||
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 ${
|
||||
currentChatId === "new" ? "bg-neutral-100 dark:bg-neutral-800" : ""
|
||||
}`}
|
||||
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" : ""
|
||||
}`}
|
||||
draggable={false}
|
||||
>
|
||||
<svg
|
||||
@@ -283,6 +283,23 @@ export function ChatSidebar({ currentChatId }: ChatSidebarProps) {
|
||||
</svg>
|
||||
<span className="truncate">New Chat</span>
|
||||
</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 && (
|
||||
<Link
|
||||
href="/settings"
|
||||
@@ -304,19 +321,18 @@ export function ChatSidebar({ currentChatId }: ChatSidebarProps) {
|
||||
{group.chats.map((chat) => (
|
||||
<div
|
||||
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 ${
|
||||
chat.id === currentChatId
|
||||
? "bg-neutral-100 text-black dark: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
|
||||
? "bg-neutral-100 text-black dark:bg-neutral-800"
|
||||
: ""
|
||||
}`}
|
||||
onMouseEnter={() => handleMouseEnter(chat.id)}
|
||||
onContextMenu={(e) =>
|
||||
handleContextMenu(
|
||||
e,
|
||||
chat.id,
|
||||
chat.title ||
|
||||
chat.userExcerpt ||
|
||||
chat.createdAt.toLocaleString(),
|
||||
chat.userExcerpt ||
|
||||
chat.createdAt.toLocaleString(),
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -10,6 +10,7 @@ interface CopyButtonProps {
|
||||
showLabels?: boolean;
|
||||
className?: string;
|
||||
title?: string;
|
||||
onCopy?: () => void;
|
||||
}
|
||||
|
||||
const CopyButton: React.FC<CopyButtonProps> = ({
|
||||
@@ -20,6 +21,7 @@ const CopyButton: React.FC<CopyButtonProps> = ({
|
||||
showLabels = false,
|
||||
className = "",
|
||||
title = "",
|
||||
onCopy,
|
||||
}) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
@@ -48,12 +50,14 @@ const CopyButton: React.FC<CopyButtonProps> = ({
|
||||
}
|
||||
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error("Clipboard API failed, falling back to plain text", error);
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
} catch (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;
|
||||
try {
|
||||
args = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
args = null;
|
||||
}
|
||||
const query = args && typeof args.query === "string" ? args.query : "";
|
||||
@@ -562,7 +562,7 @@ function ToolCallDisplay({
|
||||
let args: Record<string, unknown> | null = null;
|
||||
try {
|
||||
args = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
args = null;
|
||||
}
|
||||
const url = args && typeof args.url === "string" ? args.url : "";
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function MessageList({
|
||||
? String(args.url).trim()
|
||||
: "";
|
||||
if (candidate) lastQuery = candidate;
|
||||
} catch {}
|
||||
} catch { /* ignored */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,24 +61,7 @@ export const ModelPicker = forwardRef<
|
||||
try {
|
||||
const upstreamInfo = await getModelUpstreamInfo(model);
|
||||
|
||||
// Compare local digest with upstream digest
|
||||
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) {
|
||||
if (upstreamInfo.stale) {
|
||||
const currentStaleModels =
|
||||
queryClient.getQueryData<Map<string, boolean>>(["staleModels"]) ||
|
||||
new Map();
|
||||
|
||||
@@ -273,6 +273,10 @@ export default function Settings() {
|
||||
}
|
||||
|
||||
const isWindows = navigator.platform.toLowerCase().includes("win");
|
||||
const handleCloseSettings = () => {
|
||||
const chatId = settings.LastHomeView === "chat" ? "new" : "launch";
|
||||
navigate({ to: "/c/$chatId", params: { chatId } });
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex h-screen w-full flex-col select-none dark:bg-neutral-900">
|
||||
@@ -286,7 +290,7 @@ export default function Settings() {
|
||||
>
|
||||
{isWindows && (
|
||||
<button
|
||||
onClick={() => navigate({ to: "/" })}
|
||||
onClick={handleCloseSettings}
|
||||
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" />
|
||||
@@ -296,7 +300,7 @@ export default function Settings() {
|
||||
</h1>
|
||||
{!isWindows && (
|
||||
<button
|
||||
onClick={() => navigate({ to: "/" })}
|
||||
onClick={handleCloseSettings}
|
||||
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" />
|
||||
|
||||
@@ -65,7 +65,7 @@ export const BadgeButton = forwardRef(function BadgeButton(
|
||||
),
|
||||
ref: React.ForwardedRef<HTMLElement>,
|
||||
) {
|
||||
let classes = clsx(
|
||||
const classes = clsx(
|
||||
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",
|
||||
);
|
||||
|
||||
@@ -171,7 +171,7 @@ export const Button = forwardRef(function Button(
|
||||
{ color, outline, plain, className, children, ...props }: ButtonProps,
|
||||
ref: React.ForwardedRef<HTMLElement>,
|
||||
) {
|
||||
let classes = clsx(
|
||||
const classes = clsx(
|
||||
className,
|
||||
styles.base,
|
||||
outline
|
||||
|
||||
@@ -9,6 +9,7 @@ interface SettingsState {
|
||||
webSearchEnabled: boolean;
|
||||
selectedModel: string;
|
||||
sidebarOpen: boolean;
|
||||
lastHomeView: string;
|
||||
thinkEnabled: boolean;
|
||||
thinkLevel: string;
|
||||
}
|
||||
@@ -21,6 +22,7 @@ type SettingsUpdate = Partial<{
|
||||
ThinkLevel: string;
|
||||
SelectedModel: string;
|
||||
SidebarOpen: boolean;
|
||||
LastHomeView: string;
|
||||
}>;
|
||||
|
||||
export function useSettings() {
|
||||
@@ -50,6 +52,7 @@ export function useSettings() {
|
||||
thinkLevel: settingsData?.settings?.ThinkLevel ?? "none",
|
||||
selectedModel: settingsData?.settings?.SelectedModel ?? "",
|
||||
sidebarOpen: settingsData?.settings?.SidebarOpen ?? false,
|
||||
lastHomeView: settingsData?.settings?.LastHomeView ?? "launch",
|
||||
}),
|
||||
[settingsData?.settings],
|
||||
);
|
||||
|
||||
@@ -4,12 +4,37 @@ import Chat from "@/components/Chat";
|
||||
import { getChat } from "@/api";
|
||||
import { SidebarLayout } from "@/components/layout/layout";
|
||||
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")({
|
||||
component: RouteComponent,
|
||||
loader: async ({ context, params }) => {
|
||||
// Skip loading for "new" chat
|
||||
if (params.chatId !== "new") {
|
||||
// Skip loading for special non-chat views
|
||||
if (params.chatId !== "new" && params.chatId !== "launch") {
|
||||
context.queryClient.ensureQueryData({
|
||||
queryKey: ["chat", params.chatId],
|
||||
queryFn: () => getChat(params.chatId),
|
||||
@@ -21,13 +46,70 @@ export const Route = createFileRoute("/c/$chatId")({
|
||||
|
||||
function RouteComponent() {
|
||||
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 {
|
||||
data: chatData,
|
||||
isLoading: chatLoading,
|
||||
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
|
||||
if (chatId === "new") {
|
||||
@@ -38,6 +120,14 @@ function RouteComponent() {
|
||||
);
|
||||
}
|
||||
|
||||
if (chatId === "launch") {
|
||||
return (
|
||||
<SidebarLayout sidebar={<ChatSidebar currentChatId={chatId} />}>
|
||||
<LaunchCommands />
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle existing chat case
|
||||
if (chatLoading) {
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { getSettings } from "@/api";
|
||||
|
||||
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({
|
||||
to: "/c/$chatId",
|
||||
params: { chatId: "new" },
|
||||
params: { chatId },
|
||||
mask: {
|
||||
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);
|
||||
});
|
||||
|
||||
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 result = validateFile(file, {
|
||||
hasVisionCapability: false,
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("This model does not support images");
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept PNG images when vision capability is enabled", () => {
|
||||
|
||||
@@ -63,7 +63,6 @@ export function validateFile(
|
||||
const {
|
||||
maxFileSize = 10,
|
||||
allowedExtensions = [...TEXT_FILE_EXTENSIONS, ...IMAGE_EXTENSIONS],
|
||||
hasVisionCapability = false,
|
||||
customValidator,
|
||||
} = options;
|
||||
|
||||
@@ -83,10 +82,6 @@ export function validateFile(
|
||||
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
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return { valid: false, error: "File too large" };
|
||||
|
||||
@@ -2,27 +2,28 @@ import { Model } from "@/gotypes";
|
||||
|
||||
// Featured models list (in priority order)
|
||||
export const FEATURED_MODELS = [
|
||||
"kimi-k2.5:cloud",
|
||||
"glm-5:cloud",
|
||||
"minimax-m2.7:cloud",
|
||||
"gemma4:31b-cloud",
|
||||
"qwen3.5:397b-cloud",
|
||||
"gpt-oss:120b-cloud",
|
||||
"gpt-oss:20b-cloud",
|
||||
"deepseek-v3.1:671b-cloud",
|
||||
"qwen3-coder:480b-cloud",
|
||||
"qwen3-vl:235b-cloud",
|
||||
"minimax-m2:cloud",
|
||||
"glm-4.6:cloud",
|
||||
"gpt-oss:120b",
|
||||
"gpt-oss:20b",
|
||||
"gemma3:27b",
|
||||
"gemma3:12b",
|
||||
"gemma3:4b",
|
||||
"gemma3:1b",
|
||||
"gemma4:31b",
|
||||
"gemma4:26b",
|
||||
"gemma4:e4b",
|
||||
"gemma4:e2b",
|
||||
"deepseek-r1:8b",
|
||||
"qwen3-coder:30b",
|
||||
"qwen3-vl:30b",
|
||||
"qwen3-vl:8b",
|
||||
"qwen3-vl:4b",
|
||||
"qwen3:30b",
|
||||
"qwen3:8b",
|
||||
"qwen3:4b",
|
||||
"qwen3.5:27b",
|
||||
"qwen3.5:9b",
|
||||
"qwen3.5:4b",
|
||||
];
|
||||
|
||||
function alphabeticalSort(a: Model, b: Model): number {
|
||||
|
||||
@@ -133,9 +133,8 @@ type Error struct {
|
||||
}
|
||||
|
||||
type ModelUpstreamResponse struct {
|
||||
Digest string `json:"digest,omitempty"`
|
||||
PushTime int64 `json:"pushTime"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Stale bool `json:"stale"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Serializable data for the browser state
|
||||
|
||||
37
app/ui/ui.go
@@ -32,6 +32,7 @@ import (
|
||||
"github.com/ollama/ollama/app/version"
|
||||
ollamaAuth "github.com/ollama/ollama/auth"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/manifest"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
_ "github.com/tkrajina/typescriptify-golang-structs/typescriptify"
|
||||
)
|
||||
@@ -193,7 +194,7 @@ func (s *Server) Handler() http.Handler {
|
||||
if CORS() {
|
||||
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-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")
|
||||
|
||||
// Handle preflight requests
|
||||
@@ -318,7 +319,7 @@ func (s *Server) handleError(w http.ResponseWriter, e error) {
|
||||
if CORS() {
|
||||
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-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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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{
|
||||
Timeout: 10 * time.Second,
|
||||
Timeout: timeout,
|
||||
Transport: &userAgentTransport{
|
||||
base: http.DefaultTransport,
|
||||
},
|
||||
@@ -720,11 +731,7 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) error {
|
||||
_, cancelLoading := context.WithCancel(ctx)
|
||||
loading := false
|
||||
|
||||
c, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
cancelLoading()
|
||||
return err
|
||||
}
|
||||
c := s.inferenceClient()
|
||||
|
||||
// Check if the model exists locally by trying to show it
|
||||
// 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)
|
||||
}
|
||||
|
||||
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{
|
||||
Digest: digest,
|
||||
PushTime: pushTime,
|
||||
Stale: stale,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -1672,7 +1688,6 @@ func supportsBrowserTools(model string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(model), "gpt-oss")
|
||||
}
|
||||
|
||||
|
||||
// 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) {
|
||||
var msgs []api.Message
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/app/store"
|
||||
"github.com/ollama/ollama/app/updater"
|
||||
)
|
||||
@@ -526,6 +527,33 @@ func TestUserAgentTransport(t *testing.T) {
|
||||
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) {
|
||||
tests := []struct {
|
||||
model string
|
||||
|
||||
@@ -32,6 +32,7 @@ type flagOptions struct {
|
||||
verbose *bool
|
||||
warmup *int
|
||||
promptTokens *int
|
||||
numCtx *int
|
||||
}
|
||||
|
||||
type Metrics struct {
|
||||
@@ -48,6 +49,7 @@ type ModelInfo struct {
|
||||
Family string
|
||||
SizeBytes 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.`
|
||||
@@ -64,9 +66,12 @@ var promptWordList = []string{
|
||||
"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 {
|
||||
// ~1.3 tokens per word heuristic
|
||||
targetWords := int(float64(targetTokens) / 1.3)
|
||||
targetWords := int(float64(targetTokens) / tokensPerWord)
|
||||
if targetWords < 1 {
|
||||
targetWords = 1
|
||||
}
|
||||
@@ -81,6 +86,17 @@ func generatePromptForTokenCount(targetTokens int, epoch int) string {
|
||||
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 {
|
||||
options := make(map[string]interface{})
|
||||
if *fOpt.maxTokens > 0 {
|
||||
@@ -90,6 +106,9 @@ func buildGenerateRequest(model string, fOpt flagOptions, imgData api.ImageData,
|
||||
if fOpt.seed != nil && *fOpt.seed > 0 {
|
||||
options["seed"] = *fOpt.seed
|
||||
}
|
||||
if fOpt.numCtx != nil && *fOpt.numCtx > 0 {
|
||||
options["num_ctx"] = *fOpt.numCtx
|
||||
}
|
||||
|
||||
var keepAliveDuration *api.Duration
|
||||
if *fOpt.keepAlive > 0 {
|
||||
@@ -146,7 +165,6 @@ func fetchMemoryUsage(ctx context.Context, client *api.Client, model string) (si
|
||||
return m.Size, m.SizeVRAM
|
||||
}
|
||||
}
|
||||
// Try prefix match (model names may include :latest or tags)
|
||||
for _, m := range resp.Models {
|
||||
if strings.HasPrefix(m.Name, model) || strings.HasPrefix(m.Model, model) {
|
||||
return m.Size, m.SizeVRAM
|
||||
@@ -155,6 +173,19 @@ func fetchMemoryUsage(ctx context.Context, client *api.Client, model string) (si
|
||||
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) {
|
||||
switch format {
|
||||
case "benchstat":
|
||||
@@ -177,8 +208,12 @@ func outputModelInfo(w io.Writer, format string, info ModelInfo) {
|
||||
if info.SizeBytes > 0 {
|
||||
memStr = fmt.Sprintf(" | Size: %d | VRAM: %d", info.SizeBytes, info.VRAMBytes)
|
||||
}
|
||||
fmt.Fprintf(w, "# Model: %s | Params: %s | Quant: %s | Family: %s%s\n",
|
||||
info.Name, params, quant, family, memStr)
|
||||
ctxStr := ""
|
||||
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) {
|
||||
@@ -276,21 +311,38 @@ func BenchmarkModel(fOpt flagOptions) error {
|
||||
req := buildGenerateRequest(model, fOpt, imgData, -(i + 1))
|
||||
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 {
|
||||
if resp.Done {
|
||||
warmupMetrics = &resp.Metrics
|
||||
}
|
||||
return nil
|
||||
})
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: Warmup %d/%d for %s failed: %v\n", i+1, *fOpt.warmup, model, err)
|
||||
} else if *fOpt.debug {
|
||||
fmt.Fprintf(os.Stderr, "Warmup %d/%d for %s complete\n", i+1, *fOpt.warmup, model)
|
||||
} else {
|
||||
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)
|
||||
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()
|
||||
|
||||
outputModelInfo(out, *fOpt.format, info)
|
||||
@@ -479,6 +531,7 @@ func main() {
|
||||
debug: flag.Bool("debug", false, "Show debug information"),
|
||||
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)"),
|
||||
numCtx: flag.Int("num-ctx", 0, "Context size (0 = server default)"),
|
||||
}
|
||||
|
||||
flag.Usage = func() {
|
||||
|
||||
105
cmd/cmd.go
@@ -54,7 +54,6 @@ import (
|
||||
"github.com/ollama/ollama/types/syncmap"
|
||||
"github.com/ollama/ollama/version"
|
||||
xcmd "github.com/ollama/ollama/x/cmd"
|
||||
"github.com/ollama/ollama/x/create"
|
||||
xcreateclient "github.com/ollama/ollama/x/create/client"
|
||||
"github.com/ollama/ollama/x/imagegen"
|
||||
)
|
||||
@@ -93,13 +92,7 @@ func init() {
|
||||
return userName, err
|
||||
}
|
||||
|
||||
launch.DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
ok, err := tui.RunConfirm(prompt)
|
||||
if errors.Is(err, tui.ErrCancelled) {
|
||||
return false, launch.ErrCancelled
|
||||
}
|
||||
return ok, err
|
||||
}
|
||||
launch.DefaultConfirmPrompt = tui.RunConfirmWithOptions
|
||||
}
|
||||
|
||||
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
|
||||
// This gates both safetensors LLM and imagegen model creation
|
||||
experimental, _ := cmd.Flags().GetBool("experimental")
|
||||
if experimental {
|
||||
if !isLocalhost() {
|
||||
return errors.New("remote safetensor model creation not yet supported")
|
||||
}
|
||||
|
||||
// Get Modelfile content - either from -f flag or default to "FROM ."
|
||||
var reader io.Reader
|
||||
filename, err := getModelfileName(cmd)
|
||||
@@ -211,23 +206,12 @@ func CreateHandler(cmd *cobra.Command, args []string) error {
|
||||
}, p)
|
||||
}
|
||||
|
||||
// Standard Modelfile + API path
|
||||
var reader io.Reader
|
||||
|
||||
filename, err := getModelfileName(cmd)
|
||||
if os.IsNotExist(err) {
|
||||
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")
|
||||
} else {
|
||||
return errModelfileNotFound
|
||||
@@ -695,7 +679,8 @@ func RunHandler(cmd *cobra.Command, args []string) error {
|
||||
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,
|
||||
// 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
|
||||
isEmbeddingModel := slices.Contains(info.Capabilities, model.CapabilityEmbedding)
|
||||
@@ -1426,23 +1411,30 @@ func PullHandler(cmd *cobra.Command, args []string) error {
|
||||
type generateContextKey string
|
||||
|
||||
type runOptions struct {
|
||||
Model string
|
||||
ParentModel string
|
||||
Prompt string
|
||||
Messages []api.Message
|
||||
WordWrap bool
|
||||
Format string
|
||||
System string
|
||||
Images []api.ImageData
|
||||
Options map[string]any
|
||||
MultiModal bool
|
||||
KeepAlive *api.Duration
|
||||
Think *api.ThinkValue
|
||||
HideThinking bool
|
||||
ShowConnect bool
|
||||
Model string
|
||||
ParentModel string
|
||||
LoadedMessages []api.Message
|
||||
Prompt string
|
||||
Messages []api.Message
|
||||
WordWrap bool
|
||||
Format string
|
||||
System string
|
||||
Images []api.ImageData
|
||||
Options map[string]any
|
||||
MultiModal bool
|
||||
KeepAlive *api.Duration
|
||||
Think *api.ThinkValue
|
||||
HideThinking bool
|
||||
ShowConnect bool
|
||||
}
|
||||
|
||||
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
|
||||
if r.Messages != nil {
|
||||
messages = make([]api.Message, len(r.Messages))
|
||||
@@ -1470,23 +1462,29 @@ func (r runOptions) Copy() runOptions {
|
||||
}
|
||||
|
||||
return runOptions{
|
||||
Model: r.Model,
|
||||
ParentModel: r.ParentModel,
|
||||
Prompt: r.Prompt,
|
||||
Messages: messages,
|
||||
WordWrap: r.WordWrap,
|
||||
Format: r.Format,
|
||||
System: r.System,
|
||||
Images: images,
|
||||
Options: opts,
|
||||
MultiModal: r.MultiModal,
|
||||
KeepAlive: r.KeepAlive,
|
||||
Think: think,
|
||||
HideThinking: r.HideThinking,
|
||||
ShowConnect: r.ShowConnect,
|
||||
Model: r.Model,
|
||||
ParentModel: r.ParentModel,
|
||||
LoadedMessages: loadedMessages,
|
||||
Prompt: r.Prompt,
|
||||
Messages: messages,
|
||||
WordWrap: r.WordWrap,
|
||||
Format: r.Format,
|
||||
System: r.System,
|
||||
Images: images,
|
||||
Options: opts,
|
||||
MultiModal: r.MultiModal,
|
||||
KeepAlive: r.KeepAlive,
|
||||
Think: think,
|
||||
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 {
|
||||
lineLength int
|
||||
wordBuffer string
|
||||
@@ -1494,6 +1492,9 @@ type displayResponseState struct {
|
||||
|
||||
func displayResponse(content string, wordWrap bool, state *displayResponseState) {
|
||||
termWidth, _, _ := term.GetSize(int(os.Stdout.Fd()))
|
||||
if termWidth == 0 {
|
||||
termWidth = 80
|
||||
}
|
||||
if wordWrap && termWidth >= 10 {
|
||||
for _, ch := range content {
|
||||
if state.lineLength+1 > termWidth-5 {
|
||||
@@ -2065,6 +2066,10 @@ func runLauncherAction(cmd *cobra.Command, action tui.TUIAction, deps launcherDe
|
||||
if err != nil {
|
||||
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
|
||||
default:
|
||||
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) {
|
||||
setCmdTestHome(t, t.TempDir())
|
||||
|
||||
|
||||
101
cmd/cmd_test.go
@@ -301,7 +301,7 @@ Weigh anchor!
|
||||
ParameterSize: "7B",
|
||||
QuantizationLevel: "FP16",
|
||||
},
|
||||
Requires: "0.14.0",
|
||||
Requires: "0.19.0",
|
||||
}, false, &b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -310,10 +310,17 @@ Weigh anchor!
|
||||
architecture test
|
||||
parameters 7B
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -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 {
|
||||
@@ -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) {
|
||||
// Setup test data
|
||||
originalKeepAlive := &api.Duration{Duration: 5 * time.Minute}
|
||||
originalThink := &api.ThinkValue{Value: "test reasoning"}
|
||||
|
||||
original := runOptions{
|
||||
Model: "test-model",
|
||||
ParentModel: "parent-model",
|
||||
Prompt: "test prompt",
|
||||
Model: "test-model",
|
||||
ParentModel: "parent-model",
|
||||
LoadedMessages: []api.Message{{Role: "assistant", Content: "loaded hello"}},
|
||||
Prompt: "test prompt",
|
||||
Messages: []api.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
{Role: "assistant", Content: "hi there"},
|
||||
@@ -1708,6 +1761,7 @@ func TestRunOptions_Copy(t *testing.T) {
|
||||
}{
|
||||
{"Model", copied.Model, original.Model},
|
||||
{"ParentModel", copied.ParentModel, original.ParentModel},
|
||||
{"LoadedMessages", copied.LoadedMessages, original.LoadedMessages},
|
||||
{"Prompt", copied.Prompt, original.Prompt},
|
||||
{"WordWrap", copied.WordWrap, original.WordWrap},
|
||||
{"Format", copied.Format, original.Format},
|
||||
@@ -1812,13 +1866,18 @@ func TestRunOptions_Copy(t *testing.T) {
|
||||
func TestRunOptions_Copy_EmptySlicesAndMaps(t *testing.T) {
|
||||
// Test with empty slices and maps
|
||||
original := runOptions{
|
||||
Messages: []api.Message{},
|
||||
Images: []api.ImageData{},
|
||||
Options: map[string]any{},
|
||||
LoadedMessages: []api.Message{},
|
||||
Messages: []api.Message{},
|
||||
Images: []api.ImageData{},
|
||||
Options: map[string]any{},
|
||||
}
|
||||
|
||||
copied := original.Copy()
|
||||
|
||||
if copied.LoadedMessages == nil {
|
||||
t.Error("Empty LoadedMessages slice should remain empty, not nil")
|
||||
}
|
||||
|
||||
if copied.Messages == 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")
|
||||
}
|
||||
|
||||
if len(copied.LoadedMessages) != 0 {
|
||||
t.Error("Empty LoadedMessages slice should remain empty")
|
||||
}
|
||||
|
||||
if len(copied.Images) != 0 {
|
||||
t.Error("Empty Images slice should remain empty")
|
||||
}
|
||||
@@ -1912,7 +1975,7 @@ func TestShowInfoImageGen(t *testing.T) {
|
||||
QuantizationLevel: "Q8",
|
||||
},
|
||||
Capabilities: []model.Capability{model.CapabilityImage},
|
||||
Requires: "0.14.0",
|
||||
Requires: "0.19.0",
|
||||
}, false, &b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1922,7 +1985,7 @@ func TestShowInfoImageGen(t *testing.T) {
|
||||
" architecture ZImagePipeline \n" +
|
||||
" parameters 10.3B \n" +
|
||||
" quantization Q8 \n" +
|
||||
" requires 0.14.0 \n" +
|
||||
" requires 0.19.0 \n" +
|
||||
"\n" +
|
||||
" Capabilities\n" +
|
||||
" image \n" +
|
||||
@@ -1980,16 +2043,20 @@ func TestRunOptions_Copy_Independence(t *testing.T) {
|
||||
// Test that modifications to original don't affect copy
|
||||
originalThink := &api.ThinkValue{Value: "original"}
|
||||
original := runOptions{
|
||||
Model: "original-model",
|
||||
Messages: []api.Message{{Role: "user", Content: "original"}},
|
||||
Options: map[string]any{"key": "value"},
|
||||
Think: originalThink,
|
||||
Model: "original-model",
|
||||
LoadedMessages: []api.Message{{Role: "assistant", Content: "loaded"}},
|
||||
Messages: []api.Message{{Role: "user", Content: "original"}},
|
||||
Options: map[string]any{"key": "value"},
|
||||
Think: originalThink,
|
||||
}
|
||||
|
||||
copied := original.Copy()
|
||||
|
||||
// Modify original
|
||||
original.Model = "modified-model"
|
||||
if len(original.LoadedMessages) > 0 {
|
||||
original.LoadedMessages[0].Content = "modified loaded"
|
||||
}
|
||||
if len(original.Messages) > 0 {
|
||||
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")
|
||||
}
|
||||
|
||||
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" {
|
||||
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.")
|
||||
|
||||
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, "")
|
||||
@@ -214,10 +214,17 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
||||
}
|
||||
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.Messages = []api.Message{}
|
||||
opts.LoadedMessages = nil
|
||||
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 strings.Contains(err.Error(), "not found") {
|
||||
fmt.Printf("Couldn't find model '%s'\n", opts.Model)
|
||||
@@ -226,6 +233,11 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
||||
}
|
||||
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 strings.Contains(err.Error(), "not found") {
|
||||
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
|
||||
}
|
||||
|
||||
if len(opts.Messages) > 0 {
|
||||
req.Messages = opts.Messages
|
||||
messages := slices.Clone(opts.LoadedMessages)
|
||||
messages = append(messages, opts.Messages...)
|
||||
if len(messages) > 0 {
|
||||
req.Messages = messages
|
||||
}
|
||||
|
||||
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)
|
||||
// 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
|
||||
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)
|
||||
|
||||
return re.FindAllString(input, -1)
|
||||
@@ -608,10 +622,16 @@ func extractFileData(input string) (string, []api.ImageData, error) {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
} 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
|
||||
}
|
||||
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, "'"+fp+"'", "")
|
||||
input = strings.ReplaceAll(input, fp, "")
|
||||
@@ -685,9 +705,9 @@ func getImageData(filePath string) ([]byte, error) {
|
||||
}
|
||||
|
||||
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) {
|
||||
return nil, fmt.Errorf("invalid image type: %s", contentType)
|
||||
return nil, fmt.Errorf("invalid file type: %s", contentType)
|
||||
}
|
||||
|
||||
info, err := file.Stat()
|
||||
@@ -695,8 +715,7 @@ func getImageData(filePath string) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if the file size exceeds 100MB
|
||||
var maxSize int64 = 100 * 1024 * 1024 // 100MB in bytes
|
||||
var maxSize int64 = 100 * 1024 * 1024 // 100MB
|
||||
if info.Size() > maxSize {
|
||||
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.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"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
@@ -15,8 +16,10 @@ type Codex struct{}
|
||||
|
||||
func (c *Codex) String() string { return "Codex" }
|
||||
|
||||
const codexProfileName = "ollama-launch"
|
||||
|
||||
func (c *Codex) args(model string, extra []string) []string {
|
||||
args := []string{"--oss"}
|
||||
args := []string{"--profile", codexProfileName}
|
||||
if model != "" {
|
||||
args = append(args, "-m", model)
|
||||
}
|
||||
@@ -29,17 +32,95 @@ func (c *Codex) Run(model string, args []string) error {
|
||||
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.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = append(os.Environ(),
|
||||
"OPENAI_BASE_URL="+envconfig.Host().String()+"/v1/",
|
||||
"OPENAI_API_KEY=ollama",
|
||||
)
|
||||
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 {
|
||||
if _, err := exec.LookPath("codex"); err != nil {
|
||||
return fmt.Errorf("codex is not installed, install with: npm install -g @openai/codex")
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -14,10 +17,10 @@ func TestCodexArgs(t *testing.T) {
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{"with model", "llama3.2", nil, []string{"--oss", "-m", "llama3.2"}},
|
||||
{"empty model", "", nil, []string{"--oss"}},
|
||||
{"with model and profile", "qwen3.5", []string{"-p", "myprofile"}, []string{"--oss", "-m", "qwen3.5", "-p", "myprofile"}},
|
||||
{"with sandbox flag", "llama3.2", []string{"--sandbox", "workspace-write"}, []string{"--oss", "-m", "llama3.2", "--sandbox", "workspace-write"}},
|
||||
{"with model", "llama3.2", nil, []string{"--profile", "ollama-launch", "-m", "llama3.2"}},
|
||||
{"empty model", "", nil, []string{"--profile", "ollama-launch"}},
|
||||
{"with model and extra args", "qwen3.5", []string{"-p", "myprofile"}, []string{"--profile", "ollama-launch", "-m", "qwen3.5", "-p", "myprofile"}},
|
||||
{"with sandbox flag", "llama3.2", []string{"--sandbox", "workspace-write"}, []string{"--profile", "ollama-launch", "-m", "llama3.2", "--sandbox", "workspace-write"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -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)
|
||||
defer restore()
|
||||
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatalf("unexpected prompt with --yes: %q", prompt)
|
||||
return false, nil
|
||||
}
|
||||
@@ -393,7 +393,7 @@ func TestLaunchCmdHeadlessWithYes_AutoPullsMissingLocalModel(t *testing.T) {
|
||||
restore := OverrideIntegration("stubapp", stub)
|
||||
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)
|
||||
return false, nil
|
||||
}
|
||||
@@ -436,7 +436,7 @@ func TestLaunchCmdHeadlessWithoutYes_ReturnsActionableConfirmError(t *testing.T)
|
||||
restore := OverrideIntegration("stubeditor", stub)
|
||||
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)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
)
|
||||
|
||||
type stubEditorRunner struct {
|
||||
@@ -290,7 +291,7 @@ func TestParseArgs(t *testing.T) {
|
||||
func TestIsCloudModel(t *testing.T) {
|
||||
// isCloudModel now only uses Show API, so nil client always returns false
|
||||
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 {
|
||||
if isCloudModel(context.Background(), nil, 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) {
|
||||
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 != "" {
|
||||
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)
|
||||
|
||||
// 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 != "" {
|
||||
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) {
|
||||
existing := []modelInfo{
|
||||
{Name: "llama3.2:latest", Remote: false},
|
||||
{Name: "glm-5:cloud", Remote: true},
|
||||
{Name: "glm-5.1:cloud", Remote: true},
|
||||
}
|
||||
|
||||
items, _, _, _ := buildModelList(existing, nil, "")
|
||||
got := names(items)
|
||||
|
||||
// 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 != "" {
|
||||
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) {
|
||||
existing := []modelInfo{
|
||||
{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"}, "")
|
||||
@@ -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) {
|
||||
existing := []modelInfo{
|
||||
{Name: "glm-4.7-flash", Remote: false},
|
||||
{Name: "glm-5:cloud", Remote: true},
|
||||
{Name: "gemma4", Remote: false},
|
||||
{Name: "glm-5.1:cloud", Remote: true},
|
||||
}
|
||||
|
||||
items, _, _, _ := buildModelList(existing, nil, "")
|
||||
|
||||
for _, item := range items {
|
||||
switch item.Name {
|
||||
case "glm-4.7-flash", "glm-5:cloud":
|
||||
case "gemma4", "glm-5.1:cloud":
|
||||
if strings.HasSuffix(item.Description, "(not downloaded)") {
|
||||
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) {
|
||||
existing := []modelInfo{
|
||||
{Name: "glm-4.7-flash", Remote: false},
|
||||
{Name: "glm-5:cloud", Remote: true},
|
||||
{Name: "gemma4", Remote: false},
|
||||
{Name: "glm-5.1:cloud", Remote: true},
|
||||
}
|
||||
|
||||
items, _, _, _ := buildModelList(existing, nil, "")
|
||||
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
|
||||
// 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 != "" {
|
||||
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;
|
||||
// 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
|
||||
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 != "" {
|
||||
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) {
|
||||
existing := []modelInfo{
|
||||
{Name: "glm-4.7-flash:latest", Remote: false},
|
||||
{Name: "gemma4: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
|
||||
for _, name := range got {
|
||||
if name == "glm-4.7-flash" {
|
||||
if name == "gemma4" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
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
|
||||
if !existingModels["glm-4.7-flash"] {
|
||||
t.Error("glm-4.7-flash should be in existingModels")
|
||||
if !existingModels["gemma4"] {
|
||||
t.Error("gemma4 should be in existingModels")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelList_ReturnsExistingAndCloudMaps(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{Name: "llama3.2:latest", Remote: false},
|
||||
{Name: "glm-5:cloud", Remote: true},
|
||||
{Name: "glm-5.1:cloud", Remote: true},
|
||||
}
|
||||
|
||||
_, _, existingModels, cloudModels := buildModelList(existing, nil, "")
|
||||
@@ -494,15 +528,15 @@ func TestBuildModelList_ReturnsExistingAndCloudMaps(t *testing.T) {
|
||||
if !existingModels["llama3.2"] {
|
||||
t.Error("llama3.2 should be in existingModels")
|
||||
}
|
||||
if !existingModels["glm-5:cloud"] {
|
||||
t.Error("glm-5:cloud should be in existingModels")
|
||||
if !existingModels["glm-5.1:cloud"] {
|
||||
t.Error("glm-5.1:cloud should be in existingModels")
|
||||
}
|
||||
if existingModels["glm-4.7-flash"] {
|
||||
t.Error("glm-4.7-flash should not be in existingModels (it's a recommendation)")
|
||||
if existingModels["gemma4"] {
|
||||
t.Error("gemma4 should not be in existingModels (it's a recommendation)")
|
||||
}
|
||||
|
||||
if !cloudModels["glm-5:cloud"] {
|
||||
t.Error("glm-5:cloud should be in cloudModels")
|
||||
if !cloudModels["glm-5.1:cloud"] {
|
||||
t.Error("glm-5.1:cloud should be in cloudModels")
|
||||
}
|
||||
if !cloudModels["kimi-k2.5: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) {
|
||||
existing := []modelInfo{
|
||||
{Name: "glm-4.7-flash", Remote: false},
|
||||
{Name: "gemma4", Remote: false},
|
||||
{Name: "llama3.2:latest", Remote: false},
|
||||
}
|
||||
|
||||
@@ -525,7 +559,7 @@ func TestBuildModelList_RecommendedFieldSet(t *testing.T) {
|
||||
|
||||
for _, item := range items {
|
||||
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 {
|
||||
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) {
|
||||
existing := []modelInfo{
|
||||
{Name: "llama3.2:latest", Remote: false},
|
||||
{Name: "glm-5:cloud", Remote: true},
|
||||
{Name: "glm-5.1:cloud", Remote: true},
|
||||
}
|
||||
|
||||
items, _, _, _ := buildModelList(existing, nil, "")
|
||||
got := names(items)
|
||||
|
||||
// Cloud recs should sort before local recs in mixed case
|
||||
cloudIdx := slices.Index(got, "glm-5:cloud")
|
||||
localIdx := slices.Index(got, "glm-4.7-flash")
|
||||
cloudIdx := slices.Index(got, "glm-5.1:cloud")
|
||||
localIdx := slices.Index(got, "gemma4")
|
||||
if cloudIdx > localIdx {
|
||||
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)
|
||||
|
||||
// Local recs should sort before cloud recs in only-local case
|
||||
localIdx := slices.Index(got, "glm-4.7-flash")
|
||||
cloudIdx := slices.Index(got, "glm-5:cloud")
|
||||
localIdx := slices.Index(got, "gemma4")
|
||||
cloudIdx := slices.Index(got, "glm-5.1:cloud")
|
||||
if localIdx > cloudIdx {
|
||||
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
|
||||
firstNonRecIdx := len(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 {
|
||||
lastRecIdx = i
|
||||
}
|
||||
@@ -599,7 +633,7 @@ func TestBuildModelList_RecsAboveNonRecs(t *testing.T) {
|
||||
func TestBuildModelList_CheckedBeforeRecs(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{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"}, "")
|
||||
@@ -680,7 +714,7 @@ func TestLauncherClientFilterDisabledCloudModels_ChecksStatusOncePerInvocation(t
|
||||
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 != "" {
|
||||
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) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
@@ -754,7 +841,7 @@ func TestShowOrPullWithPolicy_ModelExists(t *testing.T) {
|
||||
|
||||
func TestShowOrPullWithPolicy_ModelNotFound_FailDoesNotPromptOrPull(t *testing.T) {
|
||||
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")
|
||||
return false, nil
|
||||
}
|
||||
@@ -793,7 +880,7 @@ func TestShowOrPullWithPolicy_ModelNotFound_FailDoesNotPromptOrPull(t *testing.T
|
||||
|
||||
func TestShowOrPullWithPolicy_ModelNotFound_PromptPolicyPulls(t *testing.T) {
|
||||
oldHook := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
if !strings.Contains(prompt, "missing-model") {
|
||||
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) {
|
||||
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)
|
||||
return false, nil
|
||||
}
|
||||
@@ -867,7 +954,7 @@ func TestShowOrPullWithPolicy_ModelNotFound_AutoPullPolicyPullsWithoutPrompt(t *
|
||||
|
||||
func TestShowOrPullWithPolicy_CloudModelNotFound_FailsEarlyForAllPolicies(t *testing.T) {
|
||||
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")
|
||||
return false, nil
|
||||
}
|
||||
@@ -897,11 +984,11 @@ func TestShowOrPullWithPolicy_CloudModelNotFound_FailsEarlyForAllPolicies(t *tes
|
||||
u, _ := url.Parse(srv.URL)
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
if pullCalled {
|
||||
@@ -913,7 +1000,7 @@ func TestShowOrPullWithPolicy_CloudModelNotFound_FailsEarlyForAllPolicies(t *tes
|
||||
|
||||
func TestShowOrPullWithPolicy_CloudModelDisabled_FailsWithCloudDisabledError(t *testing.T) {
|
||||
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")
|
||||
return false, nil
|
||||
}
|
||||
@@ -943,7 +1030,7 @@ func TestShowOrPullWithPolicy_CloudModelDisabled_FailsWithCloudDisabledError(t *
|
||||
u, _ := url.Parse(srv.URL)
|
||||
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 {
|
||||
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) {
|
||||
// Set up hook so confirmPrompt doesn't need a terminal
|
||||
oldHook := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
if !strings.Contains(prompt, "missing-model") {
|
||||
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) {
|
||||
oldHook := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
return false, ErrCancelled
|
||||
}
|
||||
defer func() { DefaultConfirmPrompt = oldHook }()
|
||||
@@ -1070,7 +1157,7 @@ func TestShowOrPull_ModelNotFound_ConfirmNo_Cancelled(t *testing.T) {
|
||||
func TestShowOrPull_CloudModel_NotFoundDoesNotPull(t *testing.T) {
|
||||
// Confirm prompt should NOT be called for explicit cloud models
|
||||
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")
|
||||
return false, nil
|
||||
}
|
||||
@@ -1095,11 +1182,11 @@ func TestShowOrPull_CloudModel_NotFoundDoesNotPull(t *testing.T) {
|
||||
u, _ := url.Parse(srv.URL)
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
if pullCalled {
|
||||
@@ -1110,7 +1197,7 @@ func TestShowOrPull_CloudModel_NotFoundDoesNotPull(t *testing.T) {
|
||||
func TestShowOrPull_CloudLegacySuffix_NotFoundDoesNotPull(t *testing.T) {
|
||||
// Confirm prompt should NOT be called for explicit cloud models
|
||||
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")
|
||||
return false, nil
|
||||
}
|
||||
@@ -1150,7 +1237,7 @@ func TestShowOrPull_CloudLegacySuffix_NotFoundDoesNotPull(t *testing.T) {
|
||||
func TestConfirmPrompt_DelegatesToHook(t *testing.T) {
|
||||
oldHook := DefaultConfirmPrompt
|
||||
var hookCalled bool
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
hookCalled = true
|
||||
if prompt != "test 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) {
|
||||
oldConfirm := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
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) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
@@ -179,6 +180,7 @@ Supported integrations:
|
||||
opencode OpenCode
|
||||
openclaw OpenClaw (aliases: clawdbot, moltbot)
|
||||
pi Pi
|
||||
vscode VS Code (aliases: code)
|
||||
|
||||
Examples:
|
||||
ollama launch
|
||||
@@ -489,15 +491,17 @@ func (c *launcherClient) launchEditorIntegration(ctx context.Context, name strin
|
||||
return err
|
||||
}
|
||||
models = selected
|
||||
} else if err := c.ensureModelsReady(ctx, models); err != nil {
|
||||
return err
|
||||
} else if len(models) > 0 {
|
||||
if err := c.ensureModelsReady(ctx, models[:1]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if needsConfigure || req.ModelOverride != "" {
|
||||
if (needsConfigure || req.ModelOverride != "") && !savedMatchesModels(saved, models) {
|
||||
if err := prepareEditorIntegration(name, runner, editor, models); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -537,24 +541,19 @@ func (c *launcherClient) selectMultiModelsForIntegration(ctx context.Context, ru
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
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 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) {
|
||||
@@ -575,16 +574,7 @@ func (c *launcherClient) loadSelectableModels(ctx context.Context, preChecked []
|
||||
}
|
||||
|
||||
func (c *launcherClient) ensureModelsReady(ctx context.Context, models []string) error {
|
||||
var deduped []string
|
||||
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
|
||||
models = dedupeModelList(models)
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -602,6 +592,56 @@ func (c *launcherClient) ensureModelsReady(ctx context.Context, models []string)
|
||||
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) {
|
||||
if req.ForceConfigure {
|
||||
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 {
|
||||
fmt.Fprintf(os.Stderr, "\nLaunching %s with %s...\n", runner, modelName)
|
||||
return runner.Run(modelName, args)
|
||||
}
|
||||
|
||||
@@ -801,13 +840,6 @@ func cloneAliases(aliases map[string]string) map[string]string {
|
||||
return cloned
|
||||
}
|
||||
|
||||
func singleModelPrechecked(current string) []string {
|
||||
if current == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{current}
|
||||
}
|
||||
|
||||
func firstModel(models []string) string {
|
||||
if len(models) == 0 {
|
||||
return ""
|
||||
@@ -815,6 +847,13 @@ func firstModel(models []string) string {
|
||||
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 {
|
||||
if override == "" {
|
||||
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) {
|
||||
switch r.URL.Path {
|
||||
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":
|
||||
fmt.Fprint(w, `{"model":"qwen3.5"}`)
|
||||
default:
|
||||
@@ -540,7 +540,7 @@ func TestResolveRunModel_ForcePicker_DoesNotReorderByLastModel(t *testing.T) {
|
||||
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")
|
||||
if glmIdx == -1 || qwenIdx == -1 {
|
||||
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
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
if prompt == "Proceed?" {
|
||||
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()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
withLauncherHooks(t)
|
||||
@@ -725,8 +725,8 @@ func TestLaunchIntegration_EditorForceConfigure_DoesNotFloatCheckedModelsInPicke
|
||||
if len(gotItems) == 0 {
|
||||
t.Fatal("expected multi selector to receive items")
|
||||
}
|
||||
if gotItems[0] != "kimi-k2.5:cloud" {
|
||||
t.Fatalf("expected stable recommendation order with kimi-k2.5:cloud first, got %v", gotItems)
|
||||
if gotItems[0] != "qwen3.5:cloud" {
|
||||
t.Fatalf("expected checked models floated to top with qwen3.5:cloud first, got %v", gotItems)
|
||||
}
|
||||
if len(gotPreChecked) < 2 {
|
||||
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) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
@@ -848,7 +1245,7 @@ func TestLaunchIntegration_ConfiguredEditorLaunchSkipsReconfigure(t *testing.T)
|
||||
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)
|
||||
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) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
@@ -979,7 +1410,7 @@ func TestLaunchIntegration_ConfigureOnlyDoesNotRequireInstalledBinary(t *testing
|
||||
}
|
||||
|
||||
var prompts []string
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
prompts = append(prompts, prompt)
|
||||
if strings.Contains(prompt, "Launch LauncherEditor now?") {
|
||||
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) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
@@ -1139,7 +1631,7 @@ func TestLaunchIntegration_ClaudeModelOverrideSkipsSelector(t *testing.T) {
|
||||
}
|
||||
|
||||
var confirmCalls int
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
confirmCalls++
|
||||
if !strings.Contains(prompt, "glm-4") {
|
||||
t.Fatalf("expected download prompt for override model, got %q", prompt)
|
||||
@@ -1203,7 +1695,7 @@ func TestLaunchIntegration_ConfigureOnlyPrompt(t *testing.T) {
|
||||
}
|
||||
|
||||
var prompts []string
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
prompts = append(prompts, prompt)
|
||||
if strings.Contains(prompt, "Launch StubSingle now?") {
|
||||
return false, nil
|
||||
@@ -1253,7 +1745,7 @@ func TestLaunchIntegration_ModelOverrideHeadlessMissingFailsWithoutPrompt(t *tes
|
||||
withIntegrationOverride(t, "droid", runner)
|
||||
|
||||
confirmCalled := false
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
confirmCalled = true
|
||||
return true, nil
|
||||
}
|
||||
@@ -1310,7 +1802,7 @@ func TestLaunchIntegration_ModelOverrideHeadlessCanOverrideMissingModelPolicy(t
|
||||
withIntegrationOverride(t, "droid", runner)
|
||||
|
||||
confirmCalled := false
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
confirmCalled = true
|
||||
if !strings.Contains(prompt, "missing-model") {
|
||||
t.Fatalf("expected prompt to mention missing model, got %q", prompt)
|
||||
@@ -1368,7 +1860,7 @@ func TestLaunchIntegration_ModelOverrideInteractiveMissingPromptsAndPulls(t *tes
|
||||
withIntegrationOverride(t, "droid", runner)
|
||||
|
||||
confirmCalled := false
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
confirmCalled = true
|
||||
if !strings.Contains(prompt, "missing-model") {
|
||||
t.Fatalf("expected prompt to mention missing model, got %q", prompt)
|
||||
@@ -1429,7 +1921,7 @@ func TestLaunchIntegration_HeadlessSelectorFlowFailsWithoutPrompt(t *testing.T)
|
||||
}
|
||||
|
||||
confirmCalled := false
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
confirmCalled = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -23,15 +23,15 @@ import (
|
||||
var recommendedModels = []ModelItem{
|
||||
{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: "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: "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},
|
||||
}
|
||||
|
||||
var recommendedVRAM = map[string]string{
|
||||
"glm-4.7-flash": "~25GB",
|
||||
"qwen3.5": "~11GB",
|
||||
"gemma4": "~16GB",
|
||||
"qwen3.5": "~11GB",
|
||||
}
|
||||
|
||||
// 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},
|
||||
"deepseek-v3.1:671b": {Context: 163_840, Output: 163_840},
|
||||
"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.7": {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:20b": {Context: 131_072, Output: 131_072},
|
||||
"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]
|
||||
}
|
||||
// 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 {
|
||||
return 1
|
||||
|
||||
@@ -102,8 +102,6 @@ func (c *Openclaw) Run(model string, args []string) error {
|
||||
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
|
||||
// after setup and skip the built-in gateway+TUI convenience flow.
|
||||
if len(args) > 0 {
|
||||
@@ -118,6 +116,15 @@ func (c *Openclaw) Run(model string, args []string) error {
|
||||
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()
|
||||
addr := fmt.Sprintf("localhost:%d", port)
|
||||
|
||||
@@ -172,6 +179,94 @@ func (c *Openclaw) Run(model string, args []string) error {
|
||||
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.
|
||||
func (c *Openclaw) gatewayInfo() (token string, port int) {
|
||||
port = defaultGatewayPort
|
||||
@@ -218,12 +313,9 @@ func printOpenclawReady(bin, token string, port int, firstLaunch bool) {
|
||||
if firstLaunch {
|
||||
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 %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 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)
|
||||
} 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 != ""
|
||||
}
|
||||
|
||||
// patchDeviceScopes upgrades the local CLI device's paired scopes to include
|
||||
// operator.admin. Only patches the local device, not remote ones.
|
||||
// Best-effort: silently returns on any error.
|
||||
// patchDeviceScopes upgrades the local CLI device's paired operator scopes so
|
||||
// newer gateway auth baselines (approvedScopes) allow launch+TUI reconnects
|
||||
// without forcing an interactive re-pair. Only patches the local device,
|
||||
// not remote ones. Best-effort: silently returns on any error.
|
||||
func patchDeviceScopes() {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
@@ -350,9 +443,15 @@ func patchDeviceScopes() {
|
||||
}
|
||||
|
||||
changed := patchScopes(dev, "scopes", required)
|
||||
if patchScopes(dev, "approvedScopes", required) {
|
||||
changed = true
|
||||
}
|
||||
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 !isOperatorToken(role, tokenMap) {
|
||||
continue
|
||||
}
|
||||
if patchScopes(tokenMap, "scopes", required) {
|
||||
changed = true
|
||||
}
|
||||
@@ -408,6 +507,14 @@ func patchScopes(obj map[string]any, key string, required []string) bool {
|
||||
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
|
||||
// background service. Returns false on Linux when systemd is absent (e.g.
|
||||
// containers) so that --install-daemon is omitted and the gateway is started
|
||||
@@ -445,7 +552,7 @@ func ensureOpenclawInstalled() (string, error) {
|
||||
if gitErr != nil {
|
||||
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?")
|
||||
@@ -678,7 +785,7 @@ func ensureWebSearchPlugin() bool {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -64,6 +65,17 @@ func TestOpenclawRunPassthroughArgs(t *testing.T) {
|
||||
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{}
|
||||
if err := c.Run("llama3.2", []string{"gateway", "--someflag"}); err != nil {
|
||||
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) {
|
||||
c := &Openclaw{}
|
||||
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) {
|
||||
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) {
|
||||
t.Run("includes port in URL", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
@@ -1126,14 +1846,17 @@ func TestPrintOpenclawReady(t *testing.T) {
|
||||
buf.ReadFrom(r)
|
||||
|
||||
output := buf.String()
|
||||
for _, want := range []string{"/help", "channels", "skills", "gateway"} {
|
||||
for _, want := range []string{"/help", "skills", "gateway"} {
|
||||
if !strings.Contains(output, want) {
|
||||
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
|
||||
old := os.Stderr
|
||||
r, w, _ := os.Pipe()
|
||||
@@ -1146,12 +1869,15 @@ func TestPrintOpenclawReady(t *testing.T) {
|
||||
buf.ReadFrom(r)
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "Tip:") {
|
||||
t.Errorf("expected single tip line, got:\n%s", output)
|
||||
}
|
||||
if strings.Contains(output, "Quick start") {
|
||||
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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
modeltype "github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
// OpenCode implements Runner and Editor for OpenCode integration
|
||||
type OpenCode struct{}
|
||||
// OpenCode implements Runner and Editor for OpenCode integration.
|
||||
// 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" }
|
||||
|
||||
// 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 {
|
||||
if _, err := exec.LookPath("opencode"); err != nil {
|
||||
opencodePath, ok := findOpenCode()
|
||||
if !ok {
|
||||
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.Stdout = os.Stdout
|
||||
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()
|
||||
}
|
||||
|
||||
// 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 {
|
||||
home, err := os.UserHomeDir()
|
||||
sp, err := openCodeStatePath()
|
||||
if err != 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 {
|
||||
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 {
|
||||
@@ -54,109 +110,17 @@ func (o *OpenCode) Edit(modelList []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
content, err := buildInlineConfig(modelList[0], modelList)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.configContent = content
|
||||
|
||||
configPath := filepath.Join(home, ".config", "opencode", "opencode.json")
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
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, "", " ")
|
||||
// Write model state file so models appear in OpenCode's model picker
|
||||
statePath, err := openCodeStatePath()
|
||||
if err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -208,33 +172,127 @@ func (o *OpenCode) Edit(modelList []string) error {
|
||||
}
|
||||
|
||||
func (o *OpenCode) Models() []string {
|
||||
home, err := os.UserHomeDir()
|
||||
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
|
||||
return nil
|
||||
}
|
||||
|
||||
// isOllamaModel reports whether a model config entry is managed by us
|
||||
func isOllamaModel(cfg map[string]any) bool {
|
||||
if v, ok := cfg["_launch"].(bool); ok && v {
|
||||
return true
|
||||
// buildInlineConfig produces the JSON string for OPENCODE_CONFIG_CONTENT.
|
||||
// primary is the model to launch with, models is the full list of available models.
|
||||
func buildInlineConfig(primary string, models []string) (string, error) {
|
||||
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
|
||||
if name, ok := cfg["name"].(string); ok {
|
||||
return strings.HasSuffix(name, "[Ollama]")
|
||||
config := map[string]any{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"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
|
||||
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) Run(model string, args []string) error {
|
||||
if _, err := exec.LookPath("pi"); err != nil {
|
||||
return fmt.Errorf("pi is not installed, install with: npm install -g @mariozechner/pi-coding-agent")
|
||||
fmt.Fprintf(os.Stderr, "\n%sPreparing Pi...%s\n", ansiGray, ansiReset)
|
||||
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.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
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 {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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) {
|
||||
pi := &Pi{}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ type IntegrationInfo struct {
|
||||
Description string
|
||||
}
|
||||
|
||||
var launcherIntegrationOrder = []string{"opencode", "droid", "pi", "cline"}
|
||||
var launcherIntegrationOrder = []string{"opencode", "droid", "pi"}
|
||||
|
||||
var integrationSpecs = []*IntegrationSpec{
|
||||
{
|
||||
@@ -52,6 +52,7 @@ var integrationSpecs = []*IntegrationSpec{
|
||||
Name: "cline",
|
||||
Runner: &Cline{},
|
||||
Description: "Autonomous coding agent with parallel execution",
|
||||
Hidden: true,
|
||||
Install: IntegrationInstallSpec{
|
||||
CheckInstalled: func() bool {
|
||||
_, err := exec.LookPath("cline")
|
||||
@@ -91,8 +92,8 @@ var integrationSpecs = []*IntegrationSpec{
|
||||
Description: "Anomaly's open-source coding agent",
|
||||
Install: IntegrationInstallSpec{
|
||||
CheckInstalled: func() bool {
|
||||
_, err := exec.LookPath("opencode")
|
||||
return err == nil
|
||||
_, ok := findOpenCode()
|
||||
return ok
|
||||
},
|
||||
URL: "https://opencode.ai",
|
||||
},
|
||||
@@ -128,7 +129,24 @@ var integrationSpecs = []*IntegrationSpec{
|
||||
_, err := exec.LookPath("pi")
|
||||
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",
|
||||
runner: &OpenCode{},
|
||||
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()
|
||||
writeFakeBinary(t, binDir, tt.binary)
|
||||
if tt.name == "pi" {
|
||||
writeFakeBinary(t, binDir, "npm")
|
||||
}
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
configPath := tt.checkPath(home)
|
||||
|
||||
@@ -25,7 +25,13 @@ var errCancelled = ErrCancelled
|
||||
|
||||
// DefaultConfirmPrompt provides a TUI-based confirmation prompt.
|
||||
// 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.
|
||||
// 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
|
||||
// withLaunchConfirmPolicy in LaunchCmd (e.g. auto-approve with --yes).
|
||||
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 {
|
||||
return true, nil
|
||||
}
|
||||
@@ -73,7 +85,7 @@ func ConfirmPrompt(prompt string) (bool, error) {
|
||||
}
|
||||
|
||||
if DefaultConfirmPrompt != nil {
|
||||
return DefaultConfirmPrompt(prompt)
|
||||
return DefaultConfirmPrompt(prompt, options)
|
||||
}
|
||||
|
||||
fd := int(os.Stdin.Fd())
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestWithLaunchConfirmPolicy_ScopesAndRestores(t *testing.T) {
|
||||
|
||||
currentLaunchConfirmPolicy = launchConfirmPolicy{}
|
||||
var hookCalls int
|
||||
DefaultConfirmPrompt = func(prompt string) (bool, error) {
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
hookCalls++
|
||||
return true, nil
|
||||
}
|
||||
@@ -74,3 +74,39 @@ func TestWithLaunchConfirmPolicy_ScopesAndRestores(t *testing.T) {
|
||||
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"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/ollama/ollama/cmd/launch"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -18,12 +19,16 @@ var (
|
||||
|
||||
type confirmModel struct {
|
||||
prompt string
|
||||
yesLabel string
|
||||
noLabel string
|
||||
yes bool
|
||||
confirmed bool
|
||||
cancelled bool
|
||||
width int
|
||||
}
|
||||
|
||||
type ConfirmOptions = launch.ConfirmOptions
|
||||
|
||||
func (m confirmModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
@@ -40,22 +45,16 @@ func (m confirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc", "n":
|
||||
case "ctrl+c", "esc":
|
||||
m.cancelled = true
|
||||
return m, tea.Quit
|
||||
case "y":
|
||||
m.yes = true
|
||||
m.confirmed = true
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
m.confirmed = true
|
||||
return m, tea.Quit
|
||||
case "left", "h":
|
||||
case "left":
|
||||
m.yes = true
|
||||
case "right", "l":
|
||||
case "right":
|
||||
m.yes = false
|
||||
case "tab":
|
||||
m.yes = !m.yes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,12 +67,20 @@ func (m confirmModel) View() string {
|
||||
}
|
||||
|
||||
var yesBtn, noBtn string
|
||||
yesLabel := m.yesLabel
|
||||
if yesLabel == "" {
|
||||
yesLabel = "Yes"
|
||||
}
|
||||
noLabel := m.noLabel
|
||||
if noLabel == "" {
|
||||
noLabel = "No"
|
||||
}
|
||||
if m.yes {
|
||||
yesBtn = confirmActiveStyle.Render(" Yes ")
|
||||
noBtn = confirmInactiveStyle.Render(" No ")
|
||||
yesBtn = confirmActiveStyle.Render(" " + yesLabel + " ")
|
||||
noBtn = confirmInactiveStyle.Render(" " + noLabel + " ")
|
||||
} else {
|
||||
yesBtn = confirmInactiveStyle.Render(" Yes ")
|
||||
noBtn = confirmActiveStyle.Render(" No ")
|
||||
yesBtn = confirmInactiveStyle.Render(" " + yesLabel + " ")
|
||||
noBtn = confirmActiveStyle.Render(" " + noLabel + " ")
|
||||
}
|
||||
|
||||
s := selectorTitleStyle.Render(m.prompt) + "\n\n"
|
||||
@@ -89,9 +96,26 @@ func (m confirmModel) View() string {
|
||||
// RunConfirm shows a bubbletea yes/no confirmation prompt.
|
||||
// Returns true if the user confirmed, false if cancelled.
|
||||
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{
|
||||
prompt: prompt,
|
||||
yes: true, // default to yes
|
||||
prompt: prompt,
|
||||
yesLabel: yesLabel,
|
||||
noLabel: noLabel,
|
||||
yes: true, // default to yes
|
||||
}
|
||||
|
||||
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) {
|
||||
m := confirmModel{prompt: "Download?", yes: true}
|
||||
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}
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
|
||||
fm := updated.(confirmModel)
|
||||
if !fm.cancelled {
|
||||
t.Error("'n' should set cancelled=true")
|
||||
if fm.cancelled {
|
||||
t.Error("'n' should not cancel")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("'n' should return tea.Quit")
|
||||
if fm.confirmed {
|
||||
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}
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}})
|
||||
fm := updated.(confirmModel)
|
||||
if !fm.confirmed {
|
||||
t.Error("'y' should set confirmed=true")
|
||||
if fm.confirmed {
|
||||
t.Error("'y' should not confirm")
|
||||
}
|
||||
if !fm.yes {
|
||||
t.Error("'y' should set yes=true")
|
||||
if fm.yes {
|
||||
t.Error("'y' should not change selection")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("'y' should return tea.Quit")
|
||||
if cmd != nil {
|
||||
t.Error("'y' should not quit")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,36 +159,33 @@ func TestConfirmModel_ArrowKeysNavigate(t *testing.T) {
|
||||
m := confirmModel{prompt: "Download?", yes: true}
|
||||
|
||||
// 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)
|
||||
if fm.yes {
|
||||
t.Error("right/l should move to No")
|
||||
t.Error("right should move to No")
|
||||
}
|
||||
if fm.confirmed || fm.cancelled {
|
||||
t.Error("navigation should not confirm or cancel")
|
||||
}
|
||||
|
||||
// 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)
|
||||
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}
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab})
|
||||
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 {
|
||||
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
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -56,7 +55,7 @@ var (
|
||||
const maxSelectorItems = 10
|
||||
|
||||
// ErrCancelled is returned when the user cancels the selection.
|
||||
var ErrCancelled = errors.New("cancelled")
|
||||
var ErrCancelled = launch.ErrCancelled
|
||||
|
||||
type SelectItem struct {
|
||||
Name string
|
||||
@@ -242,6 +241,10 @@ func (m selectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.cancelled = true
|
||||
return m, tea.Quit
|
||||
|
||||
case tea.KeyLeft:
|
||||
m.cancelled = true
|
||||
return m, tea.Quit
|
||||
|
||||
case tea.KeyEnter:
|
||||
filtered := m.filteredItems()
|
||||
if len(filtered) > 0 && m.cursor < len(filtered) {
|
||||
@@ -354,7 +357,7 @@ func (m selectorModel) renderContent() string {
|
||||
}
|
||||
|
||||
s.WriteString("\n")
|
||||
help := "↑/↓ navigate • enter select • esc cancel"
|
||||
help := "↑/↓ navigate • enter select • ← back"
|
||||
if m.helpText != "" {
|
||||
help = m.helpText
|
||||
}
|
||||
@@ -608,6 +611,10 @@ func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.cancelled = true
|
||||
return m, tea.Quit
|
||||
|
||||
case tea.KeyLeft:
|
||||
m.cancelled = true
|
||||
return m, tea.Quit
|
||||
|
||||
case tea.KeyTab:
|
||||
m.multi = !m.multi
|
||||
|
||||
@@ -809,17 +816,21 @@ func (m multiSelectorModel) View() string {
|
||||
|
||||
s.WriteString("\n")
|
||||
|
||||
count := m.selectedCount()
|
||||
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 {
|
||||
count := m.selectedCount()
|
||||
if count == 0 {
|
||||
s.WriteString(selectorDescStyle.Render(" Select at least one model."))
|
||||
s.WriteString(sectionHeaderStyle.Render("Select at least one model."))
|
||||
} 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(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()
|
||||
|
||||
@@ -782,6 +782,9 @@ func TestMulti_MultiModeHelpText(t *testing.T) {
|
||||
if !strings.Contains(content, "tab select single") {
|
||||
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 ---
|
||||
@@ -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
|
||||
|
||||
type keyType = int
|
||||
|
||||
@@ -47,18 +47,18 @@ type menuItem struct {
|
||||
|
||||
var mainMenuItems = []menuItem{
|
||||
{
|
||||
title: "Run a model",
|
||||
title: "Chat with a model",
|
||||
description: "Start an interactive chat with a model",
|
||||
isRunModel: true,
|
||||
},
|
||||
{
|
||||
integration: "openclaw",
|
||||
},
|
||||
{
|
||||
integration: "claude",
|
||||
},
|
||||
{
|
||||
integration: "codex",
|
||||
},
|
||||
{
|
||||
integration: "openclaw",
|
||||
integration: "opencode",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -136,9 +136,9 @@ func integrationMenuItem(state launch.LauncherIntegrationState) menuItem {
|
||||
|
||||
func otherIntegrationItems(state *launch.LauncherState) []menuItem {
|
||||
pinned := map[string]bool{
|
||||
"claude": true,
|
||||
"codex": true,
|
||||
"openclaw": true,
|
||||
"claude": true,
|
||||
"opencode": true,
|
||||
}
|
||||
|
||||
var items []menuItem
|
||||
|
||||
@@ -36,6 +36,13 @@ func launcherTestState() *launch.LauncherState {
|
||||
Changeable: true,
|
||||
AutoInstallable: true,
|
||||
},
|
||||
"opencode": {
|
||||
Name: "opencode",
|
||||
DisplayName: "OpenCode",
|
||||
Description: "Anomaly's open-source coding agent",
|
||||
Selectable: true,
|
||||
Changeable: true,
|
||||
},
|
||||
"droid": {
|
||||
Name: "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) {
|
||||
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) {
|
||||
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) {
|
||||
@@ -102,7 +121,10 @@ func TestMenuRightOnRunSelectsChangeRun(t *testing.T) {
|
||||
|
||||
func TestMenuEnterOnIntegrationSelectsLaunch(t *testing.T) {
|
||||
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})
|
||||
got := updated.(model)
|
||||
want := TUIAction{Kind: TUIActionLaunchIntegration, Integration: "claude"}
|
||||
@@ -113,7 +135,10 @@ func TestMenuEnterOnIntegrationSelectsLaunch(t *testing.T) {
|
||||
|
||||
func TestMenuRightOnIntegrationSelectsConfigure(t *testing.T) {
|
||||
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})
|
||||
got := updated.(model)
|
||||
want := TUIAction{Kind: TUIActionLaunchIntegration, Integration: "claude", ForceConfigure: true}
|
||||
@@ -130,7 +155,10 @@ func TestMenuIgnoresDisabledActions(t *testing.T) {
|
||||
state.Integrations["claude"] = claude
|
||||
|
||||
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})
|
||||
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)
|
||||
}
|
||||
|
||||
menu.cursor = 1
|
||||
menu.cursor = findMenuCursorByIntegration(menu.items, "claude")
|
||||
if menu.cursor == -1 {
|
||||
t.Fatal("expected claude menu item")
|
||||
}
|
||||
integrationView := menu.View()
|
||||
if !strings.Contains(integrationView, "(glm-5:cloud)") {
|
||||
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"
|
||||
state.Integrations["codex"] = codex
|
||||
|
||||
state.LastSelection = "codex"
|
||||
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()
|
||||
if !strings.Contains(view, "(not installed)") {
|
||||
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]}
|
||||
case "Gemma3nForConditionalGeneration":
|
||||
conv = &gemma3nModel{}
|
||||
case "Gemma4ForCausalLM", "Gemma4ForConditionalGeneration":
|
||||
conv = &gemma4Model{Architecture: p.Architectures[0]}
|
||||
case "Phi3ForCausalLM":
|
||||
conv = &phi3Model{}
|
||||
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)
|
||||
|
||||
err = ConvertModel(os.DirFS(tempDir), f)
|
||||
if err == nil || err.Error() != "unsupported safetensors model" {
|
||||
t.Errorf("expected error but didn't get one")
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown data type") {
|
||||
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, ".shortconv.conv.weight") ||
|
||||
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 == "v.positional_embedding_vlm" ||
|
||||
t.name == "v.position_embd.weight" ||
|
||||
t.name == "v.tile_position_embd.weight" ||
|
||||
t.name == "v.pre_tile_position_embd.weight" ||
|
||||
t.name == "v.post_tile_position_embd.weight" ||
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
@@ -53,9 +52,10 @@ func parseSafetensors(fsys fs.FS, replacer *strings.Replacer, ps ...string) ([]T
|
||||
|
||||
for _, key := range keys {
|
||||
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 {
|
||||
return nil, errors.New("unsupported safetensors model")
|
||||
value.Shape = []uint64{1}
|
||||
}
|
||||
ggufName := replacer.Replace(key)
|
||||
if _, ok := names[ggufName]; ok {
|
||||
|
||||
@@ -94,7 +94,7 @@ func GPUDevices(ctx context.Context, runners []ml.FilteredRunnerDiscovery) []ml.
|
||||
}
|
||||
var dirs []string
|
||||
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)
|
||||
continue
|
||||
} 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
|
||||
- **Claude Code** - Anthropic's agentic coding tool
|
||||
- **Codex** - OpenAI's coding assistant
|
||||
- **VS Code** - Microsoft's IDE with built-in AI chat
|
||||
- **Droid** - Factory's AI coding agent
|
||||
|
||||
#### Examples
|
||||
|
||||
@@ -110,7 +110,8 @@
|
||||
"group": "Assistants",
|
||||
"expanded": true,
|
||||
"pages": [
|
||||
"/integrations/openclaw"
|
||||
"/integrations/openclaw",
|
||||
"/integrations/hermes"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -127,6 +128,7 @@
|
||||
},
|
||||
{
|
||||
"group": "IDEs & Editors",
|
||||
"expanded": true,
|
||||
"pages": [
|
||||
"/integrations/cline",
|
||||
"/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
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### Changing Models
|
||||
|
||||
By default, codex will use the local `gpt-oss:20b` model. However, you can specify a different model with the `-m` flag:
|
||||
To use a specific model, pass the `-m` flag:
|
||||
|
||||
```
|
||||
codex --oss -m gpt-oss:120b
|
||||
```
|
||||
|
||||
### Cloud Models
|
||||
To use a cloud model:
|
||||
|
||||
```
|
||||
codex --oss -m gpt-oss:120b-cloud
|
||||
```
|
||||
|
||||
### Profile-based setup
|
||||
|
||||
## Connecting to ollama.com
|
||||
|
||||
|
||||
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.
|
||||
For a persistent configuration, add an Ollama provider and profiles to `~/.codex/config.toml`:
|
||||
|
||||
```toml
|
||||
model = "gpt-oss:120b"
|
||||
model_provider = "ollama"
|
||||
|
||||
[model_providers.ollama]
|
||||
[model_providers.ollama-launch]
|
||||
name = "Ollama"
|
||||
base_url = "https://ollama.com/v1"
|
||||
env_key = "OLLAMA_API_KEY"
|
||||
base_url = "http://localhost:11434/v1"
|
||||
|
||||
[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.
|
||||
|
||||
- [OpenClaw](/integrations/openclaw)
|
||||
- [Hermes Agent](/integrations/hermes)
|
||||
|
||||
## IDEs & Editors
|
||||
|
||||
|
||||
@@ -59,12 +59,14 @@ If the gateway is already running, it restarts automatically to pick up the new
|
||||
**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
|
||||
- `glm-5:cloud` — Reasoning and code generation
|
||||
|
||||
**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).
|
||||
|
||||
|
||||
@@ -28,79 +28,4 @@ To configure without launching:
|
||||
ollama launch opencode --config
|
||||
```
|
||||
|
||||
### Manual setup
|
||||
|
||||
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.
|
||||
<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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Pi
|
||||
---
|
||||
|
||||
Pi is a minimal AI agent toolkit with plugin support.
|
||||
Pi is a minimal and extensible coding agent.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -20,13 +20,65 @@ npm install -g @mariozechner/pi-coding-agent
|
||||
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:
|
||||
|
||||
```shell
|
||||
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`:
|
||||
|
||||
|
||||