Compare commits
63 Commits
pdevine/ml
...
v0.20.0-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d846fdbc0 | ||
|
|
f3536a356e | ||
|
|
c89280fb0c | ||
|
|
eb5434d7fb | ||
|
|
2b949a11d9 | ||
|
|
6b013002fc | ||
|
|
5e622289c5 | ||
|
|
9c8bcecdb2 | ||
|
|
1cbe7950d6 | ||
|
|
95073400fc | ||
|
|
c29932c631 | ||
|
|
1ce101c9a0 | ||
|
|
5a7928ed38 | ||
|
|
7fdc051091 | ||
|
|
5bad871241 | ||
|
|
82437d620a | ||
|
|
570c53859d | ||
|
|
ebd70f73b7 | ||
|
|
eb5df80733 | ||
|
|
356c0b8e34 | ||
|
|
ea3c6a3cbe | ||
|
|
f6b69f3f28 | ||
|
|
e38b606e8b | ||
|
|
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 |
1
.github/workflows/release.yaml
vendored
@@ -424,6 +424,7 @@ jobs:
|
|||||||
lib/ollama/cuda_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
lib/ollama/cuda_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||||
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||||
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||||
|
lib/ollama/include*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||||
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
|
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
|
||||||
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
|
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
|
||||||
lib/ollama/rocm) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
|
lib/ollama/rocm) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
|
||||||
|
|||||||
7
.github/workflows/test.yaml
vendored
@@ -64,6 +64,7 @@ jobs:
|
|||||||
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
|
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
|
||||||
extra-packages: libcudnn9-dev-cuda-13 libopenblas-dev liblapack-dev liblapacke-dev git curl
|
extra-packages: libcudnn9-dev-cuda-13 libopenblas-dev liblapack-dev liblapacke-dev git curl
|
||||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=87 -DBLAS_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu -DLAPACK_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu'
|
flags: '-DCMAKE_CUDA_ARCHITECTURES=87 -DBLAS_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu -DLAPACK_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu'
|
||||||
|
install-go: true
|
||||||
runs-on: linux
|
runs-on: linux
|
||||||
container: ${{ matrix.container }}
|
container: ${{ matrix.container }}
|
||||||
steps:
|
steps:
|
||||||
@@ -90,6 +91,12 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
env:
|
env:
|
||||||
DEBIAN_FRONTEND: noninteractive
|
DEBIAN_FRONTEND: noninteractive
|
||||||
|
- if: matrix.install-go
|
||||||
|
name: Install Go
|
||||||
|
run: |
|
||||||
|
GO_VERSION=$(awk '/^go / { print $2 }' go.mod)
|
||||||
|
curl -fsSL "https://golang.org/dl/go${GO_VERSION}.linux-$(dpkg --print-architecture).tar.gz" | tar xz -C /usr/local
|
||||||
|
echo "/usr/local/go/bin" >> $GITHUB_PATH
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: /github/home/.cache/ccache
|
path: /github/home/.cache/ccache
|
||||||
|
|||||||
@@ -246,13 +246,21 @@ if(MLX_ENGINE)
|
|||||||
COMPONENT MLX)
|
COMPONENT MLX)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# Install CCCL headers for NVRTC JIT compilation at runtime.
|
# Install headers for NVRTC JIT compilation at runtime.
|
||||||
# MLX's own install rules use the default component so they get skipped by
|
# MLX's own install rules use the default component so they get skipped by
|
||||||
# --component MLX. Headers are installed alongside libmlx in OLLAMA_INSTALL_DIR.
|
# --component MLX. Headers are installed alongside libmlx in OLLAMA_INSTALL_DIR.
|
||||||
|
#
|
||||||
|
# Layout:
|
||||||
|
# ${OLLAMA_INSTALL_DIR}/include/cccl/{cuda,nv}/ — CCCL headers
|
||||||
|
# ${OLLAMA_INSTALL_DIR}/include/*.h — CUDA toolkit headers
|
||||||
|
#
|
||||||
|
# MLX's jit_module.cpp resolves CCCL via
|
||||||
|
# current_binary_dir()[.parent_path()] / "include" / "cccl"
|
||||||
# On Linux, MLX's jit_module.cpp resolves CCCL via
|
# On Linux, MLX's jit_module.cpp resolves CCCL via
|
||||||
# current_binary_dir().parent_path() / "include" / "cccl", so we create a
|
# current_binary_dir().parent_path() / "include" / "cccl", so we create a
|
||||||
# symlink from lib/ollama/include -> ${OLLAMA_RUNNER_DIR}/include
|
# symlink from lib/ollama/include -> ${OLLAMA_RUNNER_DIR}/include
|
||||||
# This will need refinement if we add multiple CUDA versions for MLX in the future.
|
# This will need refinement if we add multiple CUDA versions for MLX in the future.
|
||||||
|
# CUDA runtime headers are found via CUDA_PATH env var (set by mlxrunner).
|
||||||
if(EXISTS ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda)
|
if(EXISTS ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda)
|
||||||
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda
|
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda
|
||||||
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
|
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
|
||||||
@@ -271,6 +279,61 @@ if(MLX_ENGINE)
|
|||||||
endif()
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# Install minimal CUDA toolkit headers needed by MLX JIT kernels.
|
||||||
|
# These are the transitive closure of includes from mlx/backend/cuda/device/*.cuh.
|
||||||
|
# The Go mlxrunner sets CUDA_PATH to OLLAMA_INSTALL_DIR so MLX finds them at
|
||||||
|
# $CUDA_PATH/include/*.h via NVRTC --include-path.
|
||||||
|
if(CUDAToolkit_FOUND)
|
||||||
|
# CUDAToolkit_INCLUDE_DIRS may be a semicolon-separated list
|
||||||
|
# (e.g. ".../include;.../include/cccl"). Find the entry that
|
||||||
|
# contains the CUDA runtime headers we need.
|
||||||
|
set(_cuda_inc "")
|
||||||
|
foreach(_dir ${CUDAToolkit_INCLUDE_DIRS})
|
||||||
|
if(EXISTS "${_dir}/cuda_runtime_api.h")
|
||||||
|
set(_cuda_inc "${_dir}")
|
||||||
|
break()
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
if(NOT _cuda_inc)
|
||||||
|
message(WARNING "Could not find cuda_runtime_api.h in CUDAToolkit_INCLUDE_DIRS: ${CUDAToolkit_INCLUDE_DIRS}")
|
||||||
|
else()
|
||||||
|
set(_dst "${OLLAMA_INSTALL_DIR}/include")
|
||||||
|
set(_MLX_JIT_CUDA_HEADERS
|
||||||
|
builtin_types.h
|
||||||
|
cooperative_groups.h
|
||||||
|
cuda_bf16.h
|
||||||
|
cuda_bf16.hpp
|
||||||
|
cuda_device_runtime_api.h
|
||||||
|
cuda_fp16.h
|
||||||
|
cuda_fp16.hpp
|
||||||
|
cuda_fp8.h
|
||||||
|
cuda_fp8.hpp
|
||||||
|
cuda_runtime_api.h
|
||||||
|
device_types.h
|
||||||
|
driver_types.h
|
||||||
|
math_constants.h
|
||||||
|
surface_types.h
|
||||||
|
texture_types.h
|
||||||
|
vector_functions.h
|
||||||
|
vector_functions.hpp
|
||||||
|
vector_types.h
|
||||||
|
)
|
||||||
|
foreach(_hdr ${_MLX_JIT_CUDA_HEADERS})
|
||||||
|
install(FILES "${_cuda_inc}/${_hdr}"
|
||||||
|
DESTINATION ${_dst}
|
||||||
|
COMPONENT MLX)
|
||||||
|
endforeach()
|
||||||
|
# Subdirectory headers
|
||||||
|
install(DIRECTORY "${_cuda_inc}/cooperative_groups"
|
||||||
|
DESTINATION ${_dst}
|
||||||
|
COMPONENT MLX
|
||||||
|
FILES_MATCHING PATTERN "*.h")
|
||||||
|
install(FILES "${_cuda_inc}/crt/host_defines.h"
|
||||||
|
DESTINATION "${_dst}/crt"
|
||||||
|
COMPONENT MLX)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
# On Windows, explicitly install dl.dll (dlfcn-win32 POSIX dlopen emulation)
|
# On Windows, explicitly install dl.dll (dlfcn-win32 POSIX dlopen emulation)
|
||||||
# RUNTIME_DEPENDENCIES auto-excludes it via POST_EXCLUDE_FILES_STRICT because
|
# RUNTIME_DEPENDENCIES auto-excludes it via POST_EXCLUDE_FILES_STRICT because
|
||||||
# dlfcn-win32 is a known CMake target with its own install rules (which install
|
# dlfcn-win32 is a known CMake target with its own install rules (which install
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ COPY CMakeLists.txt CMakePresets.json .
|
|||||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||||
COPY x/imagegen/mlx x/imagegen/mlx
|
COPY x/imagegen/mlx x/imagegen/mlx
|
||||||
COPY go.mod go.sum .
|
COPY go.mod go.sum .
|
||||||
COPY MLX_VERSION MLX_CORE_VERSION .
|
COPY MLX_VERSION MLX_C_VERSION .
|
||||||
RUN curl -fsSL https://golang.org/dl/go$(awk '/^go/ { print $2 }' go.mod).linux-$(case $(uname -m) in x86_64) echo amd64 ;; aarch64) echo arm64 ;; esac).tar.gz | tar xz -C /usr/local
|
RUN curl -fsSL https://golang.org/dl/go$(awk '/^go/ { print $2 }' go.mod).linux-$(case $(uname -m) in x86_64) echo amd64 ;; aarch64) echo arm64 ;; esac).tar.gz | tar xz -C /usr/local
|
||||||
ENV PATH=/usr/local/go/bin:$PATH
|
ENV PATH=/usr/local/go/bin:$PATH
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
v0.30.6
|
|
||||||
1
MLX_C_VERSION
Normal file
@@ -0,0 +1 @@
|
|||||||
|
0726ca922fc902c4c61ef9c27d94132be418e945
|
||||||
@@ -1 +1 @@
|
|||||||
v0.5.0
|
38ad257088fb2193ad47e527cf6534a689f30943
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ type MessagesRequest struct {
|
|||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
MaxTokens int `json:"max_tokens"`
|
MaxTokens int `json:"max_tokens"`
|
||||||
Messages []MessageParam `json:"messages"`
|
Messages []MessageParam `json:"messages"`
|
||||||
System any `json:"system,omitempty"` // string or []ContentBlock
|
System any `json:"system,omitempty"` // string or []map[string]any (JSON-decoded ContentBlock)
|
||||||
Stream bool `json:"stream,omitempty"`
|
Stream bool `json:"stream,omitempty"`
|
||||||
Temperature *float64 `json:"temperature,omitempty"`
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
TopP *float64 `json:"top_p,omitempty"`
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
@@ -82,8 +82,27 @@ type MessagesRequest struct {
|
|||||||
|
|
||||||
// MessageParam represents a message in the request
|
// MessageParam represents a message in the request
|
||||||
type MessageParam struct {
|
type MessageParam struct {
|
||||||
Role string `json:"role"` // "user" or "assistant"
|
Role string `json:"role"` // "user" or "assistant"
|
||||||
Content any `json:"content"` // string or []ContentBlock
|
Content []ContentBlock `json:"content"` // always []ContentBlock; plain strings are normalized on unmarshal
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MessageParam) UnmarshalJSON(data []byte) error {
|
||||||
|
var raw struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content json.RawMessage `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.Role = raw.Role
|
||||||
|
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(raw.Content, &s); err == nil {
|
||||||
|
m.Content = []ContentBlock{{Type: "text", Text: &s}}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Unmarshal(raw.Content, &m.Content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContentBlock represents a content block in a message.
|
// ContentBlock represents a content block in a message.
|
||||||
@@ -102,9 +121,9 @@ type ContentBlock struct {
|
|||||||
Source *ImageSource `json:"source,omitempty"`
|
Source *ImageSource `json:"source,omitempty"`
|
||||||
|
|
||||||
// For tool_use and server_tool_use blocks
|
// For tool_use and server_tool_use blocks
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Input any `json:"input,omitempty"`
|
Input api.ToolCallFunctionArguments `json:"input,omitzero"`
|
||||||
|
|
||||||
// For tool_result and web_search_tool_result blocks
|
// For tool_result and web_search_tool_result blocks
|
||||||
ToolUseID string `json:"tool_use_id,omitempty"`
|
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||||
@@ -377,178 +396,145 @@ func convertMessage(msg MessageParam) ([]api.Message, error) {
|
|||||||
var messages []api.Message
|
var messages []api.Message
|
||||||
role := strings.ToLower(msg.Role)
|
role := strings.ToLower(msg.Role)
|
||||||
|
|
||||||
switch content := msg.Content.(type) {
|
var textContent strings.Builder
|
||||||
case string:
|
var images []api.ImageData
|
||||||
messages = append(messages, api.Message{Role: role, Content: content})
|
var toolCalls []api.ToolCall
|
||||||
|
var thinking string
|
||||||
|
var toolResults []api.Message
|
||||||
|
textBlocks := 0
|
||||||
|
imageBlocks := 0
|
||||||
|
toolUseBlocks := 0
|
||||||
|
toolResultBlocks := 0
|
||||||
|
serverToolUseBlocks := 0
|
||||||
|
webSearchToolResultBlocks := 0
|
||||||
|
thinkingBlocks := 0
|
||||||
|
unknownBlocks := 0
|
||||||
|
|
||||||
case []any:
|
for _, block := range msg.Content {
|
||||||
var textContent strings.Builder
|
switch block.Type {
|
||||||
var images []api.ImageData
|
case "text":
|
||||||
var toolCalls []api.ToolCall
|
textBlocks++
|
||||||
var thinking string
|
if block.Text != nil {
|
||||||
var toolResults []api.Message
|
textContent.WriteString(*block.Text)
|
||||||
textBlocks := 0
|
|
||||||
imageBlocks := 0
|
|
||||||
toolUseBlocks := 0
|
|
||||||
toolResultBlocks := 0
|
|
||||||
serverToolUseBlocks := 0
|
|
||||||
webSearchToolResultBlocks := 0
|
|
||||||
thinkingBlocks := 0
|
|
||||||
unknownBlocks := 0
|
|
||||||
|
|
||||||
for _, block := range content {
|
|
||||||
blockMap, ok := block.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
logutil.Trace("anthropic: invalid content block format", "role", role)
|
|
||||||
return nil, errors.New("invalid content block format")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
blockType, _ := blockMap["type"].(string)
|
case "image":
|
||||||
|
imageBlocks++
|
||||||
|
if block.Source == nil {
|
||||||
|
logutil.Trace("anthropic: invalid image source", "role", role)
|
||||||
|
return nil, errors.New("invalid image source")
|
||||||
|
}
|
||||||
|
|
||||||
switch blockType {
|
if block.Source.Type == "base64" {
|
||||||
case "text":
|
decoded, err := base64.StdEncoding.DecodeString(block.Source.Data)
|
||||||
textBlocks++
|
if err != nil {
|
||||||
if text, ok := blockMap["text"].(string); ok {
|
logutil.Trace("anthropic: invalid base64 image data", "role", role, "error", err)
|
||||||
textContent.WriteString(text)
|
return nil, fmt.Errorf("invalid base64 image data: %w", err)
|
||||||
}
|
}
|
||||||
|
images = append(images, decoded)
|
||||||
|
} else {
|
||||||
|
logutil.Trace("anthropic: unsupported image source type", "role", role, "source_type", block.Source.Type)
|
||||||
|
return nil, fmt.Errorf("invalid image source type: %s. Only base64 images are supported.", block.Source.Type)
|
||||||
|
}
|
||||||
|
|
||||||
case "image":
|
case "tool_use":
|
||||||
imageBlocks++
|
toolUseBlocks++
|
||||||
source, ok := blockMap["source"].(map[string]any)
|
if block.ID == "" {
|
||||||
if !ok {
|
logutil.Trace("anthropic: tool_use block missing id", "role", role)
|
||||||
logutil.Trace("anthropic: invalid image source", "role", role)
|
return nil, errors.New("tool_use block missing required 'id' field")
|
||||||
return nil, errors.New("invalid image source")
|
}
|
||||||
}
|
if block.Name == "" {
|
||||||
|
logutil.Trace("anthropic: tool_use block missing name", "role", role)
|
||||||
|
return nil, errors.New("tool_use block missing required 'name' field")
|
||||||
|
}
|
||||||
|
toolCalls = append(toolCalls, api.ToolCall{
|
||||||
|
ID: block.ID,
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: block.Name,
|
||||||
|
Arguments: block.Input,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
sourceType, _ := source["type"].(string)
|
case "tool_result":
|
||||||
if sourceType == "base64" {
|
toolResultBlocks++
|
||||||
data, _ := source["data"].(string)
|
var resultContent string
|
||||||
decoded, err := base64.StdEncoding.DecodeString(data)
|
|
||||||
if err != nil {
|
|
||||||
logutil.Trace("anthropic: invalid base64 image data", "role", role, "error", err)
|
|
||||||
return nil, fmt.Errorf("invalid base64 image data: %w", err)
|
|
||||||
}
|
|
||||||
images = append(images, decoded)
|
|
||||||
} else {
|
|
||||||
logutil.Trace("anthropic: unsupported image source type", "role", role, "source_type", sourceType)
|
|
||||||
return nil, fmt.Errorf("invalid image source type: %s. Only base64 images are supported.", sourceType)
|
|
||||||
}
|
|
||||||
// URL images would need to be fetched - skip for now
|
|
||||||
|
|
||||||
case "tool_use":
|
switch c := block.Content.(type) {
|
||||||
toolUseBlocks++
|
case string:
|
||||||
id, ok := blockMap["id"].(string)
|
resultContent = c
|
||||||
if !ok {
|
case []any:
|
||||||
logutil.Trace("anthropic: tool_use block missing id", "role", role)
|
for _, cb := range c {
|
||||||
return nil, errors.New("tool_use block missing required 'id' field")
|
if cbMap, ok := cb.(map[string]any); ok {
|
||||||
}
|
if cbMap["type"] == "text" {
|
||||||
name, ok := blockMap["name"].(string)
|
if text, ok := cbMap["text"].(string); ok {
|
||||||
if !ok {
|
resultContent += text
|
||||||
logutil.Trace("anthropic: tool_use block missing name", "role", role)
|
|
||||||
return nil, errors.New("tool_use block missing required 'name' field")
|
|
||||||
}
|
|
||||||
tc := api.ToolCall{
|
|
||||||
ID: id,
|
|
||||||
Function: api.ToolCallFunction{
|
|
||||||
Name: name,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if input, ok := blockMap["input"].(map[string]any); ok {
|
|
||||||
tc.Function.Arguments = mapToArgs(input)
|
|
||||||
}
|
|
||||||
toolCalls = append(toolCalls, tc)
|
|
||||||
|
|
||||||
case "tool_result":
|
|
||||||
toolResultBlocks++
|
|
||||||
toolUseID, _ := blockMap["tool_use_id"].(string)
|
|
||||||
var resultContent string
|
|
||||||
|
|
||||||
switch c := blockMap["content"].(type) {
|
|
||||||
case string:
|
|
||||||
resultContent = c
|
|
||||||
case []any:
|
|
||||||
for _, cb := range c {
|
|
||||||
if cbMap, ok := cb.(map[string]any); ok {
|
|
||||||
if cbMap["type"] == "text" {
|
|
||||||
if text, ok := cbMap["text"].(string); ok {
|
|
||||||
resultContent += text
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toolResults = append(toolResults, api.Message{
|
|
||||||
Role: "tool",
|
|
||||||
Content: resultContent,
|
|
||||||
ToolCallID: toolUseID,
|
|
||||||
})
|
|
||||||
|
|
||||||
case "thinking":
|
|
||||||
thinkingBlocks++
|
|
||||||
if t, ok := blockMap["thinking"].(string); ok {
|
|
||||||
thinking = t
|
|
||||||
}
|
|
||||||
|
|
||||||
case "server_tool_use":
|
|
||||||
serverToolUseBlocks++
|
|
||||||
id, _ := blockMap["id"].(string)
|
|
||||||
name, _ := blockMap["name"].(string)
|
|
||||||
tc := api.ToolCall{
|
|
||||||
ID: id,
|
|
||||||
Function: api.ToolCallFunction{
|
|
||||||
Name: name,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if input, ok := blockMap["input"].(map[string]any); ok {
|
|
||||||
tc.Function.Arguments = mapToArgs(input)
|
|
||||||
}
|
|
||||||
toolCalls = append(toolCalls, tc)
|
|
||||||
|
|
||||||
case "web_search_tool_result":
|
|
||||||
webSearchToolResultBlocks++
|
|
||||||
toolUseID, _ := blockMap["tool_use_id"].(string)
|
|
||||||
toolResults = append(toolResults, api.Message{
|
|
||||||
Role: "tool",
|
|
||||||
Content: formatWebSearchToolResultContent(blockMap["content"]),
|
|
||||||
ToolCallID: toolUseID,
|
|
||||||
})
|
|
||||||
default:
|
|
||||||
unknownBlocks++
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if textContent.Len() > 0 || len(images) > 0 || len(toolCalls) > 0 || thinking != "" {
|
toolResults = append(toolResults, api.Message{
|
||||||
m := api.Message{
|
Role: "tool",
|
||||||
Role: role,
|
Content: resultContent,
|
||||||
Content: textContent.String(),
|
ToolCallID: block.ToolUseID,
|
||||||
Images: images,
|
})
|
||||||
ToolCalls: toolCalls,
|
|
||||||
Thinking: thinking,
|
case "thinking":
|
||||||
|
thinkingBlocks++
|
||||||
|
if block.Thinking != nil {
|
||||||
|
thinking = *block.Thinking
|
||||||
}
|
}
|
||||||
messages = append(messages, m)
|
|
||||||
|
case "server_tool_use":
|
||||||
|
serverToolUseBlocks++
|
||||||
|
toolCalls = append(toolCalls, api.ToolCall{
|
||||||
|
ID: block.ID,
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: block.Name,
|
||||||
|
Arguments: block.Input,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
case "web_search_tool_result":
|
||||||
|
webSearchToolResultBlocks++
|
||||||
|
toolResults = append(toolResults, api.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: formatWebSearchToolResultContent(block.Content),
|
||||||
|
ToolCallID: block.ToolUseID,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
unknownBlocks++
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add tool results as separate messages
|
|
||||||
messages = append(messages, toolResults...)
|
|
||||||
logutil.Trace("anthropic: converted block message",
|
|
||||||
"role", role,
|
|
||||||
"blocks", len(content),
|
|
||||||
"text", textBlocks,
|
|
||||||
"image", imageBlocks,
|
|
||||||
"tool_use", toolUseBlocks,
|
|
||||||
"tool_result", toolResultBlocks,
|
|
||||||
"server_tool_use", serverToolUseBlocks,
|
|
||||||
"web_search_result", webSearchToolResultBlocks,
|
|
||||||
"thinking", thinkingBlocks,
|
|
||||||
"unknown", unknownBlocks,
|
|
||||||
"messages", TraceAPIMessages(messages),
|
|
||||||
)
|
|
||||||
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("invalid message content type: %T", content)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if textContent.Len() > 0 || len(images) > 0 || len(toolCalls) > 0 || thinking != "" {
|
||||||
|
m := api.Message{
|
||||||
|
Role: role,
|
||||||
|
Content: textContent.String(),
|
||||||
|
Images: images,
|
||||||
|
ToolCalls: toolCalls,
|
||||||
|
Thinking: thinking,
|
||||||
|
}
|
||||||
|
messages = append(messages, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tool results as separate messages
|
||||||
|
messages = append(messages, toolResults...)
|
||||||
|
logutil.Trace("anthropic: converted block message",
|
||||||
|
"role", role,
|
||||||
|
"blocks", len(msg.Content),
|
||||||
|
"text", textBlocks,
|
||||||
|
"image", imageBlocks,
|
||||||
|
"tool_use", toolUseBlocks,
|
||||||
|
"tool_result", toolResultBlocks,
|
||||||
|
"server_tool_use", serverToolUseBlocks,
|
||||||
|
"web_search_result", webSearchToolResultBlocks,
|
||||||
|
"thinking", thinkingBlocks,
|
||||||
|
"unknown", unknownBlocks,
|
||||||
|
"messages", TraceAPIMessages(messages),
|
||||||
|
)
|
||||||
|
|
||||||
return messages, nil
|
return messages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -882,7 +868,6 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
|||||||
slog.Error("failed to marshal tool arguments", "error", err, "tool_id", tc.ID)
|
slog.Error("failed to marshal tool arguments", "error", err, "tool_id", tc.ID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
events = append(events, StreamEvent{
|
events = append(events, StreamEvent{
|
||||||
Event: "content_block_start",
|
Event: "content_block_start",
|
||||||
Data: ContentBlockStartEvent{
|
Data: ContentBlockStartEvent{
|
||||||
@@ -892,7 +877,7 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
|||||||
Type: "tool_use",
|
Type: "tool_use",
|
||||||
ID: tc.ID,
|
ID: tc.ID,
|
||||||
Name: tc.Function.Name,
|
Name: tc.Function.Name,
|
||||||
Input: map[string]any{},
|
Input: api.NewToolCallFunctionArguments(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -989,15 +974,6 @@ func ptr(s string) *string {
|
|||||||
return &s
|
return &s
|
||||||
}
|
}
|
||||||
|
|
||||||
// mapToArgs converts a map to ToolCallFunctionArguments
|
|
||||||
func mapToArgs(m map[string]any) api.ToolCallFunctionArguments {
|
|
||||||
args := api.NewToolCallFunctionArguments()
|
|
||||||
for k, v := range m {
|
|
||||||
args.Set(k, v)
|
|
||||||
}
|
|
||||||
return args
|
|
||||||
}
|
|
||||||
|
|
||||||
// CountTokensRequest represents an Anthropic count_tokens request
|
// CountTokensRequest represents an Anthropic count_tokens request
|
||||||
type CountTokensRequest struct {
|
type CountTokensRequest struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
@@ -1030,17 +1006,13 @@ func estimateTokens(req CountTokensRequest) int {
|
|||||||
var totalLen int
|
var totalLen int
|
||||||
|
|
||||||
// Count system prompt
|
// Count system prompt
|
||||||
if req.System != nil {
|
totalLen += countAnyContent(req.System)
|
||||||
totalLen += countAnyContent(req.System)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count messages
|
|
||||||
for _, msg := range req.Messages {
|
for _, msg := range req.Messages {
|
||||||
// Count role (always present)
|
// Count role (always present)
|
||||||
totalLen += len(msg.Role)
|
totalLen += len(msg.Role)
|
||||||
// Count content
|
// Count content
|
||||||
contentLen := countAnyContent(msg.Content)
|
totalLen += countAnyContent(msg.Content)
|
||||||
totalLen += contentLen
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tool := range req.Tools {
|
for _, tool := range req.Tools {
|
||||||
@@ -1063,12 +1035,25 @@ func countAnyContent(content any) int {
|
|||||||
switch c := content.(type) {
|
switch c := content.(type) {
|
||||||
case string:
|
case string:
|
||||||
return len(c)
|
return len(c)
|
||||||
case []any:
|
case []ContentBlock:
|
||||||
total := 0
|
total := 0
|
||||||
for _, block := range c {
|
for _, block := range c {
|
||||||
total += countContentBlock(block)
|
total += countContentBlock(block)
|
||||||
}
|
}
|
||||||
return total
|
return total
|
||||||
|
case []any:
|
||||||
|
total := 0
|
||||||
|
for _, item := range c {
|
||||||
|
data, err := json.Marshal(item)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var block ContentBlock
|
||||||
|
if err := json.Unmarshal(data, &block); err == nil {
|
||||||
|
total += countContentBlock(block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
default:
|
default:
|
||||||
if data, err := json.Marshal(content); err == nil {
|
if data, err := json.Marshal(content); err == nil {
|
||||||
return len(data)
|
return len(data)
|
||||||
@@ -1077,38 +1062,19 @@ func countAnyContent(content any) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func countContentBlock(block any) int {
|
func countContentBlock(block ContentBlock) int {
|
||||||
blockMap, ok := block.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
if s, ok := block.(string); ok {
|
|
||||||
return len(s)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
total := 0
|
total := 0
|
||||||
blockType, _ := blockMap["type"].(string)
|
if block.Text != nil {
|
||||||
|
total += len(*block.Text)
|
||||||
if text, ok := blockMap["text"].(string); ok {
|
|
||||||
total += len(text)
|
|
||||||
}
|
}
|
||||||
|
if block.Thinking != nil {
|
||||||
if thinking, ok := blockMap["thinking"].(string); ok {
|
total += len(*block.Thinking)
|
||||||
total += len(thinking)
|
|
||||||
}
|
}
|
||||||
|
if block.Type == "tool_use" || block.Type == "tool_result" {
|
||||||
if blockType == "tool_use" {
|
if data, err := json.Marshal(block); err == nil {
|
||||||
if data, err := json.Marshal(blockMap); err == nil {
|
|
||||||
total += len(data)
|
total += len(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if blockType == "tool_result" {
|
|
||||||
if data, err := json.Marshal(blockMap); err == nil {
|
|
||||||
total += len(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,16 @@ const (
|
|||||||
testImage = `iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=`
|
testImage = `iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=`
|
||||||
)
|
)
|
||||||
|
|
||||||
// testArgs creates ToolCallFunctionArguments from a map (convenience function for tests)
|
// textContent is a convenience for constructing []ContentBlock with a single text block in tests.
|
||||||
func testArgs(m map[string]any) api.ToolCallFunctionArguments {
|
func textContent(s string) []ContentBlock {
|
||||||
|
return []ContentBlock{{Type: "text", Text: &s}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeArgs creates ToolCallFunctionArguments from key-value pairs (convenience function for tests)
|
||||||
|
func makeArgs(kvs ...any) api.ToolCallFunctionArguments {
|
||||||
args := api.NewToolCallFunctionArguments()
|
args := api.NewToolCallFunctionArguments()
|
||||||
for k, v := range m {
|
for i := 0; i < len(kvs)-1; i += 2 {
|
||||||
args.Set(k, v)
|
args.Set(kvs[i].(string), kvs[i+1])
|
||||||
}
|
}
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
@@ -29,7 +34,7 @@ func TestFromMessagesRequest_Basic(t *testing.T) {
|
|||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 1024,
|
MaxTokens: 1024,
|
||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{Role: "user", Content: "Hello"},
|
{Role: "user", Content: textContent("Hello")},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +66,7 @@ func TestFromMessagesRequest_WithSystemPrompt(t *testing.T) {
|
|||||||
MaxTokens: 1024,
|
MaxTokens: 1024,
|
||||||
System: "You are a helpful assistant.",
|
System: "You are a helpful assistant.",
|
||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{Role: "user", Content: "Hello"},
|
{Role: "user", Content: textContent("Hello")},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +93,7 @@ func TestFromMessagesRequest_WithSystemPromptArray(t *testing.T) {
|
|||||||
map[string]any{"type": "text", "text": " Be concise."},
|
map[string]any{"type": "text", "text": " Be concise."},
|
||||||
},
|
},
|
||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{Role: "user", Content: "Hello"},
|
{Role: "user", Content: textContent("Hello")},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +118,7 @@ func TestFromMessagesRequest_WithOptions(t *testing.T) {
|
|||||||
req := MessagesRequest{
|
req := MessagesRequest{
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 2048,
|
MaxTokens: 2048,
|
||||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||||
Temperature: &temp,
|
Temperature: &temp,
|
||||||
TopP: &topP,
|
TopP: &topP,
|
||||||
TopK: &topK,
|
TopK: &topK,
|
||||||
@@ -148,14 +153,14 @@ func TestFromMessagesRequest_WithImage(t *testing.T) {
|
|||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{
|
{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{"type": "text", "text": "What's in this image?"},
|
{Type: "text", Text: ptr("What's in this image?")},
|
||||||
map[string]any{
|
{
|
||||||
"type": "image",
|
Type: "image",
|
||||||
"source": map[string]any{
|
Source: &ImageSource{
|
||||||
"type": "base64",
|
Type: "base64",
|
||||||
"media_type": "image/png",
|
MediaType: "image/png",
|
||||||
"data": testImage,
|
Data: testImage,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -190,15 +195,15 @@ func TestFromMessagesRequest_WithToolUse(t *testing.T) {
|
|||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 1024,
|
MaxTokens: 1024,
|
||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{Role: "user", Content: "What's the weather in Paris?"},
|
{Role: "user", Content: textContent("What's the weather in Paris?")},
|
||||||
{
|
{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{
|
{
|
||||||
"type": "tool_use",
|
Type: "tool_use",
|
||||||
"id": "call_123",
|
ID: "call_123",
|
||||||
"name": "get_weather",
|
Name: "get_weather",
|
||||||
"input": map[string]any{"location": "Paris"},
|
Input: makeArgs("location", "Paris"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -234,11 +239,11 @@ func TestFromMessagesRequest_WithToolResult(t *testing.T) {
|
|||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{
|
{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{
|
{
|
||||||
"type": "tool_result",
|
Type: "tool_result",
|
||||||
"tool_use_id": "call_123",
|
ToolUseID: "call_123",
|
||||||
"content": "The weather in Paris is sunny, 22°C",
|
Content: "The weather in Paris is sunny, 22°C",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -270,7 +275,7 @@ func TestFromMessagesRequest_WithTools(t *testing.T) {
|
|||||||
req := MessagesRequest{
|
req := MessagesRequest{
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 1024,
|
MaxTokens: 1024,
|
||||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||||
Tools: []Tool{
|
Tools: []Tool{
|
||||||
{
|
{
|
||||||
Name: "get_weather",
|
Name: "get_weather",
|
||||||
@@ -305,7 +310,7 @@ func TestFromMessagesRequest_DropsCustomWebSearchWhenBuiltinPresent(t *testing.T
|
|||||||
req := MessagesRequest{
|
req := MessagesRequest{
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 1024,
|
MaxTokens: 1024,
|
||||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||||
Tools: []Tool{
|
Tools: []Tool{
|
||||||
{
|
{
|
||||||
Type: "web_search_20250305",
|
Type: "web_search_20250305",
|
||||||
@@ -346,7 +351,7 @@ func TestFromMessagesRequest_KeepsCustomWebSearchWhenBuiltinAbsent(t *testing.T)
|
|||||||
req := MessagesRequest{
|
req := MessagesRequest{
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 1024,
|
MaxTokens: 1024,
|
||||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||||
Tools: []Tool{
|
Tools: []Tool{
|
||||||
{
|
{
|
||||||
Type: "custom",
|
Type: "custom",
|
||||||
@@ -377,7 +382,7 @@ func TestFromMessagesRequest_WithThinking(t *testing.T) {
|
|||||||
req := MessagesRequest{
|
req := MessagesRequest{
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 1024,
|
MaxTokens: 1024,
|
||||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||||
Thinking: &ThinkingConfig{Type: "enabled", BudgetTokens: 1000},
|
Thinking: &ThinkingConfig{Type: "enabled", BudgetTokens: 1000},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,13 +404,13 @@ func TestFromMessagesRequest_ThinkingOnlyBlock(t *testing.T) {
|
|||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 1024,
|
MaxTokens: 1024,
|
||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{Role: "user", Content: "Hello"},
|
{Role: "user", Content: textContent("Hello")},
|
||||||
{
|
{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{
|
{
|
||||||
"type": "thinking",
|
Type: "thinking",
|
||||||
"thinking": "Let me think about this...",
|
Thinking: ptr("Let me think about this..."),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -434,10 +439,10 @@ func TestFromMessagesRequest_ToolUseMissingID(t *testing.T) {
|
|||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{
|
{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{
|
{
|
||||||
"type": "tool_use",
|
Type: "tool_use",
|
||||||
"name": "get_weather",
|
Name: "get_weather",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -460,10 +465,10 @@ func TestFromMessagesRequest_ToolUseMissingName(t *testing.T) {
|
|||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{
|
{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{
|
{
|
||||||
"type": "tool_use",
|
Type: "tool_use",
|
||||||
"id": "call_123",
|
ID: "call_123",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -483,7 +488,7 @@ func TestFromMessagesRequest_InvalidToolSchema(t *testing.T) {
|
|||||||
req := MessagesRequest{
|
req := MessagesRequest{
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 1024,
|
MaxTokens: 1024,
|
||||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||||
Tools: []Tool{
|
Tools: []Tool{
|
||||||
{
|
{
|
||||||
Name: "bad_tool",
|
Name: "bad_tool",
|
||||||
@@ -548,7 +553,7 @@ func TestToMessagesResponse_WithToolCalls(t *testing.T) {
|
|||||||
ID: "call_123",
|
ID: "call_123",
|
||||||
Function: api.ToolCallFunction{
|
Function: api.ToolCallFunction{
|
||||||
Name: "get_weather",
|
Name: "get_weather",
|
||||||
Arguments: testArgs(map[string]any{"location": "Paris"}),
|
Arguments: makeArgs("location", "Paris"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -760,7 +765,7 @@ func TestStreamConverter_WithToolCalls(t *testing.T) {
|
|||||||
ID: "call_123",
|
ID: "call_123",
|
||||||
Function: api.ToolCallFunction{
|
Function: api.ToolCallFunction{
|
||||||
Name: "get_weather",
|
Name: "get_weather",
|
||||||
Arguments: testArgs(map[string]any{"location": "Paris"}),
|
Arguments: makeArgs("location", "Paris"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -843,7 +848,7 @@ func TestStreamConverter_ThinkingDirectlyFollowedByToolCall(t *testing.T) {
|
|||||||
ID: "call_abc",
|
ID: "call_abc",
|
||||||
Function: api.ToolCallFunction{
|
Function: api.ToolCallFunction{
|
||||||
Name: "ask_user",
|
Name: "ask_user",
|
||||||
Arguments: testArgs(map[string]any{"question": "cats or dogs?"}),
|
Arguments: makeArgs("question", "cats or dogs?"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -965,7 +970,7 @@ func TestStreamConverter_MultipleToolCallsWithMixedValidity(t *testing.T) {
|
|||||||
ID: "call_good",
|
ID: "call_good",
|
||||||
Function: api.ToolCallFunction{
|
Function: api.ToolCallFunction{
|
||||||
Name: "good_function",
|
Name: "good_function",
|
||||||
Arguments: testArgs(map[string]any{"location": "Paris"}),
|
Arguments: makeArgs("location", "Paris"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1067,6 +1072,57 @@ func TestContentBlockJSON_EmptyFieldsPresent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestContentBlockJSON_NonToolBlocksDoNotIncludeInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
block ContentBlock
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "text block",
|
||||||
|
block: ContentBlock{
|
||||||
|
Type: "text",
|
||||||
|
Text: ptr("hello"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "thinking block",
|
||||||
|
block: ContentBlock{
|
||||||
|
Type: "thinking",
|
||||||
|
Thinking: ptr("let me think"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "image block",
|
||||||
|
block: ContentBlock{
|
||||||
|
Type: "image",
|
||||||
|
Source: &ImageSource{
|
||||||
|
Type: "base64",
|
||||||
|
MediaType: "image/png",
|
||||||
|
Data: testImage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
data, err := json.Marshal(tt.block)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := result["input"]; ok {
|
||||||
|
t.Fatalf("unexpected input field in non-tool block JSON: %s", string(data))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStreamConverter_ContentBlockStartIncludesEmptyFields(t *testing.T) {
|
func TestStreamConverter_ContentBlockStartIncludesEmptyFields(t *testing.T) {
|
||||||
t.Run("text block start includes empty text", func(t *testing.T) {
|
t.Run("text block start includes empty text", func(t *testing.T) {
|
||||||
conv := NewStreamConverter("msg_123", "test-model", 0)
|
conv := NewStreamConverter("msg_123", "test-model", 0)
|
||||||
@@ -1087,7 +1143,9 @@ func TestStreamConverter_ContentBlockStartIncludesEmptyFields(t *testing.T) {
|
|||||||
// Marshal and verify the text field is present
|
// Marshal and verify the text field is present
|
||||||
data, _ := json.Marshal(start)
|
data, _ := json.Marshal(start)
|
||||||
var result map[string]any
|
var result map[string]any
|
||||||
json.Unmarshal(data, &result)
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal content_block_start JSON: %v", err)
|
||||||
|
}
|
||||||
cb := result["content_block"].(map[string]any)
|
cb := result["content_block"].(map[string]any)
|
||||||
if _, ok := cb["text"]; !ok {
|
if _, ok := cb["text"]; !ok {
|
||||||
t.Error("content_block_start for text should include 'text' field")
|
t.Error("content_block_start for text should include 'text' field")
|
||||||
@@ -1134,13 +1192,71 @@ func TestStreamConverter_ContentBlockStartIncludesEmptyFields(t *testing.T) {
|
|||||||
t.Error("expected thinking content_block_start event")
|
t.Error("expected thinking content_block_start event")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("tool_use block start includes empty input object", func(t *testing.T) {
|
||||||
|
conv := NewStreamConverter("msg_123", "test-model", 0)
|
||||||
|
|
||||||
|
resp := api.ChatResponse{
|
||||||
|
Model: "test-model",
|
||||||
|
Message: api.Message{
|
||||||
|
Role: "assistant",
|
||||||
|
ToolCalls: []api.ToolCall{
|
||||||
|
{
|
||||||
|
ID: "call_123",
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: "get_weather",
|
||||||
|
Arguments: makeArgs("location", "Paris"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
events := conv.Process(resp)
|
||||||
|
|
||||||
|
var foundToolStart bool
|
||||||
|
for _, e := range events {
|
||||||
|
if e.Event == "content_block_start" {
|
||||||
|
if start, ok := e.Data.(ContentBlockStartEvent); ok {
|
||||||
|
if start.ContentBlock.Type == "tool_use" {
|
||||||
|
foundToolStart = true
|
||||||
|
if start.ContentBlock.Input.Len() != 0 {
|
||||||
|
t.Errorf("expected empty input object, got len=%d", start.ContentBlock.Input.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(start)
|
||||||
|
var result map[string]any
|
||||||
|
json.Unmarshal(data, &result)
|
||||||
|
cb := result["content_block"].(map[string]any)
|
||||||
|
input, ok := cb["input"]
|
||||||
|
if !ok {
|
||||||
|
t.Error("content_block_start for tool_use should include 'input' field")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inputMap, ok := input.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("input field should be an object, got %T", input)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(inputMap) != 0 {
|
||||||
|
t.Errorf("expected empty input object in content_block_start, got %v", inputMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundToolStart {
|
||||||
|
t.Error("expected tool_use content_block_start event")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEstimateTokens_SimpleMessage(t *testing.T) {
|
func TestEstimateTokens_SimpleMessage(t *testing.T) {
|
||||||
req := CountTokensRequest{
|
req := CountTokensRequest{
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{Role: "user", Content: "Hello, world!"},
|
{Role: "user", Content: textContent("Hello, world!")},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1161,7 +1277,7 @@ func TestEstimateTokens_WithSystemPrompt(t *testing.T) {
|
|||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
System: "You are a helpful assistant.",
|
System: "You are a helpful assistant.",
|
||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{Role: "user", Content: "Hello"},
|
{Role: "user", Content: textContent("Hello")},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1177,7 +1293,7 @@ func TestEstimateTokens_WithTools(t *testing.T) {
|
|||||||
req := CountTokensRequest{
|
req := CountTokensRequest{
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{Role: "user", Content: "What's the weather?"},
|
{Role: "user", Content: textContent("What's the weather?")},
|
||||||
},
|
},
|
||||||
Tools: []Tool{
|
Tools: []Tool{
|
||||||
{
|
{
|
||||||
@@ -1200,17 +1316,17 @@ func TestEstimateTokens_WithThinking(t *testing.T) {
|
|||||||
req := CountTokensRequest{
|
req := CountTokensRequest{
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
Messages: []MessageParam{
|
Messages: []MessageParam{
|
||||||
{Role: "user", Content: "Hello"},
|
{Role: "user", Content: textContent("Hello")},
|
||||||
{
|
{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{
|
{
|
||||||
"type": "thinking",
|
Type: "thinking",
|
||||||
"thinking": "Let me think about this carefully...",
|
Thinking: ptr("Let me think about this carefully..."),
|
||||||
},
|
},
|
||||||
map[string]any{
|
{
|
||||||
"type": "text",
|
Type: "text",
|
||||||
"text": "Here is my response.",
|
Text: ptr("Here is my response."),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1308,12 +1424,12 @@ func TestConvertTool_RegularTool(t *testing.T) {
|
|||||||
func TestConvertMessage_ServerToolUse(t *testing.T) {
|
func TestConvertMessage_ServerToolUse(t *testing.T) {
|
||||||
msg := MessageParam{
|
msg := MessageParam{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{
|
{
|
||||||
"type": "server_tool_use",
|
Type: "server_tool_use",
|
||||||
"id": "srvtoolu_123",
|
ID: "srvtoolu_123",
|
||||||
"name": "web_search",
|
Name: "web_search",
|
||||||
"input": map[string]any{"query": "test query"},
|
Input: makeArgs("query", "test query"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1344,11 +1460,11 @@ func TestConvertMessage_ServerToolUse(t *testing.T) {
|
|||||||
func TestConvertMessage_WebSearchToolResult(t *testing.T) {
|
func TestConvertMessage_WebSearchToolResult(t *testing.T) {
|
||||||
msg := MessageParam{
|
msg := MessageParam{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{
|
{
|
||||||
"type": "web_search_tool_result",
|
Type: "web_search_tool_result",
|
||||||
"tool_use_id": "srvtoolu_123",
|
ToolUseID: "srvtoolu_123",
|
||||||
"content": []any{
|
Content: []any{
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"type": "web_search_result",
|
"type": "web_search_result",
|
||||||
"title": "Test Result",
|
"title": "Test Result",
|
||||||
@@ -1385,11 +1501,11 @@ func TestConvertMessage_WebSearchToolResult(t *testing.T) {
|
|||||||
func TestConvertMessage_WebSearchToolResultEmptyStillCreatesToolMessage(t *testing.T) {
|
func TestConvertMessage_WebSearchToolResultEmptyStillCreatesToolMessage(t *testing.T) {
|
||||||
msg := MessageParam{
|
msg := MessageParam{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{
|
{
|
||||||
"type": "web_search_tool_result",
|
Type: "web_search_tool_result",
|
||||||
"tool_use_id": "srvtoolu_empty",
|
ToolUseID: "srvtoolu_empty",
|
||||||
"content": []any{},
|
Content: []any{},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1416,11 +1532,11 @@ func TestConvertMessage_WebSearchToolResultEmptyStillCreatesToolMessage(t *testi
|
|||||||
func TestConvertMessage_WebSearchToolResultErrorStillCreatesToolMessage(t *testing.T) {
|
func TestConvertMessage_WebSearchToolResultErrorStillCreatesToolMessage(t *testing.T) {
|
||||||
msg := MessageParam{
|
msg := MessageParam{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: []any{
|
Content: []ContentBlock{
|
||||||
map[string]any{
|
{
|
||||||
"type": "web_search_tool_result",
|
Type: "web_search_tool_result",
|
||||||
"tool_use_id": "srvtoolu_error",
|
ToolUseID: "srvtoolu_error",
|
||||||
"content": map[string]any{
|
Content: map[string]any{
|
||||||
"type": "web_search_tool_result_error",
|
"type": "web_search_tool_result_error",
|
||||||
"error_code": "max_uses_exceeded",
|
"error_code": "max_uses_exceeded",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -436,6 +436,7 @@ type ToolProperty struct {
|
|||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Enum []any `json:"enum,omitempty"`
|
Enum []any `json:"enum,omitempty"`
|
||||||
Properties *ToolPropertiesMap `json:"properties,omitempty"`
|
Properties *ToolPropertiesMap `json:"properties,omitempty"`
|
||||||
|
Required []string `json:"required,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToTypeScriptType converts a ToolProperty to a TypeScript type string
|
// ToTypeScriptType converts a ToolProperty to a TypeScript type string
|
||||||
|
|||||||
@@ -550,14 +550,12 @@ export class Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class ModelUpstreamResponse {
|
export class ModelUpstreamResponse {
|
||||||
digest?: string;
|
stale: boolean;
|
||||||
pushTime: number;
|
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.digest = source["digest"];
|
this.stale = source["stale"];
|
||||||
this.pushTime = source["pushTime"];
|
|
||||||
this.error = source["error"];
|
this.error = source["error"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export async function getModels(query?: string): Promise<Model[]> {
|
|||||||
// Add query if it's in the registry and not already in the list
|
// Add query if it's in the registry and not already in the list
|
||||||
if (!exactMatch) {
|
if (!exactMatch) {
|
||||||
const result = await getModelUpstreamInfo(new Model({ model: query }));
|
const result = await getModelUpstreamInfo(new Model({ model: query }));
|
||||||
const existsUpstream = !!result.digest && !result.error;
|
const existsUpstream = result.exists;
|
||||||
if (existsUpstream) {
|
if (existsUpstream) {
|
||||||
filteredModels.push(new Model({ model: query }));
|
filteredModels.push(new Model({ model: query }));
|
||||||
}
|
}
|
||||||
@@ -339,7 +339,7 @@ export async function deleteChat(chatId: string): Promise<void> {
|
|||||||
// Get upstream information for model staleness checking
|
// Get upstream information for model staleness checking
|
||||||
export async function getModelUpstreamInfo(
|
export async function getModelUpstreamInfo(
|
||||||
model: Model,
|
model: Model,
|
||||||
): Promise<{ digest?: string; pushTime: number; error?: string }> {
|
): Promise<{ stale: boolean; exists: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/model/upstream`, {
|
const response = await fetch(`${API_BASE}/api/v1/model/upstream`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -353,22 +353,22 @@ export async function getModelUpstreamInfo(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`Failed to check upstream digest for ${model.model}: ${response.status}`,
|
`Failed to check upstream for ${model.model}: ${response.status}`,
|
||||||
);
|
);
|
||||||
return { pushTime: 0 };
|
return { stale: false, exists: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
console.warn(`Upstream digest check: ${data.error}`);
|
console.warn(`Upstream check: ${data.error}`);
|
||||||
return { error: data.error, pushTime: 0 };
|
return { stale: false, exists: false, error: data.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { digest: data.digest, pushTime: data.pushTime || 0 };
|
return { stale: !!data.stale, exists: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Error checking model staleness:`, error);
|
console.warn(`Error checking model staleness:`, error);
|
||||||
return { pushTime: 0 };
|
return { stale: false, exists: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,24 +61,7 @@ export const ModelPicker = forwardRef<
|
|||||||
try {
|
try {
|
||||||
const upstreamInfo = await getModelUpstreamInfo(model);
|
const upstreamInfo = await getModelUpstreamInfo(model);
|
||||||
|
|
||||||
// Compare local digest with upstream digest
|
if (upstreamInfo.stale) {
|
||||||
let isStale =
|
|
||||||
model.digest &&
|
|
||||||
upstreamInfo.digest &&
|
|
||||||
model.digest !== upstreamInfo.digest;
|
|
||||||
|
|
||||||
// If the model has a modified time and upstream has a push time,
|
|
||||||
// check if the model was modified after the push time - if so, it's not stale
|
|
||||||
if (isStale && model.modified_at && upstreamInfo.pushTime > 0) {
|
|
||||||
const modifiedAtTime =
|
|
||||||
new Date(model.modified_at as string | number | Date).getTime() /
|
|
||||||
1000;
|
|
||||||
if (modifiedAtTime > upstreamInfo.pushTime) {
|
|
||||||
isStale = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isStale) {
|
|
||||||
const currentStaleModels =
|
const currentStaleModels =
|
||||||
queryClient.getQueryData<Map<string, boolean>>(["staleModels"]) ||
|
queryClient.getQueryData<Map<string, boolean>>(["staleModels"]) ||
|
||||||
new Map();
|
new Map();
|
||||||
|
|||||||
@@ -133,9 +133,8 @@ type Error struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ModelUpstreamResponse struct {
|
type ModelUpstreamResponse struct {
|
||||||
Digest string `json:"digest,omitempty"`
|
Stale bool `json:"stale"`
|
||||||
PushTime int64 `json:"pushTime"`
|
Error string `json:"error,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serializable data for the browser state
|
// Serializable data for the browser state
|
||||||
|
|||||||
18
app/ui/ui.go
@@ -32,6 +32,7 @@ import (
|
|||||||
"github.com/ollama/ollama/app/version"
|
"github.com/ollama/ollama/app/version"
|
||||||
ollamaAuth "github.com/ollama/ollama/auth"
|
ollamaAuth "github.com/ollama/ollama/auth"
|
||||||
"github.com/ollama/ollama/envconfig"
|
"github.com/ollama/ollama/envconfig"
|
||||||
|
"github.com/ollama/ollama/manifest"
|
||||||
"github.com/ollama/ollama/types/model"
|
"github.com/ollama/ollama/types/model"
|
||||||
_ "github.com/tkrajina/typescriptify-golang-structs/typescriptify"
|
_ "github.com/tkrajina/typescriptify-golang-structs/typescriptify"
|
||||||
)
|
)
|
||||||
@@ -193,7 +194,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
if CORS() {
|
if CORS() {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, User-Agent, Accept, X-Requested-With")
|
||||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
|
||||||
// Handle preflight requests
|
// Handle preflight requests
|
||||||
@@ -318,7 +319,7 @@ func (s *Server) handleError(w http.ResponseWriter, e error) {
|
|||||||
if CORS() {
|
if CORS() {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, User-Agent, Accept, X-Requested-With")
|
||||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1572,9 +1573,18 @@ func (s *Server) modelUpstream(w http.ResponseWriter, r *http.Request) error {
|
|||||||
return json.NewEncoder(w).Encode(response)
|
return json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
n := model.ParseName(req.Model)
|
||||||
|
stale := true
|
||||||
|
if m, err := manifest.ParseNamedManifest(n); err == nil {
|
||||||
|
if m.Digest() == digest {
|
||||||
|
stale = false
|
||||||
|
} else if pushTime > 0 && m.FileInfo().ModTime().Unix() >= pushTime {
|
||||||
|
stale = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
response := responses.ModelUpstreamResponse{
|
response := responses.ModelUpstreamResponse{
|
||||||
Digest: digest,
|
Stale: stale,
|
||||||
PushTime: pushTime,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|||||||
216
cmd/audio.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
audioSampleRate = 16000
|
||||||
|
audioChannels = 1
|
||||||
|
audioFrameSize = 1024 // samples per callback
|
||||||
|
)
|
||||||
|
|
||||||
|
// AudioRecorder captures audio from the default microphone.
|
||||||
|
// Platform-specific capture is provided by audioStream (audio_darwin.go, etc.).
|
||||||
|
type AudioRecorder struct {
|
||||||
|
stream audioStream
|
||||||
|
mu sync.Mutex
|
||||||
|
samples []float32
|
||||||
|
started time.Time
|
||||||
|
MaxChunkSeconds int // hard split limit in seconds; 0 means use default
|
||||||
|
}
|
||||||
|
|
||||||
|
// audioStream is the platform-specific audio capture interface.
|
||||||
|
type audioStream interface {
|
||||||
|
// Start begins capturing. Samples are delivered via the callback.
|
||||||
|
Start(callback func(samples []float32)) error
|
||||||
|
// Stop ends capturing and releases resources.
|
||||||
|
Stop() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAudioRecorder creates a recorder ready to capture from the default mic.
|
||||||
|
func NewAudioRecorder() (*AudioRecorder, error) {
|
||||||
|
stream, err := newAudioStream(audioSampleRate, audioChannels, audioFrameSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &AudioRecorder{stream: stream}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins capturing audio from the microphone.
|
||||||
|
func (r *AudioRecorder) Start() error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
r.samples = make([]float32, 0, audioSampleRate*30) // preallocate ~30s
|
||||||
|
r.started = time.Now()
|
||||||
|
|
||||||
|
return r.stream.Start(func(samples []float32) {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.samples = append(r.samples, samples...)
|
||||||
|
r.mu.Unlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop ends the recording and returns the duration.
|
||||||
|
func (r *AudioRecorder) Stop() (time.Duration, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
dur := time.Since(r.started)
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
if r.stream != nil {
|
||||||
|
r.stream.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return dur, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duration returns how long the current recording has been running.
|
||||||
|
func (r *AudioRecorder) Duration() time.Duration {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.started.IsZero() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return time.Since(r.started)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunking constants for live transcription.
|
||||||
|
const (
|
||||||
|
chunkTargetSamples = 8 * audioSampleRate // 8s — start yielding when silence found
|
||||||
|
chunkMinSamples = 5 * audioSampleRate // start scanning for silence at 5s
|
||||||
|
defaultMaxAudioSeconds = 28 // default hard split (just under typical 30s model cap)
|
||||||
|
silenceWindow = 800 // 50ms RMS window
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *AudioRecorder) maxChunk() int {
|
||||||
|
if r.MaxChunkSeconds > 0 {
|
||||||
|
return r.MaxChunkSeconds * audioSampleRate
|
||||||
|
}
|
||||||
|
return defaultMaxAudioSeconds * audioSampleRate
|
||||||
|
}
|
||||||
|
|
||||||
|
// TakeChunk checks if there are enough accumulated samples to yield a chunk.
|
||||||
|
// If so, it splits at the best silence boundary, removes the consumed samples
|
||||||
|
// from the buffer, and returns the chunk as WAV bytes. Returns nil if not enough
|
||||||
|
// audio has accumulated yet.
|
||||||
|
func (r *AudioRecorder) TakeChunk() []byte {
|
||||||
|
r.mu.Lock()
|
||||||
|
n := len(r.samples)
|
||||||
|
if n < chunkMinSamples {
|
||||||
|
r.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
maxSamples := r.maxChunk()
|
||||||
|
|
||||||
|
if n < chunkTargetSamples && n < maxSamples {
|
||||||
|
r.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := n
|
||||||
|
if limit > maxSamples {
|
||||||
|
limit = maxSamples
|
||||||
|
}
|
||||||
|
|
||||||
|
splitAt := limit
|
||||||
|
bestEnergy := float64(1e30)
|
||||||
|
|
||||||
|
scanStart := limit - silenceWindow
|
||||||
|
scanEnd := chunkMinSamples
|
||||||
|
for pos := scanStart; pos >= scanEnd; pos -= silenceWindow / 2 {
|
||||||
|
end := pos + silenceWindow
|
||||||
|
if end > n {
|
||||||
|
end = n
|
||||||
|
}
|
||||||
|
var sumSq float64
|
||||||
|
for _, s := range r.samples[pos:end] {
|
||||||
|
sumSq += float64(s) * float64(s)
|
||||||
|
}
|
||||||
|
rms := sumSq / float64(end-pos)
|
||||||
|
if rms < bestEnergy {
|
||||||
|
bestEnergy = rms
|
||||||
|
splitAt = pos + silenceWindow/2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chunk := make([]float32, splitAt)
|
||||||
|
copy(chunk, r.samples[:splitAt])
|
||||||
|
remaining := make([]float32, n-splitAt)
|
||||||
|
copy(remaining, r.samples[splitAt:])
|
||||||
|
r.samples = remaining
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
return encodeWAV(chunk, audioSampleRate, audioChannels)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlushWAV returns any remaining samples as WAV, clearing the buffer.
|
||||||
|
func (r *AudioRecorder) FlushWAV() []byte {
|
||||||
|
r.mu.Lock()
|
||||||
|
samples := r.samples
|
||||||
|
r.samples = nil
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
if len(samples) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return encodeWAV(samples, audioSampleRate, audioChannels)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WAV encodes the captured samples as a WAV file in memory.
|
||||||
|
func (r *AudioRecorder) WAV() ([]byte, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
samples := make([]float32, len(r.samples))
|
||||||
|
copy(samples, r.samples)
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
if len(samples) == 0 {
|
||||||
|
return nil, errNoAudio
|
||||||
|
}
|
||||||
|
|
||||||
|
return encodeWAV(samples, audioSampleRate, audioChannels), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeWAV produces a 16-bit PCM WAV file from float32 samples.
|
||||||
|
func encodeWAV(samples []float32, sampleRate, channels int) []byte {
|
||||||
|
numSamples := len(samples)
|
||||||
|
bitsPerSample := 16
|
||||||
|
byteRate := sampleRate * channels * bitsPerSample / 8
|
||||||
|
blockAlign := channels * bitsPerSample / 8
|
||||||
|
dataSize := numSamples * blockAlign
|
||||||
|
|
||||||
|
buf := make([]byte, 44+dataSize)
|
||||||
|
|
||||||
|
copy(buf[0:4], "RIFF")
|
||||||
|
binary.LittleEndian.PutUint32(buf[4:8], uint32(36+dataSize))
|
||||||
|
copy(buf[8:12], "WAVE")
|
||||||
|
|
||||||
|
copy(buf[12:16], "fmt ")
|
||||||
|
binary.LittleEndian.PutUint32(buf[16:20], 16)
|
||||||
|
binary.LittleEndian.PutUint16(buf[20:22], 1)
|
||||||
|
binary.LittleEndian.PutUint16(buf[22:24], uint16(channels))
|
||||||
|
binary.LittleEndian.PutUint32(buf[24:28], uint32(sampleRate))
|
||||||
|
binary.LittleEndian.PutUint32(buf[28:32], uint32(byteRate))
|
||||||
|
binary.LittleEndian.PutUint16(buf[32:34], uint16(blockAlign))
|
||||||
|
binary.LittleEndian.PutUint16(buf[34:36], uint16(bitsPerSample))
|
||||||
|
|
||||||
|
copy(buf[36:40], "data")
|
||||||
|
binary.LittleEndian.PutUint32(buf[40:44], uint32(dataSize))
|
||||||
|
|
||||||
|
offset := 44
|
||||||
|
for _, s := range samples {
|
||||||
|
if s > 1.0 {
|
||||||
|
s = 1.0
|
||||||
|
} else if s < -1.0 {
|
||||||
|
s = -1.0
|
||||||
|
}
|
||||||
|
val := int16(s * 32767)
|
||||||
|
binary.LittleEndian.PutUint16(buf[offset:offset+2], uint16(val))
|
||||||
|
offset += 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf
|
||||||
|
}
|
||||||
180
cmd/audio_darwin.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo LDFLAGS: -framework CoreAudio -framework AudioToolbox
|
||||||
|
#include <AudioToolbox/AudioQueue.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
// Callback context passed to AudioQueue.
|
||||||
|
typedef struct {
|
||||||
|
int ready; // set to 1 when a buffer is filled
|
||||||
|
} AQContext;
|
||||||
|
|
||||||
|
// C callback — re-enqueues the buffer so recording continues.
|
||||||
|
// Not static — must be visible to the linker for Go's function pointer.
|
||||||
|
void aqInputCallback(
|
||||||
|
void *inUserData,
|
||||||
|
AudioQueueRef inAQ,
|
||||||
|
AudioQueueBufferRef inBuffer,
|
||||||
|
const AudioTimeStamp *inStartTime,
|
||||||
|
UInt32 inNumberPacketDescriptions,
|
||||||
|
const AudioStreamPacketDescription *inPacketDescs)
|
||||||
|
{
|
||||||
|
// Re-enqueue the buffer immediately so recording continues.
|
||||||
|
AudioQueueEnqueueBuffer(inAQ, inBuffer, 0, NULL);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNoAudio = fmt.Errorf("no audio recorded")
|
||||||
|
|
||||||
|
const numAQBuffers = 3
|
||||||
|
|
||||||
|
type coreAudioStream struct {
|
||||||
|
queue C.AudioQueueRef
|
||||||
|
buffers [numAQBuffers]C.AudioQueueBufferRef
|
||||||
|
mu sync.Mutex
|
||||||
|
callback func(samples []float32)
|
||||||
|
running bool
|
||||||
|
pollDone chan struct{}
|
||||||
|
|
||||||
|
sampleRate int
|
||||||
|
channels int
|
||||||
|
frameSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAudioStream(sampleRate, channels, frameSize int) (audioStream, error) {
|
||||||
|
return &coreAudioStream{
|
||||||
|
sampleRate: sampleRate,
|
||||||
|
channels: channels,
|
||||||
|
frameSize: frameSize,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *coreAudioStream) Start(callback func(samples []float32)) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.callback = callback
|
||||||
|
|
||||||
|
// Set up audio format: 16-bit signed integer PCM, mono, 16kHz.
|
||||||
|
var format C.AudioStreamBasicDescription
|
||||||
|
format.mSampleRate = C.Float64(s.sampleRate)
|
||||||
|
format.mFormatID = C.kAudioFormatLinearPCM
|
||||||
|
format.mFormatFlags = C.kLinearPCMFormatFlagIsSignedInteger | C.kLinearPCMFormatFlagIsPacked
|
||||||
|
format.mBitsPerChannel = 16
|
||||||
|
format.mChannelsPerFrame = C.UInt32(s.channels)
|
||||||
|
format.mBytesPerFrame = 2 * C.UInt32(s.channels)
|
||||||
|
format.mFramesPerPacket = 1
|
||||||
|
format.mBytesPerPacket = format.mBytesPerFrame
|
||||||
|
|
||||||
|
// Create the audio queue.
|
||||||
|
var status C.OSStatus
|
||||||
|
status = C.AudioQueueNewInput(
|
||||||
|
&format,
|
||||||
|
C.AudioQueueInputCallback(C.aqInputCallback),
|
||||||
|
nil, // user data
|
||||||
|
C.CFRunLoopRef(0), // NULL run loop — use internal thread
|
||||||
|
C.CFStringRef(0), // NULL run loop mode
|
||||||
|
0, // flags
|
||||||
|
&s.queue,
|
||||||
|
)
|
||||||
|
if status != 0 {
|
||||||
|
return fmt.Errorf("AudioQueueNewInput failed: %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate and enqueue buffers.
|
||||||
|
bufferBytes := C.UInt32(s.frameSize * int(format.mBytesPerFrame))
|
||||||
|
for i := range s.buffers {
|
||||||
|
status = C.AudioQueueAllocateBuffer(s.queue, bufferBytes, &s.buffers[i])
|
||||||
|
if status != 0 {
|
||||||
|
C.AudioQueueDispose(s.queue, C.true)
|
||||||
|
return fmt.Errorf("AudioQueueAllocateBuffer failed: %d", status)
|
||||||
|
}
|
||||||
|
status = C.AudioQueueEnqueueBuffer(s.queue, s.buffers[i], 0, nil)
|
||||||
|
if status != 0 {
|
||||||
|
C.AudioQueueDispose(s.queue, C.true)
|
||||||
|
return fmt.Errorf("AudioQueueEnqueueBuffer failed: %d", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start recording.
|
||||||
|
status = C.AudioQueueStart(s.queue, nil)
|
||||||
|
if status != 0 {
|
||||||
|
C.AudioQueueDispose(s.queue, C.true)
|
||||||
|
return fmt.Errorf("AudioQueueStart failed: %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.running = true
|
||||||
|
s.pollDone = make(chan struct{})
|
||||||
|
|
||||||
|
// Poll buffers for data. AudioQueue re-enqueues in the C callback,
|
||||||
|
// so we read the data out periodically.
|
||||||
|
go s.pollLoop()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *coreAudioStream) pollLoop() {
|
||||||
|
defer close(s.pollDone)
|
||||||
|
|
||||||
|
// Read at roughly frameSize intervals.
|
||||||
|
interval := time.Duration(float64(s.frameSize) / float64(s.sampleRate) * float64(time.Second))
|
||||||
|
if interval < 10*time.Millisecond {
|
||||||
|
interval = 10 * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
s.mu.Lock()
|
||||||
|
if !s.running {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read available data from each buffer.
|
||||||
|
for i := range s.buffers {
|
||||||
|
buf := s.buffers[i]
|
||||||
|
if buf.mAudioDataByteSize > 0 {
|
||||||
|
numSamples := int(buf.mAudioDataByteSize) / 2 // 16-bit samples
|
||||||
|
if numSamples > 0 {
|
||||||
|
raw := (*[1 << 28]int16)(buf.mAudioData)[:numSamples:numSamples]
|
||||||
|
floats := make([]float32, numSamples)
|
||||||
|
for j, v := range raw {
|
||||||
|
floats[j] = float32(v) / float32(math.MaxInt16)
|
||||||
|
}
|
||||||
|
s.callback(floats)
|
||||||
|
}
|
||||||
|
buf.mAudioDataByteSize = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *coreAudioStream) Stop() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.running = false
|
||||||
|
queue := s.queue
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if queue != nil {
|
||||||
|
C.AudioQueueStop(queue, C.true)
|
||||||
|
C.AudioQueueDispose(queue, C.true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.pollDone != nil {
|
||||||
|
<-s.pollDone
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
275
cmd/audio_linux.go
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo LDFLAGS: -ldl
|
||||||
|
#include <dlfcn.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
// Function pointer types for ALSA functions loaded at runtime.
|
||||||
|
typedef int (*pcm_open_fn)(void**, const char*, int, int);
|
||||||
|
typedef int (*pcm_simple_fn)(void*);
|
||||||
|
typedef long (*pcm_readi_fn)(void*, void*, unsigned long);
|
||||||
|
typedef int (*hw_malloc_fn)(void**);
|
||||||
|
typedef void (*hw_free_fn)(void*);
|
||||||
|
typedef int (*hw_any_fn)(void*, void*);
|
||||||
|
typedef int (*hw_set_int_fn)(void*, void*, int);
|
||||||
|
typedef int (*hw_set_uint_fn)(void*, void*, unsigned int);
|
||||||
|
typedef int (*hw_set_rate_fn)(void*, void*, unsigned int*, int*);
|
||||||
|
typedef int (*hw_set_period_fn)(void*, void*, unsigned long*, int*);
|
||||||
|
typedef int (*hw_apply_fn)(void*, void*);
|
||||||
|
typedef const char* (*strerror_fn)(int);
|
||||||
|
|
||||||
|
// Trampoline functions — call dynamically loaded ALSA symbols.
|
||||||
|
static int alsa_pcm_open(void* fn, void** h, const char* name, int stream, int mode) {
|
||||||
|
return ((pcm_open_fn)fn)(h, name, stream, mode);
|
||||||
|
}
|
||||||
|
static int alsa_pcm_close(void* fn, void* h) { return ((pcm_simple_fn)fn)(h); }
|
||||||
|
static int alsa_pcm_prepare(void* fn, void* h) { return ((pcm_simple_fn)fn)(h); }
|
||||||
|
static int alsa_pcm_drop(void* fn, void* h) { return ((pcm_simple_fn)fn)(h); }
|
||||||
|
static long alsa_pcm_readi(void* fn, void* h, void* buf, unsigned long frames) {
|
||||||
|
return ((pcm_readi_fn)fn)(h, buf, frames);
|
||||||
|
}
|
||||||
|
static int alsa_hw_malloc(void* fn, void** p) { return ((hw_malloc_fn)fn)(p); }
|
||||||
|
static void alsa_hw_free(void* fn, void* p) { ((hw_free_fn)fn)(p); }
|
||||||
|
static int alsa_hw_any(void* fn, void* h, void* p) { return ((hw_any_fn)fn)(h, p); }
|
||||||
|
static int alsa_hw_set_access(void* fn, void* h, void* p, int v) { return ((hw_set_int_fn)fn)(h, p, v); }
|
||||||
|
static int alsa_hw_set_format(void* fn, void* h, void* p, int v) { return ((hw_set_int_fn)fn)(h, p, v); }
|
||||||
|
static int alsa_hw_set_channels(void* fn, void* h, void* p, unsigned int v) { return ((hw_set_uint_fn)fn)(h, p, v); }
|
||||||
|
static int alsa_hw_set_rate(void* fn, void* h, void* p, unsigned int* v, int* d) { return ((hw_set_rate_fn)fn)(h, p, v, d); }
|
||||||
|
static int alsa_hw_set_period(void* fn, void* h, void* p, unsigned long* v, int* d) { return ((hw_set_period_fn)fn)(h, p, v, d); }
|
||||||
|
static int alsa_hw_apply(void* fn, void* h, void* p) { return ((hw_apply_fn)fn)(h, p); }
|
||||||
|
static const char* alsa_strerror(void* fn, int e) { return ((strerror_fn)fn)(e); }
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNoAudio = fmt.Errorf("no audio recorded")
|
||||||
|
|
||||||
|
const (
|
||||||
|
sndPCMStreamCapture = 1
|
||||||
|
sndPCMAccessRWInterleaved = 3
|
||||||
|
sndPCMFormatS16LE = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
alsaLoadErr error
|
||||||
|
alsaOnce sync.Once
|
||||||
|
alsa alsaFuncs
|
||||||
|
)
|
||||||
|
|
||||||
|
type alsaFuncs struct {
|
||||||
|
pcmOpen, pcmClose, pcmPrepare, pcmDrop, pcmReadi unsafe.Pointer
|
||||||
|
hwMalloc, hwFree, hwAny unsafe.Pointer
|
||||||
|
hwSetAccess, hwSetFormat, hwSetChannels unsafe.Pointer
|
||||||
|
hwSetRate, hwSetPeriod, hwApply unsafe.Pointer
|
||||||
|
strerror unsafe.Pointer
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadALSA() {
|
||||||
|
var lib unsafe.Pointer
|
||||||
|
for _, name := range []string{"libasound.so.2", "libasound.so"} {
|
||||||
|
cName := C.CString(name)
|
||||||
|
lib = C.dlopen(cName, C.RTLD_NOW)
|
||||||
|
C.free(unsafe.Pointer(cName))
|
||||||
|
if lib != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lib == nil {
|
||||||
|
alsaLoadErr = fmt.Errorf("audio capture unavailable: libasound.so not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sym := func(name string) unsafe.Pointer {
|
||||||
|
cName := C.CString(name)
|
||||||
|
defer C.free(unsafe.Pointer(cName))
|
||||||
|
return C.dlsym(lib, cName)
|
||||||
|
}
|
||||||
|
|
||||||
|
syms := []struct {
|
||||||
|
ptr *unsafe.Pointer
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{&alsa.pcmOpen, "snd_pcm_open"},
|
||||||
|
{&alsa.pcmClose, "snd_pcm_close"},
|
||||||
|
{&alsa.pcmPrepare, "snd_pcm_prepare"},
|
||||||
|
{&alsa.pcmDrop, "snd_pcm_drop"},
|
||||||
|
{&alsa.pcmReadi, "snd_pcm_readi"},
|
||||||
|
{&alsa.hwMalloc, "snd_pcm_hw_params_malloc"},
|
||||||
|
{&alsa.hwFree, "snd_pcm_hw_params_free"},
|
||||||
|
{&alsa.hwAny, "snd_pcm_hw_params_any"},
|
||||||
|
{&alsa.hwSetAccess, "snd_pcm_hw_params_set_access"},
|
||||||
|
{&alsa.hwSetFormat, "snd_pcm_hw_params_set_format"},
|
||||||
|
{&alsa.hwSetChannels, "snd_pcm_hw_params_set_channels"},
|
||||||
|
{&alsa.hwSetRate, "snd_pcm_hw_params_set_rate_near"},
|
||||||
|
{&alsa.hwSetPeriod, "snd_pcm_hw_params_set_period_size_near"},
|
||||||
|
{&alsa.hwApply, "snd_pcm_hw_params"},
|
||||||
|
{&alsa.strerror, "snd_strerror"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range syms {
|
||||||
|
*s.ptr = sym(s.name)
|
||||||
|
if *s.ptr == nil {
|
||||||
|
alsaLoadErr = fmt.Errorf("audio capture unavailable: missing %s in libasound", s.name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func alsaError(code C.int) string {
|
||||||
|
if alsa.strerror == nil {
|
||||||
|
return fmt.Sprintf("error %d", code)
|
||||||
|
}
|
||||||
|
return C.GoString(C.alsa_strerror(alsa.strerror, code))
|
||||||
|
}
|
||||||
|
|
||||||
|
type alsaStream struct {
|
||||||
|
handle unsafe.Pointer
|
||||||
|
mu sync.Mutex
|
||||||
|
callback func(samples []float32)
|
||||||
|
running bool
|
||||||
|
done chan struct{}
|
||||||
|
|
||||||
|
sampleRate int
|
||||||
|
channels int
|
||||||
|
frameSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAudioStream(sampleRate, channels, frameSize int) (audioStream, error) {
|
||||||
|
alsaOnce.Do(loadALSA)
|
||||||
|
if alsaLoadErr != nil {
|
||||||
|
return nil, alsaLoadErr
|
||||||
|
}
|
||||||
|
return &alsaStream{
|
||||||
|
sampleRate: sampleRate,
|
||||||
|
channels: channels,
|
||||||
|
frameSize: frameSize,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *alsaStream) Start(callback func(samples []float32)) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.callback = callback
|
||||||
|
|
||||||
|
cName := C.CString("default")
|
||||||
|
defer C.free(unsafe.Pointer(cName))
|
||||||
|
|
||||||
|
rc := C.alsa_pcm_open(alsa.pcmOpen, (*unsafe.Pointer)(unsafe.Pointer(&s.handle)), cName, C.int(sndPCMStreamCapture), 0)
|
||||||
|
if rc < 0 {
|
||||||
|
return fmt.Errorf("snd_pcm_open: %s", alsaError(rc))
|
||||||
|
}
|
||||||
|
|
||||||
|
var hwParams unsafe.Pointer
|
||||||
|
C.alsa_hw_malloc(alsa.hwMalloc, (*unsafe.Pointer)(unsafe.Pointer(&hwParams)))
|
||||||
|
defer C.alsa_hw_free(alsa.hwFree, hwParams)
|
||||||
|
|
||||||
|
C.alsa_hw_any(alsa.hwAny, s.handle, hwParams)
|
||||||
|
|
||||||
|
if rc = C.alsa_hw_set_access(alsa.hwSetAccess, s.handle, hwParams, C.int(sndPCMAccessRWInterleaved)); rc < 0 {
|
||||||
|
C.alsa_pcm_close(alsa.pcmClose, s.handle)
|
||||||
|
return fmt.Errorf("set access: %s", alsaError(rc))
|
||||||
|
}
|
||||||
|
if rc = C.alsa_hw_set_format(alsa.hwSetFormat, s.handle, hwParams, C.int(sndPCMFormatS16LE)); rc < 0 {
|
||||||
|
C.alsa_pcm_close(alsa.pcmClose, s.handle)
|
||||||
|
return fmt.Errorf("set format: %s", alsaError(rc))
|
||||||
|
}
|
||||||
|
if rc = C.alsa_hw_set_channels(alsa.hwSetChannels, s.handle, hwParams, C.uint(s.channels)); rc < 0 {
|
||||||
|
C.alsa_pcm_close(alsa.pcmClose, s.handle)
|
||||||
|
return fmt.Errorf("set channels: %s", alsaError(rc))
|
||||||
|
}
|
||||||
|
|
||||||
|
rate := C.uint(s.sampleRate)
|
||||||
|
if rc = C.alsa_hw_set_rate(alsa.hwSetRate, s.handle, hwParams, &rate, nil); rc < 0 {
|
||||||
|
C.alsa_pcm_close(alsa.pcmClose, s.handle)
|
||||||
|
return fmt.Errorf("set rate: %s", alsaError(rc))
|
||||||
|
}
|
||||||
|
|
||||||
|
periodSize := C.ulong(s.frameSize)
|
||||||
|
if rc = C.alsa_hw_set_period(alsa.hwSetPeriod, s.handle, hwParams, &periodSize, nil); rc < 0 {
|
||||||
|
C.alsa_pcm_close(alsa.pcmClose, s.handle)
|
||||||
|
return fmt.Errorf("set period: %s", alsaError(rc))
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc = C.alsa_hw_apply(alsa.hwApply, s.handle, hwParams); rc < 0 {
|
||||||
|
C.alsa_pcm_close(alsa.pcmClose, s.handle)
|
||||||
|
return fmt.Errorf("apply hw params: %s", alsaError(rc))
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc = C.alsa_pcm_prepare(alsa.pcmPrepare, s.handle); rc < 0 {
|
||||||
|
C.alsa_pcm_close(alsa.pcmClose, s.handle)
|
||||||
|
return fmt.Errorf("prepare: %s", alsaError(rc))
|
||||||
|
}
|
||||||
|
|
||||||
|
s.running = true
|
||||||
|
s.done = make(chan struct{})
|
||||||
|
go s.captureLoop(int(periodSize))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *alsaStream) captureLoop(periodSize int) {
|
||||||
|
defer close(s.done)
|
||||||
|
|
||||||
|
buf := make([]int16, periodSize*s.channels)
|
||||||
|
|
||||||
|
for {
|
||||||
|
s.mu.Lock()
|
||||||
|
if !s.running {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handle := s.handle
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
frames := C.alsa_pcm_readi(alsa.pcmReadi, handle, unsafe.Pointer(&buf[0]), C.ulong(periodSize))
|
||||||
|
if frames < 0 {
|
||||||
|
C.alsa_pcm_prepare(alsa.pcmPrepare, handle)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if frames == 0 {
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
numSamples := int(frames) * s.channels
|
||||||
|
floats := make([]float32, numSamples)
|
||||||
|
for i := 0; i < numSamples; i++ {
|
||||||
|
floats[i] = float32(buf[i]) / float32(math.MaxInt16)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.callback != nil {
|
||||||
|
s.callback(floats)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *alsaStream) Stop() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.running = false
|
||||||
|
handle := s.handle
|
||||||
|
s.handle = nil
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.done != nil {
|
||||||
|
<-s.done
|
||||||
|
}
|
||||||
|
|
||||||
|
if handle != nil {
|
||||||
|
C.alsa_pcm_drop(alsa.pcmDrop, handle)
|
||||||
|
C.alsa_pcm_close(alsa.pcmClose, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
288
cmd/audio_windows.go
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNoAudio = fmt.Errorf("no audio recorded")
|
||||||
|
|
||||||
|
// WASAPI COM GUIDs
|
||||||
|
var (
|
||||||
|
iidIMMDeviceEnumerator = guid{0xA95664D2, 0x9614, 0x4F35, [8]byte{0xA7, 0x46, 0xDE, 0x8D, 0xB6, 0x36, 0x17, 0xE6}}
|
||||||
|
clsidMMDeviceEnumerator = guid{0xBCDE0395, 0xE52F, 0x467C, [8]byte{0x8E, 0x3D, 0xC4, 0x57, 0x92, 0x91, 0x69, 0x2E}}
|
||||||
|
iidIAudioClient = guid{0x1CB9AD4C, 0xDBFA, 0x4C32, [8]byte{0xB1, 0x78, 0xC2, 0xF5, 0x68, 0xA7, 0x03, 0xB2}}
|
||||||
|
iidIAudioCaptureClient = guid{0xC8ADBD64, 0xE71E, 0x48A0, [8]byte{0xA4, 0xDE, 0x18, 0x5C, 0x39, 0x5C, 0xD3, 0x17}}
|
||||||
|
)
|
||||||
|
|
||||||
|
type guid struct {
|
||||||
|
Data1 uint32
|
||||||
|
Data2 uint16
|
||||||
|
Data3 uint16
|
||||||
|
Data4 [8]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// WAVEFORMATEX structure
|
||||||
|
type waveFormatEx struct {
|
||||||
|
FormatTag uint16
|
||||||
|
Channels uint16
|
||||||
|
SamplesPerSec uint32
|
||||||
|
AvgBytesPerSec uint32
|
||||||
|
BlockAlign uint16
|
||||||
|
BitsPerSample uint16
|
||||||
|
CbSize uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
wavePCM = 1
|
||||||
|
eCapture = 1
|
||||||
|
eConsole = 0
|
||||||
|
audclntSharemode = 0 // AUDCLNT_SHAREMODE_SHARED
|
||||||
|
audclntStreamflagsEventcallback = 0x00040000
|
||||||
|
|
||||||
|
coinitMultithreaded = 0x0
|
||||||
|
clsctxAll = 0x17
|
||||||
|
|
||||||
|
reftimesPerSec = 10000000 // 100ns units per second
|
||||||
|
reftimesPerMillis = 10000
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ole32 = syscall.NewLazyDLL("ole32.dll")
|
||||||
|
coInit = ole32.NewProc("CoInitializeEx")
|
||||||
|
coCreate = ole32.NewProc("CoCreateInstance")
|
||||||
|
)
|
||||||
|
|
||||||
|
type wasapiStream struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
callback func(samples []float32)
|
||||||
|
running bool
|
||||||
|
done chan struct{}
|
||||||
|
|
||||||
|
sampleRate int
|
||||||
|
channels int
|
||||||
|
frameSize int
|
||||||
|
|
||||||
|
// COM interfaces (stored as uintptr for syscall)
|
||||||
|
enumerator uintptr
|
||||||
|
device uintptr
|
||||||
|
client uintptr
|
||||||
|
capture uintptr
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAudioStream(sampleRate, channels, frameSize int) (audioStream, error) {
|
||||||
|
return &wasapiStream{
|
||||||
|
sampleRate: sampleRate,
|
||||||
|
channels: channels,
|
||||||
|
frameSize: frameSize,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *wasapiStream) Start(callback func(samples []float32)) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.callback = callback
|
||||||
|
|
||||||
|
// Initialize COM
|
||||||
|
hr, _, _ := coInit.Call(0, uintptr(coinitMultithreaded))
|
||||||
|
// S_OK or S_FALSE (already initialized) are both fine
|
||||||
|
if hr != 0 && hr != 1 {
|
||||||
|
return fmt.Errorf("CoInitializeEx failed: 0x%08x", hr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create device enumerator
|
||||||
|
hr, _, _ = coCreate.Call(
|
||||||
|
uintptr(unsafe.Pointer(&clsidMMDeviceEnumerator)),
|
||||||
|
0,
|
||||||
|
uintptr(clsctxAll),
|
||||||
|
uintptr(unsafe.Pointer(&iidIMMDeviceEnumerator)),
|
||||||
|
uintptr(unsafe.Pointer(&s.enumerator)),
|
||||||
|
)
|
||||||
|
if hr != 0 {
|
||||||
|
return fmt.Errorf("CoCreateInstance(MMDeviceEnumerator) failed: 0x%08x", hr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get default capture device
|
||||||
|
// IMMDeviceEnumerator::GetDefaultAudioEndpoint is vtable index 4
|
||||||
|
hr = comCall(s.enumerator, 4, uintptr(eCapture), uintptr(eConsole), uintptr(unsafe.Pointer(&s.device)))
|
||||||
|
if hr != 0 {
|
||||||
|
return fmt.Errorf("GetDefaultAudioEndpoint failed: 0x%08x", hr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate IAudioClient
|
||||||
|
// IMMDevice::Activate is vtable index 3
|
||||||
|
hr = comCall(s.device, 3,
|
||||||
|
uintptr(unsafe.Pointer(&iidIAudioClient)),
|
||||||
|
uintptr(clsctxAll),
|
||||||
|
0,
|
||||||
|
uintptr(unsafe.Pointer(&s.client)),
|
||||||
|
)
|
||||||
|
if hr != 0 {
|
||||||
|
return fmt.Errorf("IMMDevice::Activate failed: 0x%08x", hr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up format: 16-bit PCM mono 16kHz
|
||||||
|
format := waveFormatEx{
|
||||||
|
FormatTag: wavePCM,
|
||||||
|
Channels: uint16(s.channels),
|
||||||
|
SamplesPerSec: uint32(s.sampleRate),
|
||||||
|
BitsPerSample: 16,
|
||||||
|
BlockAlign: uint16(2 * s.channels),
|
||||||
|
AvgBytesPerSec: uint32(s.sampleRate * 2 * s.channels),
|
||||||
|
CbSize: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize audio client
|
||||||
|
// IAudioClient::Initialize is vtable index 3
|
||||||
|
bufferDuration := int64(reftimesPerSec) // 1 second buffer
|
||||||
|
hr = comCall(s.client, 3,
|
||||||
|
uintptr(audclntSharemode),
|
||||||
|
0, // stream flags
|
||||||
|
uintptr(bufferDuration),
|
||||||
|
0, // periodicity (0 = use default)
|
||||||
|
uintptr(unsafe.Pointer(&format)),
|
||||||
|
0, // audio session GUID (NULL = default)
|
||||||
|
)
|
||||||
|
if hr != 0 {
|
||||||
|
return fmt.Errorf("IAudioClient::Initialize failed: 0x%08x", hr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get capture client
|
||||||
|
// IAudioClient::GetService is vtable index 8
|
||||||
|
hr = comCall(s.client, 8,
|
||||||
|
uintptr(unsafe.Pointer(&iidIAudioCaptureClient)),
|
||||||
|
uintptr(unsafe.Pointer(&s.capture)),
|
||||||
|
)
|
||||||
|
if hr != 0 {
|
||||||
|
return fmt.Errorf("IAudioClient::GetService failed: 0x%08x", hr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start capture
|
||||||
|
// IAudioClient::Start is vtable index 6
|
||||||
|
hr = comCall(s.client, 6)
|
||||||
|
if hr != 0 {
|
||||||
|
return fmt.Errorf("IAudioClient::Start failed: 0x%08x", hr)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.running = true
|
||||||
|
s.done = make(chan struct{})
|
||||||
|
go s.captureLoop()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *wasapiStream) captureLoop() {
|
||||||
|
defer close(s.done)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(20 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
s.mu.Lock()
|
||||||
|
if !s.running {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read available packets
|
||||||
|
for {
|
||||||
|
var data uintptr
|
||||||
|
var numFrames uint32
|
||||||
|
var flags uint32
|
||||||
|
|
||||||
|
// IAudioCaptureClient::GetBuffer is vtable index 3
|
||||||
|
hr := comCall(s.capture, 3,
|
||||||
|
uintptr(unsafe.Pointer(&data)),
|
||||||
|
uintptr(unsafe.Pointer(&numFrames)),
|
||||||
|
uintptr(unsafe.Pointer(&flags)),
|
||||||
|
0, // device position (not needed)
|
||||||
|
0, // QPC position (not needed)
|
||||||
|
)
|
||||||
|
if hr != 0 || numFrames == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert int16 samples to float32
|
||||||
|
samples := make([]float32, numFrames*uint32(s.channels))
|
||||||
|
raw := (*[1 << 28]int16)(unsafe.Pointer(data))[:len(samples):len(samples)]
|
||||||
|
for i, v := range raw {
|
||||||
|
samples[i] = float32(v) / float32(math.MaxInt16)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.callback(samples)
|
||||||
|
|
||||||
|
// IAudioCaptureClient::ReleaseBuffer is vtable index 4
|
||||||
|
comCall(s.capture, 4, uintptr(numFrames))
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *wasapiStream) Stop() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.running = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.done != nil {
|
||||||
|
<-s.done
|
||||||
|
}
|
||||||
|
|
||||||
|
// IAudioClient::Stop is vtable index 7
|
||||||
|
if s.client != 0 {
|
||||||
|
comCall(s.client, 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release COM interfaces (IUnknown::Release is vtable index 2)
|
||||||
|
if s.capture != 0 {
|
||||||
|
comCall(s.capture, 2)
|
||||||
|
}
|
||||||
|
if s.client != 0 {
|
||||||
|
comCall(s.client, 2)
|
||||||
|
}
|
||||||
|
if s.device != 0 {
|
||||||
|
comCall(s.device, 2)
|
||||||
|
}
|
||||||
|
if s.enumerator != 0 {
|
||||||
|
comCall(s.enumerator, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// comCall invokes a COM method by vtable index.
|
||||||
|
func comCall(obj uintptr, method uintptr, args ...uintptr) uintptr {
|
||||||
|
vtable := *(*uintptr)(unsafe.Pointer(obj))
|
||||||
|
fn := *(*uintptr)(unsafe.Pointer(vtable + method*unsafe.Sizeof(uintptr(0))))
|
||||||
|
|
||||||
|
// Build syscall args: first arg is always 'this' pointer
|
||||||
|
callArgs := make([]uintptr, 1+len(args))
|
||||||
|
callArgs[0] = obj
|
||||||
|
copy(callArgs[1:], args)
|
||||||
|
|
||||||
|
var hr uintptr
|
||||||
|
switch len(callArgs) {
|
||||||
|
case 1:
|
||||||
|
hr, _, _ = syscall.SyscallN(fn, callArgs[0])
|
||||||
|
case 2:
|
||||||
|
hr, _, _ = syscall.SyscallN(fn, callArgs[0], callArgs[1])
|
||||||
|
case 3:
|
||||||
|
hr, _, _ = syscall.SyscallN(fn, callArgs[0], callArgs[1], callArgs[2])
|
||||||
|
case 4:
|
||||||
|
hr, _, _ = syscall.SyscallN(fn, callArgs[0], callArgs[1], callArgs[2], callArgs[3])
|
||||||
|
case 5:
|
||||||
|
hr, _, _ = syscall.SyscallN(fn, callArgs[0], callArgs[1], callArgs[2], callArgs[3], callArgs[4])
|
||||||
|
case 6:
|
||||||
|
hr, _, _ = syscall.SyscallN(fn, callArgs[0], callArgs[1], callArgs[2], callArgs[3], callArgs[4], callArgs[5])
|
||||||
|
case 7:
|
||||||
|
hr, _, _ = syscall.SyscallN(fn, callArgs[0], callArgs[1], callArgs[2], callArgs[3], callArgs[4], callArgs[5], callArgs[6])
|
||||||
|
default:
|
||||||
|
hr, _, _ = syscall.SyscallN(fn, callArgs...)
|
||||||
|
}
|
||||||
|
return hr
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ type flagOptions struct {
|
|||||||
verbose *bool
|
verbose *bool
|
||||||
warmup *int
|
warmup *int
|
||||||
promptTokens *int
|
promptTokens *int
|
||||||
|
numCtx *int
|
||||||
}
|
}
|
||||||
|
|
||||||
type Metrics struct {
|
type Metrics struct {
|
||||||
@@ -48,6 +49,7 @@ type ModelInfo struct {
|
|||||||
Family string
|
Family string
|
||||||
SizeBytes int64
|
SizeBytes int64
|
||||||
VRAMBytes int64
|
VRAMBytes int64
|
||||||
|
NumCtx int64
|
||||||
}
|
}
|
||||||
|
|
||||||
const DefaultPrompt = `Please write a descriptive story about a llama named Alonso who grows up to be President of the Land of Llamas. Include details about Alonso's childhood, adolescent years, and how he grew up to be a political mover and shaker. Write the story with a sense of whimsy.`
|
const DefaultPrompt = `Please write a descriptive story about a llama named Alonso who grows up to be President of the Land of Llamas. Include details about Alonso's childhood, adolescent years, and how he grew up to be a political mover and shaker. Write the story with a sense of whimsy.`
|
||||||
@@ -64,9 +66,12 @@ var promptWordList = []string{
|
|||||||
"old", "stone", "bridge", "that", "crosses", "winding", "river",
|
"old", "stone", "bridge", "that", "crosses", "winding", "river",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tokensPerWord is the calibrated ratio of tokens to words for the current model.
|
||||||
|
// Initialized with a heuristic, then updated during warmup based on actual tokenization.
|
||||||
|
var tokensPerWord = 1.3
|
||||||
|
|
||||||
func generatePromptForTokenCount(targetTokens int, epoch int) string {
|
func generatePromptForTokenCount(targetTokens int, epoch int) string {
|
||||||
// ~1.3 tokens per word heuristic
|
targetWords := int(float64(targetTokens) / tokensPerWord)
|
||||||
targetWords := int(float64(targetTokens) / 1.3)
|
|
||||||
if targetWords < 1 {
|
if targetWords < 1 {
|
||||||
targetWords = 1
|
targetWords = 1
|
||||||
}
|
}
|
||||||
@@ -81,6 +86,17 @@ func generatePromptForTokenCount(targetTokens int, epoch int) string {
|
|||||||
return strings.Join(words, " ")
|
return strings.Join(words, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// calibratePromptTokens adjusts tokensPerWord based on actual tokenization from a warmup run.
|
||||||
|
func calibratePromptTokens(targetTokens, actualTokens, wordCount int) {
|
||||||
|
if actualTokens <= 0 || wordCount <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokensPerWord = float64(actualTokens) / float64(wordCount)
|
||||||
|
newWords := int(float64(targetTokens) / tokensPerWord)
|
||||||
|
fmt.Fprintf(os.Stderr, "bench: calibrated %.2f tokens/word (target=%d, got=%d, words=%d → %d)\n",
|
||||||
|
tokensPerWord, targetTokens, actualTokens, wordCount, newWords)
|
||||||
|
}
|
||||||
|
|
||||||
func buildGenerateRequest(model string, fOpt flagOptions, imgData api.ImageData, epoch int) *api.GenerateRequest {
|
func buildGenerateRequest(model string, fOpt flagOptions, imgData api.ImageData, epoch int) *api.GenerateRequest {
|
||||||
options := make(map[string]interface{})
|
options := make(map[string]interface{})
|
||||||
if *fOpt.maxTokens > 0 {
|
if *fOpt.maxTokens > 0 {
|
||||||
@@ -90,6 +106,9 @@ func buildGenerateRequest(model string, fOpt flagOptions, imgData api.ImageData,
|
|||||||
if fOpt.seed != nil && *fOpt.seed > 0 {
|
if fOpt.seed != nil && *fOpt.seed > 0 {
|
||||||
options["seed"] = *fOpt.seed
|
options["seed"] = *fOpt.seed
|
||||||
}
|
}
|
||||||
|
if fOpt.numCtx != nil && *fOpt.numCtx > 0 {
|
||||||
|
options["num_ctx"] = *fOpt.numCtx
|
||||||
|
}
|
||||||
|
|
||||||
var keepAliveDuration *api.Duration
|
var keepAliveDuration *api.Duration
|
||||||
if *fOpt.keepAlive > 0 {
|
if *fOpt.keepAlive > 0 {
|
||||||
@@ -146,7 +165,6 @@ func fetchMemoryUsage(ctx context.Context, client *api.Client, model string) (si
|
|||||||
return m.Size, m.SizeVRAM
|
return m.Size, m.SizeVRAM
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Try prefix match (model names may include :latest or tags)
|
|
||||||
for _, m := range resp.Models {
|
for _, m := range resp.Models {
|
||||||
if strings.HasPrefix(m.Name, model) || strings.HasPrefix(m.Model, model) {
|
if strings.HasPrefix(m.Name, model) || strings.HasPrefix(m.Model, model) {
|
||||||
return m.Size, m.SizeVRAM
|
return m.Size, m.SizeVRAM
|
||||||
@@ -155,6 +173,19 @@ func fetchMemoryUsage(ctx context.Context, client *api.Client, model string) (si
|
|||||||
return 0, 0
|
return 0, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fetchContextLength(ctx context.Context, client *api.Client, model string) int64 {
|
||||||
|
resp, err := client.ListRunning(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for _, m := range resp.Models {
|
||||||
|
if m.Name == model || m.Model == model || strings.HasPrefix(m.Name, model) || strings.HasPrefix(m.Model, model) {
|
||||||
|
return int64(m.ContextLength)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func outputFormatHeader(w io.Writer, format string, verbose bool) {
|
func outputFormatHeader(w io.Writer, format string, verbose bool) {
|
||||||
switch format {
|
switch format {
|
||||||
case "benchstat":
|
case "benchstat":
|
||||||
@@ -177,8 +208,12 @@ func outputModelInfo(w io.Writer, format string, info ModelInfo) {
|
|||||||
if info.SizeBytes > 0 {
|
if info.SizeBytes > 0 {
|
||||||
memStr = fmt.Sprintf(" | Size: %d | VRAM: %d", info.SizeBytes, info.VRAMBytes)
|
memStr = fmt.Sprintf(" | Size: %d | VRAM: %d", info.SizeBytes, info.VRAMBytes)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "# Model: %s | Params: %s | Quant: %s | Family: %s%s\n",
|
ctxStr := ""
|
||||||
info.Name, params, quant, family, memStr)
|
if info.NumCtx > 0 {
|
||||||
|
ctxStr = fmt.Sprintf(" | NumCtx: %d", info.NumCtx)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "# Model: %s | Params: %s | Quant: %s | Family: %s%s%s\n",
|
||||||
|
info.Name, params, quant, family, memStr, ctxStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func OutputMetrics(w io.Writer, format string, metrics []Metrics, verbose bool) {
|
func OutputMetrics(w io.Writer, format string, metrics []Metrics, verbose bool) {
|
||||||
@@ -276,21 +311,38 @@ func BenchmarkModel(fOpt flagOptions) error {
|
|||||||
req := buildGenerateRequest(model, fOpt, imgData, -(i + 1))
|
req := buildGenerateRequest(model, fOpt, imgData, -(i + 1))
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*fOpt.timeout)*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*fOpt.timeout)*time.Second)
|
||||||
|
|
||||||
|
var warmupMetrics *api.Metrics
|
||||||
err = client.Generate(ctx, req, func(resp api.GenerateResponse) error {
|
err = client.Generate(ctx, req, func(resp api.GenerateResponse) error {
|
||||||
|
if resp.Done {
|
||||||
|
warmupMetrics = &resp.Metrics
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "WARNING: Warmup %d/%d for %s failed: %v\n", i+1, *fOpt.warmup, model, err)
|
fmt.Fprintf(os.Stderr, "WARNING: Warmup %d/%d for %s failed: %v\n", i+1, *fOpt.warmup, model, err)
|
||||||
} else if *fOpt.debug {
|
} else {
|
||||||
fmt.Fprintf(os.Stderr, "Warmup %d/%d for %s complete\n", i+1, *fOpt.warmup, model)
|
if *fOpt.debug {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warmup %d/%d for %s complete\n", i+1, *fOpt.warmup, model)
|
||||||
|
}
|
||||||
|
// Calibrate prompt token count on last warmup run
|
||||||
|
if i == *fOpt.warmup-1 && *fOpt.promptTokens > 0 && warmupMetrics != nil {
|
||||||
|
prompt := generatePromptForTokenCount(*fOpt.promptTokens, -(i + 1))
|
||||||
|
wordCount := len(strings.Fields(prompt))
|
||||||
|
calibratePromptTokens(*fOpt.promptTokens, warmupMetrics.PromptEvalCount, wordCount)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch memory usage once after warmup (model is loaded and stable)
|
// Fetch memory/context info once after warmup (model is loaded and stable)
|
||||||
memCtx, memCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
memCtx, memCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
info.SizeBytes, info.VRAMBytes = fetchMemoryUsage(memCtx, client, model)
|
info.SizeBytes, info.VRAMBytes = fetchMemoryUsage(memCtx, client, model)
|
||||||
|
if fOpt.numCtx != nil && *fOpt.numCtx > 0 {
|
||||||
|
info.NumCtx = int64(*fOpt.numCtx)
|
||||||
|
} else {
|
||||||
|
info.NumCtx = fetchContextLength(memCtx, client, model)
|
||||||
|
}
|
||||||
memCancel()
|
memCancel()
|
||||||
|
|
||||||
outputModelInfo(out, *fOpt.format, info)
|
outputModelInfo(out, *fOpt.format, info)
|
||||||
@@ -479,6 +531,7 @@ func main() {
|
|||||||
debug: flag.Bool("debug", false, "Show debug information"),
|
debug: flag.Bool("debug", false, "Show debug information"),
|
||||||
warmup: flag.Int("warmup", 1, "Number of warmup requests before timing"),
|
warmup: flag.Int("warmup", 1, "Number of warmup requests before timing"),
|
||||||
promptTokens: flag.Int("prompt-tokens", 0, "Generate prompt targeting ~N tokens (0 = use -p prompt)"),
|
promptTokens: flag.Int("prompt-tokens", 0, "Generate prompt targeting ~N tokens (0 = use -p prompt)"),
|
||||||
|
numCtx: flag.Int("num-ctx", 0, "Context size (0 = server default)"),
|
||||||
}
|
}
|
||||||
|
|
||||||
flag.Usage = func() {
|
flag.Usage = func() {
|
||||||
|
|||||||
10
cmd/cmd.go
@@ -695,7 +695,8 @@ func RunHandler(cmd *cobra.Command, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
opts.MultiModal = slices.Contains(info.Capabilities, model.CapabilityVision)
|
audioCapable := slices.Contains(info.Capabilities, model.CapabilityAudio)
|
||||||
|
opts.MultiModal = slices.Contains(info.Capabilities, model.CapabilityVision) || audioCapable
|
||||||
|
|
||||||
// TODO: remove the projector info and vision info checks below,
|
// TODO: remove the projector info and vision info checks below,
|
||||||
// these are left in for backwards compatibility with older servers
|
// these are left in for backwards compatibility with older servers
|
||||||
@@ -1494,6 +1495,9 @@ type displayResponseState struct {
|
|||||||
|
|
||||||
func displayResponse(content string, wordWrap bool, state *displayResponseState) {
|
func displayResponse(content string, wordWrap bool, state *displayResponseState) {
|
||||||
termWidth, _, _ := term.GetSize(int(os.Stdout.Fd()))
|
termWidth, _, _ := term.GetSize(int(os.Stdout.Fd()))
|
||||||
|
if termWidth == 0 {
|
||||||
|
termWidth = 80
|
||||||
|
}
|
||||||
if wordWrap && termWidth >= 10 {
|
if wordWrap && termWidth >= 10 {
|
||||||
for _, ch := range content {
|
for _, ch := range content {
|
||||||
if state.lineLength+1 > termWidth-5 {
|
if state.lineLength+1 > termWidth-5 {
|
||||||
@@ -2065,6 +2069,10 @@ func runLauncherAction(cmd *cobra.Command, action tui.TUIAction, deps launcherDe
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return true, fmt.Errorf("launching %s: %w", action.Integration, err)
|
return true, fmt.Errorf("launching %s: %w", action.Integration, err)
|
||||||
}
|
}
|
||||||
|
// VS Code is a GUI app — exit the TUI loop after launching
|
||||||
|
if action.Integration == "vscode" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
default:
|
default:
|
||||||
return false, fmt.Errorf("unknown launcher action: %d", action.Kind)
|
return false, fmt.Errorf("unknown launcher action: %d", action.Kind)
|
||||||
|
|||||||
@@ -209,6 +209,43 @@ func TestRunLauncherAction_RunModelContinuesAfterCancellation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunLauncherAction_VSCodeExitsTUILoop(t *testing.T) {
|
||||||
|
setCmdTestHome(t, t.TempDir())
|
||||||
|
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.SetContext(context.Background())
|
||||||
|
|
||||||
|
// VS Code should exit the TUI loop (return false) after a successful launch.
|
||||||
|
continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "vscode"}, launcherDeps{
|
||||||
|
resolveRunModel: unexpectedRunModelResolution(t),
|
||||||
|
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
runModel: unexpectedModelLaunch(t),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil error, got %v", err)
|
||||||
|
}
|
||||||
|
if continueLoop {
|
||||||
|
t.Fatal("expected vscode launch to exit the TUI loop (return false)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other integrations should continue the TUI loop (return true).
|
||||||
|
continueLoop, err = runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "claude"}, launcherDeps{
|
||||||
|
resolveRunModel: unexpectedRunModelResolution(t),
|
||||||
|
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
runModel: unexpectedModelLaunch(t),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil error, got %v", err)
|
||||||
|
}
|
||||||
|
if !continueLoop {
|
||||||
|
t.Fatal("expected non-vscode integration to continue the TUI loop (return true)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunLauncherAction_IntegrationContinuesAfterCancellation(t *testing.T) {
|
func TestRunLauncherAction_IntegrationContinuesAfterCancellation(t *testing.T) {
|
||||||
setCmdTestHome(t, t.TempDir())
|
setCmdTestHome(t, t.TempDir())
|
||||||
|
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ Weigh anchor!
|
|||||||
ParameterSize: "7B",
|
ParameterSize: "7B",
|
||||||
QuantizationLevel: "FP16",
|
QuantizationLevel: "FP16",
|
||||||
},
|
},
|
||||||
Requires: "0.14.0",
|
Requires: "0.19.0",
|
||||||
}, false, &b); err != nil {
|
}, false, &b); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -310,10 +310,17 @@ Weigh anchor!
|
|||||||
architecture test
|
architecture test
|
||||||
parameters 7B
|
parameters 7B
|
||||||
quantization FP16
|
quantization FP16
|
||||||
requires 0.14.0
|
requires 0.19.0
|
||||||
|
|
||||||
`
|
`
|
||||||
if diff := cmp.Diff(expect, b.String()); diff != "" {
|
trimLinePadding := func(s string) string {
|
||||||
|
lines := strings.Split(s, "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
lines[i] = strings.TrimRight(line, " \t\r")
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
if diff := cmp.Diff(trimLinePadding(expect), trimLinePadding(b.String())); diff != "" {
|
||||||
t.Errorf("unexpected output (-want +got):\n%s", diff)
|
t.Errorf("unexpected output (-want +got):\n%s", diff)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1912,7 +1919,7 @@ func TestShowInfoImageGen(t *testing.T) {
|
|||||||
QuantizationLevel: "Q8",
|
QuantizationLevel: "Q8",
|
||||||
},
|
},
|
||||||
Capabilities: []model.Capability{model.CapabilityImage},
|
Capabilities: []model.Capability{model.CapabilityImage},
|
||||||
Requires: "0.14.0",
|
Requires: "0.19.0",
|
||||||
}, false, &b)
|
}, false, &b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -1922,7 +1929,7 @@ func TestShowInfoImageGen(t *testing.T) {
|
|||||||
" architecture ZImagePipeline \n" +
|
" architecture ZImagePipeline \n" +
|
||||||
" parameters 10.3B \n" +
|
" parameters 10.3B \n" +
|
||||||
" quantization Q8 \n" +
|
" quantization Q8 \n" +
|
||||||
" requires 0.14.0 \n" +
|
" requires 0.19.0 \n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
" Capabilities\n" +
|
" Capabilities\n" +
|
||||||
" image \n" +
|
" image \n" +
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
|||||||
fmt.Fprintln(os.Stderr, "Use \"\"\" to begin a multi-line message.")
|
fmt.Fprintln(os.Stderr, "Use \"\"\" to begin a multi-line message.")
|
||||||
|
|
||||||
if opts.MultiModal {
|
if opts.MultiModal {
|
||||||
fmt.Fprintf(os.Stderr, "Use %s to include .jpg, .png, or .webp images.\n", filepath.FromSlash("/path/to/file"))
|
fmt.Fprintf(os.Stderr, "Use %s to include .jpg, .png, .webp images, or .wav audio files.\n", filepath.FromSlash("/path/to/file"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(os.Stderr, "")
|
fmt.Fprintln(os.Stderr, "")
|
||||||
@@ -592,7 +592,7 @@ func extractFileNames(input string) []string {
|
|||||||
// Regex to match file paths starting with optional drive letter, / ./ \ or .\ and include escaped or unescaped spaces (\ or %20)
|
// Regex to match file paths starting with optional drive letter, / ./ \ or .\ and include escaped or unescaped spaces (\ or %20)
|
||||||
// and followed by more characters and a file extension
|
// and followed by more characters and a file extension
|
||||||
// This will capture non filename strings, but we'll check for file existence to remove mismatches
|
// This will capture non filename strings, but we'll check for file existence to remove mismatches
|
||||||
regexPattern := `(?:[a-zA-Z]:)?(?:\./|/|\\)[\S\\ ]+?\.(?i:jpg|jpeg|png|webp)\b`
|
regexPattern := `(?:[a-zA-Z]:)?(?:\./|/|\\)[\S\\ ]+?\.(?i:jpg|jpeg|png|webp|wav|mp4|webm|mov|avi|mkv|m4v)\b`
|
||||||
re := regexp.MustCompile(regexPattern)
|
re := regexp.MustCompile(regexPattern)
|
||||||
|
|
||||||
return re.FindAllString(input, -1)
|
return re.FindAllString(input, -1)
|
||||||
@@ -608,10 +608,16 @@ func extractFileData(input string) (string, []api.ImageData, error) {
|
|||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
continue
|
continue
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Couldn't process image: %q\n", err)
|
fmt.Fprintf(os.Stderr, "Couldn't process file: %q\n", err)
|
||||||
return "", imgs, err
|
return "", imgs, err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "Added image '%s'\n", nfp)
|
ext := strings.ToLower(filepath.Ext(nfp))
|
||||||
|
switch ext {
|
||||||
|
case ".wav":
|
||||||
|
fmt.Fprintf(os.Stderr, "Added audio '%s'\n", nfp)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "Added image '%s'\n", nfp)
|
||||||
|
}
|
||||||
input = strings.ReplaceAll(input, "'"+nfp+"'", "")
|
input = strings.ReplaceAll(input, "'"+nfp+"'", "")
|
||||||
input = strings.ReplaceAll(input, "'"+fp+"'", "")
|
input = strings.ReplaceAll(input, "'"+fp+"'", "")
|
||||||
input = strings.ReplaceAll(input, fp, "")
|
input = strings.ReplaceAll(input, fp, "")
|
||||||
@@ -685,9 +691,9 @@ func getImageData(filePath string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
contentType := http.DetectContentType(buf)
|
contentType := http.DetectContentType(buf)
|
||||||
allowedTypes := []string{"image/jpeg", "image/jpg", "image/png", "image/webp"}
|
allowedTypes := []string{"image/jpeg", "image/jpg", "image/png", "image/webp", "audio/wave"}
|
||||||
if !slices.Contains(allowedTypes, contentType) {
|
if !slices.Contains(allowedTypes, contentType) {
|
||||||
return nil, fmt.Errorf("invalid image type: %s", contentType)
|
return nil, fmt.Errorf("invalid file type: %s", contentType)
|
||||||
}
|
}
|
||||||
|
|
||||||
info, err := file.Stat()
|
info, err := file.Stat()
|
||||||
@@ -695,8 +701,7 @@ func getImageData(filePath string) ([]byte, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the file size exceeds 100MB
|
var maxSize int64 = 100 * 1024 * 1024 // 100MB
|
||||||
var maxSize int64 = 100 * 1024 * 1024 // 100MB in bytes
|
|
||||||
if info.Size() > maxSize {
|
if info.Size() > maxSize {
|
||||||
return nil, errors.New("file size exceeds maximum limit (100MB)")
|
return nil, errors.New("file size exceeds maximum limit (100MB)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,3 +84,33 @@ func TestExtractFileDataRemovesQuotedFilepath(t *testing.T) {
|
|||||||
assert.Len(t, imgs, 1)
|
assert.Len(t, imgs, 1)
|
||||||
assert.Equal(t, cleaned, "before after")
|
assert.Equal(t, cleaned, "before after")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractFileDataWAV(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
fp := filepath.Join(dir, "sample.wav")
|
||||||
|
data := make([]byte, 600)
|
||||||
|
copy(data[:44], []byte{
|
||||||
|
'R', 'I', 'F', 'F',
|
||||||
|
0x58, 0x02, 0x00, 0x00, // file size - 8
|
||||||
|
'W', 'A', 'V', 'E',
|
||||||
|
'f', 'm', 't', ' ',
|
||||||
|
0x10, 0x00, 0x00, 0x00, // fmt chunk size
|
||||||
|
0x01, 0x00, // PCM
|
||||||
|
0x01, 0x00, // mono
|
||||||
|
0x80, 0x3e, 0x00, 0x00, // 16000 Hz
|
||||||
|
0x00, 0x7d, 0x00, 0x00, // byte rate
|
||||||
|
0x02, 0x00, // block align
|
||||||
|
0x10, 0x00, // 16-bit
|
||||||
|
'd', 'a', 't', 'a',
|
||||||
|
0x34, 0x02, 0x00, 0x00, // data size
|
||||||
|
})
|
||||||
|
if err := os.WriteFile(fp, data, 0o600); err != nil {
|
||||||
|
t.Fatalf("failed to write test audio: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
input := "before " + fp + " after"
|
||||||
|
cleaned, imgs, err := extractFileData(input)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, imgs, 1)
|
||||||
|
assert.Equal(t, "before after", cleaned)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1551,6 +1551,31 @@ func TestIntegration_Editor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIntegration_AutoInstallable(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"openclaw", true},
|
||||||
|
{"pi", true},
|
||||||
|
{"claude", false},
|
||||||
|
{"codex", false},
|
||||||
|
{"opencode", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := false
|
||||||
|
integration, err := integrationFor(tt.name)
|
||||||
|
if err == nil {
|
||||||
|
got = integration.autoInstallable
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("integrationFor(%q).autoInstallable = %v, want %v", tt.name, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIntegrationModels(t *testing.T) {
|
func TestIntegrationModels(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
setTestHome(t, tmpDir)
|
setTestHome(t, tmpDir)
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ Supported integrations:
|
|||||||
opencode OpenCode
|
opencode OpenCode
|
||||||
openclaw OpenClaw (aliases: clawdbot, moltbot)
|
openclaw OpenClaw (aliases: clawdbot, moltbot)
|
||||||
pi Pi
|
pi Pi
|
||||||
|
vscode VS Code (aliases: code)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
ollama launch
|
ollama launch
|
||||||
@@ -489,8 +490,10 @@ func (c *launcherClient) launchEditorIntegration(ctx context.Context, name strin
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
models = selected
|
models = selected
|
||||||
} else if err := c.ensureModelsReady(ctx, models); err != nil {
|
} else if len(models) > 0 {
|
||||||
return err
|
if err := c.ensureModelsReady(ctx, models[:1]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(models) == 0 {
|
if len(models) == 0 {
|
||||||
@@ -551,10 +554,14 @@ func (c *launcherClient) selectMultiModelsForIntegration(ctx context.Context, ru
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := c.ensureModelsReady(ctx, selected); err != nil {
|
accepted, skipped, err := c.selectReadyModelsForSave(ctx, selected)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return selected, nil
|
for _, skip := range skipped {
|
||||||
|
fmt.Fprintf(os.Stderr, "Skipped %s: %s\n", skip.model, skip.reason)
|
||||||
|
}
|
||||||
|
return accepted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *launcherClient) loadSelectableModels(ctx context.Context, preChecked []string, current, emptyMessage string) ([]ModelItem, []string, error) {
|
func (c *launcherClient) loadSelectableModels(ctx context.Context, preChecked []string, current, emptyMessage string) ([]ModelItem, []string, error) {
|
||||||
@@ -575,16 +582,7 @@ func (c *launcherClient) loadSelectableModels(ctx context.Context, preChecked []
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *launcherClient) ensureModelsReady(ctx context.Context, models []string) error {
|
func (c *launcherClient) ensureModelsReady(ctx context.Context, models []string) error {
|
||||||
var deduped []string
|
models = dedupeModelList(models)
|
||||||
seen := make(map[string]bool, len(models))
|
|
||||||
for _, model := range models {
|
|
||||||
if model == "" || seen[model] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[model] = true
|
|
||||||
deduped = append(deduped, model)
|
|
||||||
}
|
|
||||||
models = deduped
|
|
||||||
if len(models) == 0 {
|
if len(models) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -602,6 +600,56 @@ func (c *launcherClient) ensureModelsReady(ctx context.Context, models []string)
|
|||||||
return ensureAuth(ctx, c.apiClient, cloudModels, models)
|
return ensureAuth(ctx, c.apiClient, cloudModels, models)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dedupeModelList(models []string) []string {
|
||||||
|
deduped := make([]string, 0, len(models))
|
||||||
|
seen := make(map[string]bool, len(models))
|
||||||
|
for _, model := range models {
|
||||||
|
if model == "" || seen[model] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[model] = true
|
||||||
|
deduped = append(deduped, model)
|
||||||
|
}
|
||||||
|
return deduped
|
||||||
|
}
|
||||||
|
|
||||||
|
type skippedModel struct {
|
||||||
|
model string
|
||||||
|
reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *launcherClient) selectReadyModelsForSave(ctx context.Context, selected []string) ([]string, []skippedModel, error) {
|
||||||
|
selected = dedupeModelList(selected)
|
||||||
|
accepted := make([]string, 0, len(selected))
|
||||||
|
skipped := make([]skippedModel, 0, len(selected))
|
||||||
|
|
||||||
|
for _, model := range selected {
|
||||||
|
if err := c.ensureModelsReady(ctx, []string{model}); err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
skipped = append(skipped, skippedModel{
|
||||||
|
model: model,
|
||||||
|
reason: skippedModelReason(model, err),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
accepted = append(accepted, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
return accepted, skipped, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func skippedModelReason(model string, err error) string {
|
||||||
|
if errors.Is(err, ErrCancelled) {
|
||||||
|
if isCloudModelName(model) {
|
||||||
|
return "sign in was cancelled"
|
||||||
|
}
|
||||||
|
return "download was cancelled"
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
func (c *launcherClient) resolveEditorLaunchModels(ctx context.Context, saved *config.IntegrationConfig, req IntegrationLaunchRequest) ([]string, bool) {
|
func (c *launcherClient) resolveEditorLaunchModels(ctx context.Context, saved *config.IntegrationConfig, req IntegrationLaunchRequest) ([]string, bool) {
|
||||||
if req.ForceConfigure {
|
if req.ForceConfigure {
|
||||||
return editorPreCheckedModels(saved, req.ModelOverride), true
|
return editorPreCheckedModels(saved, req.ModelOverride), true
|
||||||
@@ -801,13 +849,6 @@ func cloneAliases(aliases map[string]string) map[string]string {
|
|||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
func singleModelPrechecked(current string) []string {
|
|
||||||
if current == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []string{current}
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstModel(models []string) string {
|
func firstModel(models []string) string {
|
||||||
if len(models) == 0 {
|
if len(models) == 0 {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -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) (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) (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) (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) (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) (bool, error) {
|
||||||
|
t.Fatalf("did not expect prompt during normal configured launch: %q", prompt)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var missingShowCalled bool
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/show" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req apiShowRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
switch req.Model {
|
||||||
|
case "llama3.2":
|
||||||
|
fmt.Fprint(w, `{"model":"llama3.2"}`)
|
||||||
|
case "missing-local":
|
||||||
|
missingShowCalled = true
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprint(w, `{"error":"model not found"}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||||
|
|
||||||
|
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "droid"}); err != nil {
|
||||||
|
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||||
|
}
|
||||||
|
if missingShowCalled {
|
||||||
|
t.Fatal("expected configured launch to validate only the primary model")
|
||||||
|
}
|
||||||
|
if editor.ranModel != "llama3.2" {
|
||||||
|
t.Fatalf("expected launch to use saved primary model, got %q", editor.ranModel)
|
||||||
|
}
|
||||||
|
if len(editor.edited) != 0 {
|
||||||
|
t.Fatalf("expected no editor writes during normal launch, got %v", editor.edited)
|
||||||
|
}
|
||||||
|
|
||||||
|
saved, err := config.LoadIntegration("droid")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to reload saved config: %v", err)
|
||||||
|
}
|
||||||
|
if diff := compareStrings(saved.Models, []string{"llama3.2", "missing-local"}); diff != "" {
|
||||||
|
t.Fatalf("unexpected saved models (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLaunchIntegration_ConfiguredEditorLaunchSkipsReconfigure(t *testing.T) {
|
func TestLaunchIntegration_ConfiguredEditorLaunchSkipsReconfigure(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
setLaunchTestHome(t, tmpDir)
|
setLaunchTestHome(t, tmpDir)
|
||||||
@@ -965,6 +1362,40 @@ func TestLaunchIntegration_OpenclawInstallsBeforeConfigSideEffects(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLaunchIntegration_PiInstallsBeforeConfigSideEffects(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
withLauncherHooks(t)
|
||||||
|
|
||||||
|
t.Setenv("PATH", t.TempDir())
|
||||||
|
|
||||||
|
editor := &launcherEditorRunner{}
|
||||||
|
withIntegrationOverride(t, "pi", editor)
|
||||||
|
|
||||||
|
selectorCalled := false
|
||||||
|
DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) {
|
||||||
|
selectorCalled = true
|
||||||
|
return []string{"llama3.2"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "pi"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected launch to fail before configuration when Pi is missing")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "required dependencies are missing") {
|
||||||
|
t.Fatalf("expected install prerequisite error, got %v", err)
|
||||||
|
}
|
||||||
|
if selectorCalled {
|
||||||
|
t.Fatal("expected install check to happen before model selection")
|
||||||
|
}
|
||||||
|
if len(editor.edited) != 0 {
|
||||||
|
t.Fatalf("expected no editor writes before install succeeds, got %v", editor.edited)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(filepath.Join(tmpDir, ".pi", "agent", "models.json")); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("expected no Pi config file to be created, stat err = %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLaunchIntegration_ConfigureOnlyDoesNotRequireInstalledBinary(t *testing.T) {
|
func TestLaunchIntegration_ConfigureOnlyDoesNotRequireInstalledBinary(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
setLaunchTestHome(t, tmpDir)
|
setLaunchTestHome(t, tmpDir)
|
||||||
@@ -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) (bool, error) {
|
||||||
|
if prompt == "Download missing-model?" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected prompt: %q", prompt)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/tags":
|
||||||
|
fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`)
|
||||||
|
case "/api/show":
|
||||||
|
var req apiShowRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if req.Model == "missing-model" {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprint(w, `{"error":"model not found"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `{"model":%q}`, req.Model)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||||
|
|
||||||
|
err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
|
||||||
|
Name: "claude",
|
||||||
|
ForceConfigure: true,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected missing selected model to abort launch")
|
||||||
|
}
|
||||||
|
|
||||||
|
saved, loadErr := config.LoadIntegration("claude")
|
||||||
|
if loadErr != nil {
|
||||||
|
t.Fatalf("failed to reload saved config: %v", loadErr)
|
||||||
|
}
|
||||||
|
if diff := compareStrings(saved.Models, []string{"llama3.2"}); diff != "" {
|
||||||
|
t.Fatalf("unexpected saved models (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLaunchIntegration_ClaudeModelOverrideSkipsSelector(t *testing.T) {
|
func TestLaunchIntegration_ClaudeModelOverrideSkipsSelector(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
setLaunchTestHome(t, tmpDir)
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ func (o *OpenCode) Edit(modelList []string) error {
|
|||||||
ollama["models"] = models
|
ollama["models"] = models
|
||||||
provider["ollama"] = ollama
|
provider["ollama"] = ollama
|
||||||
config["provider"] = provider
|
config["provider"] = provider
|
||||||
|
config["model"] = "ollama/" + modelList[0]
|
||||||
|
|
||||||
configData, err := json.MarshalIndent(config, "", " ")
|
configData, err := json.MarshalIndent(config, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ func TestOpenCodeEdit(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
assertOpenCodeModelExists(t, configPath, "llama3.2")
|
assertOpenCodeModelExists(t, configPath, "llama3.2")
|
||||||
|
assertOpenCodeDefaultModel(t, configPath, "ollama/llama3.2")
|
||||||
assertOpenCodeRecentModel(t, statePath, 0, "ollama", "llama3.2")
|
assertOpenCodeRecentModel(t, statePath, 0, "ollama", "llama3.2")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -157,11 +158,13 @@ func TestOpenCodeEdit(t *testing.T) {
|
|||||||
o.Edit([]string{"llama3.2", "mistral"})
|
o.Edit([]string{"llama3.2", "mistral"})
|
||||||
assertOpenCodeModelExists(t, configPath, "llama3.2")
|
assertOpenCodeModelExists(t, configPath, "llama3.2")
|
||||||
assertOpenCodeModelExists(t, configPath, "mistral")
|
assertOpenCodeModelExists(t, configPath, "mistral")
|
||||||
|
assertOpenCodeDefaultModel(t, configPath, "ollama/llama3.2")
|
||||||
|
|
||||||
// Then remove one by only selecting the other
|
// Then remove one by only selecting the other
|
||||||
o.Edit([]string{"llama3.2"})
|
o.Edit([]string{"llama3.2"})
|
||||||
assertOpenCodeModelExists(t, configPath, "llama3.2")
|
assertOpenCodeModelExists(t, configPath, "llama3.2")
|
||||||
assertOpenCodeModelNotExists(t, configPath, "mistral")
|
assertOpenCodeModelNotExists(t, configPath, "mistral")
|
||||||
|
assertOpenCodeDefaultModel(t, configPath, "ollama/llama3.2")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("preserve user customizations on managed models", func(t *testing.T) {
|
t.Run("preserve user customizations on managed models", func(t *testing.T) {
|
||||||
@@ -338,6 +341,22 @@ func assertOpenCodeModelNotExists(t *testing.T, path, model string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertOpenCodeDefaultModel(t *testing.T, path, want string) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var cfg map[string]any
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := cfg["model"].(string)
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("default model = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func assertOpenCodeRecentModel(t *testing.T, path string, index int, providerID, modelID string) {
|
func assertOpenCodeRecentModel(t *testing.T, path string, index int, providerID, modelID string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
|
|||||||
137
cmd/launch/pi.go
@@ -20,20 +20,151 @@ import (
|
|||||||
// Pi implements Runner and Editor for Pi (Pi Coding Agent) integration
|
// Pi implements Runner and Editor for Pi (Pi Coding Agent) integration
|
||||||
type Pi struct{}
|
type Pi struct{}
|
||||||
|
|
||||||
|
const (
|
||||||
|
piNpmPackage = "@mariozechner/pi-coding-agent"
|
||||||
|
piWebSearchSource = "npm:@ollama/pi-web-search"
|
||||||
|
piWebSearchPkg = "@ollama/pi-web-search"
|
||||||
|
)
|
||||||
|
|
||||||
func (p *Pi) String() string { return "Pi" }
|
func (p *Pi) String() string { return "Pi" }
|
||||||
|
|
||||||
func (p *Pi) Run(model string, args []string) error {
|
func (p *Pi) Run(model string, args []string) error {
|
||||||
if _, err := exec.LookPath("pi"); err != nil {
|
fmt.Fprintf(os.Stderr, "\n%sPreparing Pi...%s\n", ansiGray, ansiReset)
|
||||||
return fmt.Errorf("pi is not installed, install with: npm install -g @mariozechner/pi-coding-agent")
|
if err := ensureNpmInstalled(); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("pi", args...)
|
fmt.Fprintf(os.Stderr, "%sChecking Pi installation...%s\n", ansiGray, ansiReset)
|
||||||
|
bin, err := ensurePiInstalled()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ensurePiWebSearchPackage(bin)
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "\n%sLaunching Pi...%s\n\n", ansiGray, ansiReset)
|
||||||
|
|
||||||
|
cmd := exec.Command(bin, args...)
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureNpmInstalled() error {
|
||||||
|
if _, err := exec.LookPath("npm"); err != nil {
|
||||||
|
return fmt.Errorf("npm (Node.js) is required to launch pi\n\nInstall it first:\n https://nodejs.org/")
|
||||||
|
}
|
||||||
|
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/")
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := ConfirmPrompt("Pi is not installed. Install with npm?")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("pi installation cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "\nInstalling Pi...\n")
|
||||||
|
cmd := exec.Command("npm", "install", "-g", piNpmPackage+"@latest")
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to install pi: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("pi"); err != nil {
|
||||||
|
return "", fmt.Errorf("pi was installed but the binary was not found on PATH\n\nYou may need to restart your shell")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "%sPi installed successfully%s\n\n", ansiGreen, ansiReset)
|
||||||
|
return "pi", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensurePiWebSearchPackage(bin string) {
|
||||||
|
if !shouldManagePiWebSearch() {
|
||||||
|
fmt.Fprintf(os.Stderr, "%sCloud is disabled; skipping %s setup.%s\n", ansiGray, piWebSearchPkg, ansiReset)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "%sChecking Pi web search package...%s\n", ansiGray, ansiReset)
|
||||||
|
|
||||||
|
installed, err := piPackageInstalled(bin, piWebSearchSource)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s Warning: could not check %s installation: %v%s\n", ansiYellow, piWebSearchPkg, err, ansiReset)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !installed {
|
||||||
|
fmt.Fprintf(os.Stderr, "%sInstalling %s...%s\n", ansiGray, piWebSearchPkg, ansiReset)
|
||||||
|
cmd := exec.Command(bin, "install", piWebSearchSource)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s Warning: could not install %s: %v%s\n", ansiYellow, piWebSearchPkg, err, ansiReset)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "%s ✓ Installed %s%s\n", ansiGreen, piWebSearchPkg, ansiReset)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "%sUpdating %s...%s\n", ansiGray, piWebSearchPkg, ansiReset)
|
||||||
|
cmd := exec.Command(bin, "update", piWebSearchSource)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s Warning: could not update %s: %v%s\n", ansiYellow, piWebSearchPkg, err, ansiReset)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "%s ✓ Updated %s%s\n", ansiGreen, piWebSearchPkg, ansiReset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldManagePiWebSearch() bool {
|
||||||
|
client, err := api.ClientFromEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
disabled, known := cloudStatusDisabled(context.Background(), client)
|
||||||
|
if known && disabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func piPackageInstalled(bin, source string) (bool, error) {
|
||||||
|
cmd := exec.Command(bin, "list")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
msg := strings.TrimSpace(string(out))
|
||||||
|
if msg == "" {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("%w: %s", err, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trimmed, source) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Pi) Paths() []string {
|
func (p *Pi) Paths() []string {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/ollama/ollama/api"
|
"github.com/ollama/ollama/api"
|
||||||
@@ -33,6 +35,339 @@ 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 = fn
|
||||||
|
t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm })
|
||||||
|
}
|
||||||
|
|
||||||
|
setCloudStatus := func(t *testing.T, disabled bool) {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/status" {
|
||||||
|
fmt.Fprintf(w, `{"cloud":{"disabled":%t,"source":"config"}}`, disabled)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("pi missing + user accepts install", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
setCloudStatus(t, false)
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n npm:@ollama/pi-web-search\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
npmScript := fmt.Sprintf(`#!/bin/sh
|
||||||
|
echo "$@" >> %q
|
||||||
|
if [ "$1" = "install" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then
|
||||||
|
/bin/cat > %q <<'EOS'
|
||||||
|
#!/bin/sh
|
||||||
|
echo "$@" >> %q
|
||||||
|
if [ "$1" = "list" ]; then
|
||||||
|
if [ -f %q ]; then
|
||||||
|
/bin/cat %q
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
EOS
|
||||||
|
/bin/chmod +x %q
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
`, filepath.Join(tmpDir, "npm.log"), piNpmPackage+"@latest", filepath.Join(tmpDir, "pi"), filepath.Join(tmpDir, "pi.log"), filepath.Join(tmpDir, "pi-list.txt"), filepath.Join(tmpDir, "pi-list.txt"), filepath.Join(tmpDir, "pi"))
|
||||||
|
writeScript(t, filepath.Join(tmpDir, "npm"), npmScript)
|
||||||
|
|
||||||
|
withConfirm(t, func(prompt string) (bool, error) {
|
||||||
|
if strings.Contains(prompt, "Pi is not installed.") {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
p := &Pi{}
|
||||||
|
if err := p.Run("ignored", []string{"--version"}); err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
npmCalls, err := os.ReadFile(filepath.Join(tmpDir, "npm.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(npmCalls), "install -g "+piNpmPackage+"@latest") {
|
||||||
|
t.Fatalf("expected npm install call, got:\n%s", npmCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := string(piCalls)
|
||||||
|
if !strings.Contains(got, "list\n") {
|
||||||
|
t.Fatalf("expected pi list call, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "update "+piWebSearchSource+"\n") {
|
||||||
|
t.Fatalf("expected pi update call, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "--version\n") {
|
||||||
|
t.Fatalf("expected final pi launch call, got:\n%s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pi missing + user declines install", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
setCloudStatus(t, false)
|
||||||
|
writeScript(t, filepath.Join(tmpDir, "npm"), "#!/bin/sh\nexit 0\n")
|
||||||
|
|
||||||
|
withConfirm(t, func(prompt string) (bool, error) {
|
||||||
|
if strings.Contains(prompt, "Pi is not installed.") {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
p := &Pi{}
|
||||||
|
err := p.Run("ignored", nil)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "pi installation cancelled") {
|
||||||
|
t.Fatalf("expected install cancellation error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pi installed + web search missing auto-installs", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
setCloudStatus(t, false)
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seedPiScript(t, tmpDir)
|
||||||
|
seedNpmNoop(t, tmpDir)
|
||||||
|
withConfirm(t, func(prompt string) (bool, error) {
|
||||||
|
t.Fatalf("did not expect confirmation prompt, got %q", prompt)
|
||||||
|
return false, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
p := &Pi{}
|
||||||
|
if err := p.Run("ignored", []string{"session"}); err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := string(piCalls)
|
||||||
|
if !strings.Contains(got, "list\n") {
|
||||||
|
t.Fatalf("expected pi list call, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "install "+piWebSearchSource+"\n") {
|
||||||
|
t.Fatalf("expected pi install call, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "update "+piWebSearchSource+"\n") {
|
||||||
|
t.Fatalf("did not expect pi update call when package missing, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "session\n") {
|
||||||
|
t.Fatalf("expected final pi launch call, got:\n%s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pi installed + web search present updates every launch", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
setCloudStatus(t, false)
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n "+piWebSearchSource+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seedPiScript(t, tmpDir)
|
||||||
|
seedNpmNoop(t, tmpDir)
|
||||||
|
|
||||||
|
p := &Pi{}
|
||||||
|
if err := p.Run("ignored", []string{"doctor"}); err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := string(piCalls)
|
||||||
|
if !strings.Contains(got, "update "+piWebSearchSource+"\n") {
|
||||||
|
t.Fatalf("expected pi update call, got:\n%s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("web search update failure warns and continues", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
setCloudStatus(t, false)
|
||||||
|
t.Setenv("PI_FAIL_UPDATE", "1")
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n "+piWebSearchSource+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seedPiScript(t, tmpDir)
|
||||||
|
seedNpmNoop(t, tmpDir)
|
||||||
|
|
||||||
|
p := &Pi{}
|
||||||
|
stderr := captureStderr(t, func() {
|
||||||
|
if err := p.Run("ignored", []string{"session"}); err != nil {
|
||||||
|
t.Fatalf("Run() should continue after web search update failure, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if !strings.Contains(stderr, "Warning: could not update "+piWebSearchPkg) {
|
||||||
|
t.Fatalf("expected update warning, got:\n%s", stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(piCalls), "session\n") {
|
||||||
|
t.Fatalf("expected final pi launch call, got:\n%s", piCalls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("web search install failure warns and continues", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
setCloudStatus(t, false)
|
||||||
|
t.Setenv("PI_FAIL_INSTALL", "1")
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seedPiScript(t, tmpDir)
|
||||||
|
seedNpmNoop(t, tmpDir)
|
||||||
|
withConfirm(t, func(prompt string) (bool, error) {
|
||||||
|
t.Fatalf("did not expect confirmation prompt, got %q", prompt)
|
||||||
|
return false, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
p := &Pi{}
|
||||||
|
stderr := captureStderr(t, func() {
|
||||||
|
if err := p.Run("ignored", []string{"session"}); err != nil {
|
||||||
|
t.Fatalf("Run() should continue after web search install failure, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if !strings.Contains(stderr, "Warning: could not install "+piWebSearchPkg) {
|
||||||
|
t.Fatalf("expected install warning, got:\n%s", stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(piCalls), "session\n") {
|
||||||
|
t.Fatalf("expected final pi launch call, got:\n%s", piCalls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cloud disabled skips web search package management", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
setCloudStatus(t, true)
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seedPiScript(t, tmpDir)
|
||||||
|
seedNpmNoop(t, tmpDir)
|
||||||
|
|
||||||
|
p := &Pi{}
|
||||||
|
stderr := captureStderr(t, func() {
|
||||||
|
if err := p.Run("ignored", []string{"session"}); err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if !strings.Contains(stderr, "Cloud is disabled; skipping "+piWebSearchPkg+" setup.") {
|
||||||
|
t.Fatalf("expected cloud-disabled skip message, got:\n%s", stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := string(piCalls)
|
||||||
|
if strings.Contains(got, "list\n") || strings.Contains(got, "install "+piWebSearchSource+"\n") || strings.Contains(got, "update "+piWebSearchSource+"\n") {
|
||||||
|
t.Fatalf("did not expect web search package management calls, got:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "session\n") {
|
||||||
|
t.Fatalf("expected final pi launch call, got:\n%s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing npm returns error before pi flow", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
t.Setenv("PATH", tmpDir)
|
||||||
|
setCloudStatus(t, false)
|
||||||
|
seedPiScript(t, tmpDir)
|
||||||
|
|
||||||
|
p := &Pi{}
|
||||||
|
err := p.Run("ignored", []string{"session"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "npm (Node.js) is required to launch pi") {
|
||||||
|
t.Fatalf("expected missing npm error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, statErr := os.Stat(filepath.Join(tmpDir, "pi.log")); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("expected pi not to run when npm is missing, stat err = %v", statErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestPiPaths(t *testing.T) {
|
func TestPiPaths(t *testing.T) {
|
||||||
pi := &Pi{}
|
pi := &Pi{}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ type IntegrationInfo struct {
|
|||||||
Description string
|
Description string
|
||||||
}
|
}
|
||||||
|
|
||||||
var launcherIntegrationOrder = []string{"opencode", "droid", "pi", "cline"}
|
var launcherIntegrationOrder = []string{"opencode", "droid", "pi"}
|
||||||
|
|
||||||
var integrationSpecs = []*IntegrationSpec{
|
var integrationSpecs = []*IntegrationSpec{
|
||||||
{
|
{
|
||||||
@@ -52,6 +52,7 @@ var integrationSpecs = []*IntegrationSpec{
|
|||||||
Name: "cline",
|
Name: "cline",
|
||||||
Runner: &Cline{},
|
Runner: &Cline{},
|
||||||
Description: "Autonomous coding agent with parallel execution",
|
Description: "Autonomous coding agent with parallel execution",
|
||||||
|
Hidden: true,
|
||||||
Install: IntegrationInstallSpec{
|
Install: IntegrationInstallSpec{
|
||||||
CheckInstalled: func() bool {
|
CheckInstalled: func() bool {
|
||||||
_, err := exec.LookPath("cline")
|
_, err := exec.LookPath("cline")
|
||||||
@@ -128,7 +129,24 @@ var integrationSpecs = []*IntegrationSpec{
|
|||||||
_, err := exec.LookPath("pi")
|
_, err := exec.LookPath("pi")
|
||||||
return err == nil
|
return err == nil
|
||||||
},
|
},
|
||||||
Command: []string{"npm", "install", "-g", "@mariozechner/pi-coding-agent"},
|
EnsureInstalled: func() error {
|
||||||
|
_, err := ensurePiInstalled()
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
Command: []string{"npm", "install", "-g", "@mariozechner/pi-coding-agent@latest"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "vscode",
|
||||||
|
Runner: &VSCode{},
|
||||||
|
Aliases: []string{"code"},
|
||||||
|
Description: "Microsoft's open-source AI code editor",
|
||||||
|
Hidden: true,
|
||||||
|
Install: IntegrationInstallSpec{
|
||||||
|
CheckInstalled: func() bool {
|
||||||
|
return (&VSCode{}).findBinary() != ""
|
||||||
|
},
|
||||||
|
URL: "https://code.visualstudio.com",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) {
|
|||||||
|
|
||||||
binDir := t.TempDir()
|
binDir := t.TempDir()
|
||||||
writeFakeBinary(t, binDir, tt.binary)
|
writeFakeBinary(t, binDir, tt.binary)
|
||||||
|
if tt.name == "pi" {
|
||||||
|
writeFakeBinary(t, binDir, "npm")
|
||||||
|
}
|
||||||
t.Setenv("PATH", binDir)
|
t.Setenv("PATH", binDir)
|
||||||
|
|
||||||
configPath := tt.checkPath(home)
|
configPath := tt.checkPath(home)
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -242,6 +242,10 @@ func (m selectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.cancelled = true
|
m.cancelled = true
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
|
||||||
|
case tea.KeyLeft:
|
||||||
|
m.cancelled = true
|
||||||
|
return m, tea.Quit
|
||||||
|
|
||||||
case tea.KeyEnter:
|
case tea.KeyEnter:
|
||||||
filtered := m.filteredItems()
|
filtered := m.filteredItems()
|
||||||
if len(filtered) > 0 && m.cursor < len(filtered) {
|
if len(filtered) > 0 && m.cursor < len(filtered) {
|
||||||
@@ -354,7 +358,7 @@ func (m selectorModel) renderContent() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.WriteString("\n")
|
s.WriteString("\n")
|
||||||
help := "↑/↓ navigate • enter select • esc cancel"
|
help := "↑/↓ navigate • enter select • ← back"
|
||||||
if m.helpText != "" {
|
if m.helpText != "" {
|
||||||
help = m.helpText
|
help = m.helpText
|
||||||
}
|
}
|
||||||
@@ -608,6 +612,10 @@ func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.cancelled = true
|
m.cancelled = true
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
|
||||||
|
case tea.KeyLeft:
|
||||||
|
m.cancelled = true
|
||||||
|
return m, tea.Quit
|
||||||
|
|
||||||
case tea.KeyTab:
|
case tea.KeyTab:
|
||||||
m.multi = !m.multi
|
m.multi = !m.multi
|
||||||
|
|
||||||
@@ -810,7 +818,7 @@ func (m multiSelectorModel) View() string {
|
|||||||
s.WriteString("\n")
|
s.WriteString("\n")
|
||||||
|
|
||||||
if !m.multi {
|
if !m.multi {
|
||||||
s.WriteString(selectorHelpStyle.Render("↑/↓ navigate • enter select • tab add multiple • esc cancel"))
|
s.WriteString(selectorHelpStyle.Render("↑/↓ navigate • enter select • tab add multiple • ← back"))
|
||||||
} else {
|
} else {
|
||||||
count := m.selectedCount()
|
count := m.selectedCount()
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
@@ -819,7 +827,7 @@ func (m multiSelectorModel) View() string {
|
|||||||
s.WriteString(selectorDescStyle.Render(fmt.Sprintf(" %d selected - press enter to continue", count)))
|
s.WriteString(selectorDescStyle.Render(fmt.Sprintf(" %d selected - press enter to continue", count)))
|
||||||
}
|
}
|
||||||
s.WriteString("\n\n")
|
s.WriteString("\n\n")
|
||||||
s.WriteString(selectorHelpStyle.Render("↑/↓ navigate • space toggle • tab select single • enter confirm • esc cancel"))
|
s.WriteString(selectorHelpStyle.Render("↑/↓ navigate • space toggle • tab select single • enter confirm • ← back"))
|
||||||
}
|
}
|
||||||
|
|
||||||
result := s.String()
|
result := s.String()
|
||||||
|
|||||||
@@ -782,6 +782,9 @@ func TestMulti_MultiModeHelpText(t *testing.T) {
|
|||||||
if !strings.Contains(content, "tab select single") {
|
if !strings.Contains(content, "tab select single") {
|
||||||
t.Error("multi mode should show 'tab select single' in help")
|
t.Error("multi mode should show 'tab select single' in help")
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(content, "← back") {
|
||||||
|
t.Error("multi mode should show '← back' in help")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- preChecked initialization order ---
|
// --- preChecked initialization order ---
|
||||||
@@ -868,6 +871,46 @@ func TestMulti_UncheckingTopDefaultFallsBackToNearestCheckedBelow(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Left arrow back navigation ---
|
||||||
|
|
||||||
|
func TestSelectorLeftArrowCancelsWhenNoFilter(t *testing.T) {
|
||||||
|
m := selectorModelWithCurrent("Pick:", items("a", "b", "c"), "")
|
||||||
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft})
|
||||||
|
got := updated.(selectorModel)
|
||||||
|
if !got.cancelled {
|
||||||
|
t.Error("left arrow with empty filter should cancel (go back)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectorLeftArrowCancelsWhenFiltering(t *testing.T) {
|
||||||
|
m := selectorModelWithCurrent("Pick:", items("a", "b", "c"), "")
|
||||||
|
m.filter = "a"
|
||||||
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft})
|
||||||
|
got := updated.(selectorModel)
|
||||||
|
if !got.cancelled {
|
||||||
|
t.Error("left arrow with active filter should still cancel (go back)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiSelectorLeftArrowCancelsWhenNoFilter(t *testing.T) {
|
||||||
|
m := newMultiSelectorModel("Pick:", items("a", "b", "c"), nil)
|
||||||
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft})
|
||||||
|
got := updated.(multiSelectorModel)
|
||||||
|
if !got.cancelled {
|
||||||
|
t.Error("left arrow with empty filter should cancel (go back)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiSelectorLeftArrowCancelsWhenFiltering(t *testing.T) {
|
||||||
|
m := newMultiSelectorModel("Pick:", items("a", "b", "c"), nil)
|
||||||
|
m.filter = "a"
|
||||||
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft})
|
||||||
|
got := updated.(multiSelectorModel)
|
||||||
|
if !got.cancelled {
|
||||||
|
t.Error("left arrow with active filter should still cancel (go back)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Key message helpers for testing
|
// Key message helpers for testing
|
||||||
|
|
||||||
type keyType = int
|
type keyType = int
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ type menuItem struct {
|
|||||||
|
|
||||||
var mainMenuItems = []menuItem{
|
var mainMenuItems = []menuItem{
|
||||||
{
|
{
|
||||||
title: "Run a model",
|
title: "Chat with a model",
|
||||||
description: "Start an interactive chat with a model",
|
description: "Start an interactive chat with a model",
|
||||||
isRunModel: true,
|
isRunModel: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func launcherTestState() *launch.LauncherState {
|
|||||||
|
|
||||||
func TestMenuRendersPinnedItemsAndMore(t *testing.T) {
|
func TestMenuRendersPinnedItemsAndMore(t *testing.T) {
|
||||||
view := newModel(launcherTestState()).View()
|
view := newModel(launcherTestState()).View()
|
||||||
for _, want := range []string{"Run a model", "Launch Claude Code", "Launch Codex", "Launch OpenClaw", "More..."} {
|
for _, want := range []string{"Chat with a model", "Launch Claude Code", "Launch Codex", "Launch OpenClaw", "More..."} {
|
||||||
if !strings.Contains(view, want) {
|
if !strings.Contains(view, want) {
|
||||||
t.Fatalf("expected menu view to contain %q\n%s", want, view)
|
t.Fatalf("expected menu view to contain %q\n%s", want, view)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,6 +290,8 @@ func LoadModelMetadata(fsys fs.FS) (ModelKV, *Tokenizer, error) {
|
|||||||
conv = &gemma3Model{Architecture: p.Architectures[0]}
|
conv = &gemma3Model{Architecture: p.Architectures[0]}
|
||||||
case "Gemma3nForConditionalGeneration":
|
case "Gemma3nForConditionalGeneration":
|
||||||
conv = &gemma3nModel{}
|
conv = &gemma3nModel{}
|
||||||
|
case "Gemma4ForCausalLM", "Gemma4ForConditionalGeneration":
|
||||||
|
conv = &gemma4Model{Architecture: p.Architectures[0]}
|
||||||
case "Phi3ForCausalLM":
|
case "Phi3ForCausalLM":
|
||||||
conv = &phi3Model{}
|
conv = &phi3Model{}
|
||||||
case "Qwen2ForCausalLM":
|
case "Qwen2ForCausalLM":
|
||||||
|
|||||||
556
convert/convert_gemma4.go
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
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.input_proj_linear", "a.pre_encode.out",
|
||||||
|
|
||||||
|
// Audio conformer blocks
|
||||||
|
"model.audio_tower.conformer", "a.blk",
|
||||||
|
|
||||||
|
// Audio conformer attention
|
||||||
|
"attention.attn.relative_position_embedding.pos_proj", "linear_pos",
|
||||||
|
"attention.attn.per_dim_key_scale", "per_dim_k_scale",
|
||||||
|
"attention.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",
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
}
|
||||||
|
}
|
||||||
263
convert/convert_gemma4_test.go
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
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",
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Conformer lightweight conv1d
|
||||||
|
{
|
||||||
|
"conv dw weight",
|
||||||
|
"model.audio_tower.conformer.0.lconv1d.depthwise_conv1d.weight",
|
||||||
|
"a.blk.0.conv_dw.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conv norm (pre_layer_norm)",
|
||||||
|
"model.audio_tower.conformer.0.lconv1d.pre_layer_norm.weight",
|
||||||
|
"a.blk.0.conv_norm.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm conv (conv_norm)",
|
||||||
|
"model.audio_tower.conformer.0.lconv1d.conv_norm.weight",
|
||||||
|
"a.blk.0.norm_conv.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conv pw1 weight",
|
||||||
|
"model.audio_tower.conformer.0.lconv1d.linear_start.linear.weight",
|
||||||
|
"a.blk.0.conv_pw1.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conv pw2 weight",
|
||||||
|
"model.audio_tower.conformer.0.lconv1d.linear_end.linear.weight",
|
||||||
|
"a.blk.0.conv_pw2.weight",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Audio embedder
|
||||||
|
{
|
||||||
|
"audio embedder projection weight",
|
||||||
|
"model.embed_audio.embedding_projection.linear.weight",
|
||||||
|
"mm.a.input_projection.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"audio embedder projection bias",
|
||||||
|
"model.embed_audio.embedding_projection.linear.bias",
|
||||||
|
"mm.a.input_projection.bias",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Audio output projection
|
||||||
|
{
|
||||||
|
"audio output proj weight",
|
||||||
|
"model.audio_tower.output_proj.weight",
|
||||||
|
"mm.a.fc.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"audio output proj bias",
|
||||||
|
"model.audio_tower.output_proj.bias",
|
||||||
|
"mm.a.fc.bias",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Verify vision tensors still work
|
||||||
|
{
|
||||||
|
"vision q weight",
|
||||||
|
"model.vision_tower.encoder.layers.0.self_attn.q_proj.linear.weight",
|
||||||
|
"v.blk.0.attn_q.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vision std bias",
|
||||||
|
"model.vision_tower.std_bias",
|
||||||
|
"v.std_bias",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vision std scale",
|
||||||
|
"model.vision_tower.std_scale",
|
||||||
|
"v.std_scale",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vision patch embd",
|
||||||
|
"model.vision_tower.patch_embedder.input_proj.weight",
|
||||||
|
"v.patch_embd.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vision projector",
|
||||||
|
"model.embed_vision.embedding_projection.linear.weight",
|
||||||
|
"mm.input_projection.weight",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Verify text tensors still work
|
||||||
|
{
|
||||||
|
"text attn q",
|
||||||
|
"model.language_model.layers.0.self_attn.q_proj.weight",
|
||||||
|
"blk.0.attn_q.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text token embd",
|
||||||
|
"model.language_model.embed_tokens.weight",
|
||||||
|
"token_embd.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text moe gate up fused",
|
||||||
|
"model.language_model.layers.0.experts.gate_up_proj",
|
||||||
|
"blk.0.ffn_gate_up_exps.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text moe down",
|
||||||
|
"model.language_model.layers.0.experts.down_proj",
|
||||||
|
"blk.0.ffn_down_exps.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text moe down with weight suffix",
|
||||||
|
"model.language_model.layers.0.experts.down_proj.weight",
|
||||||
|
"blk.0.ffn_down_exps.weight",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text moe per expert scale",
|
||||||
|
"model.language_model.layers.0.router.per_expert_scale",
|
||||||
|
"blk.0.ffn_down_exps.scale",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text moe per expert scale with weight suffix",
|
||||||
|
"model.language_model.layers.0.router.per_expert_scale.weight",
|
||||||
|
"blk.0.ffn_down_exps.scale",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := r.Replace(tt.in); got != tt.want {
|
||||||
|
t.Errorf("Replace(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -205,8 +205,8 @@ func TestConvertInvalidDatatype(t *testing.T) {
|
|||||||
generateSafetensorTestData(t, tempDir, td)
|
generateSafetensorTestData(t, tempDir, td)
|
||||||
|
|
||||||
err = ConvertModel(os.DirFS(tempDir), f)
|
err = ConvertModel(os.DirFS(tempDir), f)
|
||||||
if err == nil || err.Error() != "unsupported safetensors model" {
|
if err == nil || !strings.Contains(err.Error(), "unknown data type") {
|
||||||
t.Errorf("expected error but didn't get one")
|
t.Errorf("expected 'unknown data type' error but got: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,11 @@ func (t tensorBase) Kind() uint32 {
|
|||||||
strings.HasSuffix(t.name, ".bias") ||
|
strings.HasSuffix(t.name, ".bias") ||
|
||||||
strings.HasSuffix(t.name, ".shortconv.conv.weight") ||
|
strings.HasSuffix(t.name, ".shortconv.conv.weight") ||
|
||||||
strings.HasSuffix(t.name, ".ssm_conv1d.weight") || // SSM conv kernel must be F32 for Metal
|
strings.HasSuffix(t.name, ".ssm_conv1d.weight") || // SSM conv kernel must be F32 for Metal
|
||||||
|
strings.HasPrefix(t.name, "a.conv1d.") || // audio SSCP conv weights must be F32 for im2col
|
||||||
|
strings.Contains(t.name, ".conv_dw.") || // audio depthwise conv weights must be F32
|
||||||
t.name == "token_types.weight" ||
|
t.name == "token_types.weight" ||
|
||||||
t.name == "v.positional_embedding_vlm" ||
|
t.name == "v.positional_embedding_vlm" ||
|
||||||
|
t.name == "v.position_embd.weight" ||
|
||||||
t.name == "v.tile_position_embd.weight" ||
|
t.name == "v.tile_position_embd.weight" ||
|
||||||
t.name == "v.pre_tile_position_embd.weight" ||
|
t.name == "v.pre_tile_position_embd.weight" ||
|
||||||
t.name == "v.post_tile_position_embd.weight" ||
|
t.name == "v.post_tile_position_embd.weight" ||
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -53,9 +52,10 @@ func parseSafetensors(fsys fs.FS, replacer *strings.Replacer, ps ...string) ([]T
|
|||||||
|
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
if value := headers[key]; value.Type != "" {
|
if value := headers[key]; value.Type != "" {
|
||||||
// bitsandbytes quantized models are unsupported
|
// Scalar tensors (e.g. clipped linear min/max) are 0-dim in safetensors.
|
||||||
|
// Promote them to 1-dim so they can be stored in GGUF.
|
||||||
if len(value.Shape) == 0 {
|
if len(value.Shape) == 0 {
|
||||||
return nil, errors.New("unsupported safetensors model")
|
value.Shape = []uint64{1}
|
||||||
}
|
}
|
||||||
ggufName := replacer.Replace(key)
|
ggufName := replacer.Replace(key)
|
||||||
if _, ok := names[ggufName]; ok {
|
if _, ok := names[ggufName]; ok {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Configure and launch external applications to use Ollama models. This provides a
|
|||||||
- **OpenCode** - Open-source coding assistant
|
- **OpenCode** - Open-source coding assistant
|
||||||
- **Claude Code** - Anthropic's agentic coding tool
|
- **Claude Code** - Anthropic's agentic coding tool
|
||||||
- **Codex** - OpenAI's coding assistant
|
- **Codex** - OpenAI's coding assistant
|
||||||
|
- **VS Code** - Microsoft's IDE with built-in AI chat
|
||||||
- **Droid** - Factory's AI coding agent
|
- **Droid** - Factory's AI coding agent
|
||||||
|
|
||||||
#### Examples
|
#### Examples
|
||||||
|
|||||||
@@ -127,6 +127,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"group": "IDEs & Editors",
|
"group": "IDEs & Editors",
|
||||||
|
"expanded": true,
|
||||||
"pages": [
|
"pages": [
|
||||||
"/integrations/cline",
|
"/integrations/cline",
|
||||||
"/integrations/jetbrains",
|
"/integrations/jetbrains",
|
||||||
|
|||||||
BIN
docs/images/local.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/images/vscode-add-ollama.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 56 KiB |
BIN
docs/images/vscode-other-models.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
docs/images/vscode-unhide.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
docs/images/vscode.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
@@ -96,6 +96,18 @@ The `/loop` command runs a prompt or slash command on a recurring schedule insid
|
|||||||
/loop 1h Remind me to review the deploy status
|
/loop 1h Remind me to review the deploy status
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Telegram
|
||||||
|
|
||||||
|
Chat with Claude Code from Telegram by connecting a bot to your session. Install the [Telegram plugin](https://github.com/anthropics/claude-plugins-official), create a bot via [@BotFather](https://t.me/BotFather), then launch with the channel flag:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ollama launch claude -- --channels plugin:telegram@claude-plugins-official
|
||||||
|
```
|
||||||
|
|
||||||
|
Claude Code will prompt for permission on most actions. To allow the bot to work autonomously, configure [permission rules](https://code.claude.com/docs/en/permissions) or pass `--dangerously-skip-permissions` in isolated environments.
|
||||||
|
|
||||||
|
See the [plugin README](https://github.com/anthropics/claude-plugins-official/tree/main/external_plugins/telegram) for full setup instructions including pairing and access control.
|
||||||
|
|
||||||
## Manual setup
|
## Manual setup
|
||||||
|
|
||||||
Claude Code connects to Ollama using the Anthropic-compatible API.
|
Claude Code connects to Ollama using the Anthropic-compatible API.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
title: Pi
|
title: Pi
|
||||||
---
|
---
|
||||||
|
|
||||||
Pi is a minimal AI agent toolkit with plugin support.
|
Pi is a minimal and extensible coding agent.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -20,13 +20,65 @@ npm install -g @mariozechner/pi-coding-agent
|
|||||||
ollama launch pi
|
ollama launch pi
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This installs Pi, configures Ollama as a provider including web tools, and drops you into an interactive session.
|
||||||
|
|
||||||
To configure without launching:
|
To configure without launching:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
ollama launch pi --config
|
ollama launch pi --config
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual setup
|
### Run directly with a model
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ollama launch pi --model qwen3.5:cloud
|
||||||
|
```
|
||||||
|
|
||||||
|
Cloud models are also available at [ollama.com](https://ollama.com/search?c=cloud).
|
||||||
|
|
||||||
|
## Extensions
|
||||||
|
|
||||||
|
Pi ships with four core tools: `read`, `write`, `edit`, and `bash`. All other capabilities are added through its extension system.
|
||||||
|
|
||||||
|
On-demand capability packages invoked via `/skill:name` commands.
|
||||||
|
|
||||||
|
Install from npm or git:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi install npm:@foo/some-tools
|
||||||
|
pi install git:github.com/user/repo@v1
|
||||||
|
```
|
||||||
|
|
||||||
|
See all packages at [pi.dev](https://pi.dev/packages)
|
||||||
|
|
||||||
|
### Web search
|
||||||
|
|
||||||
|
Pi can use web search and fetch tools via the `@ollama/pi-web-search` package.
|
||||||
|
|
||||||
|
When launching Pi through Ollama, package install/update is managed automatically.
|
||||||
|
To install manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi install npm:@ollama/pi-web-search
|
||||||
|
```
|
||||||
|
|
||||||
|
### Autoresearch with `pi-autoresearch`
|
||||||
|
|
||||||
|
[pi-autoresearch](https://github.com/davebcn87/pi-autoresearch) brings autonomous experiment loops to Pi. Inspired by Karpathy's autoresearch, it turns any measurable metric into an optimization target: test speed, bundle size, build time, model training loss, Lighthouse scores.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi install https://github.com/davebcn87/pi-autoresearch
|
||||||
|
```
|
||||||
|
|
||||||
|
Tell Pi what to optimize. It runs experiments, benchmarks each one, keeps improvements, reverts regressions, and repeats — all autonomously. A built-in dashboard tracks every run with confidence scoring to distinguish real gains from benchmark noise.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/autoresearch optimize unit test runtime
|
||||||
|
```
|
||||||
|
|
||||||
|
Each kept experiment is automatically committed. Each failed one is reverted. When you're done, Pi can group improvements into independent branches for clean review and merge.
|
||||||
|
|
||||||
|
## Manual setup
|
||||||
|
|
||||||
Add a configuration block to `~/.pi/agent/models.json`:
|
Add a configuration block to `~/.pi/agent/models.json`:
|
||||||
|
|
||||||
|
|||||||
@@ -2,33 +2,84 @@
|
|||||||
title: VS Code
|
title: VS Code
|
||||||
---
|
---
|
||||||
|
|
||||||
## Install
|
VS Code includes built-in AI chat through GitHub Copilot Chat. Ollama models can be used directly in the Copilot Chat model picker.
|
||||||
|
|
||||||
Install [VS Code](https://code.visualstudio.com/download).
|
|
||||||
|
|
||||||
## Usage with Ollama
|

|
||||||
|
|
||||||
1. Open Copilot side bar found in top right window
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Ollama v0.18.3+
|
||||||
|
- [VS Code 1.113+](https://code.visualstudio.com/download)
|
||||||
|
- [GitHub Copilot Chat extension 0.41.0+](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot-chat)
|
||||||
|
|
||||||
|
<Note> VS Code requires you to be logged in to use its model selector, even for custom models. This doesn't require a paid GitHub Copilot account; GitHub Copilot Free will enable model selection for custom models.</Note>
|
||||||
|
|
||||||
|
## Quick setup
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ollama launch vscode
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended models will be shown after running the command. See the latest models at [ollama.com](https://ollama.com/search?c=tools).
|
||||||
|
|
||||||
|
Make sure **Local** is selected at the bottom of the Copilot Chat panel to use your Ollama models.
|
||||||
|
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||||
|
<img
|
||||||
|
src="/images/local.png"
|
||||||
|
alt="Ollama Local Models"
|
||||||
|
width="60%"
|
||||||
|
style={{ borderRadius: "4px", marginTop: "10px", marginBottom: "10px" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
## Run directly with a model
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ollama launch vscode --model qwen3.5:cloud
|
||||||
|
```
|
||||||
|
Cloud models are also available at [ollama.com](https://ollama.com/search?c=cloud).
|
||||||
|
|
||||||
|
## Manual setup
|
||||||
|
|
||||||
|
To configure Ollama manually without `ollama launch`:
|
||||||
|
|
||||||
|
1. Open the **Copilot Chat** side bar from the top right corner
|
||||||
<div style={{ display: "flex", justifyContent: "center" }}>
|
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||||
<img
|
<img
|
||||||
src="/images/vscode-sidebar.png"
|
src="/images/vscode-sidebar.png"
|
||||||
alt="VS Code chat Sidebar"
|
alt="VS Code chat Sidebar"
|
||||||
width="75%"
|
width="75%"
|
||||||
|
style={{ borderRadius: "4px" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
2. Select the model dropdown > **Manage models**
|
2. Click the **settings gear icon** (<Icon icon="gear" />) to bring up the Language Models window
|
||||||
<div style={{ display: "flex", justifyContent: "center" }}>
|
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||||
<img
|
<img
|
||||||
src="/images/vscode-models.png"
|
src="/images/vscode-other-models.png"
|
||||||
alt="VS Code model picker"
|
alt="VS Code model picker"
|
||||||
width="75%"
|
width="75%"
|
||||||
|
style={{ borderRadius: "4px" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
3. Enter **Ollama** under **Provider Dropdown** and select desired models (e.g `qwen3, qwen3-coder:480b-cloud`)
|
3. Click **Add Models** and select **Ollama** to load all your Ollama models into VS Code
|
||||||
<div style={{ display: "flex", justifyContent: "center" }}>
|
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||||
<img
|
<img
|
||||||
src="/images/vscode-model-options.png"
|
src="/images/vscode-add-ollama.png"
|
||||||
alt="VS Code model options dropdown"
|
alt="VS Code model options dropdown to add ollama models"
|
||||||
width="75%"
|
width="75%"
|
||||||
|
style={{ borderRadius: "4px" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
4. Click the **Unhide** button in the model picker to show your Ollama models
|
||||||
|
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||||
|
<img
|
||||||
|
src="/images/vscode-unhide.png"
|
||||||
|
alt="VS Code unhide models button"
|
||||||
|
width="75%"
|
||||||
|
style={{ borderRadius: "4px" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ func (kv KV) OllamaEngineRequired() bool {
|
|||||||
"deepseekocr",
|
"deepseekocr",
|
||||||
"gemma3",
|
"gemma3",
|
||||||
"gemma3n",
|
"gemma3n",
|
||||||
|
"gemma4",
|
||||||
"gptoss", "gpt-oss",
|
"gptoss", "gpt-oss",
|
||||||
"llama4",
|
"llama4",
|
||||||
"mistral3",
|
"mistral3",
|
||||||
@@ -874,7 +875,7 @@ func (f GGML) SupportsFlashAttention() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if slices.Contains([]string{"gemma2"}, arch) {
|
if slices.Contains([]string{"gemma2", "grok"}, arch) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,4 +14,15 @@ The integration tests have 2 modes of operating.
|
|||||||
> Before running the tests locally without the "test existing" setting, compile ollama from the top of the source tree `go build .` in addition to GPU support with cmake if applicable on your platform. The integration tests expect to find an ollama binary at the top of the tree.
|
> Before running the tests locally without the "test existing" setting, compile ollama from the top of the source tree `go build .` in addition to GPU support with cmake if applicable on your platform. The integration tests expect to find an ollama binary at the top of the tree.
|
||||||
|
|
||||||
|
|
||||||
Many tests use a default small model suitable to run on many systems. You can override this default model by setting `OLLAMA_TEST_DEFAULT_MODEL`
|
## Testing a New Model
|
||||||
|
|
||||||
|
When implementing new model architecture, use `OLLAMA_TEST_MODEL` to run the
|
||||||
|
integration suite against your model.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the binary first
|
||||||
|
go build .
|
||||||
|
|
||||||
|
# Run integration tests against it
|
||||||
|
OLLAMA_TEST_MODEL=mymodel go test -tags integration -v -count 1 -timeout 15m ./integration/
|
||||||
|
```
|
||||||
|
|||||||
@@ -48,9 +48,7 @@ func TestAPIGenerate(t *testing.T) {
|
|||||||
|
|
||||||
client, _, cleanup := InitServerConnection(ctx, t)
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
if err := PullIfMissing(ctx, client, req.Model); err != nil {
|
pullOrSkip(ctx, t, client, req.Model)
|
||||||
t.Fatalf("pull failed %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -151,7 +149,11 @@ func TestAPIGenerate(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate PS while we're at it...
|
// Validate PS while we're at it — skip for local-only models
|
||||||
|
// which may lack metadata fields like family, parameter_size, etc.
|
||||||
|
if testModel != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
resp, err := client.ListRunning(ctx)
|
resp, err := client.ListRunning(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list models API error: %s", err)
|
t.Fatalf("list models API error: %s", err)
|
||||||
@@ -208,9 +210,7 @@ func TestAPIChat(t *testing.T) {
|
|||||||
|
|
||||||
client, _, cleanup := InitServerConnection(ctx, t)
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
if err := PullIfMissing(ctx, client, req.Model); err != nil {
|
pullOrSkip(ctx, t, client, req.Model)
|
||||||
t.Fatalf("pull failed %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -311,6 +311,9 @@ func TestAPIChat(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIListModels(t *testing.T) {
|
func TestAPIListModels(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
t.Skip("skipping metadata test with model override")
|
||||||
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
client, _, cleanup := InitServerConnection(ctx, t)
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
@@ -361,6 +364,9 @@ func verifyModelDetails(t *testing.T, details api.ModelDetails) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIShowModel(t *testing.T) {
|
func TestAPIShowModel(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
t.Skip("skipping metadata test with model override")
|
||||||
|
}
|
||||||
modelName := "llama3.2"
|
modelName := "llama3.2"
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -400,6 +406,10 @@ func TestAPIShowModel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIGenerateLogprobs(t *testing.T) {
|
func TestAPIGenerateLogprobs(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
// Logprobs requires runner support (e.g. llama.cpp has it, MLX does not).
|
||||||
|
t.Skip("logprobs not supported by all runners")
|
||||||
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -513,6 +523,10 @@ func TestAPIGenerateLogprobs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIChatLogprobs(t *testing.T) {
|
func TestAPIChatLogprobs(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
// Logprobs requires runner support (e.g. llama.cpp has it, MLX does not).
|
||||||
|
t.Skip("logprobs not supported by all runners")
|
||||||
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
|||||||
259
integration/audio_test.go
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
var defaultAudioModels = []string{
|
||||||
|
"gemma4-e2b",
|
||||||
|
"gemma4-e4b",
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeTestAudio returns the test audio clip ("Why is the sky blue?", 16kHz mono WAV).
|
||||||
|
func decodeTestAudio(t *testing.T) api.ImageData {
|
||||||
|
t.Helper()
|
||||||
|
data, err := base64.StdEncoding.DecodeString(audioEncodingPrompt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to decode test audio: %v", err)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupAudioModel pulls the model, preloads it, and skips if it doesn't support audio.
|
||||||
|
func setupAudioModel(ctx context.Context, t *testing.T, client *api.Client, model string) {
|
||||||
|
t.Helper()
|
||||||
|
requireCapability(ctx, t, client, model, "audio")
|
||||||
|
pullOrSkip(ctx, t, client, model)
|
||||||
|
err := client.Generate(ctx, &api.GenerateRequest{Model: model}, func(response api.GenerateResponse) error { return nil })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load model %s: %s", model, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAudioTranscription tests that the model can transcribe audio to text.
|
||||||
|
func TestAudioTranscription(t *testing.T) {
|
||||||
|
for _, model := range testModels(defaultAudioModels) {
|
||||||
|
t.Run(model, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
setupAudioModel(ctx, t, client, model)
|
||||||
|
audio := decodeTestAudio(t)
|
||||||
|
noThink := &api.ThinkValue{Value: false}
|
||||||
|
|
||||||
|
req := api.ChatRequest{
|
||||||
|
Model: model,
|
||||||
|
Think: noThink,
|
||||||
|
Messages: []api.Message{
|
||||||
|
{
|
||||||
|
Role: "system",
|
||||||
|
Content: "Transcribe the audio exactly as spoken. Output only the transcription.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: "Transcribe this audio.",
|
||||||
|
Images: []api.ImageData{audio},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Stream: &stream,
|
||||||
|
Options: map[string]any{
|
||||||
|
"temperature": 0,
|
||||||
|
"seed": 123,
|
||||||
|
"num_predict": 50,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// The audio says "Why is the sky blue?" — expect key words in transcription.
|
||||||
|
DoChat(ctx, t, client, req, []string{"sky", "blue"}, 60*time.Second, 10*time.Second)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAudioResponse tests that the model can respond to a spoken question.
|
||||||
|
func TestAudioResponse(t *testing.T) {
|
||||||
|
for _, model := range testModels(defaultAudioModels) {
|
||||||
|
t.Run(model, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
setupAudioModel(ctx, t, client, model)
|
||||||
|
audio := decodeTestAudio(t)
|
||||||
|
noThink := &api.ThinkValue{Value: false}
|
||||||
|
|
||||||
|
req := api.ChatRequest{
|
||||||
|
Model: model,
|
||||||
|
Think: noThink,
|
||||||
|
Messages: []api.Message{
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: "",
|
||||||
|
Images: []api.ImageData{audio},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Stream: &stream,
|
||||||
|
Options: map[string]any{
|
||||||
|
"temperature": 0,
|
||||||
|
"seed": 123,
|
||||||
|
"num_predict": 200,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// The audio asks "Why is the sky blue?" — expect an answer about light/scattering.
|
||||||
|
DoChat(ctx, t, client, req, []string{
|
||||||
|
"scatter", "light", "blue", "atmosphere", "wavelength", "rayleigh",
|
||||||
|
}, 60*time.Second, 10*time.Second)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenAIAudioTranscription tests the /v1/audio/transcriptions endpoint.
|
||||||
|
func TestOpenAIAudioTranscription(t *testing.T) {
|
||||||
|
for _, model := range testModels(defaultAudioModels) {
|
||||||
|
t.Run(model, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
client, endpoint, cleanup := InitServerConnection(ctx, t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
setupAudioModel(ctx, t, client, model)
|
||||||
|
audioBytes := decodeTestAudio(t)
|
||||||
|
|
||||||
|
// Build multipart form request.
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
writer.WriteField("model", model)
|
||||||
|
part, err := writer.CreateFormFile("file", "prompt.wav")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
part.Write(audioBytes)
|
||||||
|
writer.Close()
|
||||||
|
|
||||||
|
url := fmt.Sprintf("http://%s/v1/audio/transcriptions", endpoint)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := strings.ToLower(string(respBody))
|
||||||
|
if !strings.Contains(text, "sky") && !strings.Contains(text, "blue") {
|
||||||
|
t.Errorf("transcription response missing expected words, got: %s", string(respBody))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenAIChatWithAudio tests /v1/chat/completions with input_audio content.
|
||||||
|
func TestOpenAIChatWithAudio(t *testing.T) {
|
||||||
|
for _, model := range testModels(defaultAudioModels) {
|
||||||
|
t.Run(model, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
client, endpoint, cleanup := InitServerConnection(ctx, t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
setupAudioModel(ctx, t, client, model)
|
||||||
|
audioB64 := audioEncodingPrompt
|
||||||
|
|
||||||
|
reqBody := fmt.Sprintf(`{
|
||||||
|
"model": %q,
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "input_audio", "input_audio": {"data": %q, "format": "wav"}}
|
||||||
|
]
|
||||||
|
}],
|
||||||
|
"temperature": 0,
|
||||||
|
"seed": 123,
|
||||||
|
"max_tokens": 200,
|
||||||
|
"think": false
|
||||||
|
}`, model, strings.TrimSpace(audioB64))
|
||||||
|
|
||||||
|
url := fmt.Sprintf("http://%s/v1/chat/completions", endpoint)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(reqBody))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
respBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Reasoning string `json:"reasoning"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBytes, &result); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Choices) == 0 {
|
||||||
|
t.Fatal("no choices in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
text := strings.ToLower(result.Choices[0].Message.Content + " " + result.Choices[0].Message.Reasoning)
|
||||||
|
found := false
|
||||||
|
for _, word := range []string{"sky", "blue", "scatter", "light", "atmosphere"} {
|
||||||
|
if strings.Contains(text, word) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("response missing expected words about sky/blue/light, got: %s", result.Choices[0].Message.Content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
9
integration/audio_test_data_test.go
Normal file
@@ -35,6 +35,9 @@ func TestBlueSky(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUnicode(t *testing.T) {
|
func TestUnicode(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
t.Skip("uses hardcoded model, not applicable with model override")
|
||||||
|
}
|
||||||
skipUnderMinVRAM(t, 6)
|
skipUnderMinVRAM(t, 6)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -59,9 +62,7 @@ func TestUnicode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
client, _, cleanup := InitServerConnection(ctx, t)
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
if err := PullIfMissing(ctx, client, req.Model); err != nil {
|
pullOrSkip(ctx, t, client, req.Model)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
slog.Info("loading", "model", req.Model)
|
slog.Info("loading", "model", req.Model)
|
||||||
err := client.Generate(ctx, &api.GenerateRequest{Model: req.Model}, func(response api.GenerateResponse) error { return nil })
|
err := client.Generate(ctx, &api.GenerateRequest{Model: req.Model}, func(response api.GenerateResponse) error { return nil })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -81,6 +82,9 @@ func TestUnicode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExtendedUnicodeOutput(t *testing.T) {
|
func TestExtendedUnicodeOutput(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
t.Skip("uses hardcoded model, not applicable with model override")
|
||||||
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
// Set up the test data
|
// Set up the test data
|
||||||
@@ -100,9 +104,7 @@ func TestExtendedUnicodeOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
client, _, cleanup := InitServerConnection(ctx, t)
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
if err := PullIfMissing(ctx, client, req.Model); err != nil {
|
pullOrSkip(ctx, t, client, req.Model)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
DoChat(ctx, t, client, req, []string{"😀", "😊", "😁", "😂", "😄", "😃"}, 120*time.Second, 120*time.Second)
|
DoChat(ctx, t, client, req, []string{"😀", "😊", "😁", "😂", "😄", "😃"}, 120*time.Second, 120*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,15 +150,16 @@ func TestUnicodeModelDir(t *testing.T) {
|
|||||||
// TestNumPredict verifies that when num_predict is set, the model generates
|
// TestNumPredict verifies that when num_predict is set, the model generates
|
||||||
// exactly that many tokens. It uses logprobs to count the actual tokens output.
|
// exactly that many tokens. It uses logprobs to count the actual tokens output.
|
||||||
func TestNumPredict(t *testing.T) {
|
func TestNumPredict(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
t.Skip("uses hardcoded model, not applicable with model override")
|
||||||
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
client, _, cleanup := InitServerConnection(ctx, t)
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
if err := PullIfMissing(ctx, client, "qwen3:0.6b"); err != nil {
|
pullOrSkip(ctx, t, client, "qwen3:0.6b")
|
||||||
t.Fatalf("failed to pull model: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := api.GenerateRequest{
|
req := api.GenerateRequest{
|
||||||
Model: "qwen3:0.6b",
|
Model: "qwen3:0.6b",
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ func TestConcurrentChat(t *testing.T) {
|
|||||||
// Stress the scheduler and attempt to load more models than will fit to cause thrashing
|
// Stress the scheduler and attempt to load more models than will fit to cause thrashing
|
||||||
// This test will always load at least 2 models even on CPU based systems
|
// This test will always load at least 2 models even on CPU based systems
|
||||||
func TestMultiModelStress(t *testing.T) {
|
func TestMultiModelStress(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
t.Skip("uses hardcoded models, not applicable with model override")
|
||||||
|
}
|
||||||
s := os.Getenv("OLLAMA_MAX_VRAM")
|
s := os.Getenv("OLLAMA_MAX_VRAM")
|
||||||
if s == "" {
|
if s == "" {
|
||||||
s = "0"
|
s = "0"
|
||||||
@@ -114,9 +117,7 @@ func TestMultiModelStress(t *testing.T) {
|
|||||||
|
|
||||||
// Make sure all the models are pulled before we get started
|
// Make sure all the models are pulled before we get started
|
||||||
for _, model := range chosenModels {
|
for _, model := range chosenModels {
|
||||||
if err := PullIfMissing(ctx, client, model); err != nil {
|
pullOrSkip(ctx, t, client, model)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine how many models we can load in parallel before we exceed VRAM
|
// Determine how many models we can load in parallel before we exceed VRAM
|
||||||
|
|||||||
@@ -38,9 +38,7 @@ func TestLongInputContext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
client, _, cleanup := InitServerConnection(ctx, t)
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
if err := PullIfMissing(ctx, client, req.Model); err != nil {
|
pullOrSkip(ctx, t, client, req.Model)
|
||||||
t.Fatalf("PullIfMissing failed: %v", err)
|
|
||||||
}
|
|
||||||
DoChat(ctx, t, client, req, []string{"russia", "german", "france", "england", "austria", "prussia", "europe", "individuals", "coalition", "conflict"}, 120*time.Second, 10*time.Second)
|
DoChat(ctx, t, client, req, []string{"russia", "german", "france", "england", "austria", "prussia", "europe", "individuals", "coalition", "conflict"}, 120*time.Second, 10*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +51,7 @@ func TestContextExhaustion(t *testing.T) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
// Set up the test data
|
// Set up the test data
|
||||||
|
thinkOff := api.ThinkValue{Value: false}
|
||||||
req := api.ChatRequest{
|
req := api.ChatRequest{
|
||||||
Model: smol,
|
Model: smol,
|
||||||
Messages: []api.Message{
|
Messages: []api.Message{
|
||||||
@@ -61,6 +60,7 @@ func TestContextExhaustion(t *testing.T) {
|
|||||||
Content: "Write me a story in english with a lot of emojis",
|
Content: "Write me a story in english with a lot of emojis",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Think: &thinkOff,
|
||||||
Stream: &stream,
|
Stream: &stream,
|
||||||
Options: map[string]any{
|
Options: map[string]any{
|
||||||
"temperature": 0,
|
"temperature": 0,
|
||||||
@@ -70,14 +70,15 @@ func TestContextExhaustion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
client, _, cleanup := InitServerConnection(ctx, t)
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
if err := PullIfMissing(ctx, client, req.Model); err != nil {
|
pullOrSkip(ctx, t, client, req.Model)
|
||||||
t.Fatalf("PullIfMissing failed: %v", err)
|
|
||||||
}
|
|
||||||
DoChat(ctx, t, client, req, []string{"once", "upon", "lived", "sunny", "cloudy", "clear", "water", "time", "travel", "world"}, 120*time.Second, 10*time.Second)
|
DoChat(ctx, t, client, req, []string{"once", "upon", "lived", "sunny", "cloudy", "clear", "water", "time", "travel", "world"}, 120*time.Second, 10*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send multiple generate requests with prior context and ensure the response is coherant and expected
|
// Send multiple generate requests with prior context and ensure the response is coherant and expected
|
||||||
func TestParallelGenerateWithHistory(t *testing.T) {
|
func TestParallelGenerateWithHistory(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
t.Skip("uses hardcoded model, not applicable with model override")
|
||||||
|
}
|
||||||
modelName := "gpt-oss:20b"
|
modelName := "gpt-oss:20b"
|
||||||
req, resp := GenerateRequests()
|
req, resp := GenerateRequests()
|
||||||
numParallel := 2
|
numParallel := 2
|
||||||
@@ -133,6 +134,12 @@ func TestParallelGenerateWithHistory(t *testing.T) {
|
|||||||
|
|
||||||
// Send generate requests with prior context and ensure the response is coherant and expected
|
// Send generate requests with prior context and ensure the response is coherant and expected
|
||||||
func TestGenerateWithHistory(t *testing.T) {
|
func TestGenerateWithHistory(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
// The Generate API's Context field (token array continuation) is not
|
||||||
|
// supported by all runners (e.g. MLX). Chat history works; this is
|
||||||
|
// the only generate-specific continuation path.
|
||||||
|
t.Skip("generate context continuation not supported by all runners")
|
||||||
|
}
|
||||||
req := api.GenerateRequest{
|
req := api.GenerateRequest{
|
||||||
Model: smol,
|
Model: smol,
|
||||||
Prompt: rainbowPrompt,
|
Prompt: rainbowPrompt,
|
||||||
@@ -173,6 +180,9 @@ func TestGenerateWithHistory(t *testing.T) {
|
|||||||
|
|
||||||
// Send multiple chat requests with prior context and ensure the response is coherant and expected
|
// Send multiple chat requests with prior context and ensure the response is coherant and expected
|
||||||
func TestParallelChatWithHistory(t *testing.T) {
|
func TestParallelChatWithHistory(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
t.Skip("uses hardcoded model, not applicable with model override")
|
||||||
|
}
|
||||||
modelName := "gpt-oss:20b"
|
modelName := "gpt-oss:20b"
|
||||||
req, resp := ChatRequests()
|
req, resp := ChatRequests()
|
||||||
numParallel := 2
|
numParallel := 2
|
||||||
|
|||||||
@@ -78,8 +78,11 @@ func TestEmbedCosineDistanceCorrelation(t *testing.T) {
|
|||||||
client, _, cleanup := InitServerConnection(ctx, t)
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
for _, model := range libraryEmbedModels {
|
for _, model := range testModels(libraryEmbedModels) {
|
||||||
t.Run(model, func(t *testing.T) {
|
t.Run(model, func(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
requireCapability(ctx, t, client, model, "embedding")
|
||||||
|
}
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
a string
|
a string
|
||||||
b string
|
b string
|
||||||
@@ -145,6 +148,9 @@ func TestEmbedCosineDistanceCorrelation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAllMiniLMEmbeddings(t *testing.T) {
|
func TestAllMiniLMEmbeddings(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
t.Skip("uses hardcoded model, not applicable with model override")
|
||||||
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
client, _, cleanup := InitServerConnection(ctx, t)
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
@@ -175,6 +181,9 @@ func TestAllMiniLMEmbeddings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAllMiniLMEmbed(t *testing.T) {
|
func TestAllMiniLMEmbed(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
t.Skip("uses hardcoded model, not applicable with model override")
|
||||||
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
client, _, cleanup := InitServerConnection(ctx, t)
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
@@ -212,6 +221,9 @@ func TestAllMiniLMEmbed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAllMiniLMBatchEmbed(t *testing.T) {
|
func TestAllMiniLMBatchEmbed(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
t.Skip("uses hardcoded model, not applicable with model override")
|
||||||
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
client, _, cleanup := InitServerConnection(ctx, t)
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
@@ -259,6 +271,9 @@ func TestAllMiniLMBatchEmbed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAllMiniLMEmbedTruncate(t *testing.T) {
|
func TestAllMiniLMEmbedTruncate(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
t.Skip("uses hardcoded model, not applicable with model override")
|
||||||
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
client, _, cleanup := InitServerConnection(ctx, t)
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
@@ -397,21 +412,13 @@ func TestAllMiniLMEmbedTruncate(t *testing.T) {
|
|||||||
|
|
||||||
func embeddingTestHelper(ctx context.Context, client *api.Client, t *testing.T, req api.EmbeddingRequest) (*api.EmbeddingResponse, error) {
|
func embeddingTestHelper(ctx context.Context, client *api.Client, t *testing.T, req api.EmbeddingRequest) (*api.EmbeddingResponse, error) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
pullOrSkip(ctx, t, client, req.Model)
|
||||||
if err := PullIfMissing(ctx, client, req.Model); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return client.Embeddings(ctx, &req)
|
return client.Embeddings(ctx, &req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func embedTestHelper(ctx context.Context, client *api.Client, t *testing.T, req api.EmbedRequest) (*api.EmbedResponse, error) {
|
func embedTestHelper(ctx context.Context, client *api.Client, t *testing.T, req api.EmbedRequest) (*api.EmbedResponse, error) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
pullOrSkip(ctx, t, client, req.Model)
|
||||||
if err := PullIfMissing(ctx, client, req.Model); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return client.Embed(ctx, &req)
|
return client.Embed(ctx, &req)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,9 +433,12 @@ func TestEmbedTruncation(t *testing.T) {
|
|||||||
client, _, cleanup := InitServerConnection(ctx, t)
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
for _, model := range libraryEmbedModels {
|
for _, model := range testModels(libraryEmbedModels) {
|
||||||
model := model
|
model := model
|
||||||
t.Run(model, func(t *testing.T) {
|
t.Run(model, func(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
requireCapability(ctx, t, client, model, "embedding")
|
||||||
|
}
|
||||||
// Check if we're running out of time (reserve 20s for current model)
|
// Check if we're running out of time (reserve 20s for current model)
|
||||||
if deadline, ok := t.Deadline(); ok && time.Until(deadline) < 20*time.Second {
|
if deadline, ok := t.Deadline(); ok && time.Until(deadline) < 20*time.Second {
|
||||||
t.Skip("skipping remaining tests to avoid timeout")
|
t.Skip("skipping remaining tests to avoid timeout")
|
||||||
@@ -494,9 +504,12 @@ func TestEmbedLargeInput(t *testing.T) {
|
|||||||
client, _, cleanup := InitServerConnection(ctx, t)
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
for _, model := range libraryEmbedModels {
|
for _, model := range testModels(libraryEmbedModels) {
|
||||||
model := model
|
model := model
|
||||||
t.Run(model, func(t *testing.T) {
|
t.Run(model, func(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
requireCapability(ctx, t, client, model, "embedding")
|
||||||
|
}
|
||||||
mctx, mcancel := context.WithTimeout(ctx, 2*time.Minute)
|
mctx, mcancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||||
defer mcancel()
|
defer mcancel()
|
||||||
|
|
||||||
@@ -559,9 +572,12 @@ func TestEmbedStatusCode(t *testing.T) {
|
|||||||
client, _, cleanup := InitServerConnection(ctx, t)
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
for _, model := range libraryEmbedModels {
|
for _, model := range testModels(libraryEmbedModels) {
|
||||||
model := model
|
model := model
|
||||||
t.Run(model, func(t *testing.T) {
|
t.Run(model, func(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
requireCapability(ctx, t, client, model, "embedding")
|
||||||
|
}
|
||||||
// Check if we're running out of time (reserve 20s for current model)
|
// Check if we're running out of time (reserve 20s for current model)
|
||||||
if deadline, ok := t.Deadline(); ok && time.Until(deadline) < 20*time.Second {
|
if deadline, ok := t.Deadline(); ok && time.Until(deadline) < 20*time.Second {
|
||||||
t.Skip("skipping remaining tests to avoid timeout")
|
t.Skip("skipping remaining tests to avoid timeout")
|
||||||
@@ -571,9 +587,7 @@ func TestEmbedStatusCode(t *testing.T) {
|
|||||||
defer mcancel()
|
defer mcancel()
|
||||||
|
|
||||||
// Pull the model if needed
|
// Pull the model if needed
|
||||||
if err := PullIfMissing(mctx, client, model); err != nil {
|
pullOrSkip(mctx, t, client, model)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("truncation error status code", func(t *testing.T) {
|
t.Run("truncation error status code", func(t *testing.T) {
|
||||||
truncFalse := false
|
truncFalse := false
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestImageGeneration(t *testing.T) {
|
func TestImageGeneration(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
t.Skip("uses hardcoded models, not applicable with model override")
|
||||||
|
}
|
||||||
skipUnderMinVRAM(t, 8)
|
skipUnderMinVRAM(t, 8)
|
||||||
|
|
||||||
type testCase struct {
|
type testCase struct {
|
||||||
@@ -41,12 +44,8 @@ func TestImageGeneration(t *testing.T) {
|
|||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
// Pull both models
|
// Pull both models
|
||||||
if err := PullIfMissing(ctx, client, tc.imageGenModel); err != nil {
|
pullOrSkip(ctx, t, client, tc.imageGenModel)
|
||||||
t.Fatalf("failed to pull image gen model: %v", err)
|
pullOrSkip(ctx, t, client, tc.visionModel)
|
||||||
}
|
|
||||||
if err := PullIfMissing(ctx, client, tc.visionModel); err != nil {
|
|
||||||
t.Fatalf("failed to pull vision model: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate the image
|
// Generate the image
|
||||||
t.Logf("Generating image with prompt: %s", tc.prompt)
|
t.Logf("Generating image with prompt: %s", tc.prompt)
|
||||||
|
|||||||
@@ -24,15 +24,12 @@ func TestLibraryModelsChat(t *testing.T) {
|
|||||||
defer cleanup()
|
defer cleanup()
|
||||||
targetArch := os.Getenv("OLLAMA_TEST_ARCHITECTURE")
|
targetArch := os.Getenv("OLLAMA_TEST_ARCHITECTURE")
|
||||||
|
|
||||||
chatModels := libraryChatModels
|
for _, model := range testModels(libraryChatModels) {
|
||||||
for _, model := range chatModels {
|
|
||||||
t.Run(model, func(t *testing.T) {
|
t.Run(model, func(t *testing.T) {
|
||||||
if time.Now().Sub(started) > softTimeout {
|
if time.Now().Sub(started) > softTimeout {
|
||||||
t.Skip("skipping remaining tests to avoid excessive runtime")
|
t.Skip("skipping remaining tests to avoid excessive runtime")
|
||||||
}
|
}
|
||||||
if err := PullIfMissing(ctx, client, model); err != nil {
|
pullOrSkip(ctx, t, client, model)
|
||||||
t.Fatalf("pull failed %s", err)
|
|
||||||
}
|
|
||||||
if targetArch != "" {
|
if targetArch != "" {
|
||||||
resp, err := client.Show(ctx, &api.ShowRequest{Name: model})
|
resp, err := client.Show(ctx, &api.ShowRequest{Name: model})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -13,39 +13,35 @@ import (
|
|||||||
|
|
||||||
func TestVisionModels(t *testing.T) {
|
func TestVisionModels(t *testing.T) {
|
||||||
skipUnderMinVRAM(t, 6)
|
skipUnderMinVRAM(t, 6)
|
||||||
type testCase struct {
|
|
||||||
model string
|
defaultVisionModels := []string{
|
||||||
}
|
"gemma4",
|
||||||
testCases := []testCase{
|
"qwen2.5vl",
|
||||||
{
|
"llama3.2-vision",
|
||||||
model: "qwen2.5vl",
|
"gemma3",
|
||||||
},
|
"qwen3-vl:8b",
|
||||||
{
|
"qwen3-vl:30b",
|
||||||
model: "llama3.2-vision",
|
"ministral-3",
|
||||||
},
|
|
||||||
{
|
|
||||||
model: "gemma3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: "qwen3-vl:8b",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Qwen 3 VL mixture of experts
|
|
||||||
model: "qwen3-vl:30b",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: "ministral-3",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, v := range testCases {
|
skipIfNoVisionOverride(t)
|
||||||
t.Run(v.model, func(t *testing.T) {
|
|
||||||
|
for _, model := range testModels(defaultVisionModels) {
|
||||||
|
t.Run(model, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
requireCapability(ctx, t, client, model, "vision")
|
||||||
|
pullOrSkip(ctx, t, client, model)
|
||||||
|
|
||||||
image, err := base64.StdEncoding.DecodeString(imageEncoding)
|
image, err := base64.StdEncoding.DecodeString(imageEncoding)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
req := api.ChatRequest{
|
req := api.ChatRequest{
|
||||||
Model: v.model,
|
Model: model,
|
||||||
Messages: []api.Message{
|
Messages: []api.Message{
|
||||||
{
|
{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
@@ -61,16 +57,7 @@ func TestVisionModels(t *testing.T) {
|
|||||||
"temperature": 0.0,
|
"temperature": 0.0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
client, _, cleanup := InitServerConnection(ctx, t)
|
|
||||||
|
|
||||||
// Note: sometimes it returns "the ollamas" sometimes "the ollams"
|
|
||||||
resp := "the ollam"
|
|
||||||
defer cleanup()
|
|
||||||
if err := PullIfMissing(ctx, client, req.Model); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// Preload to skip if we're less than 80% on GPU to avoid extremely slow tests
|
// Preload to skip if we're less than 80% on GPU to avoid extremely slow tests
|
||||||
err = client.Generate(ctx, &api.GenerateRequest{Model: req.Model}, func(response api.GenerateResponse) error { return nil })
|
err = client.Generate(ctx, &api.GenerateRequest{Model: req.Model}, func(response api.GenerateResponse) error { return nil })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -78,13 +65,17 @@ func TestVisionModels(t *testing.T) {
|
|||||||
}
|
}
|
||||||
skipIfNotGPULoaded(ctx, t, client, req.Model, 80)
|
skipIfNotGPULoaded(ctx, t, client, req.Model, 80)
|
||||||
|
|
||||||
|
// Note: sometimes it returns "the ollamas" sometimes "the ollams"
|
||||||
// llava models on CPU can be quite slow to start
|
// llava models on CPU can be quite slow to start
|
||||||
DoChat(ctx, t, client, req, []string{resp}, 240*time.Second, 30*time.Second)
|
DoChat(ctx, t, client, req, []string{"the ollam"}, 240*time.Second, 30*time.Second)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIntegrationSplitBatch(t *testing.T) {
|
func TestIntegrationSplitBatch(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
t.Skip("uses hardcoded model, not applicable with model override")
|
||||||
|
}
|
||||||
skipUnderMinVRAM(t, 6)
|
skipUnderMinVRAM(t, 6)
|
||||||
image, err := base64.StdEncoding.DecodeString(imageEncoding)
|
image, err := base64.StdEncoding.DecodeString(imageEncoding)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -111,9 +102,7 @@ func TestIntegrationSplitBatch(t *testing.T) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
client, _, cleanup := InitServerConnection(ctx, t)
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
if err := PullIfMissing(ctx, client, req.Model); err != nil {
|
pullOrSkip(ctx, t, client, req.Model)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// llava models on CPU can be quite slow to start,
|
// llava models on CPU can be quite slow to start,
|
||||||
DoGenerate(ctx, t, client, req, []string{resp}, 120*time.Second, 30*time.Second)
|
DoGenerate(ctx, t, client, req, []string{resp}, 120*time.Second, 30*time.Second)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,9 +45,7 @@ func TestMaxQueue(t *testing.T) {
|
|||||||
client, _, cleanup := InitServerConnection(ctx, t)
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
if err := PullIfMissing(ctx, client, req.Model); err != nil {
|
pullOrSkip(ctx, t, client, req.Model)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Context for the worker threads so we can shut them down
|
// Context for the worker threads so we can shut them down
|
||||||
// embedCtx, embedCancel := context.WithCancel(ctx)
|
// embedCtx, embedCancel := context.WithCancel(ctx)
|
||||||
|
|||||||
@@ -46,14 +46,12 @@ func TestModelsChat(t *testing.T) {
|
|||||||
chatModels = append(ollamaEngineChatModels, llamaRunnerChatModels...)
|
chatModels = append(ollamaEngineChatModels, llamaRunnerChatModels...)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, model := range chatModels {
|
for _, model := range testModels(chatModels) {
|
||||||
t.Run(model, func(t *testing.T) {
|
t.Run(model, func(t *testing.T) {
|
||||||
if time.Now().Sub(started) > softTimeout {
|
if time.Now().Sub(started) > softTimeout {
|
||||||
t.Skip("skipping remaining tests to avoid excessive runtime")
|
t.Skip("skipping remaining tests to avoid excessive runtime")
|
||||||
}
|
}
|
||||||
if err := PullIfMissing(ctx, client, model); err != nil {
|
pullOrSkip(ctx, t, client, model)
|
||||||
t.Fatalf("pull failed %s", err)
|
|
||||||
}
|
|
||||||
if maxVram > 0 {
|
if maxVram > 0 {
|
||||||
resp, err := client.List(ctx)
|
resp, err := client.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -133,14 +131,15 @@ func TestModelsEmbed(t *testing.T) {
|
|||||||
t.Fatalf("failed to load test data: %s", err)
|
t.Fatalf("failed to load test data: %s", err)
|
||||||
}
|
}
|
||||||
for model, expected := range testCase {
|
for model, expected := range testCase {
|
||||||
|
if testModel != "" && model != testModel {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
t.Run(model, func(t *testing.T) {
|
t.Run(model, func(t *testing.T) {
|
||||||
if time.Now().Sub(started) > softTimeout {
|
if time.Now().Sub(started) > softTimeout {
|
||||||
t.Skip("skipping remaining tests to avoid excessive runtime")
|
t.Skip("skipping remaining tests to avoid excessive runtime")
|
||||||
}
|
}
|
||||||
if err := PullIfMissing(ctx, client, model); err != nil {
|
pullOrSkip(ctx, t, client, model)
|
||||||
t.Fatalf("pull failed %s", err)
|
|
||||||
}
|
|
||||||
if maxVram > 0 {
|
if maxVram > 0 {
|
||||||
resp, err := client.List(ctx)
|
resp, err := client.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -87,9 +87,7 @@ func doModelPerfTest(t *testing.T, chatModels []string) {
|
|||||||
if time.Now().Sub(started) > softTimeout {
|
if time.Now().Sub(started) > softTimeout {
|
||||||
t.Skip("skipping remaining tests to avoid excessive runtime")
|
t.Skip("skipping remaining tests to avoid excessive runtime")
|
||||||
}
|
}
|
||||||
if err := PullIfMissing(ctx, client, model); err != nil {
|
pullOrSkip(ctx, t, client, model)
|
||||||
t.Fatalf("pull failed %s", err)
|
|
||||||
}
|
|
||||||
var maxContext int
|
var maxContext int
|
||||||
|
|
||||||
resp, err := client.Show(ctx, &api.ShowRequest{Model: model})
|
resp, err := client.Show(ctx, &api.ShowRequest{Model: model})
|
||||||
|
|||||||
@@ -33,9 +33,7 @@ func TestQuantization(t *testing.T) {
|
|||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
for _, base := range sourceModels {
|
for _, base := range sourceModels {
|
||||||
if err := PullIfMissing(ctx, client, base); err != nil {
|
pullOrSkip(ctx, t, client, base)
|
||||||
t.Fatalf("pull failed %s", err)
|
|
||||||
}
|
|
||||||
for _, quant := range quantizations {
|
for _, quant := range quantizations {
|
||||||
newName := fmt.Sprintf("%s__%s", base, quant)
|
newName := fmt.Sprintf("%s__%s", base, quant)
|
||||||
t.Run(newName, func(t *testing.T) {
|
t.Run(newName, func(t *testing.T) {
|
||||||
|
|||||||
155
integration/thinking_test.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestThinkingEnabled verifies that when thinking is requested, the model
|
||||||
|
// produces both thinking and content output without leaking raw channel tags.
|
||||||
|
func TestThinkingEnabled(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
models := testModels([]string{smol})
|
||||||
|
for _, modelName := range models {
|
||||||
|
t.Run(modelName, func(t *testing.T) {
|
||||||
|
requireCapability(ctx, t, client, modelName, "thinking")
|
||||||
|
pullOrSkip(ctx, t, client, modelName)
|
||||||
|
|
||||||
|
think := api.ThinkValue{Value: true}
|
||||||
|
stream := false
|
||||||
|
req := api.ChatRequest{
|
||||||
|
Model: modelName,
|
||||||
|
Stream: &stream,
|
||||||
|
Think: &think,
|
||||||
|
Messages: []api.Message{
|
||||||
|
{Role: "user", Content: "What is 12 * 15? Think step by step."},
|
||||||
|
},
|
||||||
|
Options: map[string]any{
|
||||||
|
"temperature": 0,
|
||||||
|
"seed": 42,
|
||||||
|
"num_predict": 512,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var response api.ChatResponse
|
||||||
|
err := client.Chat(ctx, &req, func(cr api.ChatResponse) error {
|
||||||
|
response = cr
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "model requires more system memory") {
|
||||||
|
t.Skip("model too large for test system")
|
||||||
|
}
|
||||||
|
t.Fatalf("chat failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := response.Message.Content
|
||||||
|
thinking := response.Message.Thinking
|
||||||
|
|
||||||
|
// Thinking should be non-empty when thinking is enabled
|
||||||
|
if thinking == "" {
|
||||||
|
t.Error("expected non-empty thinking output when thinking is enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The answer (180) should appear in thinking, content, or both.
|
||||||
|
// Some models put everything in thinking and leave content empty
|
||||||
|
// if they hit the token limit while still thinking.
|
||||||
|
combined := thinking + " " + content
|
||||||
|
if !strings.Contains(combined, "180") {
|
||||||
|
t.Errorf("expected '180' in thinking or content, got thinking=%q content=%q", thinking, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither thinking nor content should contain raw channel tags
|
||||||
|
if strings.Contains(content, "<|channel>") || strings.Contains(content, "<channel|>") {
|
||||||
|
t.Errorf("content contains raw channel tags: %s", content)
|
||||||
|
}
|
||||||
|
if strings.Contains(thinking, "<|channel>") || strings.Contains(thinking, "<channel|>") {
|
||||||
|
t.Errorf("thinking contains raw channel tags: %s", thinking)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("thinking (%d chars): %.100s...", len(thinking), thinking)
|
||||||
|
t.Logf("content (%d chars): %s", len(content), content)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestThinkingSuppressed verifies that when thinking is NOT requested,
|
||||||
|
// the model does not leak thinking/channel content into the response.
|
||||||
|
func TestThinkingSuppressed(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
models := testModels([]string{smol})
|
||||||
|
for _, modelName := range models {
|
||||||
|
t.Run(modelName, func(t *testing.T) {
|
||||||
|
requireCapability(ctx, t, client, modelName, "thinking")
|
||||||
|
pullOrSkip(ctx, t, client, modelName)
|
||||||
|
|
||||||
|
stream := false
|
||||||
|
req := api.ChatRequest{
|
||||||
|
Model: modelName,
|
||||||
|
Stream: &stream,
|
||||||
|
// Think is nil — thinking not requested
|
||||||
|
Messages: []api.Message{
|
||||||
|
{Role: "user", Content: "What is the capital of Japan? Answer in one word."},
|
||||||
|
},
|
||||||
|
Options: map[string]any{
|
||||||
|
"temperature": 0,
|
||||||
|
"seed": 42,
|
||||||
|
"num_predict": 64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var response api.ChatResponse
|
||||||
|
err := client.Chat(ctx, &req, func(cr api.ChatResponse) error {
|
||||||
|
response = cr
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "model requires more system memory") {
|
||||||
|
t.Skip("model too large for test system")
|
||||||
|
}
|
||||||
|
t.Fatalf("chat failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := response.Message.Content
|
||||||
|
thinking := response.Message.Thinking
|
||||||
|
|
||||||
|
// The answer should appear in content or thinking
|
||||||
|
combined := content + " " + thinking
|
||||||
|
if !strings.Contains(combined, "Tokyo") {
|
||||||
|
t.Errorf("expected 'Tokyo' in content or thinking, got content=%q thinking=%q", content, thinking)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content must NOT contain channel/thinking tags
|
||||||
|
if strings.Contains(content, "<|channel>") || strings.Contains(content, "<channel|>") {
|
||||||
|
t.Errorf("content contains leaked channel tags when thinking not requested: %s", content)
|
||||||
|
}
|
||||||
|
if strings.Contains(content, "thought") && strings.Contains(content, "<channel|>") {
|
||||||
|
t.Errorf("content contains leaked thinking block: %s", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thinking field should ideally be empty when not requested.
|
||||||
|
// Some small models may still produce thinking output; log but don't fail.
|
||||||
|
if thinking != "" {
|
||||||
|
t.Logf("WARNING: model produced thinking output when not requested (%d chars): %.100s...", len(thinking), thinking)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("content: %s", content)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
523
integration/tools_stress_test.go
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAPIToolCallingStress tests tool calling with complex, agent-style prompts
|
||||||
|
// that include large system messages, multiple tools, and multi-turn conversations.
|
||||||
|
// This catches cache corruption and parser bugs that simple tool tests miss.
|
||||||
|
func TestAPIToolCallingStress(t *testing.T) {
|
||||||
|
initialTimeout := 120 * time.Second
|
||||||
|
streamTimeout := 120 * time.Second
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
minVRAM := map[string]uint64{
|
||||||
|
"qwen3-vl": 16,
|
||||||
|
"gpt-oss:20b": 16,
|
||||||
|
"gpt-oss:120b": 70,
|
||||||
|
"qwen3": 6,
|
||||||
|
"llama3.1": 8,
|
||||||
|
"llama3.2": 4,
|
||||||
|
"mistral": 6,
|
||||||
|
"qwen2.5": 6,
|
||||||
|
"qwen2": 6,
|
||||||
|
"ministral-3": 20,
|
||||||
|
"mistral-nemo": 9,
|
||||||
|
"mistral-small": 16,
|
||||||
|
"mixtral:8x22b": 80,
|
||||||
|
"qwq": 20,
|
||||||
|
"granite3.3": 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Models that don't reliably produce tool calls with complex/multi-tool prompts.
|
||||||
|
// The stress test uses a large system prompt with many tools, simulating coding agents.
|
||||||
|
// Some models are too small, too slow, or not designed for this use case.
|
||||||
|
skipModels := map[string]string{
|
||||||
|
"lfm2.5-thinking": "returns text instead of tool calls with complex system prompts",
|
||||||
|
"qwen3-vl": "vision model, extremely slow with complex tool prompts",
|
||||||
|
"llama3.2": "3B model too small for reliable multi-tool agent prompts",
|
||||||
|
"mistral": "7B v0.3 returns text instead of tool calls with complex prompts",
|
||||||
|
"mixtral:8x22b": "returns text instead of tool calls with complex prompts",
|
||||||
|
"qwen2": "returns text instead of tool calls with complex prompts",
|
||||||
|
"granite3.3": "returns text instead of tool calls with complex prompts",
|
||||||
|
}
|
||||||
|
|
||||||
|
models := testModels(libraryToolsModels)
|
||||||
|
|
||||||
|
for _, model := range models {
|
||||||
|
t.Run(model, func(t *testing.T) {
|
||||||
|
// Skip known-bad models unless explicitly requested via env var
|
||||||
|
if reason, ok := skipModels[model]; ok && testModel == "" {
|
||||||
|
t.Skipf("skipping: %s", reason)
|
||||||
|
}
|
||||||
|
if testModel != "" {
|
||||||
|
requireCapability(ctx, t, client, model, "tools")
|
||||||
|
}
|
||||||
|
if v, ok := minVRAM[model]; ok {
|
||||||
|
skipUnderMinVRAM(t, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
pullOrSkip(ctx, t, client, model)
|
||||||
|
|
||||||
|
tools := stressTestTools()
|
||||||
|
|
||||||
|
// Large system prompt that mimics real coding agents (opencode, Claude Code, etc.)
|
||||||
|
// This is intentionally very long (~5000+ tokens) to match the prompt sizes that
|
||||||
|
// real coding agents send. The combination of a large system prompt, many tools,
|
||||||
|
// and thinking mode is what triggers failures in some models.
|
||||||
|
systemPrompt := stressTestSystemPrompt()
|
||||||
|
|
||||||
|
// Test 1: First request (fresh prompt processing)
|
||||||
|
// Use a direct prompt that tells the model exactly what tool to use,
|
||||||
|
// reducing the chance it asks for clarification instead.
|
||||||
|
t.Run("first_request", func(t *testing.T) {
|
||||||
|
testToolCall(t, ctx, client, model, systemPrompt, tools,
|
||||||
|
"Run git diff main to review the code changes on the current branch.",
|
||||||
|
initialTimeout, streamTimeout)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test 2: Repeat with same prompt (tests cache reuse)
|
||||||
|
t.Run("cached_request", func(t *testing.T) {
|
||||||
|
testToolCall(t, ctx, client, model, systemPrompt, tools,
|
||||||
|
"Run git diff main to review the code changes on the current branch.",
|
||||||
|
initialTimeout, streamTimeout)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test 3: Different user message (partial cache hit)
|
||||||
|
t.Run("different_user_message", func(t *testing.T) {
|
||||||
|
testToolCall(t, ctx, client, model, systemPrompt, tools,
|
||||||
|
"Read the file at ./go.mod and tell me what dependencies we have.",
|
||||||
|
initialTimeout, streamTimeout)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test 4: Multi-turn with tool response
|
||||||
|
t.Run("multi_turn", func(t *testing.T) {
|
||||||
|
testToolCallMultiTurn(t, ctx, client, model, systemPrompt, tools,
|
||||||
|
initialTimeout, streamTimeout)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTool(name, description string, required []string, props map[string]api.ToolProperty) api.Tool {
|
||||||
|
return api.Tool{
|
||||||
|
Type: "function",
|
||||||
|
Function: api.ToolFunction{
|
||||||
|
Name: name,
|
||||||
|
Description: description,
|
||||||
|
Parameters: api.ToolFunctionParameters{
|
||||||
|
Type: "object",
|
||||||
|
Required: required,
|
||||||
|
Properties: testPropsMap(props),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stressTestTools returns a set of tools matching the scale and verbosity of
|
||||||
|
// real coding agent tool definitions (opencode, Claude Code, etc.). The tool
|
||||||
|
// descriptions are intentionally verbose to match real-world prompt sizes.
|
||||||
|
func stressTestTools() []api.Tool {
|
||||||
|
return []api.Tool{
|
||||||
|
newTool("bash", "Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. All commands run in the working directory by default. Before executing the command, verify that the parent directory exists. Always quote file paths that contain spaces with double quotes. After ensuring proper quoting, execute the command and capture the output. Avoid using bash with find, grep, cat, head, tail, sed, awk, or echo commands unless explicitly instructed. Instead, always prefer using the dedicated tools for these commands. When issuing multiple commands, if they are independent and can run in parallel, make multiple tool calls in a single message.",
|
||||||
|
[]string{"command"},
|
||||||
|
map[string]api.ToolProperty{
|
||||||
|
"command": {Type: api.PropertyType{"string"}, Description: "The bash command to execute"},
|
||||||
|
"description": {Type: api.PropertyType{"string"}, Description: "Short description of what this command does in 5-10 words"},
|
||||||
|
"timeout": {Type: api.PropertyType{"number"}, Description: "Optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes)"},
|
||||||
|
}),
|
||||||
|
newTool("read", "Read a file or directory from the local filesystem. If the path does not exist, an error is returned. By default, this tool returns up to 2000 lines from the start of the file. The offset parameter is the line number to start from (1-indexed). To read later sections, call this tool again with a larger offset. Use the grep tool to find specific content in large files or files with long lines. If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern. Contents are returned with each line prefixed by its line number. Any line longer than 2000 characters is truncated. Call this tool in parallel when you know there are multiple files you want to read. Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window. This tool can read image files and PDFs and return them as file attachments.",
|
||||||
|
[]string{"path"},
|
||||||
|
map[string]api.ToolProperty{
|
||||||
|
"path": {Type: api.PropertyType{"string"}, Description: "The absolute path to the file to read"},
|
||||||
|
"offset": {Type: api.PropertyType{"number"}, Description: "Line number to start reading from (1-indexed)"},
|
||||||
|
"limit": {Type: api.PropertyType{"number"}, Description: "Maximum number of lines to read"},
|
||||||
|
}),
|
||||||
|
newTool("glob", "Fast file pattern matching tool that works with any codebase size. Supports glob patterns like '**/*.js' or 'src/**/*.ts'. Returns matching file paths sorted by modification time. Use this tool when you need to find files by name patterns. When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the task tool instead. You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.",
|
||||||
|
[]string{"pattern"},
|
||||||
|
map[string]api.ToolProperty{
|
||||||
|
"pattern": {Type: api.PropertyType{"string"}, Description: "The glob pattern to match files against"},
|
||||||
|
"path": {Type: api.PropertyType{"string"}, Description: "The directory to search in"},
|
||||||
|
}),
|
||||||
|
newTool("grep", "Fast content search tool that works with any codebase size. Searches file contents using regular expressions. Supports full regex syntax (eg. 'log.*Error', 'function\\s+\\w+'). Filter files by pattern with the include parameter (eg. '*.js', '*.{ts,tsx}'). Returns file paths and line numbers with at least one match sorted by modification time. Use this tool when you need to find files containing specific patterns. If you need to identify or count the number of matches within files, use the bash tool with rg (ripgrep) directly. When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the task tool instead.",
|
||||||
|
[]string{"pattern"},
|
||||||
|
map[string]api.ToolProperty{
|
||||||
|
"pattern": {Type: api.PropertyType{"string"}, Description: "The regex pattern to search for in file contents"},
|
||||||
|
"path": {Type: api.PropertyType{"string"}, Description: "The directory to search in"},
|
||||||
|
"include": {Type: api.PropertyType{"string"}, Description: "File pattern to include (eg. '*.js', '*.{ts,tsx}')"},
|
||||||
|
}),
|
||||||
|
newTool("edit", "Performs exact string replacements in files. You must use your read tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. When editing text from read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears after the line number prefix. Always prefer editing existing files in the codebase. Never write new files unless explicitly required. Only use emojis if the user explicitly requests it. The edit will fail if oldString is not found in the file. The edit will fail if oldString is found multiple times in the file. Use replaceAll for replacing and renaming strings across the file.",
|
||||||
|
[]string{"path", "old_string", "new_string"},
|
||||||
|
map[string]api.ToolProperty{
|
||||||
|
"path": {Type: api.PropertyType{"string"}, Description: "The absolute path to the file to modify"},
|
||||||
|
"old_string": {Type: api.PropertyType{"string"}, Description: "The text to replace (must be unique in the file)"},
|
||||||
|
"new_string": {Type: api.PropertyType{"string"}, Description: "The replacement text"},
|
||||||
|
}),
|
||||||
|
newTool("write", "Writes a file to the local filesystem. This tool will overwrite the existing file if there is one at the provided path. If this is an existing file, you must use the read tool first to read the file contents. This tool will fail if you did not read the file first. Always prefer editing existing files in the codebase. Never write new files unless explicitly required. Never proactively create documentation files or README files. Only create documentation files if explicitly requested by the user.",
|
||||||
|
[]string{"path", "content"},
|
||||||
|
map[string]api.ToolProperty{
|
||||||
|
"path": {Type: api.PropertyType{"string"}, Description: "The absolute path to the file to write"},
|
||||||
|
"content": {Type: api.PropertyType{"string"}, Description: "The content to write to the file"},
|
||||||
|
}),
|
||||||
|
newTool("question", "Use this tool when you need to ask the user questions during execution. This allows you to gather user preferences or requirements, clarify ambiguous instructions, get decisions on implementation choices as you work, and offer choices to the user about what direction to take. When custom is enabled (default), a 'Type your own answer' option is added automatically. Answers are returned as arrays of labels. Set multiple to true to allow selecting more than one answer. If you recommend a specific option, make that the first option in the list and add '(Recommended)' at the end of the label.",
|
||||||
|
[]string{"questions"},
|
||||||
|
map[string]api.ToolProperty{
|
||||||
|
"questions": {Type: api.PropertyType{"string"}, Description: "The question to ask the user"},
|
||||||
|
}),
|
||||||
|
newTool("task", "Launch a new agent to handle complex, multistep tasks autonomously. Available agent types: general (general-purpose agent for researching complex questions and executing multi-step tasks, use this to execute multiple units of work in parallel) and explore (fast agent specialized for exploring codebases, use this when you need to quickly find files by patterns, search code for keywords, or answer questions about the codebase). Launch multiple agents concurrently whenever possible to maximize performance. When the agent is done, it will return a single message back to you. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session.",
|
||||||
|
[]string{"description", "prompt", "subagent_type"},
|
||||||
|
map[string]api.ToolProperty{
|
||||||
|
"description": {Type: api.PropertyType{"string"}, Description: "A short (3-5 word) description of the task"},
|
||||||
|
"prompt": {Type: api.PropertyType{"string"}, Description: "The task for the agent to perform"},
|
||||||
|
"subagent_type": {Type: api.PropertyType{"string"}, Description: "The type of specialized agent to use (general or explore)"},
|
||||||
|
}),
|
||||||
|
newTool("webfetch", "Fetches content from a specified URL. Takes a URL and optional format as input. Fetches the URL content, converts to requested format (markdown by default). Returns the content in the specified format. Use this tool when you need to retrieve and analyze web content. The URL must be a fully-formed valid URL. HTTP URLs will be automatically upgraded to HTTPS. Format options: markdown (default), text, or html. This tool is read-only and does not modify any files. Results may be summarized if the content is very large.",
|
||||||
|
[]string{"url", "format"},
|
||||||
|
map[string]api.ToolProperty{
|
||||||
|
"url": {Type: api.PropertyType{"string"}, Description: "The URL to fetch content from"},
|
||||||
|
"format": {Type: api.PropertyType{"string"}, Description: "Output format: markdown (default), text, or html"},
|
||||||
|
}),
|
||||||
|
newTool("todowrite", "Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user. Use this tool proactively when handling complex multistep tasks, non-trivial and complex tasks, when the user explicitly requests a todo list, when the user provides multiple tasks, after receiving new instructions, and after completing a task. Do not use this tool when there is only a single straightforward task, the task is trivial, the task can be completed in less than 3 steps, or the task is purely conversational.",
|
||||||
|
[]string{"todos"},
|
||||||
|
map[string]api.ToolProperty{
|
||||||
|
"todos": {Type: api.PropertyType{"string"}, Description: "JSON array of todo items with id, title, and status fields"},
|
||||||
|
}),
|
||||||
|
newTool("skill", "Load a specialized skill that provides domain-specific instructions and workflows. Skills contain curated prompts and tool configurations for specific tasks like code review, testing, deployment, and documentation. Use this tool when the user's request matches an available skill description.",
|
||||||
|
[]string{"name"},
|
||||||
|
map[string]api.ToolProperty{
|
||||||
|
"name": {Type: api.PropertyType{"string"}, Description: "The name of the skill to load"},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stressTestSystemPrompt returns a system prompt that matches the scale and
|
||||||
|
// content of real coding agent system prompts (~5000+ tokens). This is based
|
||||||
|
// on actual prompts captured from opencode sessions. The prompt size combined
|
||||||
|
// with many tool declarations is what pushes models past their effective
|
||||||
|
// context handling and triggers tag leakage / broken tool calls.
|
||||||
|
func stressTestSystemPrompt() string {
|
||||||
|
return `You are opencode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
||||||
|
|
||||||
|
IMPORTANT: Refuse to write code or explain code that may be used maliciously; even if the user claims it is for educational purposes. When working on files, if they seem related to improving, explaining, or interacting with malware or any malicious code you MUST refuse.
|
||||||
|
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure. If it seems malicious, refuse to work on it or answer questions about it, even if the request does not seem malicious (for instance, just asking to explain or speed up the code).
|
||||||
|
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
|
||||||
|
|
||||||
|
If the user asks for help or wants to give feedback inform them of the following:
|
||||||
|
- /help: Get help with using opencode
|
||||||
|
- To give feedback, users should report the issue at https://github.com/sampleorg/opencode/issues
|
||||||
|
|
||||||
|
# Tone and style
|
||||||
|
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
|
||||||
|
Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
|
||||||
|
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
|
||||||
|
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
|
||||||
|
Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
|
||||||
|
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
|
||||||
|
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
|
||||||
|
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
|
||||||
|
|
||||||
|
user: 2 + 2
|
||||||
|
assistant: 4
|
||||||
|
|
||||||
|
user: what is 2+2?
|
||||||
|
assistant: 4
|
||||||
|
|
||||||
|
user: is 11 a prime number?
|
||||||
|
assistant: Yes
|
||||||
|
|
||||||
|
user: what command should I run to list files in the current directory?
|
||||||
|
assistant: ls
|
||||||
|
|
||||||
|
user: what command should I run to watch files in the current directory?
|
||||||
|
assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
user: How many golf balls fit inside a jetta?
|
||||||
|
assistant: 150000
|
||||||
|
|
||||||
|
user: what files are in the directory src/?
|
||||||
|
assistant: [runs ls and sees foo.c, bar.c, baz.c]
|
||||||
|
user: which file contains the implementation of foo?
|
||||||
|
assistant: src/foo.c
|
||||||
|
|
||||||
|
user: write tests for new feature
|
||||||
|
assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit file tool to write new tests]
|
||||||
|
|
||||||
|
# Proactiveness
|
||||||
|
You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
|
||||||
|
1. Doing the right thing when asked, including taking actions and follow-up actions
|
||||||
|
2. Not surprising the user with actions you take without asking
|
||||||
|
For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
|
||||||
|
3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
|
||||||
|
|
||||||
|
# Following conventions
|
||||||
|
When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
|
||||||
|
- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
|
||||||
|
- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
|
||||||
|
- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
|
||||||
|
- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
|
||||||
|
|
||||||
|
# Code style
|
||||||
|
- IMPORTANT: DO NOT ADD ANY COMMENTS unless asked
|
||||||
|
|
||||||
|
# Doing tasks
|
||||||
|
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
|
||||||
|
- Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
|
||||||
|
- Implement the solution using all tools available to you
|
||||||
|
- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
|
||||||
|
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
|
||||||
|
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
|
||||||
|
|
||||||
|
# Tool usage policy
|
||||||
|
- When doing file search, prefer to use the Task tool in order to reduce context usage.
|
||||||
|
- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple bash tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel.
|
||||||
|
|
||||||
|
You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.
|
||||||
|
|
||||||
|
# Code References
|
||||||
|
When referencing specific functions or pieces of code include the pattern file_path:line_number to allow the user to easily navigate to the source code location.
|
||||||
|
|
||||||
|
# Git workflow
|
||||||
|
When working with git:
|
||||||
|
- Create descriptive commit messages that explain WHY not just WHAT
|
||||||
|
- Use conventional commit format: feat:, fix:, refactor:, docs:, test:, chore:
|
||||||
|
- Check git status before and after operations
|
||||||
|
- Never force push to main/master
|
||||||
|
- Review diffs before committing
|
||||||
|
- NEVER update the git config
|
||||||
|
- NEVER run destructive/irreversible git commands unless the user explicitly requests them
|
||||||
|
- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it
|
||||||
|
- Avoid git commit --amend unless explicitly requested by the user
|
||||||
|
- NEVER commit changes unless the user explicitly asks you to
|
||||||
|
|
||||||
|
# Safety
|
||||||
|
- Never delete files without confirmation
|
||||||
|
- Never run destructive commands (rm -rf, DROP TABLE, etc.) without confirmation
|
||||||
|
- Always validate inputs before using them in shell commands
|
||||||
|
- Be careful with environment variables and secrets
|
||||||
|
- Do not expose API keys, passwords, or tokens in code or logs
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
Working directory: /Users/test/code/myproject
|
||||||
|
Platform: darwin
|
||||||
|
Shell: zsh
|
||||||
|
Is directory a git repo: yes
|
||||||
|
The project uses Go 1.22 with modules. Run tests with 'go test ./...' and build with 'go build ./...'.
|
||||||
|
The CI pipeline runs golangci-lint, go vet, and go test with race detector enabled.
|
||||||
|
|
||||||
|
# User instructions
|
||||||
|
Never use cd to change into the repo root or any other directory in Bash commands. The working directory is always the repo root — use relative paths directly.
|
||||||
|
Never use heredoc-style inline bash or python scripts in Bash tool calls. Instead, write the script to an ephemeral file under ./.tmp/ in the repo, then run it as a separate command.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// validStressTools is the set of tool names used in the stress test.
|
||||||
|
var validStressTools = map[string]bool{
|
||||||
|
"bash": true, "read": true, "glob": true, "grep": true,
|
||||||
|
"edit": true, "write": true, "question": true, "task": true,
|
||||||
|
"webfetch": true, "todowrite": true, "skill": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func testToolCall(t *testing.T, ctx context.Context, client *api.Client, model, systemPrompt string, tools []api.Tool, userMessage string, initialTimeout, streamTimeout time.Duration) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := api.ChatRequest{
|
||||||
|
Model: model,
|
||||||
|
Messages: []api.Message{
|
||||||
|
{Role: "system", Content: systemPrompt},
|
||||||
|
{Role: "user", Content: userMessage},
|
||||||
|
},
|
||||||
|
Tools: tools,
|
||||||
|
Options: map[string]any{
|
||||||
|
"temperature": 0,
|
||||||
|
"num_ctx": contextLength(16384),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
stallTimer := time.NewTimer(initialTimeout)
|
||||||
|
var gotToolCall bool
|
||||||
|
var lastToolCall api.ToolCall
|
||||||
|
var allContent string
|
||||||
|
|
||||||
|
fn := func(response api.ChatResponse) error {
|
||||||
|
if len(response.Message.ToolCalls) > 0 {
|
||||||
|
gotToolCall = true
|
||||||
|
lastToolCall = response.Message.ToolCalls[len(response.Message.ToolCalls)-1]
|
||||||
|
}
|
||||||
|
allContent += response.Message.Content
|
||||||
|
if !stallTimer.Reset(streamTimeout) {
|
||||||
|
return fmt.Errorf("stall detected while streaming")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
stream := true
|
||||||
|
req.Stream = &stream
|
||||||
|
done := make(chan int)
|
||||||
|
var genErr error
|
||||||
|
go func() {
|
||||||
|
genErr = client.Chat(ctx, &req, fn)
|
||||||
|
done <- 0
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-stallTimer.C:
|
||||||
|
t.Fatalf("chat stalled after %s", initialTimeout)
|
||||||
|
case <-done:
|
||||||
|
if genErr != nil {
|
||||||
|
t.Fatalf("chat failed: %v", genErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for leaked special tags in content — these should never
|
||||||
|
// appear in user-visible output regardless of model quality.
|
||||||
|
checkNoLeakedTags(t, allContent)
|
||||||
|
|
||||||
|
// The model must produce either a tool call or a text response.
|
||||||
|
// A text response (e.g. asking for clarification) is legitimate.
|
||||||
|
// Empty output with no tool call indicates a parser or model failure
|
||||||
|
// (e.g. malformed tool call that gets dropped).
|
||||||
|
if !gotToolCall && allContent == "" {
|
||||||
|
t.Fatal("model produced neither a tool call nor text content")
|
||||||
|
}
|
||||||
|
if gotToolCall {
|
||||||
|
if !validStressTools[lastToolCall.Function.Name] {
|
||||||
|
t.Errorf("unexpected tool: %q", lastToolCall.Function.Name)
|
||||||
|
}
|
||||||
|
argsJSON, _ := json.Marshal(lastToolCall.Function.Arguments)
|
||||||
|
t.Logf("tool call: %s(%s)", lastToolCall.Function.Name, string(argsJSON))
|
||||||
|
} else {
|
||||||
|
t.Logf("text response (no tool call): %q", truncate(allContent, 200))
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("context cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testToolCallMultiTurn(t *testing.T, ctx context.Context, client *api.Client, model, systemPrompt string, tools []api.Tool, initialTimeout, streamTimeout time.Duration) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := api.ChatRequest{
|
||||||
|
Model: model,
|
||||||
|
Messages: []api.Message{
|
||||||
|
{Role: "system", Content: systemPrompt},
|
||||||
|
{Role: "user", Content: "What files are in the current directory?"},
|
||||||
|
{Role: "assistant", Content: "", ToolCalls: []api.ToolCall{{
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: "bash",
|
||||||
|
Arguments: api.ToolCallFunctionArguments{},
|
||||||
|
},
|
||||||
|
}}},
|
||||||
|
{Role: "tool", Content: "go.mod\ngo.sum\nmain.go\nREADME.md\n"},
|
||||||
|
// The model should now respond with content or another tool call
|
||||||
|
},
|
||||||
|
Tools: tools,
|
||||||
|
Options: map[string]any{
|
||||||
|
"temperature": 0,
|
||||||
|
"num_ctx": contextLength(16384),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// For the tool response arguments, set the command
|
||||||
|
req.Messages[2].ToolCalls[0].Function.Arguments.Set("command", "ls")
|
||||||
|
|
||||||
|
stallTimer := time.NewTimer(initialTimeout)
|
||||||
|
var gotResponse bool
|
||||||
|
var allContent string
|
||||||
|
var gotToolCall bool
|
||||||
|
|
||||||
|
fn := func(response api.ChatResponse) error {
|
||||||
|
if response.Message.Content != "" {
|
||||||
|
gotResponse = true
|
||||||
|
allContent += response.Message.Content
|
||||||
|
}
|
||||||
|
if len(response.Message.ToolCalls) > 0 {
|
||||||
|
gotToolCall = true
|
||||||
|
gotResponse = true
|
||||||
|
}
|
||||||
|
if !stallTimer.Reset(streamTimeout) {
|
||||||
|
return fmt.Errorf("stall detected")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
stream := true
|
||||||
|
req.Stream = &stream
|
||||||
|
done := make(chan int)
|
||||||
|
var genErr error
|
||||||
|
go func() {
|
||||||
|
genErr = client.Chat(ctx, &req, fn)
|
||||||
|
done <- 0
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-stallTimer.C:
|
||||||
|
t.Fatalf("chat stalled after %s", initialTimeout)
|
||||||
|
case <-done:
|
||||||
|
if genErr != nil {
|
||||||
|
t.Fatalf("chat failed: %v", genErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkNoLeakedTags(t, allContent)
|
||||||
|
|
||||||
|
if !gotResponse {
|
||||||
|
t.Fatal("expected response (content or tool call), got nothing")
|
||||||
|
}
|
||||||
|
if gotToolCall {
|
||||||
|
t.Log("multi-turn: got follow-up tool call")
|
||||||
|
} else {
|
||||||
|
t.Logf("multi-turn: got content response: %q", truncate(allContent, 200))
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("context cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkNoLeakedTags verifies that model-internal special tags do not appear in
|
||||||
|
// user-visible content. These tags should be consumed by the parser and never
|
||||||
|
// passed through. If they appear, either the parser has a bug or the model is
|
||||||
|
// generating malformed output that the parser fails to handle.
|
||||||
|
func checkNoLeakedTags(t *testing.T, content string) {
|
||||||
|
t.Helper()
|
||||||
|
leakedTags := []string{
|
||||||
|
"<|channel>", "<channel|>",
|
||||||
|
"<|tool_call>", "<tool_call|>",
|
||||||
|
"<|tool>", "<tool|>",
|
||||||
|
"<|turn>", "<turn|>",
|
||||||
|
}
|
||||||
|
for _, tag := range leakedTags {
|
||||||
|
if strings.Contains(content, tag) {
|
||||||
|
t.Errorf("leaked special tag %q in content: %q", tag, truncate(content, 300))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextLength(defaultVal int) int {
|
||||||
|
if s := os.Getenv("OLLAMA_CONTEXT_LENGTH"); s != "" {
|
||||||
|
if n, err := strconv.Atoi(s); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "..."
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ func TestAPIToolCalling(t *testing.T) {
|
|||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
minVRAM := map[string]uint64{
|
minVRAM := map[string]uint64{
|
||||||
|
"gemma4": 8,
|
||||||
"qwen3-vl": 16,
|
"qwen3-vl": 16,
|
||||||
"gpt-oss:20b": 16,
|
"gpt-oss:20b": 16,
|
||||||
"gpt-oss:120b": 70,
|
"gpt-oss:120b": 70,
|
||||||
@@ -47,15 +48,18 @@ func TestAPIToolCalling(t *testing.T) {
|
|||||||
"granite3.3": 7,
|
"granite3.3": 7,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, model := range libraryToolsModels {
|
models := testModels(libraryToolsModels)
|
||||||
|
|
||||||
|
for _, model := range models {
|
||||||
t.Run(model, func(t *testing.T) {
|
t.Run(model, func(t *testing.T) {
|
||||||
|
if testModel != "" {
|
||||||
|
requireCapability(ctx, t, client, model, "tools")
|
||||||
|
}
|
||||||
if v, ok := minVRAM[model]; ok {
|
if v, ok := minVRAM[model]; ok {
|
||||||
skipUnderMinVRAM(t, v)
|
skipUnderMinVRAM(t, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := PullIfMissing(ctx, client, model); err != nil {
|
pullOrSkip(ctx, t, client, model)
|
||||||
t.Fatalf("pull failed %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tools := []api.Tool{
|
tools := []api.Tool{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -26,11 +27,17 @@ import (
|
|||||||
|
|
||||||
"github.com/ollama/ollama/api"
|
"github.com/ollama/ollama/api"
|
||||||
"github.com/ollama/ollama/format"
|
"github.com/ollama/ollama/format"
|
||||||
|
"github.com/ollama/ollama/types/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
smol = "llama3.2:1b"
|
smol = "llama3.2:1b"
|
||||||
stream = false
|
stream = false
|
||||||
|
|
||||||
|
// testModel is set via OLLAMA_TEST_MODEL env var. When set, all tests
|
||||||
|
// that loop over model lists will test only this model, and smol is
|
||||||
|
// also overridden to use it.
|
||||||
|
testModel string
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -38,6 +45,7 @@ var (
|
|||||||
|
|
||||||
// Note: add newer models at the top of the list to test them first
|
// Note: add newer models at the top of the list to test them first
|
||||||
ollamaEngineChatModels = []string{
|
ollamaEngineChatModels = []string{
|
||||||
|
"gemma4",
|
||||||
"lfm2.5-thinking",
|
"lfm2.5-thinking",
|
||||||
"ministral-3",
|
"ministral-3",
|
||||||
"qwen3-coder:30b",
|
"qwen3-coder:30b",
|
||||||
@@ -130,6 +138,7 @@ var (
|
|||||||
"gemma2",
|
"gemma2",
|
||||||
"gemma3",
|
"gemma3",
|
||||||
"gemma3n",
|
"gemma3n",
|
||||||
|
"gemma4",
|
||||||
"glm4",
|
"glm4",
|
||||||
"goliath",
|
"goliath",
|
||||||
"gpt-oss:20b",
|
"gpt-oss:20b",
|
||||||
@@ -265,6 +274,7 @@ var (
|
|||||||
"snowflake-arctic-embed2",
|
"snowflake-arctic-embed2",
|
||||||
}
|
}
|
||||||
libraryToolsModels = []string{
|
libraryToolsModels = []string{
|
||||||
|
"gemma4",
|
||||||
"lfm2.5-thinking",
|
"lfm2.5-thinking",
|
||||||
"qwen3-vl",
|
"qwen3-vl",
|
||||||
"gpt-oss:20b",
|
"gpt-oss:20b",
|
||||||
@@ -288,23 +298,60 @@ var (
|
|||||||
|
|
||||||
rainbowPrompt = "how do rainbows form? Be brief but factual in your reply"
|
rainbowPrompt = "how do rainbows form? Be brief but factual in your reply"
|
||||||
rainbowFollowups = []string{
|
rainbowFollowups = []string{
|
||||||
"Explain the physics involved in them. Be breif in your reply",
|
"Explain the physics involved in them. Be brief in your reply",
|
||||||
"Explain the chemistry involved in them. Be breif in your reply",
|
"Explain the chemistry involved in them. Be brief in your reply",
|
||||||
"What are common myths related to them? Be brief in your reply",
|
"What are common myths related to them? Be brief in your reply",
|
||||||
"Can they form if there is no rain? Be breif in your reply",
|
"Can they form if there is no rain? Be brief in your reply",
|
||||||
"Can they form if there are no clouds? Be breif in your reply",
|
"Can they form if there are no clouds? Be brief in your reply",
|
||||||
"Do they happen on other planets? Be brief in your reply",
|
"Do they happen on other planets? Be brief in your reply",
|
||||||
}
|
}
|
||||||
rainbowExpected = []string{"water", "droplet", "mist", "glow", "refract", "reflect", "scatter", "particles", "wave", "color", "spectrum", "raindrop", "atmosphere", "frequency", "shower", "sky", "shimmer", "light", "storm", "sunny", "sunburst", "phenomenon", "mars", "venus", "jupiter"}
|
rainbowExpected = []string{"water", "droplet", "mist", "glow", "refract", "reflect", "scatter", "particles", "wave", "color", "spectrum", "raindrop", "atmosphere", "frequency", "shower", "sky", "shimmer", "light", "storm", "sunny", "sunburst", "phenomenon", "mars", "venus", "jupiter", "rain", "sun", "rainbow", "optical", "gold", "cloud", "planet", "prism", "fog", "ice"}
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
slog.SetDefault(logger)
|
slog.SetDefault(logger)
|
||||||
custom := os.Getenv("OLLAMA_TEST_DEFAULT_MODEL")
|
|
||||||
if custom != "" {
|
testModel = os.Getenv("OLLAMA_TEST_MODEL")
|
||||||
slog.Info("setting default test model to " + custom)
|
if testModel != "" {
|
||||||
smol = custom
|
slog.Info("test model override", "model", testModel)
|
||||||
|
smol = testModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testModels returns the override model as a single-element slice when
|
||||||
|
// OLLAMA_TEST_MODEL is set, otherwise returns the provided default list.
|
||||||
|
func testModels(defaults []string) []string {
|
||||||
|
if testModel != "" {
|
||||||
|
return []string{testModel}
|
||||||
|
}
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireCapability skips the test if the model does not advertise the
|
||||||
|
// given capability. It queries the server via Show and caches nothing —
|
||||||
|
// call it once per subtest. For local-only models where Show may not
|
||||||
|
// return capabilities (e.g. models created via ollama create), this is
|
||||||
|
// a best-effort check.
|
||||||
|
func requireCapability(ctx context.Context, t *testing.T, client *api.Client, modelName string, cap model.Capability) {
|
||||||
|
t.Helper()
|
||||||
|
resp, err := client.Show(ctx, &api.ShowRequest{Name: modelName})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to show model %s: %v", modelName, err)
|
||||||
|
}
|
||||||
|
if len(resp.Capabilities) > 0 && !slices.Contains(resp.Capabilities, cap) {
|
||||||
|
t.Skipf("model %s does not have capability %q (has %v)", modelName, cap, resp.Capabilities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pullOrSkip pulls a model if it isn't already present locally. If the
|
||||||
|
// pull fails (e.g. model not in registry), the test is skipped instead
|
||||||
|
// of failed. PullIfMissing already checks Show first, so local-only
|
||||||
|
// models that exist will return immediately without hitting the registry.
|
||||||
|
func pullOrSkip(ctx context.Context, t *testing.T, client *api.Client, modelName string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := PullIfMissing(ctx, client, modelName); err != nil {
|
||||||
|
t.Skipf("model %s not available: %v", modelName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,9 +587,7 @@ func InitServerConnection(ctx context.Context, t *testing.T) (*api.Client, strin
|
|||||||
func ChatTestHelper(ctx context.Context, t *testing.T, req api.ChatRequest, anyResp []string) {
|
func ChatTestHelper(ctx context.Context, t *testing.T, req api.ChatRequest, anyResp []string) {
|
||||||
client, _, cleanup := InitServerConnection(ctx, t)
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
if err := PullIfMissing(ctx, client, req.Model); err != nil {
|
pullOrSkip(ctx, t, client, req.Model)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
DoChat(ctx, t, client, req, anyResp, 30*time.Second, 10*time.Second)
|
DoChat(ctx, t, client, req, anyResp, 30*time.Second, 10*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
386
integration/vision_test.go
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
|
"github.com/ollama/ollama/types/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default set of vision models to test. When OLLAMA_TEST_MODEL is set,
|
||||||
|
// only that model is tested (with a capability check for vision).
|
||||||
|
var defaultVisionModels = []string{
|
||||||
|
"gemma4",
|
||||||
|
"gemma3",
|
||||||
|
"llama3.2-vision",
|
||||||
|
"qwen2.5vl",
|
||||||
|
"qwen3-vl:8b",
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeTestImages returns the test images.
|
||||||
|
func decodeTestImages(t *testing.T) (abbeyRoad, docs, ollamaHome api.ImageData) {
|
||||||
|
t.Helper()
|
||||||
|
var err error
|
||||||
|
abbeyRoad, err = base64.StdEncoding.DecodeString(imageEncoding)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode abbey road image: %v", err)
|
||||||
|
}
|
||||||
|
docs, err = base64.StdEncoding.DecodeString(imageEncodingDocs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode docs image: %v", err)
|
||||||
|
}
|
||||||
|
ollamaHome, err = base64.StdEncoding.DecodeString(imageEncodingOllamaHome)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode ollama home image: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipIfNoVisionOverride skips the entire test (at parent level) when
|
||||||
|
// OLLAMA_TEST_MODEL is set to a non-vision model. This prevents the parent
|
||||||
|
// test from reporting PASS when all subtests are skipped.
|
||||||
|
func skipIfNoVisionOverride(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if testModel == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Check actual model capabilities via the API rather than a hardcoded list.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
|
defer cleanup()
|
||||||
|
resp, err := client.Show(ctx, &api.ShowRequest{Name: testModel})
|
||||||
|
if err != nil {
|
||||||
|
return // let the test proceed and fail naturally
|
||||||
|
}
|
||||||
|
if len(resp.Capabilities) > 0 && !slices.Contains(resp.Capabilities, model.CapabilityVision) {
|
||||||
|
t.Skipf("model override %q does not have vision capability (has %v)", testModel, resp.Capabilities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupVisionModel pulls the model, preloads it, and skips if not GPU-loaded.
|
||||||
|
func setupVisionModel(ctx context.Context, t *testing.T, client *api.Client, model string) {
|
||||||
|
t.Helper()
|
||||||
|
if testModel != "" {
|
||||||
|
requireCapability(ctx, t, client, model, "vision")
|
||||||
|
}
|
||||||
|
pullOrSkip(ctx, t, client, model)
|
||||||
|
err := client.Generate(ctx, &api.GenerateRequest{Model: model}, func(response api.GenerateResponse) error { return nil })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load model %s: %s", model, err)
|
||||||
|
}
|
||||||
|
skipIfNotGPULoaded(ctx, t, client, model, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVisionMultiTurn sends an image, gets a response, then asks follow-up
|
||||||
|
// questions about the same image. This verifies that the KV cache correctly
|
||||||
|
// handles cached image tokens across turns.
|
||||||
|
func TestVisionMultiTurn(t *testing.T) {
|
||||||
|
skipUnderMinVRAM(t, 6)
|
||||||
|
skipIfNoVisionOverride(t)
|
||||||
|
|
||||||
|
// Models that fail on multi-turn detail questions (e.g. misidentifying objects).
|
||||||
|
skipModels := map[string]string{
|
||||||
|
"gemma3": "misidentifies briefcase as smartphone on turn 3",
|
||||||
|
"llama3.2-vision": "miscounts animals (says 3 instead of 4) on turn 2",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, model := range testModels(defaultVisionModels) {
|
||||||
|
t.Run(model, func(t *testing.T) {
|
||||||
|
if reason, ok := skipModels[model]; ok && testModel == "" {
|
||||||
|
t.Skipf("skipping: %s", reason)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
setupVisionModel(ctx, t, client, model)
|
||||||
|
abbeyRoad, _, _ := decodeTestImages(t)
|
||||||
|
|
||||||
|
// Turn 1: describe the image
|
||||||
|
req := api.ChatRequest{
|
||||||
|
Model: model,
|
||||||
|
Messages: []api.Message{
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: "Describe this image briefly.",
|
||||||
|
Images: []api.ImageData{abbeyRoad},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Stream: &stream,
|
||||||
|
Options: map[string]any{"temperature": 0.0, "seed": 42},
|
||||||
|
}
|
||||||
|
resp1 := DoChat(ctx, t, client, req, []string{
|
||||||
|
"llama", "cross", "walk", "road", "animal", "cartoon",
|
||||||
|
}, 120*time.Second, 30*time.Second)
|
||||||
|
if resp1 == nil {
|
||||||
|
t.Fatal("no response from turn 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn 2: follow-up about count
|
||||||
|
req.Messages = append(req.Messages,
|
||||||
|
*resp1,
|
||||||
|
api.Message{Role: "user", Content: "How many animals are in the image?"},
|
||||||
|
)
|
||||||
|
resp2 := DoChat(ctx, t, client, req, []string{
|
||||||
|
"four", "4", "three", "3",
|
||||||
|
}, 60*time.Second, 30*time.Second)
|
||||||
|
if resp2 == nil {
|
||||||
|
t.Fatal("no response from turn 2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn 3: follow-up about specific detail
|
||||||
|
req.Messages = append(req.Messages,
|
||||||
|
*resp2,
|
||||||
|
api.Message{Role: "user", Content: "Is any animal carrying something? What is it?"},
|
||||||
|
)
|
||||||
|
DoChat(ctx, t, client, req, []string{
|
||||||
|
"briefcase", "suitcase", "bag", "case", "luggage",
|
||||||
|
}, 60*time.Second, 30*time.Second)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVisionObjectCounting asks the model to count objects in an image.
|
||||||
|
func TestVisionObjectCounting(t *testing.T) {
|
||||||
|
skipUnderMinVRAM(t, 6)
|
||||||
|
skipIfNoVisionOverride(t)
|
||||||
|
|
||||||
|
skipModels := map[string]string{
|
||||||
|
"llama3.2-vision": "consistently miscounts (says 3 instead of 4)",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, model := range testModels(defaultVisionModels) {
|
||||||
|
t.Run(model, func(t *testing.T) {
|
||||||
|
if reason, ok := skipModels[model]; ok && testModel == "" {
|
||||||
|
t.Skipf("skipping: %s", reason)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
setupVisionModel(ctx, t, client, model)
|
||||||
|
_, docs, _ := decodeTestImages(t)
|
||||||
|
|
||||||
|
req := api.ChatRequest{
|
||||||
|
Model: model,
|
||||||
|
Messages: []api.Message{
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: "How many animals are shown in this image? Answer with just the number.",
|
||||||
|
Images: []api.ImageData{docs},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Stream: &stream,
|
||||||
|
Options: map[string]any{"temperature": 0.0, "seed": 42},
|
||||||
|
}
|
||||||
|
DoChat(ctx, t, client, req, []string{"4", "four"}, 120*time.Second, 30*time.Second)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVisionSceneUnderstanding tests whether the model can identify
|
||||||
|
// cultural references and scene context from an image.
|
||||||
|
func TestVisionSceneUnderstanding(t *testing.T) {
|
||||||
|
skipUnderMinVRAM(t, 6)
|
||||||
|
skipIfNoVisionOverride(t)
|
||||||
|
|
||||||
|
// Models known to be too small or not capable enough for cultural reference detection.
|
||||||
|
skipModels := map[string]string{
|
||||||
|
"llama3.2-vision": "3B model lacks cultural reference knowledge",
|
||||||
|
"minicpm-v": "too small for cultural reference detection",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, model := range testModels(defaultVisionModels) {
|
||||||
|
t.Run(model, func(t *testing.T) {
|
||||||
|
if reason, ok := skipModels[model]; ok && testModel == "" {
|
||||||
|
t.Skipf("skipping: %s", reason)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
setupVisionModel(ctx, t, client, model)
|
||||||
|
abbeyRoad, _, _ := decodeTestImages(t)
|
||||||
|
|
||||||
|
req := api.ChatRequest{
|
||||||
|
Model: model,
|
||||||
|
Messages: []api.Message{
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: "What famous image or album cover is this a parody of?",
|
||||||
|
Images: []api.ImageData{abbeyRoad},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Stream: &stream,
|
||||||
|
Options: map[string]any{"temperature": 0.0, "seed": 42},
|
||||||
|
}
|
||||||
|
DoChat(ctx, t, client, req, []string{
|
||||||
|
"abbey road", "beatles", "abbey", "llama",
|
||||||
|
}, 120*time.Second, 30*time.Second)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVisionSpatialReasoning tests the model's ability to identify
|
||||||
|
// objects based on their spatial position in the image.
|
||||||
|
func TestVisionSpatialReasoning(t *testing.T) {
|
||||||
|
skipUnderMinVRAM(t, 6)
|
||||||
|
skipIfNoVisionOverride(t)
|
||||||
|
|
||||||
|
for _, model := range testModels(defaultVisionModels) {
|
||||||
|
t.Run(model, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
setupVisionModel(ctx, t, client, model)
|
||||||
|
_, docs, _ := decodeTestImages(t)
|
||||||
|
|
||||||
|
// The docs image has: leftmost llama on laptop with glasses,
|
||||||
|
// rightmost llama sleeping.
|
||||||
|
req := api.ChatRequest{
|
||||||
|
Model: model,
|
||||||
|
Messages: []api.Message{
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: "What is the animal on the far left doing in this image?",
|
||||||
|
Images: []api.ImageData{docs},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Stream: &stream,
|
||||||
|
Options: map[string]any{"temperature": 0.0, "seed": 42},
|
||||||
|
}
|
||||||
|
DoChat(ctx, t, client, req, []string{
|
||||||
|
"laptop", "computer", "typing", "working",
|
||||||
|
}, 120*time.Second, 30*time.Second)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVisionDetailRecognition tests whether the model can identify
|
||||||
|
// small details like accessories in an image.
|
||||||
|
func TestVisionDetailRecognition(t *testing.T) {
|
||||||
|
skipUnderMinVRAM(t, 6)
|
||||||
|
skipIfNoVisionOverride(t)
|
||||||
|
|
||||||
|
for _, model := range testModels(defaultVisionModels) {
|
||||||
|
t.Run(model, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
setupVisionModel(ctx, t, client, model)
|
||||||
|
_, docs, _ := decodeTestImages(t)
|
||||||
|
|
||||||
|
req := api.ChatRequest{
|
||||||
|
Model: model,
|
||||||
|
Messages: []api.Message{
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: "Are any of the animals wearing glasses? Describe what you see.",
|
||||||
|
Images: []api.ImageData{docs},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Stream: &stream,
|
||||||
|
Options: map[string]any{"temperature": 0.0, "seed": 42},
|
||||||
|
}
|
||||||
|
DoChat(ctx, t, client, req, []string{
|
||||||
|
"glasses", "spectacles", "eyeglasses",
|
||||||
|
}, 120*time.Second, 30*time.Second)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVisionMultiImage sends two images in a single message and asks
|
||||||
|
// the model to compare and contrast them. This exercises multi-image
|
||||||
|
// encoding and cross-image reasoning.
|
||||||
|
func TestVisionMultiImage(t *testing.T) {
|
||||||
|
skipUnderMinVRAM(t, 6)
|
||||||
|
skipIfNoVisionOverride(t)
|
||||||
|
|
||||||
|
// Multi-image support varies across models.
|
||||||
|
skipModels := map[string]string{
|
||||||
|
"llama3.2-vision": "does not support multi-image input",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, model := range testModels(defaultVisionModels) {
|
||||||
|
t.Run(model, func(t *testing.T) {
|
||||||
|
if reason, ok := skipModels[model]; ok && testModel == "" {
|
||||||
|
t.Skipf("skipping: %s", reason)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
setupVisionModel(ctx, t, client, model)
|
||||||
|
abbeyRoad, docs, _ := decodeTestImages(t)
|
||||||
|
|
||||||
|
req := api.ChatRequest{
|
||||||
|
Model: model,
|
||||||
|
Messages: []api.Message{
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: "I'm showing you two images. What do they have in common, and how are they different?",
|
||||||
|
Images: []api.ImageData{abbeyRoad, docs},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Stream: &stream,
|
||||||
|
Options: map[string]any{"temperature": 0.0, "seed": 42},
|
||||||
|
}
|
||||||
|
// Both images feature cartoon llamas/alpacas — the model should
|
||||||
|
// note the common subject and the different settings.
|
||||||
|
DoChat(ctx, t, client, req, []string{
|
||||||
|
"llama", "alpaca", "animal", "cartoon",
|
||||||
|
}, 120*time.Second, 30*time.Second)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVisionImageDescription verifies that the model can describe the contents
|
||||||
|
// of the ollama homepage image (a cartoon llama with "Start building with
|
||||||
|
// open models" text). Basic sanity check that the vision pipeline works.
|
||||||
|
func TestVisionImageDescription(t *testing.T) {
|
||||||
|
skipUnderMinVRAM(t, 6)
|
||||||
|
skipIfNoVisionOverride(t)
|
||||||
|
|
||||||
|
for _, model := range testModels(defaultVisionModels) {
|
||||||
|
t.Run(model, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
client, _, cleanup := InitServerConnection(ctx, t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
setupVisionModel(ctx, t, client, model)
|
||||||
|
_, _, ollamaHome := decodeTestImages(t)
|
||||||
|
|
||||||
|
req := api.ChatRequest{
|
||||||
|
Model: model,
|
||||||
|
Messages: []api.Message{
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: "Describe what you see in this image briefly.",
|
||||||
|
Images: []api.ImageData{ollamaHome},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Stream: &stream,
|
||||||
|
Options: map[string]any{"temperature": 0.0, "seed": 42},
|
||||||
|
}
|
||||||
|
DoChat(ctx, t, client, req, []string{
|
||||||
|
"llama", "animal", "build", "model", "open", "cartoon", "character",
|
||||||
|
}, 120*time.Second, 30*time.Second)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
544
integration/vision_test_data_test.go
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
// imageEncodingDocs is a 400x250 PNG of four cartoon llamas at a desk.
|
||||||
|
// One is on a laptop wearing glasses, one writing, one reading, one sleeping.
|
||||||
|
// The header text reads "Ollama's documentation".
|
||||||
|
const imageEncodingDocs = `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
|
||||||
|
2OMGTbwLxgn8J57Q4eOGKJc6xI3JI9Y5AA4jw2wlACq+AIxJo2hfADs5KcakHh7X7mOGRV4xdR5d60kXntpUg3/ahK7/oe9/eqCo+jf/LwkHEGPG+ODYAAAA
|
||||||
|
imVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAAJAAAAABAAAAkAAAAAEAA5KGAAcA
|
||||||
|
AAASAAAAeKACAAQAAAABAAABkKADAAQAAAABAAAA+gAAAABBU0NJSQAAAFNjcmVlbnNob3T1Q1G8AAAACXBIWXMAABYlAAAWJQFJUiTwAAACqGlUWHRYTUw6
|
||||||
|
Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJE
|
||||||
|
RiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91
|
||||||
|
dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDov
|
||||||
|
L25zLmFkb2JlLmNvbS9leGlmLzEuMC8iPgogICAgICAgICA8dGlmZjpZUmVzb2x1dGlvbj4xNDQ8L3RpZmY6WVJlc29sdXRpb24+CiAgICAgICAgIDx0aWZm
|
||||||
|
OlhSZXNvbHV0aW9uPjE0NDwvdGlmZjpYUmVzb2x1dGlvbj4KICAgICAgICAgPHRpZmY6UmVzb2x1dGlvblVuaXQ+MjwvdGlmZjpSZXNvbHV0aW9uVW5pdD4K
|
||||||
|
ICAgICAgICAgPGV4aWY6UGl4ZWxZRGltZW5zaW9uPjc0NjwvZXhpZjpQaXhlbFlEaW1lbnNpb24+CiAgICAgICAgIDxleGlmOlVzZXJDb21tZW50PlNjcmVl
|
||||||
|
bnNob3Q8L2V4aWY6VXNlckNvbW1lbnQ+CiAgICAgICAgIDxleGlmOlBpeGVsWERpbWVuc2lvbj4xMTk0PC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAg
|
||||||
|
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
|
||||||
|
Av8fgwPy24mbuF8AAAAASUVORK5CYII=
|
||||||
|
`
|
||||||
|
// imageEncodingOllamaHome is a 415x293 JPEG of the ollama.com homepage.
|
||||||
|
// Shows a cartoon llama character with text "Start building with open models".
|
||||||
|
const imageEncodingOllamaHome = `/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAA0JCgsKCA0LCgsODg0PEyAVExISEyccHhcgLikxMC4pLSwzOko+MzZGNywtQFdBRkxO
|
||||||
|
UlNSMj5aYVpQYEpRUk//2wBDAQ4ODhMREyYVFSZPNS01T09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09P
|
||||||
|
T09PT09PT0//wAARCAElAZ8DASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUF
|
||||||
|
BAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVW
|
||||||
|
V1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi
|
||||||
|
4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAEC
|
||||||
|
AxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVm
|
||||||
|
Z2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq
|
||||||
|
8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD06iiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiq
|
||||||
|
2o39rpllLeXsqxQRDLMf5e5oAs0V5XffEXXL6WeXQdOC2dsNzu8ZchfVuwrufCOvDxFocd80YjlDFJUHQMPT270AbdFFFABRRRQA
|
||||||
|
UUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUU
|
||||||
|
AFFFFABRRRQAUUUUAFeUeI7u68b+L00PT5CLG2chnHTj7zn+Q/8Ar12XjTxLZ6LpNzD9pUX8sRWGIcsCRjJ9BXmPg/xPJ4djuDa6
|
||||||
|
V9rnnI3SliMKO3A9eaAO/wDFyWHhfwDPYWSLGJlECDu5PUn1OM1V+HuoaVovhWFb7UbWGa4kaUo0oyAeBkduBXMXc2t/EfV44obc
|
||||||
|
W1vbLyGJKR56knHJPpXT2fwr0mOMfa7y6mkxyVwg/Ac0AdpZ6lY3wzZ3kE//AFzkDfyq1XmupfDF7YfafD2oypcJyqSnBP0YdKzU
|
||||||
|
+IWuabp1xpV/bltUjPlpM45X13DufQ96APQtf8U6T4fTF9PmYjKwxjc5/Dt+Nc7pnxP0291GO1ns5rZJWCrKzhgCemR2qn4V8Atd
|
||||||
|
v/a/ikvNPMd4t3Y9+7n19qzfHsVrfeLNM0PSoIkaHCMIkAwWI449AM/jQB61RSKNqgegxS0AFFFFABRRRQAUUUUAFFFFABRRRQAU
|
||||||
|
UUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFBIAyTgCsifxBbCYw2UU17KvUQLkD8e
|
||||||
|
lAGvRSIxZFYqVJGSD2paACub8b+JB4c0fzItrXk5KQKegPdj7D/Cukry7xWn9s/FLT9LnOYItgK9iMbz+fSgCfwh4I/tEDW/Exe4
|
||||||
|
luD5iQyE8g/xP/hXosFtb20Qit4I4kHRUUAD8qkAAAAGBXC6d4pvtL8XXGieIZ45Yp5M2064AXJ4U47duehoA7pUVc7VAzycDrS1
|
||||||
|
FPc29uM3E8cQ9XcL/OiG5t7gZt54pR/sOG/lQBLXn/xH8OXt3dWesaNbNLdQnEojGWOOVOO+OlegUUAeXr8ULuCxuLfUNMMeoouI
|
||||||
|
yMhd3+0p5HrV34ceHbgzSeJNWDNc3GTCH64PVz9e3tW94z8LW3iDTJGWNVv4lJhlA5J/un1BrK+F2tzXumTaXeMTPYkBN3XYeMfg
|
||||||
|
ePyoA7qiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKK
|
||||||
|
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
|
||||||
|
x4On+zgt5ciSSAd0B5/ofwqD4d65pEvh610+KaKC6hXbJE5Clmzyw9c0Ac3efD3xPPaO0+sJcSEZ8lpnIb8TxWr8M9ddQ/hq/i8m
|
||||||
|
4tN3l5GCQD8yn3BP5V6CzKqFmYBQMkk8AV5Xpk0er/F57zTObeMszyL0YBNpP4mgD1WiiigAooooAKKKKACiiigAooooAKKKKACi
|
||||||
|
iigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAGyIkiMkiK6MMFWGQR9KSKK
|
||||||
|
OGJYoY1jjUYVVGAPoKfRQAUUUUAFFFFACMoZSrAEEYIPeuJ1r4aaTfyvPYSyWMrHO1BuTP07fga7eigDy9vhtrxUwHX1NueCpaTG
|
||||||
|
P93pXaeF/DFj4aszFbZknkwZZmHL+3sPatyigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiii
|
||||||
|
gAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAoo
|
||||||
|
ooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAK
|
||||||
|
KKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKA
|
||||||
|
CiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiii
|
||||||
|
gAooooAKZPNHbwSTTOEjjUszHoAOSafWR4tt5rrwrqcNsC0rW7bQOp4zj8qAPPdQ8a+IvEeqNY+GIpIoudvlqPMYf3mY8KPy+tJN
|
||||||
|
p/xIsIzdfabuXb8xVbhZSP8AgPf8Ki+F2vaZpVxeW2oSJbtc7DHM/C8Z+Unt1zXrcM0U8YkgkSRD0ZGBH5igDiPAvjmTWbn+zNWV
|
||||||
|
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
|
||||||
|
igAooooAKKKKACiiigBGVXUqwBUjBBHBrhtX+GGlXtw01jcS2Jc5MaqHQH2HBH513VFAHB6T8L9Ks7hZr+5lvdpBEZUIhPuOSfzr
|
||||||
|
ugqpFsRQqqMAAYAFOoIyCKAPFfhpY22p67f2V7EJYJbJwyn/AH059j710k3wntGuC0GqzRw54RogzD8cj+VbPhXwPD4b1SS+jv5L
|
||||||
|
gyRGLa0YXGSDnr7V1tAGP4c8Nad4ctmisUYySY8yaQ5d/wDAe1XtS0601Wyks7+FZoJByp/mD2PvVqigDzm6+E9m8pa01WaKMnhZ
|
||||||
|
Ig5H45FbnhvwHpWg3C3eXu7tfuySgAJ7qo6H35rqqKAMfxRoEXiPTEsZp3hVZRJuQAngEY5+tS+HtHj0HR4dOileVIixDsACcknt
|
||||||
|
9a06KAMjxNoUXiLSxYTTvCvmCTcgBPGfX60/w5osegaRHp8MzzIjMwZwAeTntWpRQAVykngi2fxYNfN7MJfOEvlbRtyB0z1rq6KA
|
||||||
|
CuV0LwTbaLrsmqxXs0ruHBRlAHzHPauqooAhvbcXdjcWrMVE0bRlh1GRjP61geE/CFv4YluZILuWc3CqCHUDGM+n1rpaKAMjxD4b
|
||||||
|
03xFbrFqER3p/q5UOHT6H09jXG/8Klt/Nz/bEvl/3fIGfzz/AEr0migDH8O+GdM8OwNHYRkyP/rJpDl3/HsPYVf1GwtdTspLO+hW
|
||||||
|
WCUYZT/Meh96s0UAec3Hwns2nLW2qzRxE/ceIOQPrkfyrUl+HWlf2ENMglmiJlWWSfAZ5CAQAewHzHgV2VFAGb4f0iPQtGh02KVp
|
||||||
|
Uh3YdgATlie31rI8SeBdK1+c3TF7W7b70sQGH/3gev1rqaKAPObb4T2iTBrnVZpY8/dSIISPqSf5V3emabZ6TYpZ2EKwwp0A7n1J
|
||||||
|
7n3q3RQByg8EWw8V/wBv/bZvN87zfK2jbnHTPWtrW9EsNdsTaajFvTOVYHDIfUHtWjRQB5vJ8J4PMPk6zMkZ/haEE4+oI/lXSeGf
|
||||||
|
Bel+HXM8Iee7Ix50uMqO4UDgfzrpKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiig
|
||||||
|
AooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooo
|
||||||
|
oAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKK
|
||||||
|
KKACiiigAooooAKKKKACiiigAooooAKKKKAP/9k=`
|
||||||
121
llama/patches/0035-CUDA-get_rows-q6_k-support.patch
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Daniel Hiltgen <daniel@ollama.com>
|
||||||
|
Date: Fri, 20 Mar 2026 18:50:38 -0700
|
||||||
|
Subject: [PATCH] CUDA get_rows q6_k support
|
||||||
|
|
||||||
|
---
|
||||||
|
ggml/src/ggml-cuda/getrows.cu | 80 ++++++++++++++++++++++++++++++++-
|
||||||
|
ggml/src/ggml-cuda/ggml-cuda.cu | 1 +
|
||||||
|
2 files changed, 80 insertions(+), 1 deletion(-)
|
||||||
|
|
||||||
|
diff --git a/ggml/src/ggml-cuda/getrows.cu b/ggml/src/ggml-cuda/getrows.cu
|
||||||
|
index 2fab33243..dc5c4f57a 100644
|
||||||
|
--- a/ggml/src/ggml-cuda/getrows.cu
|
||||||
|
+++ b/ggml/src/ggml-cuda/getrows.cu
|
||||||
|
@@ -155,6 +155,81 @@ static void get_rows_cuda_float(
|
||||||
|
s10, s11, s12/*, s13*/);
|
||||||
|
}
|
||||||
|
|
||||||
|
+// Specialized GET_ROWS kernel for Q6_K — the k_get_rows template doesn't work for K-quants
|
||||||
|
+// because they lack the simple dequantize_kernel_t (float2) interface.
|
||||||
|
+// Based on dequantize_block_q6_K from convert.cu with row-selection logic added.
|
||||||
|
+template<typename dst_t>
|
||||||
|
+static __global__ void k_get_rows_q6_K(
|
||||||
|
+ const void * __restrict__ src0, const int32_t * __restrict__ src1, dst_t * __restrict__ dst,
|
||||||
|
+ const int64_t ne00,
|
||||||
|
+ const int64_t ne11, const int64_t ne12,
|
||||||
|
+ const size_t s1, const size_t s2, const size_t s3,
|
||||||
|
+ const size_t nb01, const size_t nb02, const size_t nb03,
|
||||||
|
+ const size_t s10, const size_t s11, const size_t s12) {
|
||||||
|
+
|
||||||
|
+ const int64_t i10 = blockIdx.x; // row index into src1
|
||||||
|
+ const int64_t z = blockIdx.z;
|
||||||
|
+ const int64_t i11 = z / ne12;
|
||||||
|
+ const int64_t i12 = z % ne12;
|
||||||
|
+
|
||||||
|
+ const int i01 = src1[i10*s10 + i11*s11 + i12*s12];
|
||||||
|
+
|
||||||
|
+ dst_t * dst_row = dst + i10*s1 + i11*s2 + i12*s3;
|
||||||
|
+ const char * src0_row = (const char *)src0 + i01*nb01 + i11*nb02 + i12*nb03;
|
||||||
|
+
|
||||||
|
+ const int64_t nb = ne00 / QK_K; // number of Q6_K blocks per row
|
||||||
|
+
|
||||||
|
+ // blockIdx.y iterates over Q6_K blocks within the row
|
||||||
|
+ for (int64_t iblk = blockIdx.y; iblk < nb; iblk += gridDim.y) {
|
||||||
|
+ const block_q6_K * x = (const block_q6_K *)src0_row + iblk;
|
||||||
|
+
|
||||||
|
+ // Same dequantization as dequantize_block_q6_K (assumes 64 threads)
|
||||||
|
+ const int64_t tid = threadIdx.x;
|
||||||
|
+ const int64_t ip = tid / 32; // 0 or 1
|
||||||
|
+ const int64_t il = tid - 32*ip; // 0..31
|
||||||
|
+ const int64_t is = 8*ip + il/16;
|
||||||
|
+
|
||||||
|
+ const int64_t y_offset = iblk * QK_K + 128*ip + il;
|
||||||
|
+
|
||||||
|
+ const float d = x->d;
|
||||||
|
+ const uint8_t * ql = x->ql + 64*ip + il;
|
||||||
|
+ const uint8_t qh = x->qh[32*ip + il];
|
||||||
|
+ const int8_t * sc = x->scales + is;
|
||||||
|
+
|
||||||
|
+ if (y_offset + 0 < ne00) dst_row[y_offset + 0] = ggml_cuda_cast<dst_t>(d * sc[0] * ((int8_t)((ql[ 0] & 0xF) | (((qh >> 0) & 3) << 4)) - 32));
|
||||||
|
+ if (y_offset + 32 < ne00) dst_row[y_offset + 32] = ggml_cuda_cast<dst_t>(d * sc[2] * ((int8_t)((ql[32] & 0xF) | (((qh >> 2) & 3) << 4)) - 32));
|
||||||
|
+ if (y_offset + 64 < ne00) dst_row[y_offset + 64] = ggml_cuda_cast<dst_t>(d * sc[4] * ((int8_t)((ql[ 0] >> 4) | (((qh >> 4) & 3) << 4)) - 32));
|
||||||
|
+ if (y_offset + 96 < ne00) dst_row[y_offset + 96] = ggml_cuda_cast<dst_t>(d * sc[6] * ((int8_t)((ql[32] >> 4) | (((qh >> 6) & 3) << 4)) - 32));
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+template<typename dst_t>
|
||||||
|
+static void get_rows_cuda_q6_K(
|
||||||
|
+ const void * src0_d, const int32_t * src1_d, dst_t * dst_d,
|
||||||
|
+ const int64_t ne00, const size_t nb01, const size_t nb02, const size_t nb03,
|
||||||
|
+ const int64_t ne10, const int64_t ne11, const int64_t ne12, const size_t nb10, const size_t nb11, const size_t nb12,
|
||||||
|
+ const size_t nb1, const size_t nb2, const size_t nb3,
|
||||||
|
+ cudaStream_t stream) {
|
||||||
|
+ const int64_t nb_blocks = ne00 / QK_K;
|
||||||
|
+ const dim3 block_dims(64, 1, 1);
|
||||||
|
+ const dim3 block_nums(ne10, MIN(nb_blocks, (int64_t)UINT16_MAX), MIN(ne11*ne12, (int64_t)UINT16_MAX));
|
||||||
|
+
|
||||||
|
+ const size_t s1 = nb1 / sizeof(dst_t);
|
||||||
|
+ const size_t s2 = nb2 / sizeof(dst_t);
|
||||||
|
+ const size_t s3 = nb3 / sizeof(dst_t);
|
||||||
|
+
|
||||||
|
+ const size_t s10 = nb10 / sizeof(int32_t);
|
||||||
|
+ const size_t s11 = nb11 / sizeof(int32_t);
|
||||||
|
+ const size_t s12 = nb12 / sizeof(int32_t);
|
||||||
|
+
|
||||||
|
+ k_get_rows_q6_K<<<block_nums, block_dims, 0, stream>>>(
|
||||||
|
+ src0_d, src1_d, dst_d,
|
||||||
|
+ ne00, ne11, ne12,
|
||||||
|
+ s1, s2, s3,
|
||||||
|
+ nb01, nb02, nb03,
|
||||||
|
+ s10, s11, s12);
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
template <typename dst_t>
|
||||||
|
static void ggml_cuda_get_rows_switch_src0_type(
|
||||||
|
const void * src0_d, const ggml_type src0_type, const int32_t * src1_d, dst_t * dst_d,
|
||||||
|
@@ -199,8 +274,11 @@ static void ggml_cuda_get_rows_switch_src0_type(
|
||||||
|
get_rows_cuda_q<QK8_0, QR8_0, dequantize_q8_0>(src0_d, src1_d, dst_d,
|
||||||
|
ne00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb1, nb2, nb3, stream);
|
||||||
|
break;
|
||||||
|
+ case GGML_TYPE_Q6_K:
|
||||||
|
+ get_rows_cuda_q6_K(src0_d, src1_d, dst_d,
|
||||||
|
+ ne00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb1, nb2, nb3, stream);
|
||||||
|
+ break;
|
||||||
|
default:
|
||||||
|
- // TODO: k-quants
|
||||||
|
GGML_ABORT("%s: unsupported src0 type: %s\n", __func__, ggml_type_name(src0_type));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
diff --git a/ggml/src/ggml-cuda/ggml-cuda.cu b/ggml/src/ggml-cuda/ggml-cuda.cu
|
||||||
|
index 5c9dfd032..b8ed3709b 100644
|
||||||
|
--- a/ggml/src/ggml-cuda/ggml-cuda.cu
|
||||||
|
+++ b/ggml/src/ggml-cuda/ggml-cuda.cu
|
||||||
|
@@ -4693,6 +4693,7 @@ static bool ggml_backend_cuda_device_supports_op(ggml_backend_dev_t dev, const g
|
||||||
|
case GGML_TYPE_Q5_0:
|
||||||
|
case GGML_TYPE_Q5_1:
|
||||||
|
case GGML_TYPE_Q8_0:
|
||||||
|
+ case GGML_TYPE_Q6_K:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
@@ -283,7 +283,7 @@ func (w *WebSearchAnthropicWriter) runWebSearchLoop(ctx context.Context, initial
|
|||||||
Type: "server_tool_use",
|
Type: "server_tool_use",
|
||||||
ID: toolUseID,
|
ID: toolUseID,
|
||||||
Name: "web_search",
|
Name: "web_search",
|
||||||
Input: map[string]any{"query": query},
|
Input: queryArgs(query),
|
||||||
},
|
},
|
||||||
anthropic.ContentBlock{
|
anthropic.ContentBlock{
|
||||||
Type: "web_search_tool_result",
|
Type: "web_search_tool_result",
|
||||||
@@ -348,7 +348,7 @@ func (w *WebSearchAnthropicWriter) runWebSearchLoop(ctx context.Context, initial
|
|||||||
Type: "server_tool_use",
|
Type: "server_tool_use",
|
||||||
ID: maxLoopToolUseID,
|
ID: maxLoopToolUseID,
|
||||||
Name: "web_search",
|
Name: "web_search",
|
||||||
Input: map[string]any{"query": maxLoopQuery},
|
Input: queryArgs(maxLoopQuery),
|
||||||
},
|
},
|
||||||
anthropic.ContentBlock{
|
anthropic.ContentBlock{
|
||||||
Type: "web_search_tool_result",
|
Type: "web_search_tool_result",
|
||||||
@@ -786,7 +786,7 @@ func (w *WebSearchAnthropicWriter) webSearchErrorResponse(errorCode, query strin
|
|||||||
Type: "server_tool_use",
|
Type: "server_tool_use",
|
||||||
ID: toolUseID,
|
ID: toolUseID,
|
||||||
Name: "web_search",
|
Name: "web_search",
|
||||||
Input: map[string]any{"query": query},
|
Input: queryArgs(query),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Type: "web_search_tool_result",
|
Type: "web_search_tool_result",
|
||||||
@@ -942,6 +942,13 @@ func writeSSE(w http.ResponseWriter, eventType string, data any) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// queryArgs creates a ToolCallFunctionArguments with a single "query" key.
|
||||||
|
func queryArgs(query string) api.ToolCallFunctionArguments {
|
||||||
|
args := api.NewToolCallFunctionArguments()
|
||||||
|
args.Set("query", query)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
// serverToolUseID derives a server tool use ID from a message ID
|
// serverToolUseID derives a server tool use ID from a message ID
|
||||||
func serverToolUseID(messageID string) string {
|
func serverToolUseID(messageID string) string {
|
||||||
return "srvtoolu_" + strings.TrimPrefix(messageID, "msg_")
|
return "srvtoolu_" + strings.TrimPrefix(messageID, "msg_")
|
||||||
|
|||||||
@@ -1208,7 +1208,7 @@ func TestWebSearchStreamResponse(t *testing.T) {
|
|||||||
Type: "server_tool_use",
|
Type: "server_tool_use",
|
||||||
ID: "srvtoolu_test123",
|
ID: "srvtoolu_test123",
|
||||||
Name: "web_search",
|
Name: "web_search",
|
||||||
Input: map[string]any{"query": "test query"},
|
Input: queryArgs("test query"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Type: "web_search_tool_result",
|
Type: "web_search_tool_result",
|
||||||
@@ -1413,12 +1413,8 @@ func TestWebSearchSendError_NonStreaming(t *testing.T) {
|
|||||||
t.Errorf("expected name 'web_search', got %q", result.Content[0].Name)
|
t.Errorf("expected name 'web_search', got %q", result.Content[0].Name)
|
||||||
}
|
}
|
||||||
// Verify input contains the query
|
// Verify input contains the query
|
||||||
inputMap, ok := result.Content[0].Input.(map[string]any)
|
if q, ok := result.Content[0].Input.Get("query"); !ok || q != "test query" {
|
||||||
if !ok {
|
t.Errorf("expected query 'test query', got %v", q)
|
||||||
t.Fatalf("expected Input to be map, got %T", result.Content[0].Input)
|
|
||||||
}
|
|
||||||
if inputMap["query"] != "test query" {
|
|
||||||
t.Errorf("expected query 'test query', got %v", inputMap["query"])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block 1: web_search_tool_result with error
|
// Block 1: web_search_tool_result with error
|
||||||
@@ -1561,12 +1557,8 @@ func TestWebSearchSendError_EmptyQuery(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify the input has empty query
|
// Verify the input has empty query
|
||||||
inputMap, ok := result.Content[0].Input.(map[string]any)
|
if q, ok := result.Content[0].Input.Get("query"); !ok || q != "" {
|
||||||
if !ok {
|
t.Errorf("expected empty query, got %v", q)
|
||||||
t.Fatalf("expected Input to be map, got %T", result.Content[0].Input)
|
|
||||||
}
|
|
||||||
if inputMap["query"] != "" {
|
|
||||||
t.Errorf("expected empty query, got %v", inputMap["query"])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -678,3 +678,113 @@ func ImageEditsMiddleware() gin.HandlerFunc {
|
|||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TranscriptionWriter collects streamed chat responses and outputs a transcription response.
|
||||||
|
type TranscriptionWriter struct {
|
||||||
|
BaseWriter
|
||||||
|
responseFormat string
|
||||||
|
text strings.Builder
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *TranscriptionWriter) Write(data []byte) (int, error) {
|
||||||
|
code := w.ResponseWriter.Status()
|
||||||
|
if code != http.StatusOK {
|
||||||
|
return w.writeError(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
var chatResponse api.ChatResponse
|
||||||
|
if err := json.Unmarshal(data, &chatResponse); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
w.text.WriteString(chatResponse.Message.Content)
|
||||||
|
|
||||||
|
if chatResponse.Done {
|
||||||
|
text := strings.TrimSpace(w.text.String())
|
||||||
|
|
||||||
|
if w.responseFormat == "text" {
|
||||||
|
w.ResponseWriter.Header().Set("Content-Type", "text/plain")
|
||||||
|
_, err := w.ResponseWriter.Write([]byte(text))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
w.ResponseWriter.Header().Set("Content-Type", "application/json")
|
||||||
|
resp := openai.TranscriptionResponse{Text: text}
|
||||||
|
if err := json.NewEncoder(w.ResponseWriter).Encode(resp); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranscriptionMiddleware handles /v1/audio/transcriptions requests.
|
||||||
|
// It accepts multipart/form-data with an audio file and converts it to a chat request.
|
||||||
|
func TranscriptionMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Parse multipart form (limit 25MB).
|
||||||
|
if err := c.Request.ParseMultipartForm(25 << 20); err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, openai.NewError(http.StatusBadRequest, "failed to parse multipart form: "+err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
model := c.Request.FormValue("model")
|
||||||
|
if model == "" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, openai.NewError(http.StatusBadRequest, "model is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, _, err := c.Request.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, openai.NewError(http.StatusBadRequest, "file is required: "+err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
audioData, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, openai.NewError(http.StatusInternalServerError, "failed to read audio file"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(audioData) == 0 {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, openai.NewError(http.StatusBadRequest, "audio file is empty"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := openai.TranscriptionRequest{
|
||||||
|
Model: model,
|
||||||
|
AudioData: audioData,
|
||||||
|
ResponseFormat: c.Request.FormValue("response_format"),
|
||||||
|
Language: c.Request.FormValue("language"),
|
||||||
|
Prompt: c.Request.FormValue("prompt"),
|
||||||
|
}
|
||||||
|
|
||||||
|
chatReq, err := openai.FromTranscriptionRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, openai.NewError(http.StatusBadRequest, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var b bytes.Buffer
|
||||||
|
if err := json.NewEncoder(&b).Encode(chatReq); err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, openai.NewError(http.StatusInternalServerError, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Request.Body = io.NopCloser(&b)
|
||||||
|
c.Request.ContentLength = int64(b.Len())
|
||||||
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := &TranscriptionWriter{
|
||||||
|
BaseWriter: BaseWriter{ResponseWriter: c.Writer},
|
||||||
|
responseFormat: req.ResponseFormat,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Writer = w
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ type Tensor interface {
|
|||||||
|
|
||||||
Bytes() []byte
|
Bytes() []byte
|
||||||
Floats() []float32
|
Floats() []float32
|
||||||
|
BackendGet() []float32
|
||||||
|
|
||||||
FromBytes([]byte)
|
FromBytes([]byte)
|
||||||
FromFloats([]float32)
|
FromFloats([]float32)
|
||||||
@@ -162,6 +163,7 @@ type Tensor interface {
|
|||||||
AvgPool2D(ctx Context, k, s int, p float32) Tensor
|
AvgPool2D(ctx Context, k, s int, p float32) Tensor
|
||||||
Conv2D(ctx Context, weight Tensor, s0, s1, p0, p1, d0, d1 int) Tensor
|
Conv2D(ctx Context, weight Tensor, s0, s1, p0, p1, d0, d1 int) Tensor
|
||||||
Conv3D(ctx Context, weight Tensor, c, s0, s1, s2, p0, p1, p2, d0, d1, d2 int) Tensor
|
Conv3D(ctx Context, weight Tensor, c, s0, s1, s2, p0, p1, p2, d0, d1, d2 int) Tensor
|
||||||
|
Conv1DDW(ctx Context, weight Tensor, s, p, d int) Tensor
|
||||||
SSMConv(ctx Context, kernel Tensor) Tensor
|
SSMConv(ctx Context, kernel Tensor) Tensor
|
||||||
SSMScan(ctx Context, x, dt, A, B, C, ids Tensor) Tensor
|
SSMScan(ctx Context, x, dt, A, B, C, ids Tensor) Tensor
|
||||||
|
|
||||||
@@ -187,6 +189,9 @@ type Tensor interface {
|
|||||||
Contiguous(ctx Context, shape ...int) Tensor
|
Contiguous(ctx Context, shape ...int) Tensor
|
||||||
|
|
||||||
Pad(ctx Context, shape ...int) Tensor
|
Pad(ctx Context, shape ...int) Tensor
|
||||||
|
// PadExt pads with independent left/right amounts per dimension.
|
||||||
|
// Arguments: lp0, rp0, lp1, rp1, lp2, rp2, lp3, rp3 for dims 0-3.
|
||||||
|
PadExt(ctx Context, lp0, rp0, lp1, rp1, lp2, rp2, lp3, rp3 int) Tensor
|
||||||
|
|
||||||
Stack(ctx Context, dim int, s ...Tensor) Tensor
|
Stack(ctx Context, dim int, s ...Tensor) Tensor
|
||||||
|
|
||||||
|
|||||||
@@ -1069,6 +1069,21 @@ func (t *Tensor) Floats() (data []float32) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *Tensor) BackendGet() []float32 {
|
||||||
|
n := int(C.ggml_nelements(t.t))
|
||||||
|
if n == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.sync != nil {
|
||||||
|
t.sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
data := make([]float32, n)
|
||||||
|
C.ggml_backend_tensor_get(t.t, unsafe.Pointer(&data[0]), 0, C.ggml_nbytes(t.t))
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
func tensorSet[S ~[]E, E byte | float32 | int32](t *Tensor, s S) {
|
func tensorSet[S ~[]E, E byte | float32 | int32](t *Tensor, s S) {
|
||||||
if len(s) == 0 {
|
if len(s) == 0 {
|
||||||
return
|
return
|
||||||
@@ -1313,6 +1328,13 @@ func (t *Tensor) Pad(ctx ml.Context, shape ...int) ml.Tensor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *Tensor) PadExt(ctx ml.Context, lp0, rp0, lp1, rp1, lp2, rp2, lp3, rp3 int) ml.Tensor {
|
||||||
|
return &Tensor{
|
||||||
|
b: t.b,
|
||||||
|
t: C.ggml_pad_ext(ctx.(*Context).ctx, t.t, C.int(lp0), C.int(rp0), C.int(lp1), C.int(rp1), C.int(lp2), C.int(rp2), C.int(lp3), C.int(rp3)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Permute permutes t according to order. Permute panics if the number of dimensions
|
// Permute permutes t according to order. Permute panics if the number of dimensions
|
||||||
// in order does not match the number of dimensions in t.
|
// in order does not match the number of dimensions in t.
|
||||||
func (t *Tensor) Permute(ctx ml.Context, order ...int) ml.Tensor {
|
func (t *Tensor) Permute(ctx ml.Context, order ...int) ml.Tensor {
|
||||||
@@ -1660,6 +1682,13 @@ func (t *Tensor) Conv2D(ctx ml.Context, t2 ml.Tensor, s0, s1, p0, p1, d0, d1 int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *Tensor) Conv1DDW(ctx ml.Context, weight ml.Tensor, s, p, d int) ml.Tensor {
|
||||||
|
return &Tensor{
|
||||||
|
b: t.b,
|
||||||
|
t: C.ggml_conv_1d_dw(ctx.(*Context).ctx, weight.(*Tensor).t, t.t, C.int(s), C.int(p), C.int(d)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (t *Tensor) Conv3D(ctx ml.Context, t2 ml.Tensor, c, s0, s1, s2, p0, p1, p2, d0, d1, d2 int) ml.Tensor {
|
func (t *Tensor) Conv3D(ctx ml.Context, t2 ml.Tensor, c, s0, s1, s2, p0, p1, p2, d0, d1, d2 int) ml.Tensor {
|
||||||
var tt ml.Tensor = &Tensor{
|
var tt ml.Tensor = &Tensor{
|
||||||
b: t.b,
|
b: t.b,
|
||||||
|
|||||||
80
ml/backend/ggml/ggml/src/ggml-cuda/getrows.cu
vendored
@@ -155,6 +155,81 @@ static void get_rows_cuda_float(
|
|||||||
s10, s11, s12/*, s13*/);
|
s10, s11, s12/*, s13*/);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Specialized GET_ROWS kernel for Q6_K — the k_get_rows template doesn't work for K-quants
|
||||||
|
// because they lack the simple dequantize_kernel_t (float2) interface.
|
||||||
|
// Based on dequantize_block_q6_K from convert.cu with row-selection logic added.
|
||||||
|
template<typename dst_t>
|
||||||
|
static __global__ void k_get_rows_q6_K(
|
||||||
|
const void * __restrict__ src0, const int32_t * __restrict__ src1, dst_t * __restrict__ dst,
|
||||||
|
const int64_t ne00,
|
||||||
|
const int64_t ne11, const int64_t ne12,
|
||||||
|
const size_t s1, const size_t s2, const size_t s3,
|
||||||
|
const size_t nb01, const size_t nb02, const size_t nb03,
|
||||||
|
const size_t s10, const size_t s11, const size_t s12) {
|
||||||
|
|
||||||
|
const int64_t i10 = blockIdx.x; // row index into src1
|
||||||
|
const int64_t z = blockIdx.z;
|
||||||
|
const int64_t i11 = z / ne12;
|
||||||
|
const int64_t i12 = z % ne12;
|
||||||
|
|
||||||
|
const int i01 = src1[i10*s10 + i11*s11 + i12*s12];
|
||||||
|
|
||||||
|
dst_t * dst_row = dst + i10*s1 + i11*s2 + i12*s3;
|
||||||
|
const char * src0_row = (const char *)src0 + i01*nb01 + i11*nb02 + i12*nb03;
|
||||||
|
|
||||||
|
const int64_t nb = ne00 / QK_K; // number of Q6_K blocks per row
|
||||||
|
|
||||||
|
// blockIdx.y iterates over Q6_K blocks within the row
|
||||||
|
for (int64_t iblk = blockIdx.y; iblk < nb; iblk += gridDim.y) {
|
||||||
|
const block_q6_K * x = (const block_q6_K *)src0_row + iblk;
|
||||||
|
|
||||||
|
// Same dequantization as dequantize_block_q6_K (assumes 64 threads)
|
||||||
|
const int64_t tid = threadIdx.x;
|
||||||
|
const int64_t ip = tid / 32; // 0 or 1
|
||||||
|
const int64_t il = tid - 32*ip; // 0..31
|
||||||
|
const int64_t is = 8*ip + il/16;
|
||||||
|
|
||||||
|
const int64_t y_offset = iblk * QK_K + 128*ip + il;
|
||||||
|
|
||||||
|
const float d = x->d;
|
||||||
|
const uint8_t * ql = x->ql + 64*ip + il;
|
||||||
|
const uint8_t qh = x->qh[32*ip + il];
|
||||||
|
const int8_t * sc = x->scales + is;
|
||||||
|
|
||||||
|
if (y_offset + 0 < ne00) dst_row[y_offset + 0] = ggml_cuda_cast<dst_t>(d * sc[0] * ((int8_t)((ql[ 0] & 0xF) | (((qh >> 0) & 3) << 4)) - 32));
|
||||||
|
if (y_offset + 32 < ne00) dst_row[y_offset + 32] = ggml_cuda_cast<dst_t>(d * sc[2] * ((int8_t)((ql[32] & 0xF) | (((qh >> 2) & 3) << 4)) - 32));
|
||||||
|
if (y_offset + 64 < ne00) dst_row[y_offset + 64] = ggml_cuda_cast<dst_t>(d * sc[4] * ((int8_t)((ql[ 0] >> 4) | (((qh >> 4) & 3) << 4)) - 32));
|
||||||
|
if (y_offset + 96 < ne00) dst_row[y_offset + 96] = ggml_cuda_cast<dst_t>(d * sc[6] * ((int8_t)((ql[32] >> 4) | (((qh >> 6) & 3) << 4)) - 32));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename dst_t>
|
||||||
|
static void get_rows_cuda_q6_K(
|
||||||
|
const void * src0_d, const int32_t * src1_d, dst_t * dst_d,
|
||||||
|
const int64_t ne00, const size_t nb01, const size_t nb02, const size_t nb03,
|
||||||
|
const int64_t ne10, const int64_t ne11, const int64_t ne12, const size_t nb10, const size_t nb11, const size_t nb12,
|
||||||
|
const size_t nb1, const size_t nb2, const size_t nb3,
|
||||||
|
cudaStream_t stream) {
|
||||||
|
const int64_t nb_blocks = ne00 / QK_K;
|
||||||
|
const dim3 block_dims(64, 1, 1);
|
||||||
|
const dim3 block_nums(ne10, MIN(nb_blocks, (int64_t)UINT16_MAX), MIN(ne11*ne12, (int64_t)UINT16_MAX));
|
||||||
|
|
||||||
|
const size_t s1 = nb1 / sizeof(dst_t);
|
||||||
|
const size_t s2 = nb2 / sizeof(dst_t);
|
||||||
|
const size_t s3 = nb3 / sizeof(dst_t);
|
||||||
|
|
||||||
|
const size_t s10 = nb10 / sizeof(int32_t);
|
||||||
|
const size_t s11 = nb11 / sizeof(int32_t);
|
||||||
|
const size_t s12 = nb12 / sizeof(int32_t);
|
||||||
|
|
||||||
|
k_get_rows_q6_K<<<block_nums, block_dims, 0, stream>>>(
|
||||||
|
src0_d, src1_d, dst_d,
|
||||||
|
ne00, ne11, ne12,
|
||||||
|
s1, s2, s3,
|
||||||
|
nb01, nb02, nb03,
|
||||||
|
s10, s11, s12);
|
||||||
|
}
|
||||||
|
|
||||||
template <typename dst_t>
|
template <typename dst_t>
|
||||||
static void ggml_cuda_get_rows_switch_src0_type(
|
static void ggml_cuda_get_rows_switch_src0_type(
|
||||||
const void * src0_d, const ggml_type src0_type, const int32_t * src1_d, dst_t * dst_d,
|
const void * src0_d, const ggml_type src0_type, const int32_t * src1_d, dst_t * dst_d,
|
||||||
@@ -199,8 +274,11 @@ static void ggml_cuda_get_rows_switch_src0_type(
|
|||||||
get_rows_cuda_q<QK8_0, QR8_0, dequantize_q8_0>(src0_d, src1_d, dst_d,
|
get_rows_cuda_q<QK8_0, QR8_0, dequantize_q8_0>(src0_d, src1_d, dst_d,
|
||||||
ne00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb1, nb2, nb3, stream);
|
ne00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb1, nb2, nb3, stream);
|
||||||
break;
|
break;
|
||||||
|
case GGML_TYPE_Q6_K:
|
||||||
|
get_rows_cuda_q6_K(src0_d, src1_d, dst_d,
|
||||||
|
ne00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb1, nb2, nb3, stream);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
// TODO: k-quants
|
|
||||||
GGML_ABORT("%s: unsupported src0 type: %s\n", __func__, ggml_type_name(src0_type));
|
GGML_ABORT("%s: unsupported src0 type: %s\n", __func__, ggml_type_name(src0_type));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4693,6 +4693,7 @@ static bool ggml_backend_cuda_device_supports_op(ggml_backend_dev_t dev, const g
|
|||||||
case GGML_TYPE_Q5_0:
|
case GGML_TYPE_Q5_0:
|
||||||
case GGML_TYPE_Q5_1:
|
case GGML_TYPE_Q5_1:
|
||||||
case GGML_TYPE_Q8_0:
|
case GGML_TYPE_Q8_0:
|
||||||
|
case GGML_TYPE_Q6_K:
|
||||||
return true;
|
return true;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ type Validator interface {
|
|||||||
Validate() error
|
Validate() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PostLoader is an optional interface that models can implement to run
|
||||||
|
// initialization steps after backend weights have been loaded.
|
||||||
|
type PostLoader interface {
|
||||||
|
PostLoad() error
|
||||||
|
}
|
||||||
|
|
||||||
// MultimodalProcessor must be implemented by multimodal models.
|
// MultimodalProcessor must be implemented by multimodal models.
|
||||||
type MultimodalProcessor interface {
|
type MultimodalProcessor interface {
|
||||||
// EncodeMultimodal processes a single input (such as an image) and
|
// EncodeMultimodal processes a single input (such as an image) and
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ func (f *fakeTensor) Fill(ctx ml.Context, _ float32) ml.Tensor
|
|||||||
func (f *fakeTensor) Repeat4D(ctx ml.Context, _, _, _, _ int) ml.Tensor { return f }
|
func (f *fakeTensor) Repeat4D(ctx ml.Context, _, _, _, _ int) ml.Tensor { return f }
|
||||||
func (f *fakeTensor) SolveTri(ctx ml.Context, _ ml.Tensor, _, _, _ bool) ml.Tensor { return f }
|
func (f *fakeTensor) SolveTri(ctx ml.Context, _ ml.Tensor, _, _, _ bool) ml.Tensor { return f }
|
||||||
func (f *fakeTensor) SSMScan(ctx ml.Context, _, _, _, _, _, _ ml.Tensor) ml.Tensor { return f }
|
func (f *fakeTensor) SSMScan(ctx ml.Context, _, _, _, _, _, _ ml.Tensor) ml.Tensor { return f }
|
||||||
|
func (f *fakeTensor) Conv1DDW(ctx ml.Context, _ ml.Tensor, _, _, _ int) ml.Tensor { return f }
|
||||||
|
func (f *fakeTensor) PadExt(ctx ml.Context, _, _, _, _, _, _, _, _ int) ml.Tensor { return f }
|
||||||
|
|
||||||
func (m *fakeBackend) Get(name string) ml.Tensor {
|
func (m *fakeBackend) Get(name string) ml.Tensor {
|
||||||
if slices.Contains(m.names, name) {
|
if slices.Contains(m.names, name) {
|
||||||
|
|||||||
265
model/models/gemma4/model.go
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
package gemma4
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"log/slog"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/fs"
|
||||||
|
"github.com/ollama/ollama/kvcache"
|
||||||
|
"github.com/ollama/ollama/ml"
|
||||||
|
"github.com/ollama/ollama/ml/nn"
|
||||||
|
"github.com/ollama/ollama/ml/nn/rope"
|
||||||
|
"github.com/ollama/ollama/model"
|
||||||
|
"github.com/ollama/ollama/model/input"
|
||||||
|
"github.com/ollama/ollama/tokenizer"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Model struct {
|
||||||
|
model.Base
|
||||||
|
tokenizer.Tokenizer
|
||||||
|
|
||||||
|
*VisionModel `gguf:"v"`
|
||||||
|
*TextModel
|
||||||
|
*AudioModel `gguf:"a"`
|
||||||
|
|
||||||
|
*MultiModalProjector `gguf:"mm"`
|
||||||
|
*AudioMultimodalProjector `gguf:"mm.a"`
|
||||||
|
|
||||||
|
ImageProcessor
|
||||||
|
|
||||||
|
imageTokenID int32
|
||||||
|
imageEndTokenID int32
|
||||||
|
audioTokenID int32
|
||||||
|
audioEndTokenID int32
|
||||||
|
|
||||||
|
audioOpts *AudioModelOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ model.MultimodalProcessor = (*Model)(nil)
|
||||||
|
|
||||||
|
type MultiModalProjector struct {
|
||||||
|
Projection *ClippableLinear `gguf:"input_projection"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MultiModalProjector) Forward(ctx ml.Context, visionOutputs ml.Tensor, eps float32) ml.Tensor {
|
||||||
|
visionOutputs = p.Projection.Forward(ctx, visionOutputs)
|
||||||
|
// Post-projection RMSNorm without learned weight
|
||||||
|
visionOutputs = visionOutputs.RMSNorm(ctx, nil, eps)
|
||||||
|
return visionOutputs
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(c fs.Config) (model.Model, error) {
|
||||||
|
vocabulary := tokenizer.Vocabulary{
|
||||||
|
Values: c.Strings("tokenizer.ggml.tokens"),
|
||||||
|
Scores: c.Floats("tokenizer.ggml.scores"),
|
||||||
|
Types: c.Ints("tokenizer.ggml.token_type"),
|
||||||
|
Merges: c.Strings("tokenizer.ggml.merges"),
|
||||||
|
AddBOS: c.Bool("tokenizer.ggml.add_bos_token", false),
|
||||||
|
BOS: []int32{int32(c.Uint("tokenizer.ggml.bos_token_id"))},
|
||||||
|
AddEOS: c.Bool("tokenizer.ggml.add_eos_token", false),
|
||||||
|
EOS: append(
|
||||||
|
[]int32{
|
||||||
|
int32(c.Uint("tokenizer.ggml.eos_token_id")),
|
||||||
|
},
|
||||||
|
c.Ints("tokenizer.ggml.eos_token_ids")...,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
vocabulary.EOS = append(vocabulary.EOS, int32(c.Uint("tokenizer.ggml.eot_token_id", 106)))
|
||||||
|
|
||||||
|
// Gemma 4 uses BPE with SentencePiece-style ▁ space markers (not GPT-2 byte-level encoding).
|
||||||
|
// The tokenizer.json has merges and a Replace normalizer (space → ▁), with no pre-tokenizer.
|
||||||
|
t := tokenizer.NewBytePairEncodingWithOptions(&vocabulary, []string{},
|
||||||
|
tokenizer.WithSentencePieceNormalizer())
|
||||||
|
|
||||||
|
// Look up special token IDs for vision and audio
|
||||||
|
imageTokenID := int32(-1)
|
||||||
|
imageEndTokenID := int32(-1)
|
||||||
|
audioTokenID := int32(-1)
|
||||||
|
audioEndTokenID := int32(-1)
|
||||||
|
for i, tok := range vocabulary.Values {
|
||||||
|
switch tok {
|
||||||
|
case "<|image>":
|
||||||
|
imageTokenID = int32(i)
|
||||||
|
case "<image|>":
|
||||||
|
imageEndTokenID = int32(i)
|
||||||
|
case "<|audio>":
|
||||||
|
audioTokenID = int32(i)
|
||||||
|
case "<audio|>":
|
||||||
|
audioEndTokenID = int32(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("gemma4: token IDs", "image", imageTokenID, "image_end", imageEndTokenID, "audio", audioTokenID, "audio_end", audioEndTokenID)
|
||||||
|
|
||||||
|
m := Model{
|
||||||
|
Tokenizer: t,
|
||||||
|
TextModel: newTextModel(c),
|
||||||
|
VisionModel: newVisionModel(c),
|
||||||
|
AudioModel: newAudioModel(c),
|
||||||
|
MultiModalProjector: &MultiModalProjector{},
|
||||||
|
AudioMultimodalProjector: &AudioMultimodalProjector{},
|
||||||
|
ImageProcessor: newImageProcessor(c),
|
||||||
|
imageTokenID: imageTokenID,
|
||||||
|
imageEndTokenID: imageEndTokenID,
|
||||||
|
audioTokenID: audioTokenID,
|
||||||
|
audioEndTokenID: audioEndTokenID,
|
||||||
|
audioOpts: newAudioModelOptions(c),
|
||||||
|
}
|
||||||
|
|
||||||
|
slidingWindowLen := int32(c.Uint("attention.sliding_window"))
|
||||||
|
m.Cache = kvcache.NewWrapperCache(
|
||||||
|
kvcache.NewSWAMemCache(slidingWindowLen, 4096, m.Shift),
|
||||||
|
kvcache.NewCausalCache(m.Shift),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) EncodeMultimodal(ctx ml.Context, multimodalData []byte) ([]input.Multimodal, error) {
|
||||||
|
// Audio input: detect WAV format and route to audio encoder.
|
||||||
|
if isAudioData(multimodalData) {
|
||||||
|
return m.encodeAudioMultimodal(ctx, multimodalData)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(m.VisionModel.Layers) == 0 {
|
||||||
|
return nil, model.ErrNoVisionModel
|
||||||
|
}
|
||||||
|
|
||||||
|
t0 := time.Now()
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(multimodalData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
slog.Info("vision: decode", "elapsed", time.Since(t0), "bounds", img.Bounds())
|
||||||
|
|
||||||
|
t1 := time.Now()
|
||||||
|
f32s, imgW, imgH, err := m.ImageProcessor.ProcessImage(img)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
slog.Info("vision: preprocess", "elapsed", time.Since(t1), "size", [2]int{imgW, imgH})
|
||||||
|
|
||||||
|
pixelValues := ctx.Input().FromFloats(f32s, imgW, imgH, m.ImageProcessor.numChannels)
|
||||||
|
slog.Info("vision: pixelValues", "shape", pixelValues.Shape(), "dim0", pixelValues.Dim(0), "dim1", pixelValues.Dim(1), "dim2", pixelValues.Dim(2))
|
||||||
|
|
||||||
|
numPatchesX := imgW / m.ImageProcessor.patchSize
|
||||||
|
numPatchesY := imgH / m.ImageProcessor.patchSize
|
||||||
|
slog.Info("vision: patches", "patchesX", numPatchesX, "patchesY", numPatchesY, "total", numPatchesX*numPatchesY, "patchSize", m.ImageProcessor.patchSize)
|
||||||
|
|
||||||
|
visionOutputs := m.VisionModel.Forward(ctx, pixelValues, numPatchesX, numPatchesY)
|
||||||
|
visionOutputs = visionPoolAndProject(ctx, visionOutputs, numPatchesX, numPatchesY, m.VisionModel.VisionModelOptions, m.MultiModalProjector, m.VisionModel.StdBias, m.VisionModel.StdScale)
|
||||||
|
slog.Info("vision: encoded", "elapsed", time.Since(t0), "shape", visionOutputs.Shape())
|
||||||
|
|
||||||
|
return []input.Multimodal{{Tensor: visionOutputs}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) PostLoad() error {
|
||||||
|
m.VisionModel.InitClamp(m.MultiModalProjector)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) encodeAudioMultimodal(ctx ml.Context, data []byte) ([]input.Multimodal, error) {
|
||||||
|
if m.AudioModel == nil || m.audioOpts == nil {
|
||||||
|
return nil, model.ErrNoVisionModel
|
||||||
|
}
|
||||||
|
|
||||||
|
t0 := time.Now()
|
||||||
|
samples, err := decodeWAV(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
slog.Info("audio: decode", "elapsed", time.Since(t0), "samples", len(samples), "duration_s", float64(len(samples))/audioSampleRate)
|
||||||
|
|
||||||
|
// Pad waveform to next multiple of 128.
|
||||||
|
if rem := len(samples) % 128; rem != 0 {
|
||||||
|
samples = append(samples, make([]float32, 128-rem)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute mel spectrogram.
|
||||||
|
melData, numFrames := computeMelSpectrogram(samples)
|
||||||
|
if numFrames == 0 {
|
||||||
|
return nil, fmt.Errorf("audio too short to encode")
|
||||||
|
}
|
||||||
|
slog.Info("audio: mel", "frames", numFrames, "elapsed", time.Since(t0))
|
||||||
|
|
||||||
|
// Create input tensor [melBins, numFrames] (GGML ne order). FromFloats creates F32.
|
||||||
|
melTensor := ctx.Input().FromFloats(melData, melBins, numFrames)
|
||||||
|
|
||||||
|
// Run audio encoder.
|
||||||
|
audioOutputs := m.AudioModel.ForwardAudio(ctx, melTensor, m.AudioMultimodalProjector, m.audioOpts)
|
||||||
|
slog.Info("audio: encoded", "elapsed", time.Since(t0), "shape", audioOutputs.Shape())
|
||||||
|
|
||||||
|
return []input.Multimodal{{Tensor: audioOutputs, Data: audioTag{}}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// audioTag marks multimodal data as audio (vs vision) for PostTokenize.
|
||||||
|
type audioTag struct{}
|
||||||
|
|
||||||
|
func (m *Model) PostTokenize(inputs []*input.Input) ([]*input.Input, error) {
|
||||||
|
var result []*input.Input
|
||||||
|
|
||||||
|
for _, inp := range inputs {
|
||||||
|
if len(inp.Multimodal) == 0 {
|
||||||
|
result = append(result, inp)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
inputMultimodal := inp.Multimodal[0].Tensor
|
||||||
|
numTokens := inputMultimodal.Dim(1)
|
||||||
|
|
||||||
|
// Determine if this is audio or vision based on the tag.
|
||||||
|
_, isAudio := inp.Multimodal[0].Data.(audioTag)
|
||||||
|
|
||||||
|
var beginToken, endToken int32
|
||||||
|
if isAudio {
|
||||||
|
beginToken = m.audioTokenID
|
||||||
|
endToken = m.audioEndTokenID
|
||||||
|
} else {
|
||||||
|
beginToken = m.imageTokenID
|
||||||
|
endToken = m.imageEndTokenID
|
||||||
|
}
|
||||||
|
|
||||||
|
if beginToken >= 0 {
|
||||||
|
result = append(result, &input.Input{Token: beginToken, SameBatch: numTokens + 2})
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result,
|
||||||
|
&input.Input{Multimodal: []input.Multimodal{{Tensor: inputMultimodal}}, MultimodalHash: inp.MultimodalHash},
|
||||||
|
)
|
||||||
|
result = append(result, slices.Repeat([]*input.Input{{Token: 0}}, numTokens-1)...)
|
||||||
|
|
||||||
|
if endToken >= 0 {
|
||||||
|
result = append(result, &input.Input{Token: endToken})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) Forward(ctx ml.Context, batch input.Batch) (ml.Tensor, error) {
|
||||||
|
hiddenState := m.TextModel.Forward(ctx, batch, m.Cache)
|
||||||
|
|
||||||
|
hiddenState = m.TextModel.Output.Forward(ctx, hiddenState)
|
||||||
|
|
||||||
|
if m.TextModel.TextOptions.finalLogitSoftcap > 0.0 {
|
||||||
|
hiddenState = hiddenState.Scale(ctx, 1.0/float64(m.TextModel.TextOptions.finalLogitSoftcap))
|
||||||
|
hiddenState = hiddenState.Tanh(ctx)
|
||||||
|
hiddenState = hiddenState.Scale(ctx, float64(m.TextModel.TextOptions.finalLogitSoftcap))
|
||||||
|
}
|
||||||
|
|
||||||
|
return hiddenState, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) Shift(ctx ml.Context, layer int, key, shift ml.Tensor) (ml.Tensor, error) {
|
||||||
|
ropeBase, ropeDims := m.TextModel.ropeForLayer(layer)
|
||||||
|
return nn.RoPE(ctx, key, shift, ropeDims, ropeBase, 1.0, rope.WithTypeNeoX()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
model.Register("gemma4", New)
|
||||||
|
}
|
||||||
612
model/models/gemma4/model_audio.go
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
package gemma4
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/fs"
|
||||||
|
"github.com/ollama/ollama/ml"
|
||||||
|
"github.com/ollama/ollama/ml/nn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AudioModel holds the audio encoder and configuration.
|
||||||
|
type AudioModel struct {
|
||||||
|
// SSCP: Sub-Sample Convolution Projection.
|
||||||
|
SSCPConv0 *AudioConvBlock `gguf:"conv1d.0"`
|
||||||
|
SSCPConv1 *AudioConvBlock `gguf:"conv1d.1"`
|
||||||
|
|
||||||
|
// SSCP output projection (linear).
|
||||||
|
SSCPInputProj *nn.Linear `gguf:"pre_encode.out"`
|
||||||
|
|
||||||
|
// Conformer blocks.
|
||||||
|
Layers []AudioConformerBlock `gguf:"blk"`
|
||||||
|
|
||||||
|
// Output projection to embedder dimension.
|
||||||
|
OutputProj *AudioOutputProj `gguf:"output_proj"`
|
||||||
|
|
||||||
|
AudioModelOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioOutputProj struct {
|
||||||
|
Weight ml.Tensor `gguf:"weight"`
|
||||||
|
Bias ml.Tensor `gguf:"bias"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioModelOptions holds audio model hyperparameters.
|
||||||
|
type AudioModelOptions struct {
|
||||||
|
hiddenSize int
|
||||||
|
numHeads int
|
||||||
|
headDim int
|
||||||
|
ffnSize int
|
||||||
|
numLayers int
|
||||||
|
melBins int
|
||||||
|
chunkSize int
|
||||||
|
maxPast int
|
||||||
|
maxFuture int
|
||||||
|
contextSize int
|
||||||
|
logitCap float32
|
||||||
|
residualWeight float32
|
||||||
|
gradClip float32
|
||||||
|
convKernelSize int
|
||||||
|
eps float32
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioConvBlock is a single 2D convolution block for the SSCP.
|
||||||
|
type AudioConvBlock struct {
|
||||||
|
Weight ml.Tensor `gguf:"weight"`
|
||||||
|
Norm *nn.LayerNorm `gguf:"norm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioConformerBlock is a single conformer layer.
|
||||||
|
// All tensors are flat at the block level (a.blk.N.<name>) using underscore naming.
|
||||||
|
type AudioConformerBlock struct {
|
||||||
|
// Block-level norm
|
||||||
|
Norm *nn.RMSNorm `gguf:"layer_pre_norm"`
|
||||||
|
|
||||||
|
// FFW start
|
||||||
|
FFWNorm *nn.RMSNorm `gguf:"ffn_norm"`
|
||||||
|
FFWUp *AudioClippableLinear `gguf:"ffn_up"`
|
||||||
|
FFWDown *AudioClippableLinear `gguf:"ffn_down"`
|
||||||
|
FFWPostNorm *nn.RMSNorm `gguf:"ffn_post_norm"`
|
||||||
|
|
||||||
|
// FFW end
|
||||||
|
FFWNorm1 *nn.RMSNorm `gguf:"ffn_norm_1"`
|
||||||
|
FFWUp1 *AudioClippableLinear `gguf:"ffn_up_1"`
|
||||||
|
FFWDown1 *AudioClippableLinear `gguf:"ffn_down_1"`
|
||||||
|
FFWPostNorm1 *nn.RMSNorm `gguf:"ffn_post_norm_1"`
|
||||||
|
|
||||||
|
// Attention
|
||||||
|
AttnQ *AudioClippableLinear `gguf:"attn_q"`
|
||||||
|
AttnK *AudioClippableLinear `gguf:"attn_k"`
|
||||||
|
AttnV *AudioClippableLinear `gguf:"attn_v"`
|
||||||
|
AttnOut *AudioClippableLinear `gguf:"attn_out"`
|
||||||
|
AttnPreNorm *nn.RMSNorm `gguf:"ln1"`
|
||||||
|
AttnPostNorm *nn.RMSNorm `gguf:"ln2"`
|
||||||
|
LinearPos ml.Tensor `gguf:"linear_pos.weight"`
|
||||||
|
PerDimScale ml.Tensor `gguf:"per_dim_scale.weight"`
|
||||||
|
|
||||||
|
// LightConv1d
|
||||||
|
ConvPW1 *AudioClippableLinear `gguf:"conv_pw1"`
|
||||||
|
ConvPW2 *AudioClippableLinear `gguf:"conv_pw2"`
|
||||||
|
ConvDW ml.Tensor `gguf:"conv_dw.weight"`
|
||||||
|
ConvNorm *nn.RMSNorm `gguf:"conv_norm"`
|
||||||
|
NormConv *nn.RMSNorm `gguf:"norm_conv"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioClippableLinear is a linear layer with optional input/output clamping.
|
||||||
|
type AudioClippableLinear struct {
|
||||||
|
Weight ml.Tensor `gguf:"weight"`
|
||||||
|
Bias ml.Tensor `gguf:"bias"`
|
||||||
|
InputMin ml.Tensor `gguf:"input_min"`
|
||||||
|
InputMax ml.Tensor `gguf:"input_max"`
|
||||||
|
OutputMin ml.Tensor `gguf:"output_min"`
|
||||||
|
OutputMax ml.Tensor `gguf:"output_max"`
|
||||||
|
|
||||||
|
// Cached scalar clamp values (populated on first forward).
|
||||||
|
inMin, inMax, outMin, outMax float32
|
||||||
|
clampsLoaded bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *AudioClippableLinear) loadClamps() {
|
||||||
|
if l.clampsLoaded {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.clampsLoaded = true
|
||||||
|
if l.InputMin != nil {
|
||||||
|
vals := l.InputMin.BackendGet()
|
||||||
|
if len(vals) > 0 {
|
||||||
|
l.inMin = vals[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.InputMax != nil {
|
||||||
|
vals := l.InputMax.BackendGet()
|
||||||
|
if len(vals) > 0 {
|
||||||
|
l.inMax = vals[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.OutputMin != nil {
|
||||||
|
vals := l.OutputMin.BackendGet()
|
||||||
|
if len(vals) > 0 {
|
||||||
|
l.outMin = vals[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.OutputMax != nil {
|
||||||
|
vals := l.OutputMax.BackendGet()
|
||||||
|
if len(vals) > 0 {
|
||||||
|
l.outMax = vals[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *AudioClippableLinear) Forward(ctx ml.Context, x ml.Tensor) ml.Tensor {
|
||||||
|
l.loadClamps()
|
||||||
|
if l.inMax != 0 {
|
||||||
|
x = x.Clamp(ctx, l.inMin, l.inMax)
|
||||||
|
}
|
||||||
|
out := l.Weight.Mulmat(ctx, x)
|
||||||
|
if l.Bias != nil {
|
||||||
|
out = out.Add(ctx, l.Bias)
|
||||||
|
}
|
||||||
|
if l.outMax != 0 {
|
||||||
|
out = out.Clamp(ctx, l.outMin, l.outMax)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioMultimodalProjector is the audio-to-text embedding projector.
|
||||||
|
type AudioMultimodalProjector struct {
|
||||||
|
Projection *AudioClippableLinear `gguf:"input_projection"`
|
||||||
|
FC *AudioFC `gguf:"fc"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioFC struct {
|
||||||
|
Weight ml.Tensor `gguf:"weight"`
|
||||||
|
Bias ml.Tensor `gguf:"bias"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AudioMultimodalProjector) Forward(ctx ml.Context, x ml.Tensor, eps float32) ml.Tensor {
|
||||||
|
// FC: output projection from conformer to embedder dimension.
|
||||||
|
x = p.FC.Weight.Mulmat(ctx, x)
|
||||||
|
if p.FC.Bias != nil {
|
||||||
|
x = x.Add(ctx, p.FC.Bias)
|
||||||
|
}
|
||||||
|
// Pre-projection RMSNorm (without learned weight) — matches Python's embedding_pre_projection_norm.
|
||||||
|
x = x.RMSNorm(ctx, nil, eps)
|
||||||
|
// Embedding projection to text hidden size.
|
||||||
|
x = p.Projection.Forward(ctx, x)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForwardAudio encodes mel spectrogram features into soft tokens.
|
||||||
|
// melFeatures: float32 tensor with ne[0]=melBins, ne[1]=numFrames.
|
||||||
|
// Returns: [hiddenSize, numTokens] tensor.
|
||||||
|
func (m *AudioModel) ForwardAudio(ctx ml.Context, melFeatures ml.Tensor, proj *AudioMultimodalProjector, opts *AudioModelOptions) ml.Tensor {
|
||||||
|
// SSCP Conv2D input: ne[0]=F (freq/width), ne[1]=T (time/height), ne[2]=C_in, ne[3]=B
|
||||||
|
// melFeatures is [melBins, numFrames], add channel and batch dims.
|
||||||
|
x := melFeatures.Reshape(ctx, melFeatures.Dim(0), melFeatures.Dim(1), 1, 1)
|
||||||
|
|
||||||
|
// SSCP Conv block 0: [F, T, 1, 1] → [F', T', C0, 1]
|
||||||
|
x = forwardConvBlock(ctx, m.SSCPConv0, x, opts)
|
||||||
|
|
||||||
|
// SSCP Conv block 1: [F', T', C0, 1] → [F'', T'', C1, 1]
|
||||||
|
x = forwardConvBlock(ctx, m.SSCPConv1, x, opts)
|
||||||
|
|
||||||
|
// After conv blocks, layout is [F'', T'', C_out, B].
|
||||||
|
// Permute to [C_out*F'', T'', B] for linear projection (channels+freq in ne[0]).
|
||||||
|
fOut := x.Dim(0)
|
||||||
|
tOut := x.Dim(1)
|
||||||
|
cOut := x.Dim(2)
|
||||||
|
// Permute [F'', T'', C, B] → [C, F'', T'', B]
|
||||||
|
// (1,2,0,3): old[0]→pos1, old[1]→pos2, old[2]→pos0
|
||||||
|
x = x.Permute(ctx, 1, 2, 0, 3).Contiguous(ctx)
|
||||||
|
x = x.Reshape(ctx, cOut*fOut, tOut)
|
||||||
|
|
||||||
|
// Linear projection to hidden size.
|
||||||
|
x = m.SSCPInputProj.Forward(ctx, x)
|
||||||
|
|
||||||
|
// Build causal-valid mask for conformer attention.
|
||||||
|
causalMask := buildCausalValidMaskF32(int(opts.chunkSize), opts.maxPast, opts.maxFuture)
|
||||||
|
|
||||||
|
// Run conformer blocks.
|
||||||
|
for i := range m.Layers {
|
||||||
|
x = m.Layers[i].Forward(ctx, x, causalMask, opts, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output projection.
|
||||||
|
if m.OutputProj != nil {
|
||||||
|
x = m.OutputProj.Weight.Mulmat(ctx, x)
|
||||||
|
if m.OutputProj.Bias != nil {
|
||||||
|
x = x.Add(ctx, m.OutputProj.Bias)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio embedder: project to text embedding space.
|
||||||
|
if proj != nil {
|
||||||
|
x = proj.Forward(ctx, x, opts.eps)
|
||||||
|
}
|
||||||
|
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardConvBlock runs a single SSCP Conv2D block.
|
||||||
|
// Conv2D receiver is the kernel, argument is the input data.
|
||||||
|
// Input: [F, T, C_in, B]. Output: [F', T', C_out, B].
|
||||||
|
func forwardConvBlock(ctx ml.Context, block *AudioConvBlock, x ml.Tensor, opts *AudioModelOptions) ml.Tensor {
|
||||||
|
// Conv2D: kernel.Conv2D(ctx, input, s0, s1, p0, p1, d0, d1)
|
||||||
|
// Kernel is 3x3, stride 2x2, padding 1x1 (matching SSCP config).
|
||||||
|
// Output layout: [F', T', C_out, B]
|
||||||
|
// Make weight contiguous — the shape reversal in the converter creates
|
||||||
|
// a tensor where the physical data order doesn't match ne[]/stride[].
|
||||||
|
weight := block.Weight.Contiguous(ctx)
|
||||||
|
x = weight.Conv2D(ctx, x, 2, 2, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
// LayerNorm needs channels in ne[0]. Permute [F', T', C_out, B] → [C_out, F', T', B],
|
||||||
|
// norm, then permute back.
|
||||||
|
// GGML permute: axis i says where old axis i goes.
|
||||||
|
// (1,2,0,3): old[0]→pos1, old[1]→pos2, old[2]→pos0 → [C_out, F', T', B]
|
||||||
|
x = x.Permute(ctx, 1, 2, 0, 3).Contiguous(ctx)
|
||||||
|
x = block.Norm.Forward(ctx, x, opts.eps)
|
||||||
|
// (2,0,1,3): old[0]→pos2, old[1]→pos0, old[2]→pos1 → [F', T', C_out, B]
|
||||||
|
x = x.Permute(ctx, 2, 0, 1, 3).Contiguous(ctx)
|
||||||
|
|
||||||
|
x = x.RELU(ctx)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward runs a single conformer block.
|
||||||
|
func (cb *AudioConformerBlock) Forward(ctx ml.Context, x ml.Tensor, causalMask []float32, opts *AudioModelOptions, blockIdx int) ml.Tensor {
|
||||||
|
|
||||||
|
// FFW start (half-residual).
|
||||||
|
x = cb.forwardFFW(ctx, cb.FFWNorm, cb.FFWUp, cb.FFWDown, cb.FFWPostNorm, x, opts)
|
||||||
|
|
||||||
|
// Self-attention.
|
||||||
|
x = cb.forwardAttention(ctx, x, causalMask, opts, blockIdx)
|
||||||
|
|
||||||
|
// Lightweight Conv1d.
|
||||||
|
x = cb.forwardLightConv(ctx, x, opts, blockIdx)
|
||||||
|
|
||||||
|
// FFW end (half-residual).
|
||||||
|
x = cb.forwardFFW(ctx, cb.FFWNorm1, cb.FFWUp1, cb.FFWDown1, cb.FFWPostNorm1, x, opts)
|
||||||
|
|
||||||
|
// Gradient clipping + final norm.
|
||||||
|
x = x.Clamp(ctx, -opts.gradClip, opts.gradClip)
|
||||||
|
x = cb.Norm.Forward(ctx, x, opts.eps)
|
||||||
|
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardFFW runs a feedforward module with half-residual connection.
|
||||||
|
func (cb *AudioConformerBlock) forwardFFW(ctx ml.Context, preNorm *nn.RMSNorm, up, down *AudioClippableLinear, postNorm *nn.RMSNorm, x ml.Tensor, opts *AudioModelOptions) ml.Tensor {
|
||||||
|
residual := x
|
||||||
|
x = x.Clamp(ctx, -opts.gradClip, opts.gradClip)
|
||||||
|
x = preNorm.Forward(ctx, x, opts.eps)
|
||||||
|
x = up.Forward(ctx, x)
|
||||||
|
x = x.SILU(ctx)
|
||||||
|
x = down.Forward(ctx, x)
|
||||||
|
x = x.Clamp(ctx, -opts.gradClip, opts.gradClip)
|
||||||
|
x = postNorm.Forward(ctx, x, opts.eps)
|
||||||
|
x = x.Scale(ctx, float64(opts.residualWeight))
|
||||||
|
return residual.Add(ctx, x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardAttention runs the conformer block-local attention with relative position embeddings.
|
||||||
|
func (cb *AudioConformerBlock) forwardAttention(ctx ml.Context, x ml.Tensor, causalMask []float32, opts *AudioModelOptions, blockIdx int) ml.Tensor {
|
||||||
|
residual := x
|
||||||
|
x = x.Clamp(ctx, -opts.gradClip, opts.gradClip)
|
||||||
|
x = cb.AttnPreNorm.Forward(ctx, x, opts.eps)
|
||||||
|
|
||||||
|
hiddenSize := x.Dim(0)
|
||||||
|
seqLen := x.Dim(1)
|
||||||
|
|
||||||
|
// QKV projections: [hiddenSize, seqLen] → [headDim, numHeads, seqLen]
|
||||||
|
q := cb.AttnQ.Forward(ctx, x).Reshape(ctx, opts.headDim, opts.numHeads, seqLen)
|
||||||
|
k := cb.AttnK.Forward(ctx, x).Reshape(ctx, opts.headDim, opts.numHeads, seqLen)
|
||||||
|
v := cb.AttnV.Forward(ctx, x).Reshape(ctx, opts.headDim, opts.numHeads, seqLen)
|
||||||
|
|
||||||
|
// Per-dim scaling for queries: (headDim^-0.5 / log(2)) * softplus(per_dim_scale)
|
||||||
|
// per_dim_scale is already softplus'd from the converter.
|
||||||
|
qScale := float64(math.Pow(float64(opts.headDim), -0.5)) / math.Log(2)
|
||||||
|
q = q.Scale(ctx, qScale)
|
||||||
|
if cb.PerDimScale != nil {
|
||||||
|
q = q.Mul(ctx, cb.PerDimScale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key scaling: softplus(1) / log(2) — matches the query base scaling convention.
|
||||||
|
kScale := math.Log(1+math.E) / math.Log(2)
|
||||||
|
k = k.Scale(ctx, kScale)
|
||||||
|
|
||||||
|
// Build sinusoidal position embeddings for the block-local context.
|
||||||
|
maxSpan := opts.maxPast + opts.maxFuture + 1 // 13 unique relative positions
|
||||||
|
posEmb := cb.buildPositionEmbeddings(ctx, maxSpan, opts)
|
||||||
|
// posEmb: [headDim, numHeads, maxSpan]
|
||||||
|
|
||||||
|
// Block-local attention: process chunks of size chunkSize.
|
||||||
|
chunkSize := opts.chunkSize
|
||||||
|
numChunks := (seqLen + chunkSize - 1) / chunkSize
|
||||||
|
contextSize := opts.contextSize
|
||||||
|
|
||||||
|
// Pad q/k/v to multiple of chunkSize on the time dimension (dim 2).
|
||||||
|
padT := numChunks*chunkSize - seqLen
|
||||||
|
if padT > 0 {
|
||||||
|
q = q.Pad(ctx, 0, 0, padT, 0)
|
||||||
|
k = k.Pad(ctx, 0, 0, padT, 0)
|
||||||
|
v = v.Pad(ctx, 0, 0, padT, 0)
|
||||||
|
}
|
||||||
|
paddedLen := numChunks * chunkSize
|
||||||
|
|
||||||
|
// Pad k/v for context extraction: add maxPast on left, (maxFuture+chunkSize-1) on right.
|
||||||
|
// Use Pad (right) + PadExt (left) workaround since PadExt+Slice has issues.
|
||||||
|
// Actually use Concat with zero tensors for reliable left-padding.
|
||||||
|
padLeft := opts.maxPast
|
||||||
|
padRight := opts.maxFuture + chunkSize - 1
|
||||||
|
zeroLeft := ctx.Input().FromFloats(make([]float32, opts.headDim*opts.numHeads*padLeft), opts.headDim, opts.numHeads, padLeft)
|
||||||
|
zeroRight := ctx.Input().FromFloats(make([]float32, opts.headDim*opts.numHeads*padRight), opts.headDim, opts.numHeads, padRight)
|
||||||
|
kPadded := zeroLeft.Concat(ctx, k, 2).Concat(ctx, zeroRight, 2)
|
||||||
|
vPadded := zeroLeft.Concat(ctx, v, 2).Concat(ctx, zeroRight, 2)
|
||||||
|
|
||||||
|
// Reshape q into chunks: [headDim, numHeads, numChunks, chunkSize]
|
||||||
|
qChunked := q.Reshape(ctx, opts.headDim, opts.numHeads, numChunks, chunkSize)
|
||||||
|
|
||||||
|
// Process each chunk and collect results.
|
||||||
|
chunkOutputs := make([]ml.Tensor, numChunks)
|
||||||
|
for u := range numChunks {
|
||||||
|
// Extract query block: [headDim, numHeads, 1, chunkSize] → [headDim, numHeads, chunkSize]
|
||||||
|
qBlock := qChunked.Slice(ctx, 2, u, u+1, 1).Reshape(ctx, opts.headDim, opts.numHeads, chunkSize)
|
||||||
|
|
||||||
|
// Extract key/value context: [headDim, numHeads, contextSize]
|
||||||
|
cStart := u * chunkSize // offset in kPadded (padLeft already accounts for left context)
|
||||||
|
kCtx := kPadded.Slice(ctx, 2, cStart, cStart+contextSize, 1).Contiguous(ctx)
|
||||||
|
vCtx := vPadded.Slice(ctx, 2, cStart, cStart+contextSize, 1).Contiguous(ctx)
|
||||||
|
|
||||||
|
// Content-content logits: qBlock^T @ kCtx → [chunkSize, contextSize] per head.
|
||||||
|
// Mulmat(a, b) = a^T @ b. We want Q^T K, so: kCtx.Mulmat(qBlock) but that gives
|
||||||
|
// [numHeads, chunkSize, contextSize] with wrong batching.
|
||||||
|
// Instead: permute to [headDim, chunkSize, numHeads] and [headDim, contextSize, numHeads]
|
||||||
|
// then Mulmat batches over numHeads.
|
||||||
|
// GGML permute(0,2,1,3): old[0]→0, old[1]→2, old[2]→1
|
||||||
|
qP := qBlock.Permute(ctx, 0, 2, 1, 3) // [headDim, chunkSize, numHeads]
|
||||||
|
kP := kCtx.Permute(ctx, 0, 2, 1, 3) // [headDim, contextSize, numHeads]
|
||||||
|
|
||||||
|
termAC := kP.MulmatFullPrec(ctx, qP) // [contextSize, chunkSize, numHeads]
|
||||||
|
|
||||||
|
// Content-position logits: qBlock^T @ posEmb → [chunkSize, maxSpan] per head.
|
||||||
|
pP := posEmb.Permute(ctx, 0, 2, 1, 3) // [headDim, maxSpan, numHeads]
|
||||||
|
termBDRaw := pP.MulmatFullPrec(ctx, qP) // [maxSpan, chunkSize, numHeads]
|
||||||
|
|
||||||
|
// Relative shift: [maxSpan, chunkSize, numHeads] → [contextSize, chunkSize, numHeads]
|
||||||
|
termBD := cb.relativeShiftGGML(ctx, termBDRaw, maxSpan, chunkSize, contextSize, opts.numHeads)
|
||||||
|
|
||||||
|
// Combined logits.
|
||||||
|
logits := termAC.Add(ctx, termBD)
|
||||||
|
|
||||||
|
// Logit softcap: tanh(logits / cap) * cap
|
||||||
|
logits = logits.Scale(ctx, 1.0/float64(opts.logitCap))
|
||||||
|
logits = logits.Tanh(ctx)
|
||||||
|
logits = logits.Scale(ctx, float64(opts.logitCap))
|
||||||
|
|
||||||
|
// Apply combined causal + validity mask.
|
||||||
|
// causalMask [chunkSize * contextSize]: 1=causal-allowed, 0=masked.
|
||||||
|
// Validity: context positions before the actual sequence start are invalid.
|
||||||
|
// For chunk u, context position c corresponds to actual time: u*chunkSize + c - padLeft.
|
||||||
|
// Valid if 0 <= actual_time < seqLen.
|
||||||
|
// Mask tensor layout: [contextSize, chunkSize, 1] with ne[0]=contextSize contiguous.
|
||||||
|
// Element at (context=j, chunk=i) is at flat index: i*contextSize + j.
|
||||||
|
maskData := make([]float32, contextSize*chunkSize)
|
||||||
|
for i := range chunkSize {
|
||||||
|
for j := range contextSize {
|
||||||
|
actualTime := u*chunkSize + j - padLeft
|
||||||
|
causalOK := causalMask[i*contextSize+j] > 0
|
||||||
|
validOK := actualTime >= 0 && actualTime < seqLen
|
||||||
|
if causalOK && validOK {
|
||||||
|
maskData[i*contextSize+j] = 0
|
||||||
|
} else {
|
||||||
|
maskData[i*contextSize+j] = -1e9
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mask := ctx.Input().FromFloats(maskData, contextSize, chunkSize, 1) // 3D for broadcasting over numHeads
|
||||||
|
logits = logits.Add(ctx, mask)
|
||||||
|
|
||||||
|
// Softmax over context dimension (dim 0 = contextSize).
|
||||||
|
logits = logits.Softmax(ctx) // softmax over ne[0]=contextSize
|
||||||
|
|
||||||
|
// Weighted sum: logits^T @ vCtx.
|
||||||
|
// logits: [contextSize, chunkSize, numHeads], vCtx: [headDim, numHeads, contextSize]
|
||||||
|
// vCtx permuted: [headDim, contextSize, numHeads]
|
||||||
|
vP := vCtx.Permute(ctx, 0, 2, 1, 3) // [headDim, contextSize, numHeads]
|
||||||
|
// Weighted sum: for each head, value[headDim, contextSize] @ weights[contextSize, chunkSize]
|
||||||
|
// = [headDim, chunkSize].
|
||||||
|
// Mulmat(a, b) = a^T @ b. Need a=[contextSize, headDim, numHeads], b=[contextSize, chunkSize, numHeads].
|
||||||
|
vPT := vP.Permute(ctx, 1, 0, 2, 3).Contiguous(ctx) // [contextSize, headDim, numHeads]
|
||||||
|
chunkOut := vPT.Mulmat(ctx, logits) // [headDim, chunkSize, numHeads]
|
||||||
|
|
||||||
|
// Permute back to [headDim, numHeads, chunkSize]
|
||||||
|
chunkOut = chunkOut.Permute(ctx, 0, 2, 1, 3).Contiguous(ctx)
|
||||||
|
chunkOutputs[u] = chunkOut
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concatenate chunk outputs along time dimension.
|
||||||
|
var attnOut ml.Tensor
|
||||||
|
if numChunks == 1 {
|
||||||
|
attnOut = chunkOutputs[0]
|
||||||
|
} else {
|
||||||
|
attnOut = chunkOutputs[0]
|
||||||
|
for _, co := range chunkOutputs[1:] {
|
||||||
|
attnOut = attnOut.Concat(ctx, co, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim to original sequence length if we padded.
|
||||||
|
if paddedLen > seqLen {
|
||||||
|
attnOut = attnOut.Slice(ctx, 2, 0, seqLen, 1).Contiguous(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reshape to [hiddenSize, seqLen] and project.
|
||||||
|
attnOut = attnOut.Reshape(ctx, hiddenSize, seqLen)
|
||||||
|
x = cb.AttnOut.Forward(ctx, attnOut)
|
||||||
|
x = x.Clamp(ctx, -opts.gradClip, opts.gradClip)
|
||||||
|
x = cb.AttnPostNorm.Forward(ctx, x, opts.eps)
|
||||||
|
|
||||||
|
return residual.Add(ctx, x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPositionEmbeddings builds sinusoidal position embeddings and projects through linear_pos.
|
||||||
|
// Returns [headDim, numHeads, maxSpan] tensor.
|
||||||
|
func (cb *AudioConformerBlock) buildPositionEmbeddings(ctx ml.Context, maxSpan int, opts *AudioModelOptions) ml.Tensor {
|
||||||
|
halfDim := opts.hiddenSize / 2
|
||||||
|
hiddenSize := opts.hiddenSize
|
||||||
|
|
||||||
|
// inv_timescales: exp(-i * log(10000) / max(D/2-1, 1))
|
||||||
|
logInc := math.Log(10000.0) / math.Max(float64(halfDim-1), 1)
|
||||||
|
|
||||||
|
// Sinusoidal embeddings for relative positions [maxPast, maxPast-1, ..., -maxFuture].
|
||||||
|
posData := make([]float32, hiddenSize*maxSpan)
|
||||||
|
for p := range maxSpan {
|
||||||
|
relPos := float64(opts.maxPast - p)
|
||||||
|
for d := range halfDim {
|
||||||
|
angle := relPos * math.Exp(float64(-d)*logInc)
|
||||||
|
posData[p*hiddenSize+d] = float32(math.Sin(angle))
|
||||||
|
posData[p*hiddenSize+halfDim+d] = float32(math.Cos(angle))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create [hiddenSize, maxSpan] input tensor.
|
||||||
|
posEmb := ctx.Input().FromFloats(posData, hiddenSize, maxSpan)
|
||||||
|
|
||||||
|
// Project through linear_pos: [hiddenSize, maxSpan] → Mulmat → [numHeads*headDim, maxSpan]
|
||||||
|
projPos := cb.LinearPos.Mulmat(ctx, posEmb)
|
||||||
|
|
||||||
|
// Reshape to [headDim, numHeads, maxSpan].
|
||||||
|
return projPos.Reshape(ctx, opts.headDim, opts.numHeads, maxSpan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// relativeShiftGGML performs the relative shift to extract correct position logits.
|
||||||
|
// Input: [maxSpan, chunkSize, numHeads]. Output: [contextSize, chunkSize, numHeads].
|
||||||
|
func (cb *AudioConformerBlock) relativeShiftGGML(ctx ml.Context, x ml.Tensor, maxSpan, chunkSize, contextSize, numHeads int) ml.Tensor {
|
||||||
|
// The shift trick: pad ne[0] to contextSize+1, reshape to flatten first two dims,
|
||||||
|
// skip first (contextSize+1-maxSpan) elements, take contextSize*chunkSize elements, reshape back.
|
||||||
|
padAmt := contextSize + 1 - maxSpan
|
||||||
|
if padAmt > 0 {
|
||||||
|
x = x.Pad(ctx, padAmt, 0, 0, 0) // [maxSpan+padAmt, chunkSize, numHeads] = [contextSize+1, chunkSize, numHeads]
|
||||||
|
}
|
||||||
|
// Reshape to [(contextSize+1)*chunkSize, numHeads]
|
||||||
|
x = x.Reshape(ctx, (contextSize+1)*chunkSize, numHeads)
|
||||||
|
// Take the first contextSize*chunkSize elements (the standard relative shift trick).
|
||||||
|
x = x.Slice(ctx, 0, 0, contextSize*chunkSize, 1).Contiguous(ctx)
|
||||||
|
// Reshape to [contextSize, chunkSize, numHeads]
|
||||||
|
return x.Reshape(ctx, contextSize, chunkSize, numHeads)
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardLightConv runs the lightweight depthwise convolution module.
|
||||||
|
func (cb *AudioConformerBlock) forwardLightConv(ctx ml.Context, x ml.Tensor, opts *AudioModelOptions, blockIdx int) ml.Tensor {
|
||||||
|
residual := x
|
||||||
|
|
||||||
|
x = cb.ConvNorm.Forward(ctx, x, opts.eps)
|
||||||
|
x = cb.ConvPW1.Forward(ctx, x) // [2*D, T, B]
|
||||||
|
|
||||||
|
// GLU: split in half along dim 0, sigmoid gate, multiply.
|
||||||
|
d := x.Dim(0) / 2
|
||||||
|
data := x.Slice(ctx, 0, 0, d, 1).Contiguous(ctx)
|
||||||
|
gate := x.Slice(ctx, 0, d, d*2, 1).Contiguous(ctx).Sigmoid(ctx)
|
||||||
|
x = data.Mul(ctx, gate) // [D, T, B]
|
||||||
|
|
||||||
|
// Depthwise Conv1d: manual implementation using model weight tensor slices.
|
||||||
|
// Kernel cb.ConvDW shape: [K=5, D=1024] (ne[0]=K, ne[1]=D) after shape reversal.
|
||||||
|
// Actually in GGML, ne[0]=K=5 contiguous, ne[1]=D=1024.
|
||||||
|
// We need per-tap weights [D] and shifted input copies.
|
||||||
|
kernelSize := cb.ConvDW.Dim(0) // K=5
|
||||||
|
seqLen := x.Dim(1)
|
||||||
|
|
||||||
|
// Transpose kernel to [D, K] for per-tap slicing.
|
||||||
|
// GGML permute(1,0,2,3): old[0]→pos1, old[1]→pos0 → swap ne[0] and ne[1]
|
||||||
|
kernelT := cb.ConvDW.Permute(ctx, 1, 0, 2, 3).Contiguous(ctx) // [D, K]
|
||||||
|
|
||||||
|
var convOut ml.Tensor
|
||||||
|
for k := range kernelSize {
|
||||||
|
shift := kernelSize - 1 - k
|
||||||
|
var shifted ml.Tensor
|
||||||
|
if shift == 0 {
|
||||||
|
shifted = x
|
||||||
|
} else {
|
||||||
|
trimmed := x.Slice(ctx, 1, 0, seqLen-shift, 1).Contiguous(ctx)
|
||||||
|
shifted = trimmed.PadExt(ctx, 0, 0, shift, 0, 0, 0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
wk := kernelT.Slice(ctx, 1, k, k+1, 1).Contiguous(ctx) // [D, 1]
|
||||||
|
term := shifted.Mul(ctx, wk)
|
||||||
|
if convOut == nil {
|
||||||
|
convOut = term
|
||||||
|
} else {
|
||||||
|
convOut = convOut.Add(ctx, term)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
x = convOut
|
||||||
|
|
||||||
|
x = x.Clamp(ctx, -opts.gradClip, opts.gradClip)
|
||||||
|
x = cb.NormConv.Forward(ctx, x, opts.eps)
|
||||||
|
x = x.SILU(ctx)
|
||||||
|
x = cb.ConvPW2.Forward(ctx, x)
|
||||||
|
|
||||||
|
return x.Add(ctx, residual)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAudioModel(c fs.Config) *AudioModel {
|
||||||
|
numLayers := int(c.Uint("audio.block_count", 0))
|
||||||
|
if numLayers == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &AudioModel{
|
||||||
|
Layers: make([]AudioConformerBlock, numLayers),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAudioModelOptions(c fs.Config) *AudioModelOptions {
|
||||||
|
hiddenSize := int(c.Uint("audio.embedding_length", 0))
|
||||||
|
if hiddenSize == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
numHeads := int(c.Uint("audio.attention.head_count", 8))
|
||||||
|
headDim := hiddenSize / numHeads
|
||||||
|
chunkSize := 12 // default conformer chunk size
|
||||||
|
maxPast := 12 // conf_attention_context_left - 1
|
||||||
|
maxFuture := 0 // conf_attention_context_right
|
||||||
|
convKernel := int(c.Uint("audio.conv_kernel_size", 5))
|
||||||
|
|
||||||
|
eps := c.Float("audio.attention.layer_norm_epsilon", 1e-6)
|
||||||
|
|
||||||
|
return &AudioModelOptions{
|
||||||
|
hiddenSize: hiddenSize,
|
||||||
|
numHeads: numHeads,
|
||||||
|
headDim: headDim,
|
||||||
|
ffnSize: int(c.Uint("audio.feed_forward_length", uint32(hiddenSize*4))),
|
||||||
|
numLayers: int(c.Uint("audio.block_count", 12)),
|
||||||
|
melBins: int(c.Uint("audio.num_mel_bins", 128)),
|
||||||
|
chunkSize: chunkSize,
|
||||||
|
maxPast: maxPast,
|
||||||
|
maxFuture: maxFuture,
|
||||||
|
contextSize: chunkSize + maxPast + maxFuture,
|
||||||
|
logitCap: 50.0,
|
||||||
|
residualWeight: 0.5,
|
||||||
|
gradClip: 1e10,
|
||||||
|
convKernelSize: convKernel,
|
||||||
|
eps: float32(eps),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCausalValidMaskF32 creates the causal-valid mask for block-local attention.
|
||||||
|
// Returns flat [chunkSize * contextSize] float32 data (1.0 = allowed, 0.0 = masked).
|
||||||
|
func buildCausalValidMaskF32(chunkSize, maxPast, maxFuture int) []float32 {
|
||||||
|
contextSize := chunkSize + maxPast + maxFuture
|
||||||
|
upperDiag := maxPast + maxFuture
|
||||||
|
|
||||||
|
result := make([]float32, chunkSize*contextSize)
|
||||||
|
for r := range chunkSize {
|
||||||
|
for c := range contextSize {
|
||||||
|
lower := (r <= c) // tril(contextSize, chunkSize) transposed
|
||||||
|
upper := (c <= r+int(upperDiag)) // tril(chunkSize, contextSize, diag=upperDiag)
|
||||||
|
if lower && upper {
|
||||||
|
result[r*contextSize+c] = 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
475
model/models/gemma4/model_text.go
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
package gemma4
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/fs"
|
||||||
|
"github.com/ollama/ollama/kvcache"
|
||||||
|
"github.com/ollama/ollama/ml"
|
||||||
|
"github.com/ollama/ollama/ml/nn"
|
||||||
|
"github.com/ollama/ollama/ml/nn/rope"
|
||||||
|
"github.com/ollama/ollama/model/input"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cacheTypeSWA = iota
|
||||||
|
cacheTypeCausal
|
||||||
|
)
|
||||||
|
|
||||||
|
type TextOptions struct {
|
||||||
|
hiddenSize int
|
||||||
|
numHeads, numKVHeads int
|
||||||
|
numGlobalKVHeads int
|
||||||
|
headDim, globalHeadDim int
|
||||||
|
hiddenLayers int
|
||||||
|
hiddenSizePerLayerInput int
|
||||||
|
|
||||||
|
eps float32
|
||||||
|
ropeBase float32
|
||||||
|
ropeLocalBase float32
|
||||||
|
partialRotaryDims int // RoPE dims for full-attention (global) layers
|
||||||
|
|
||||||
|
slidingWindowPattern []bool
|
||||||
|
// kvDonorMap maps shared layer index -> donor layer index.
|
||||||
|
// Donor is the last non-shared layer of the same type (sliding/full).
|
||||||
|
kvDonorMap map[int]int
|
||||||
|
|
||||||
|
finalLogitSoftcap float32
|
||||||
|
|
||||||
|
numExperts int
|
||||||
|
numExpertsUsed int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *TextOptions) isLocal(layer int) bool {
|
||||||
|
if layer < len(o.slidingWindowPattern) {
|
||||||
|
return o.slidingWindowPattern[layer]
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *TextOptions) ropeForLayer(layer int) (base float32, dims int) {
|
||||||
|
if o.isLocal(layer) {
|
||||||
|
return o.ropeLocalBase, o.headDim
|
||||||
|
}
|
||||||
|
return o.ropeBase, o.partialRotaryDims
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *TextOptions) kvHeadsForLayer(layer int) int {
|
||||||
|
if o.isLocal(layer) {
|
||||||
|
return o.numKVHeads
|
||||||
|
}
|
||||||
|
if o.numGlobalKVHeads > 0 {
|
||||||
|
return o.numGlobalKVHeads
|
||||||
|
}
|
||||||
|
return o.numKVHeads
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *TextOptions) headDimForLayer(layer int) int {
|
||||||
|
if o.isLocal(layer) {
|
||||||
|
return o.headDim
|
||||||
|
}
|
||||||
|
return o.globalHeadDim
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextModel struct {
|
||||||
|
TokenEmbedding *nn.Embedding `gguf:"token_embd"`
|
||||||
|
*PerLayerProjector
|
||||||
|
Layers []TextLayer `gguf:"blk"`
|
||||||
|
OutputNorm *nn.RMSNorm `gguf:"output_norm"`
|
||||||
|
Output *nn.Linear `gguf:"output,alt:token_embd"`
|
||||||
|
TextOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTextModel(c fs.Config) *TextModel {
|
||||||
|
numLayers := int(c.Uint("block_count"))
|
||||||
|
|
||||||
|
// Head dimensions: key_length is global head dim, key_length_swa is local (SWA) head dim.
|
||||||
|
globalHeadDim := int(c.Uint("attention.key_length", 512))
|
||||||
|
headDim := int(c.Uint("attention.key_length_swa", 256))
|
||||||
|
|
||||||
|
// RoPE dimensions for global (full attention) layers with proportional RoPE.
|
||||||
|
// The freq_factors tensor handles partial rotation (1.0 for rotated pairs,
|
||||||
|
// 1e30 for non-rotated), so ropeDims equals the full global head dim.
|
||||||
|
partialRotaryDims := int(c.Uint("rope.dimension_count", 0))
|
||||||
|
if partialRotaryDims == 0 {
|
||||||
|
partialFactor := c.Float("rope.partial_rotary_factor", 1.0)
|
||||||
|
partialRotaryDims = int(float32(globalHeadDim) * partialFactor)
|
||||||
|
}
|
||||||
|
|
||||||
|
ropeBase := c.Float("rope.freq_base", 1000000.0)
|
||||||
|
ropeLocalBase := c.Float("rope.freq_base_swa", 0)
|
||||||
|
if ropeLocalBase == 0 {
|
||||||
|
ropeLocalBase = c.Float("rope.local.freq_base", 10000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
numGlobalKVHeads := int(c.Uint("attention.global_head_count_kv", 0))
|
||||||
|
slidingPattern := c.Bools("attention.sliding_window_pattern")
|
||||||
|
|
||||||
|
// KV heads: try per-layer array first (MoE models), then fall back to scalar
|
||||||
|
numKVHeads := 0
|
||||||
|
kvHeadsArray := c.Ints("attention.head_count_kv")
|
||||||
|
if len(kvHeadsArray) > 0 {
|
||||||
|
numKVHeads = int(kvHeadsArray[0])
|
||||||
|
if numGlobalKVHeads == 0 && len(slidingPattern) > 0 {
|
||||||
|
for i, isLocal := range slidingPattern {
|
||||||
|
if !isLocal && i < len(kvHeadsArray) {
|
||||||
|
numGlobalKVHeads = int(kvHeadsArray[i])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if numKVHeads == 0 {
|
||||||
|
numKVHeads = int(c.Uint("attention.head_count_kv", 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute KV sharing donor map (same logic as MLX)
|
||||||
|
sharedLayers := int(c.Uint("attention.shared_kv_layers", 0))
|
||||||
|
kvDonorMap := make(map[int]int)
|
||||||
|
if sharedLayers > 0 && len(slidingPattern) > 0 {
|
||||||
|
firstShared := numLayers - sharedLayers
|
||||||
|
for i := firstShared; i < numLayers; i++ {
|
||||||
|
isLocal := slidingPattern[i]
|
||||||
|
// Find last non-shared layer of same type
|
||||||
|
for j := firstShared - 1; j >= 0; j-- {
|
||||||
|
if slidingPattern[j] == isLocal {
|
||||||
|
kvDonorMap[i] = j
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TextModel{
|
||||||
|
Layers: make([]TextLayer, numLayers),
|
||||||
|
TextOptions: TextOptions{
|
||||||
|
hiddenSize: int(c.Uint("embedding_length")),
|
||||||
|
numHeads: int(c.Uint("attention.head_count")),
|
||||||
|
numKVHeads: numKVHeads,
|
||||||
|
numGlobalKVHeads: numGlobalKVHeads,
|
||||||
|
headDim: headDim,
|
||||||
|
globalHeadDim: globalHeadDim,
|
||||||
|
hiddenLayers: numLayers,
|
||||||
|
hiddenSizePerLayerInput: int(c.Uint("embedding_length_per_layer_input", 0)),
|
||||||
|
eps: c.Float("attention.layer_norm_rms_epsilon", 1e-06),
|
||||||
|
ropeBase: ropeBase,
|
||||||
|
ropeLocalBase: ropeLocalBase,
|
||||||
|
partialRotaryDims: partialRotaryDims,
|
||||||
|
slidingWindowPattern: slidingPattern,
|
||||||
|
kvDonorMap: kvDonorMap,
|
||||||
|
finalLogitSoftcap: c.Float("final_logit_softcapping", 0.0),
|
||||||
|
numExperts: int(c.Uint("expert_count", 0)),
|
||||||
|
numExpertsUsed: int(c.Uint("expert_used_count", 0)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TextModel) Forward(ctx ml.Context, batch input.Batch, cache kvcache.Cache) ml.Tensor {
|
||||||
|
positions := ctx.Input().FromInts(batch.Positions, len(batch.Positions))
|
||||||
|
|
||||||
|
hiddenState := m.TokenEmbedding.Forward(ctx, batch.Inputs)
|
||||||
|
hiddenState = hiddenState.Scale(ctx, math.Sqrt(float64(m.hiddenSize)))
|
||||||
|
|
||||||
|
// Inject vision embeddings into the hidden state
|
||||||
|
var except []int
|
||||||
|
for _, image := range batch.Multimodal {
|
||||||
|
visionOutputs := image.Multimodal[0].Tensor
|
||||||
|
ctx.Forward(visionOutputs.Copy(ctx, hiddenState.View(ctx, image.Index*hiddenState.Stride(1), visionOutputs.Dim(0)*visionOutputs.Dim(1))))
|
||||||
|
|
||||||
|
for i := range visionOutputs.Dim(1) {
|
||||||
|
except = append(except, image.Index+i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PLE
|
||||||
|
var perLayerInputs ml.Tensor
|
||||||
|
if m.PerLayerProjector != nil {
|
||||||
|
perLayerInputs = m.PerLayerProjector.Forward(ctx, batch, hiddenState, &m.TextOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range len(m.Layers) {
|
||||||
|
layer := m.Layers[i]
|
||||||
|
if cache != nil {
|
||||||
|
cache.SetLayer(i)
|
||||||
|
cacheType := cacheTypeSWA
|
||||||
|
if !m.isLocal(i) {
|
||||||
|
cacheType = cacheTypeCausal
|
||||||
|
}
|
||||||
|
wc := cache.(*kvcache.WrapperCache)
|
||||||
|
wc.SetLayerType(cacheType)
|
||||||
|
|
||||||
|
if causal, ok := wc.UnderlyingCache().(*kvcache.Causal); ok {
|
||||||
|
causal.SetCausal(ctx, kvcache.CausalOptions{Except: except})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastLayerOutputs ml.Tensor
|
||||||
|
if i == len(m.Layers)-1 {
|
||||||
|
lastLayerOutputs = batch.Outputs
|
||||||
|
}
|
||||||
|
|
||||||
|
var perLayerInput ml.Tensor
|
||||||
|
if perLayerInputs != nil {
|
||||||
|
perLayerInput = perLayerInputs.View(ctx, i*perLayerInputs.Stride(1), perLayerInputs.Dim(0), perLayerInputs.Stride(2), perLayerInputs.Dim(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
// KV sharing: layers >= firstShared reuse K/V from donor layers
|
||||||
|
isShared := false
|
||||||
|
if donorLayer, ok := m.kvDonorMap[i]; ok {
|
||||||
|
// Set cache layer to donor so Get() reads donor's K/V
|
||||||
|
cache.SetLayer(donorLayer)
|
||||||
|
isShared = true
|
||||||
|
}
|
||||||
|
hiddenState = layer.Forward(ctx, i, hiddenState, positions, perLayerInput, lastLayerOutputs, cache, isShared, &m.TextOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.OutputNorm.Forward(ctx, hiddenState, m.eps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PerLayerProjector implements PLE.
|
||||||
|
type PerLayerProjector struct {
|
||||||
|
TokenEmbedding *nn.Embedding `gguf:"per_layer_token_embd"`
|
||||||
|
Projector *nn.Linear `gguf:"per_layer_model_proj"`
|
||||||
|
Norm *nn.RMSNorm `gguf:"per_layer_proj_norm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PerLayerProjector) Forward(ctx ml.Context, batch input.Batch, inputs ml.Tensor, opts *TextOptions) ml.Tensor {
|
||||||
|
inputsPerLayer := p.TokenEmbedding.Forward(ctx, batch.Inputs)
|
||||||
|
inputsPerLayer = inputsPerLayer.Scale(ctx, math.Sqrt(float64(opts.hiddenSizePerLayerInput)))
|
||||||
|
// Reshape to [pleDim, numLayers, numTokens] — matching projection shape
|
||||||
|
inputsPerLayer = inputsPerLayer.Reshape(ctx, opts.hiddenSizePerLayerInput, opts.hiddenLayers, inputs.Dim(1))
|
||||||
|
|
||||||
|
perLayerProjection := p.Projector.Forward(ctx, inputs)
|
||||||
|
perLayerProjection = perLayerProjection.Scale(ctx, 1.0/math.Sqrt(float64(opts.hiddenSize)))
|
||||||
|
perLayerProjection = perLayerProjection.Reshape(ctx, opts.hiddenSizePerLayerInput, opts.hiddenLayers, inputs.Dim(1))
|
||||||
|
perLayerProjection = p.Norm.Forward(ctx, perLayerProjection, opts.eps)
|
||||||
|
|
||||||
|
if inputsPerLayer != nil {
|
||||||
|
perLayerProjection = perLayerProjection.Add(ctx, inputsPerLayer)
|
||||||
|
perLayerProjection = perLayerProjection.Scale(ctx, 1/math.Sqrt(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
return perLayerProjection
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextSelfAttention struct {
|
||||||
|
Query *nn.Linear `gguf:"attn_q"`
|
||||||
|
QueryNorm *nn.RMSNorm `gguf:"attn_q_norm"`
|
||||||
|
Key *nn.Linear `gguf:"attn_k"`
|
||||||
|
KeyNorm *nn.RMSNorm `gguf:"attn_k_norm"`
|
||||||
|
Value *nn.Linear `gguf:"attn_v"`
|
||||||
|
Output *nn.Linear `gguf:"attn_output"`
|
||||||
|
RopeFactors ml.Tensor `gguf:"rope_freqs.weight"` // proportional RoPE freq_factors
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sa *TextSelfAttention) Forward(ctx ml.Context, layer int, hiddenState, positions ml.Tensor, cache kvcache.Cache, sharedKV bool, opts *TextOptions) ml.Tensor {
|
||||||
|
batchSize := hiddenState.Dim(1)
|
||||||
|
hd := opts.headDimForLayer(layer)
|
||||||
|
kvHeads := opts.kvHeadsForLayer(layer)
|
||||||
|
ropeBase, ropeDims := opts.ropeForLayer(layer)
|
||||||
|
|
||||||
|
q := sa.Query.Forward(ctx, hiddenState)
|
||||||
|
q = q.Reshape(ctx, hd, opts.numHeads, batchSize)
|
||||||
|
q = sa.QueryNorm.Forward(ctx, q, opts.eps)
|
||||||
|
|
||||||
|
var k, v ml.Tensor
|
||||||
|
if !sharedKV {
|
||||||
|
k = sa.Key.Forward(ctx, hiddenState)
|
||||||
|
k = k.Reshape(ctx, hd, kvHeads, batchSize)
|
||||||
|
|
||||||
|
if sa.Value != nil {
|
||||||
|
v = sa.Value.Forward(ctx, hiddenState)
|
||||||
|
v = v.Reshape(ctx, hd, kvHeads, batchSize)
|
||||||
|
} else {
|
||||||
|
// K=V: use raw K projection (before K norm) as V
|
||||||
|
v = k
|
||||||
|
}
|
||||||
|
|
||||||
|
k = sa.KeyNorm.Forward(ctx, k, opts.eps)
|
||||||
|
v = v.RMSNorm(ctx, nil, opts.eps) // V norm: unweighted RMSNorm
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoPE with proportional freq_factors on global layers
|
||||||
|
ropeOpts := []func(*rope.Options){rope.WithTypeNeoX()}
|
||||||
|
if sa.RopeFactors != nil && !opts.isLocal(layer) {
|
||||||
|
ropeOpts = append(ropeOpts, rope.WithFactors(sa.RopeFactors))
|
||||||
|
}
|
||||||
|
q = nn.RoPE(ctx, q, positions, ropeDims, ropeBase, 1.0, ropeOpts...)
|
||||||
|
if k != nil {
|
||||||
|
k = nn.RoPE(ctx, k, positions, ropeDims, ropeBase, 1.0, ropeOpts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
attention := nn.Attention(ctx, q, k, v, 1.0, cache)
|
||||||
|
|
||||||
|
attention = attention.Reshape(ctx, hd*opts.numHeads, batchSize)
|
||||||
|
return sa.Output.Forward(ctx, attention)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextMLP struct {
|
||||||
|
Gate *nn.Linear `gguf:"ffn_gate"`
|
||||||
|
Up *nn.Linear `gguf:"ffn_up"`
|
||||||
|
Down *nn.Linear `gguf:"ffn_down"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mlp *TextMLP) Forward(ctx ml.Context, hiddenState ml.Tensor) ml.Tensor {
|
||||||
|
hiddenState = mlp.Gate.Forward(ctx, hiddenState).GELU(ctx, mlp.Up.Forward(ctx, hiddenState))
|
||||||
|
return mlp.Down.Forward(ctx, hiddenState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextRouter implements the Gemma 4 MoE router.
|
||||||
|
type TextRouter struct {
|
||||||
|
Proj *nn.Linear `gguf:"ffn_gate_inp"`
|
||||||
|
Scale ml.Tensor `gguf:"ffn_gate_inp.scale"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TextRouter) Forward(ctx ml.Context, hiddenState ml.Tensor, opts *TextOptions) (routingWeights, selectedExperts ml.Tensor) {
|
||||||
|
// RMSNorm without learned weight
|
||||||
|
x := hiddenState.RMSNorm(ctx, nil, opts.eps)
|
||||||
|
// Scale by 1/sqrt(hidden_size)
|
||||||
|
x = x.Scale(ctx, 1.0/math.Sqrt(float64(opts.hiddenSize)))
|
||||||
|
// Multiply by learned scale parameter
|
||||||
|
x = x.Mul(ctx, r.Scale)
|
||||||
|
// Project to expert logits
|
||||||
|
expertScores := r.Proj.Forward(ctx, x)
|
||||||
|
// Softmax over experts
|
||||||
|
routingWeights = expertScores.Softmax(ctx)
|
||||||
|
// TopK expert selection
|
||||||
|
selectedExperts = routingWeights.TopK(ctx, opts.numExpertsUsed)
|
||||||
|
return routingWeights, selectedExperts
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextMoEBlock implements the Gemma 4 sparse MoE.
|
||||||
|
type TextMoEBlock struct {
|
||||||
|
GateUp *nn.LinearBatch `gguf:"ffn_gate_up_exps"`
|
||||||
|
Gate *nn.LinearBatch `gguf:"ffn_gate_exps"`
|
||||||
|
Up *nn.LinearBatch `gguf:"ffn_up_exps"`
|
||||||
|
Down *nn.LinearBatch `gguf:"ffn_down_exps"`
|
||||||
|
DownScale ml.Tensor `gguf:"ffn_down_exps.scale,alt:ffn_gate_inp.per_expert_scale"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (moe *TextMoEBlock) Forward(ctx ml.Context, hiddenState, routingWeights, selectedExperts ml.Tensor, opts *TextOptions) ml.Tensor {
|
||||||
|
// Select routing weights for chosen experts and renormalize
|
||||||
|
routingWeights = routingWeights.Reshape(ctx, 1, opts.numExperts, hiddenState.Dim(1)).Rows(ctx, selectedExperts)
|
||||||
|
routingWeights = routingWeights.Reshape(ctx, opts.numExpertsUsed, hiddenState.Dim(1))
|
||||||
|
routingWeights = routingWeights.Div(ctx, routingWeights.SumRows(ctx))
|
||||||
|
routingWeights = routingWeights.Reshape(ctx, 1, opts.numExpertsUsed, hiddenState.Dim(1))
|
||||||
|
|
||||||
|
hiddenState = hiddenState.Reshape(ctx, hiddenState.Dim(0), 1, hiddenState.Dim(1))
|
||||||
|
|
||||||
|
// Expert computation using LinearBatch (MulmatID selecting experts by index)
|
||||||
|
var gateOut, upOut ml.Tensor
|
||||||
|
if moe.GateUp != nil && moe.GateUp.Weight != nil {
|
||||||
|
gateUp := moe.GateUp.Forward(ctx, hiddenState, selectedExperts)
|
||||||
|
nFF := gateUp.Dim(0) / 2
|
||||||
|
gateOut = gateUp.Slice(ctx, 0, 0, nFF, 1)
|
||||||
|
upOut = gateUp.Slice(ctx, 0, nFF, gateUp.Dim(0), 1)
|
||||||
|
} else {
|
||||||
|
gateOut = moe.Gate.Forward(ctx, hiddenState, selectedExperts)
|
||||||
|
upOut = moe.Up.Forward(ctx, hiddenState, selectedExperts)
|
||||||
|
}
|
||||||
|
hiddenState = gateOut.GELU(ctx, upOut)
|
||||||
|
experts := moe.Down.Forward(ctx, hiddenState, selectedExperts)
|
||||||
|
|
||||||
|
// Apply per-expert down projection scale when present.
|
||||||
|
if moe.DownScale != nil {
|
||||||
|
expertScales := moe.DownScale.Reshape(ctx, opts.numExperts, 1)
|
||||||
|
expertScales = expertScales.Repeat(ctx, 1, hiddenState.Dim(2))
|
||||||
|
expertScales = expertScales.Reshape(ctx, 1, opts.numExperts, hiddenState.Dim(2)).Rows(ctx, selectedExperts)
|
||||||
|
expertScales = expertScales.Reshape(ctx, opts.numExpertsUsed, hiddenState.Dim(2))
|
||||||
|
expertScales = expertScales.Reshape(ctx, 1, opts.numExpertsUsed, hiddenState.Dim(2))
|
||||||
|
experts = experts.Mul(ctx, expertScales)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply routing weights
|
||||||
|
experts = experts.Mul(ctx, routingWeights)
|
||||||
|
|
||||||
|
// Sum across experts
|
||||||
|
nextStates := experts.View(ctx, 0, experts.Dim(0), experts.Stride(2), experts.Dim(2))
|
||||||
|
for i := 1; i < opts.numExpertsUsed; i++ {
|
||||||
|
nextStates = nextStates.Add(ctx, experts.View(ctx, i*experts.Stride(1), experts.Dim(0), experts.Stride(2), experts.Dim(2)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextStates
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextLayer struct {
|
||||||
|
AttentionNorm *nn.RMSNorm `gguf:"attn_norm"`
|
||||||
|
SelfAttention *TextSelfAttention
|
||||||
|
PostAttentionNorm *nn.RMSNorm `gguf:"post_attention_norm,alt:attn_post_norm"`
|
||||||
|
MLPNorm *nn.RMSNorm `gguf:"ffn_norm,alt:ffn_pre_norm"`
|
||||||
|
MLP *TextMLP
|
||||||
|
PostMLPNorm *nn.RMSNorm `gguf:"post_ffw_norm,alt:ffn_post_norm"`
|
||||||
|
|
||||||
|
// MoE (present only for models with enable_moe_block=true)
|
||||||
|
Router *TextRouter
|
||||||
|
MoE *TextMoEBlock
|
||||||
|
MoENorm *nn.RMSNorm `gguf:"pre_ffw_norm_2,alt:ffn_pre_norm_2"`
|
||||||
|
PostMoENorm *nn.RMSNorm `gguf:"post_ffw_norm_2,alt:ffn_post_norm_2"`
|
||||||
|
PostMLPNorm1 *nn.RMSNorm `gguf:"post_ffw_norm_1,alt:ffn_post_norm_1"` // used instead of PostMLPNorm when MoE is present
|
||||||
|
|
||||||
|
PerLayerInputGate *nn.Linear `gguf:"inp_gate"`
|
||||||
|
PerLayerProjection *nn.Linear `gguf:"proj"`
|
||||||
|
PostPerLayerNorm *nn.RMSNorm `gguf:"post_norm"`
|
||||||
|
LayerScalar ml.Tensor `gguf:"layer_scalar,alt:layer_output_scale.weight"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *TextLayer) Forward(ctx ml.Context, layer int, hiddenState, positions, perLayerInput, outputs ml.Tensor, cache kvcache.Cache, sharedKV bool, opts *TextOptions) ml.Tensor {
|
||||||
|
residual := hiddenState
|
||||||
|
|
||||||
|
hiddenState = l.AttentionNorm.Forward(ctx, hiddenState, opts.eps)
|
||||||
|
hiddenState = l.SelfAttention.Forward(ctx, layer, hiddenState, positions, cache, sharedKV, opts)
|
||||||
|
hiddenState = l.PostAttentionNorm.Forward(ctx, hiddenState, opts.eps)
|
||||||
|
|
||||||
|
if outputs != nil {
|
||||||
|
hiddenState = hiddenState.Rows(ctx, outputs)
|
||||||
|
residual = residual.Rows(ctx, outputs)
|
||||||
|
if perLayerInput != nil {
|
||||||
|
perLayerInput = perLayerInput.Rows(ctx, outputs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hiddenState = hiddenState.Add(ctx, residual)
|
||||||
|
residual = hiddenState
|
||||||
|
|
||||||
|
// MLP (+ optional MoE in parallel)
|
||||||
|
hasSplitExperts := l.MoE != nil && l.MoE.Gate != nil && l.MoE.Up != nil && l.MoE.Gate.Weight != nil && l.MoE.Up.Weight != nil
|
||||||
|
hasFusedExperts := l.MoE != nil && l.MoE.GateUp != nil && l.MoE.GateUp.Weight != nil
|
||||||
|
if l.Router != nil && l.MoE != nil && l.MoE.Down != nil && l.MoE.Down.Weight != nil && (hasSplitExperts || hasFusedExperts) {
|
||||||
|
// MoE layers: run MLP and MoE in parallel, sum results
|
||||||
|
mlpState := l.MLPNorm.Forward(ctx, hiddenState, opts.eps)
|
||||||
|
mlpState = l.MLP.Forward(ctx, mlpState)
|
||||||
|
mlpState = l.PostMLPNorm1.Forward(ctx, mlpState, opts.eps)
|
||||||
|
|
||||||
|
routingWeights, selectedExperts := l.Router.Forward(ctx, hiddenState, opts)
|
||||||
|
moeState := l.MoENorm.Forward(ctx, hiddenState, opts.eps)
|
||||||
|
moeState = l.MoE.Forward(ctx, moeState, routingWeights, selectedExperts, opts)
|
||||||
|
moeState = l.PostMoENorm.Forward(ctx, moeState, opts.eps)
|
||||||
|
|
||||||
|
// Combine MLP + MoE, apply outer post-FFN norm, then add residual
|
||||||
|
combined := mlpState.Add(ctx, moeState)
|
||||||
|
combined = l.PostMLPNorm.Forward(ctx, combined, opts.eps)
|
||||||
|
hiddenState = combined.Add(ctx, residual)
|
||||||
|
} else {
|
||||||
|
// Dense layers: MLP only
|
||||||
|
hiddenState = l.MLPNorm.Forward(ctx, hiddenState, opts.eps)
|
||||||
|
hiddenState = l.MLP.Forward(ctx, hiddenState)
|
||||||
|
hiddenState = l.PostMLPNorm.Forward(ctx, hiddenState, opts.eps)
|
||||||
|
hiddenState = hiddenState.Add(ctx, residual)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PLE injection (after MLP residual)
|
||||||
|
if perLayerInput != nil && l.PerLayerInputGate != nil {
|
||||||
|
pleState := l.PerLayerInputGate.Forward(ctx, hiddenState)
|
||||||
|
pleState = pleState.GELU(ctx, perLayerInput)
|
||||||
|
pleState = l.PerLayerProjection.Forward(ctx, pleState)
|
||||||
|
pleState = l.PostPerLayerNorm.Forward(ctx, pleState, opts.eps)
|
||||||
|
hiddenState = hiddenState.Add(ctx, pleState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer scalar applied at end of layer (full-attention layers only)
|
||||||
|
if l.LayerScalar != nil {
|
||||||
|
hiddenState = hiddenState.Mul(ctx, l.LayerScalar)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hiddenState
|
||||||
|
}
|
||||||
392
model/models/gemma4/model_vision.go
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
package gemma4
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/fs"
|
||||||
|
"github.com/ollama/ollama/ml"
|
||||||
|
"github.com/ollama/ollama/ml/nn"
|
||||||
|
"github.com/ollama/ollama/ml/nn/rope"
|
||||||
|
)
|
||||||
|
|
||||||
|
const batchSize = 1
|
||||||
|
|
||||||
|
// ClippableLinear is a linear layer with optional input/output clamping.
|
||||||
|
// Required by Gemma4 vision encoder for numerical stability with F16 weights.
|
||||||
|
type ClippableLinear struct {
|
||||||
|
Weight ml.Tensor `gguf:"weight"`
|
||||||
|
|
||||||
|
InputMin ml.Tensor `gguf:"input_min"`
|
||||||
|
InputMax ml.Tensor `gguf:"input_max"`
|
||||||
|
OutputMin ml.Tensor `gguf:"output_min"`
|
||||||
|
OutputMax ml.Tensor `gguf:"output_max"`
|
||||||
|
|
||||||
|
inMin, inMax, outMin, outMax float32
|
||||||
|
hasClamp bool
|
||||||
|
clampsLoaded bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func scalarValue(t ml.Tensor) (float32, bool) {
|
||||||
|
if t == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
data := t.BackendGet()
|
||||||
|
if len(data) == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return data[0], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ClippableLinear) loadClampFromScalars() {
|
||||||
|
if l.clampsLoaded {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.clampsLoaded = true
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultMin = -math.MaxFloat32
|
||||||
|
defaultMax = math.MaxFloat32
|
||||||
|
)
|
||||||
|
|
||||||
|
inMin, hasInMin := scalarValue(l.InputMin)
|
||||||
|
inMax, hasInMax := scalarValue(l.InputMax)
|
||||||
|
outMin, hasOutMin := scalarValue(l.OutputMin)
|
||||||
|
outMax, hasOutMax := scalarValue(l.OutputMax)
|
||||||
|
|
||||||
|
if !(hasInMin || hasInMax || hasOutMin || hasOutMax) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l.hasClamp = true
|
||||||
|
l.inMin = defaultMin
|
||||||
|
l.inMax = defaultMax
|
||||||
|
l.outMin = defaultMin
|
||||||
|
l.outMax = defaultMax
|
||||||
|
|
||||||
|
if hasInMin {
|
||||||
|
l.inMin = inMin
|
||||||
|
}
|
||||||
|
if hasInMax {
|
||||||
|
l.inMax = inMax
|
||||||
|
}
|
||||||
|
if hasOutMin {
|
||||||
|
l.outMin = outMin
|
||||||
|
}
|
||||||
|
if hasOutMax {
|
||||||
|
l.outMax = outMax
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ClippableLinear) Forward(ctx ml.Context, x ml.Tensor) ml.Tensor {
|
||||||
|
if l.hasClamp {
|
||||||
|
x = x.Clamp(ctx, l.inMin, l.inMax)
|
||||||
|
}
|
||||||
|
out := l.Weight.Mulmat(ctx, x)
|
||||||
|
if l.hasClamp {
|
||||||
|
out = out.Clamp(ctx, l.outMin, l.outMax)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitClamp distributes packed clamp values from v.clamp_data to ClippableLinear structs.
|
||||||
|
// If scalar clamp tensors (input_min/max, output_min/max) are present, they are used too.
|
||||||
|
// Layout: numLayers × 7 linears (q,k,v,out,gate,up,down) × 4 floats (inMin,inMax,outMin,outMax)
|
||||||
|
// then 4 floats for the projector.
|
||||||
|
func (m *VisionModel) InitClamp(proj *MultiModalProjector) {
|
||||||
|
if m.clampInitDone {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.clampInitDone = true
|
||||||
|
|
||||||
|
linears := func(l *VisionEncoderLayer) []*ClippableLinear {
|
||||||
|
return []*ClippableLinear{
|
||||||
|
l.SelfAttention.Query, l.SelfAttention.Key, l.SelfAttention.Value,
|
||||||
|
l.SelfAttention.Output, l.MLP.Gate, l.MLP.Up, l.MLP.Down,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range m.Layers {
|
||||||
|
for _, cl := range linears(&m.Layers[i]) {
|
||||||
|
if cl != nil {
|
||||||
|
cl.loadClampFromScalars()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if proj != nil && proj.Projection != nil {
|
||||||
|
proj.Projection.loadClampFromScalars()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load packed clamp data when present (legacy Ollama format).
|
||||||
|
if m.ClampData == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read all clamp values from packed F32 tensor
|
||||||
|
data := m.ClampData.BackendGet()
|
||||||
|
if len(data) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distribute to layer linears: 7 per layer × 4 values each
|
||||||
|
for i := range m.Layers {
|
||||||
|
for li, cl := range linears(&m.Layers[i]) {
|
||||||
|
if cl == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := (i*7 + li) * 4
|
||||||
|
if idx+3 < len(data) {
|
||||||
|
cl.inMin = data[idx]
|
||||||
|
cl.inMax = data[idx+1]
|
||||||
|
cl.outMin = data[idx+2]
|
||||||
|
cl.outMax = data[idx+3]
|
||||||
|
cl.hasClamp = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projector clamp values (last 4 floats)
|
||||||
|
if proj != nil && proj.Projection != nil {
|
||||||
|
projIdx := len(m.Layers) * 7 * 4
|
||||||
|
if projIdx+3 < len(data) {
|
||||||
|
proj.Projection.inMin = data[projIdx]
|
||||||
|
proj.Projection.inMax = data[projIdx+1]
|
||||||
|
proj.Projection.outMin = data[projIdx+2]
|
||||||
|
proj.Projection.outMax = data[projIdx+3]
|
||||||
|
proj.Projection.hasClamp = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type VisionSelfAttention struct {
|
||||||
|
Query *ClippableLinear `gguf:"attn_q"`
|
||||||
|
Key *ClippableLinear `gguf:"attn_k"`
|
||||||
|
Value *ClippableLinear `gguf:"attn_v"`
|
||||||
|
QueryNorm *nn.RMSNorm `gguf:"attn_q_norm"`
|
||||||
|
KeyNorm *nn.RMSNorm `gguf:"attn_k_norm"`
|
||||||
|
Output *ClippableLinear `gguf:"attn_out"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sa *VisionSelfAttention) Forward(ctx ml.Context, hiddenState, posX, posY, attnMask ml.Tensor, opts *VisionModelOptions) ml.Tensor {
|
||||||
|
numPatches := hiddenState.Dim(1)
|
||||||
|
headDim := opts.hiddenSize / opts.numHeads
|
||||||
|
|
||||||
|
query := sa.Query.Forward(ctx, hiddenState)
|
||||||
|
key := sa.Key.Forward(ctx, hiddenState)
|
||||||
|
value := sa.Value.Forward(ctx, hiddenState)
|
||||||
|
|
||||||
|
query = query.Reshape(ctx, headDim, opts.numHeads, numPatches, batchSize)
|
||||||
|
key = key.Reshape(ctx, headDim, opts.numHeads, numPatches, batchSize)
|
||||||
|
value = value.Reshape(ctx, headDim, opts.numHeads, numPatches, batchSize)
|
||||||
|
|
||||||
|
// Q/K norms (Gemma-style: x * (1 + weight) / rms(x))
|
||||||
|
query = sa.QueryNorm.Forward(ctx, query, opts.eps)
|
||||||
|
key = sa.KeyNorm.Forward(ctx, key, opts.eps)
|
||||||
|
|
||||||
|
// V norm (RMSNorm without learned weights)
|
||||||
|
value = value.RMSNorm(ctx, nil, opts.eps)
|
||||||
|
|
||||||
|
// 2D RoPE: split head dim in half, apply NeoX RoPE with x positions to first half,
|
||||||
|
// y positions to second half, then concatenate.
|
||||||
|
halfDim := headDim / 2
|
||||||
|
ropeOpts := rope.WithTypeNeoX()
|
||||||
|
|
||||||
|
qFirst := query.View(ctx, 0, halfDim, query.Stride(1), opts.numHeads, query.Stride(2), numPatches)
|
||||||
|
qFirst = nn.RoPE(ctx, qFirst, posX, halfDim, opts.ropeTheta, 1.0, ropeOpts)
|
||||||
|
|
||||||
|
kFirst := key.View(ctx, 0, halfDim, key.Stride(1), opts.numHeads, key.Stride(2), numPatches)
|
||||||
|
kFirst = nn.RoPE(ctx, kFirst, posX, halfDim, opts.ropeTheta, 1.0, ropeOpts)
|
||||||
|
|
||||||
|
halfOffset := halfDim * query.Stride(0)
|
||||||
|
qSecond := query.View(ctx, halfOffset, halfDim, query.Stride(1), opts.numHeads, query.Stride(2), numPatches)
|
||||||
|
qSecond = nn.RoPE(ctx, qSecond, posY, halfDim, opts.ropeTheta, 1.0, ropeOpts)
|
||||||
|
|
||||||
|
halfOffsetK := halfDim * key.Stride(0)
|
||||||
|
kSecond := key.View(ctx, halfOffsetK, halfDim, key.Stride(1), opts.numHeads, key.Stride(2), numPatches)
|
||||||
|
kSecond = nn.RoPE(ctx, kSecond, posY, halfDim, opts.ropeTheta, 1.0, ropeOpts)
|
||||||
|
|
||||||
|
query = qFirst.Concat(ctx, qSecond, 0)
|
||||||
|
key = kFirst.Concat(ctx, kSecond, 0)
|
||||||
|
|
||||||
|
// Use flash attention for numerical stability (handles large attention scores
|
||||||
|
// from unclamped RMSNorm weights, e.g. 26B has addOne weights up to 19.5)
|
||||||
|
attention := nn.Attention(ctx, query, key, value, 1.0, nil)
|
||||||
|
attention = attention.Reshape(ctx, opts.hiddenSize, attention.Dim(2), batchSize)
|
||||||
|
|
||||||
|
return sa.Output.Forward(ctx, attention)
|
||||||
|
}
|
||||||
|
|
||||||
|
type VisionMLP struct {
|
||||||
|
Gate *ClippableLinear `gguf:"ffn_gate"`
|
||||||
|
Up *ClippableLinear `gguf:"ffn_up"`
|
||||||
|
Down *ClippableLinear `gguf:"ffn_down"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mlp *VisionMLP) Forward(ctx ml.Context, hiddenState ml.Tensor) ml.Tensor {
|
||||||
|
gate := mlp.Gate.Forward(ctx, hiddenState)
|
||||||
|
up := mlp.Up.Forward(ctx, hiddenState)
|
||||||
|
hiddenState = gate.QuickGELU(ctx, up)
|
||||||
|
return mlp.Down.Forward(ctx, hiddenState)
|
||||||
|
}
|
||||||
|
|
||||||
|
type VisionEncoderLayer struct {
|
||||||
|
AttentionNorm *nn.RMSNorm `gguf:"ln1"`
|
||||||
|
SelfAttention *VisionSelfAttention
|
||||||
|
PostAttentionNorm *nn.RMSNorm `gguf:"attn_post_norm"`
|
||||||
|
|
||||||
|
FFNNorm *nn.RMSNorm `gguf:"ln2"`
|
||||||
|
MLP *VisionMLP
|
||||||
|
PostFFNNorm *nn.RMSNorm `gguf:"ffn_post_norm"`
|
||||||
|
|
||||||
|
LayerOutputScale ml.Tensor `gguf:"out_scale.weight"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *VisionEncoderLayer) Forward(ctx ml.Context, hiddenState, posX, posY, attnMask ml.Tensor, opts *VisionModelOptions) ml.Tensor {
|
||||||
|
residual := hiddenState
|
||||||
|
|
||||||
|
// Pre-attention norm -> self attention -> post-attention norm
|
||||||
|
hiddenState = e.AttentionNorm.Forward(ctx, hiddenState, opts.eps)
|
||||||
|
hiddenState = e.SelfAttention.Forward(ctx, hiddenState, posX, posY, attnMask, opts)
|
||||||
|
hiddenState = e.PostAttentionNorm.Forward(ctx, hiddenState, opts.eps)
|
||||||
|
|
||||||
|
// Residual connection
|
||||||
|
hiddenState = hiddenState.Add(ctx, residual)
|
||||||
|
residual = hiddenState
|
||||||
|
|
||||||
|
// Pre-FFN norm -> FFN -> post-FFN norm
|
||||||
|
hiddenState = e.FFNNorm.Forward(ctx, hiddenState, opts.eps)
|
||||||
|
hiddenState = e.MLP.Forward(ctx, hiddenState)
|
||||||
|
hiddenState = e.PostFFNNorm.Forward(ctx, hiddenState, opts.eps)
|
||||||
|
|
||||||
|
// Residual connection
|
||||||
|
hiddenState = hiddenState.Add(ctx, residual)
|
||||||
|
|
||||||
|
// Per-layer output scale
|
||||||
|
if e.LayerOutputScale != nil {
|
||||||
|
hiddenState = hiddenState.Mul(ctx, e.LayerOutputScale)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hiddenState
|
||||||
|
}
|
||||||
|
|
||||||
|
type VisionModelOptions struct {
|
||||||
|
hiddenSize int
|
||||||
|
numHeads int
|
||||||
|
patchSize int
|
||||||
|
nMerge int
|
||||||
|
eps float32
|
||||||
|
ropeTheta float32
|
||||||
|
}
|
||||||
|
|
||||||
|
type VisionModel struct {
|
||||||
|
PatchEmbedding *nn.Conv2D `gguf:"patch_embd"`
|
||||||
|
PositionEmbedding ml.Tensor `gguf:"position_embd.weight"`
|
||||||
|
ClampData ml.Tensor `gguf:"clamp_data"`
|
||||||
|
StdBias ml.Tensor `gguf:"std_bias"`
|
||||||
|
StdScale ml.Tensor `gguf:"std_scale"`
|
||||||
|
|
||||||
|
Layers []VisionEncoderLayer `gguf:"blk"`
|
||||||
|
|
||||||
|
*VisionModelOptions
|
||||||
|
clampInitDone bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *VisionModel) Forward(ctx ml.Context, pixelValues ml.Tensor, numPatchesX, numPatchesY int) ml.Tensor {
|
||||||
|
numPatches := numPatchesX * numPatchesY
|
||||||
|
|
||||||
|
// Patch embedding via Conv2D
|
||||||
|
hiddenState := m.PatchEmbedding.Forward(ctx, pixelValues, m.patchSize, m.patchSize, 0, 0, 1, 1)
|
||||||
|
hiddenState = hiddenState.Reshape(ctx, numPatches, m.hiddenSize)
|
||||||
|
hiddenState = hiddenState.Permute(ctx, 1, 0, 2, 3).Contiguous(ctx)
|
||||||
|
|
||||||
|
// Conv2D with F16 weights produces F16 output via im2col; cast to F32 for encoder precision
|
||||||
|
hiddenState = hiddenState.Cast(ctx, ml.DTypeF32)
|
||||||
|
|
||||||
|
// 2D positional embeddings from 3D tensor [nEmbd, maxPos, 2]
|
||||||
|
posSize := m.PositionEmbedding.Dim(1)
|
||||||
|
nb1 := m.PositionEmbedding.Stride(1)
|
||||||
|
tblX := m.PositionEmbedding.View(ctx, 0, m.hiddenSize, nb1, posSize)
|
||||||
|
tblY := m.PositionEmbedding.View(ctx, posSize*nb1, m.hiddenSize, nb1, posSize)
|
||||||
|
|
||||||
|
// Position indices for patches
|
||||||
|
posXData := make([]int32, numPatches)
|
||||||
|
posYData := make([]int32, numPatches)
|
||||||
|
for i := range numPatches {
|
||||||
|
posXData[i] = int32(i % numPatchesX)
|
||||||
|
posYData[i] = int32(i / numPatchesX)
|
||||||
|
}
|
||||||
|
|
||||||
|
posXEmb := ctx.Input().FromInts(posXData, numPatches)
|
||||||
|
posYEmb := ctx.Input().FromInts(posYData, numPatches)
|
||||||
|
|
||||||
|
hiddenState = hiddenState.Add(ctx, tblX.Rows(ctx, posXEmb))
|
||||||
|
hiddenState = hiddenState.Add(ctx, tblY.Rows(ctx, posYEmb))
|
||||||
|
|
||||||
|
// No attention mask — all positions are real patches
|
||||||
|
var attnMask ml.Tensor
|
||||||
|
|
||||||
|
// RoPE positions
|
||||||
|
posXRope := ctx.Input().FromInts(posXData, numPatches)
|
||||||
|
posYRope := ctx.Input().FromInts(posYData, numPatches)
|
||||||
|
|
||||||
|
// Vision transformer layers
|
||||||
|
for i := range m.Layers {
|
||||||
|
hiddenState = m.Layers[i].Forward(ctx, hiddenState, posXRope, posYRope, attnMask, m.VisionModelOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hiddenState
|
||||||
|
}
|
||||||
|
|
||||||
|
func newVisionModel(c fs.Config) *VisionModel {
|
||||||
|
return &VisionModel{
|
||||||
|
Layers: make([]VisionEncoderLayer, c.Uint("vision.block_count")),
|
||||||
|
VisionModelOptions: &VisionModelOptions{
|
||||||
|
hiddenSize: int(c.Uint("vision.embedding_length")),
|
||||||
|
numHeads: int(c.Uint("vision.attention.head_count")),
|
||||||
|
patchSize: int(c.Uint("vision.patch_size", 16)),
|
||||||
|
nMerge: int(c.Uint("vision.projector.scale_factor", 3)),
|
||||||
|
eps: c.Float("vision.attention.layer_norm_epsilon", 1e-6),
|
||||||
|
ropeTheta: 100.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func visionTokenCount(imageWidth, imageHeight, patchSize, nMerge int) int {
|
||||||
|
patchesX := imageWidth / patchSize
|
||||||
|
patchesY := imageHeight / patchSize
|
||||||
|
mergedX := patchesX / nMerge
|
||||||
|
mergedY := patchesY / nMerge
|
||||||
|
return mergedX * mergedY
|
||||||
|
}
|
||||||
|
|
||||||
|
func visionPoolAndProject(ctx ml.Context, hiddenState ml.Tensor, numPatchesX, numPatchesY int, opts *VisionModelOptions, proj *MultiModalProjector, stdBias, stdScale ml.Tensor) ml.Tensor {
|
||||||
|
hiddenSize := opts.hiddenSize
|
||||||
|
|
||||||
|
// Reshape from [hiddenSize, numPatches] to spatial layout for pooling
|
||||||
|
hiddenState = hiddenState.Permute(ctx, 1, 0, 2, 3).Contiguous(ctx)
|
||||||
|
hiddenState = hiddenState.Reshape(ctx, numPatchesX, numPatchesY, hiddenSize)
|
||||||
|
|
||||||
|
// AvgPool2D with kernel=stride=nMerge
|
||||||
|
hiddenState = hiddenState.AvgPool2D(ctx, opts.nMerge, opts.nMerge, 0)
|
||||||
|
|
||||||
|
// Reshape back to [hiddenSize, numMergedPatches]
|
||||||
|
mergedX := numPatchesX / opts.nMerge
|
||||||
|
mergedY := numPatchesY / opts.nMerge
|
||||||
|
hiddenState = hiddenState.Reshape(ctx, mergedX*mergedY, hiddenSize)
|
||||||
|
hiddenState = hiddenState.Permute(ctx, 1, 0, 2, 3).Contiguous(ctx)
|
||||||
|
|
||||||
|
hiddenState = hiddenState.Cast(ctx, ml.DTypeF32)
|
||||||
|
hiddenState = hiddenState.Scale(ctx, math.Sqrt(float64(hiddenSize)))
|
||||||
|
|
||||||
|
// Optional vision standardization before projection.
|
||||||
|
if stdBias != nil && stdScale != nil {
|
||||||
|
hiddenState = hiddenState.Sub(ctx, stdBias)
|
||||||
|
hiddenState = hiddenState.Mul(ctx, stdScale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project to text embedding dimension
|
||||||
|
hiddenState = proj.Forward(ctx, hiddenState, opts.eps)
|
||||||
|
|
||||||
|
return hiddenState
|
||||||
|
}
|
||||||
331
model/models/gemma4/process_audio.go
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
package gemma4
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"math/cmplx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Audio preprocessing constants.
|
||||||
|
const (
|
||||||
|
audioSampleRate = 16000
|
||||||
|
melBins = 128
|
||||||
|
frameLengthMs = 20.0
|
||||||
|
hopLengthMs = 10.0
|
||||||
|
minFrequency = 0.0
|
||||||
|
maxFrequency = 8000.0
|
||||||
|
melFloor = 1e-3
|
||||||
|
maxAudioSoftTokens = 750
|
||||||
|
|
||||||
|
// Chunking parameters for long audio.
|
||||||
|
maxChunkSamples = 28 * audioSampleRate // 28s target (headroom below 30s cap)
|
||||||
|
minChunkSamples = 20 * audioSampleRate // don't scan for silence before 20s
|
||||||
|
silenceWindowSize = 800 // 50ms at 16kHz for RMS window
|
||||||
|
)
|
||||||
|
|
||||||
|
// Computed from the above constants.
|
||||||
|
var (
|
||||||
|
frameLength = int(math.Round(audioSampleRate * frameLengthMs / 1000.0)) // 320
|
||||||
|
hopLength = int(math.Round(audioSampleRate * hopLengthMs / 1000.0)) // 160
|
||||||
|
)
|
||||||
|
|
||||||
|
// decodeWAV extracts mono float32 PCM samples from a WAV file, resampled to 16kHz.
|
||||||
|
func decodeWAV(data []byte) ([]float32, error) {
|
||||||
|
if len(data) < 12 {
|
||||||
|
return nil, fmt.Errorf("WAV file too short")
|
||||||
|
}
|
||||||
|
if string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
|
||||||
|
return nil, fmt.Errorf("not a WAV file")
|
||||||
|
}
|
||||||
|
|
||||||
|
var audioFormat uint16
|
||||||
|
var numChannels, sampleRate, bitsPerSample int
|
||||||
|
var audioData []byte
|
||||||
|
foundFmt := false
|
||||||
|
|
||||||
|
offset := 12
|
||||||
|
for offset+8 <= len(data) {
|
||||||
|
chunkID := string(data[offset : offset+4])
|
||||||
|
chunkSize := int(binary.LittleEndian.Uint32(data[offset+4 : offset+8]))
|
||||||
|
chunkData := data[offset+8 : min(offset+8+chunkSize, len(data))]
|
||||||
|
|
||||||
|
switch chunkID {
|
||||||
|
case "fmt ":
|
||||||
|
if len(chunkData) < 16 {
|
||||||
|
return nil, fmt.Errorf("fmt chunk too short")
|
||||||
|
}
|
||||||
|
audioFormat = binary.LittleEndian.Uint16(chunkData[0:2])
|
||||||
|
numChannels = int(binary.LittleEndian.Uint16(chunkData[2:4]))
|
||||||
|
sampleRate = int(binary.LittleEndian.Uint32(chunkData[4:8]))
|
||||||
|
bitsPerSample = int(binary.LittleEndian.Uint16(chunkData[14:16]))
|
||||||
|
if audioFormat == 0xFFFE && len(chunkData) >= 26 {
|
||||||
|
audioFormat = binary.LittleEndian.Uint16(chunkData[24:26])
|
||||||
|
}
|
||||||
|
foundFmt = true
|
||||||
|
case "data":
|
||||||
|
audioData = chunkData
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += 8 + chunkSize
|
||||||
|
if chunkSize%2 != 0 {
|
||||||
|
offset++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundFmt {
|
||||||
|
return nil, fmt.Errorf("no fmt chunk found in WAV file")
|
||||||
|
}
|
||||||
|
if audioFormat != 1 && audioFormat != 3 {
|
||||||
|
return nil, fmt.Errorf("unsupported WAV format: %d (need PCM=1 or float=3)", audioFormat)
|
||||||
|
}
|
||||||
|
if audioData == nil {
|
||||||
|
return nil, fmt.Errorf("no data chunk found in WAV file")
|
||||||
|
}
|
||||||
|
|
||||||
|
samples := decodeWAVSamples(audioData, audioFormat, bitsPerSample, numChannels)
|
||||||
|
if sampleRate != audioSampleRate {
|
||||||
|
samples = resampleLinear(samples, sampleRate, audioSampleRate)
|
||||||
|
}
|
||||||
|
return samples, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeWAVSamples(data []byte, format uint16, bits, channels int) []float32 {
|
||||||
|
bytesPerSample := bits / 8
|
||||||
|
totalSamples := len(data) / (bytesPerSample * channels)
|
||||||
|
mono := make([]float32, totalSamples)
|
||||||
|
|
||||||
|
for i := range totalSamples {
|
||||||
|
var sum float64
|
||||||
|
for ch := range channels {
|
||||||
|
off := (i*channels + ch) * bytesPerSample
|
||||||
|
if off+bytesPerSample > len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case format == 1 && bits == 16:
|
||||||
|
v := int16(binary.LittleEndian.Uint16(data[off : off+2]))
|
||||||
|
sum += float64(v) / 32768.0
|
||||||
|
case format == 1 && bits == 32:
|
||||||
|
v := int32(binary.LittleEndian.Uint32(data[off : off+4]))
|
||||||
|
sum += float64(v) / 2147483648.0
|
||||||
|
case format == 1 && bits == 24:
|
||||||
|
v := int32(data[off]) | int32(data[off+1])<<8 | int32(data[off+2])<<16
|
||||||
|
if v&0x800000 != 0 {
|
||||||
|
v |= ^0xFFFFFF
|
||||||
|
}
|
||||||
|
sum += float64(v) / 8388608.0
|
||||||
|
case format == 3 && bits == 32:
|
||||||
|
v := math.Float32frombits(binary.LittleEndian.Uint32(data[off : off+4]))
|
||||||
|
sum += float64(v)
|
||||||
|
case format == 1 && bits == 8:
|
||||||
|
sum += (float64(data[off]) - 128.0) / 128.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mono[i] = float32(sum / float64(channels))
|
||||||
|
}
|
||||||
|
return mono
|
||||||
|
}
|
||||||
|
|
||||||
|
func resampleLinear(samples []float32, fromRate, toRate int) []float32 {
|
||||||
|
n := int(float64(len(samples)) / float64(fromRate) * float64(toRate))
|
||||||
|
out := make([]float32, n)
|
||||||
|
for i := range n {
|
||||||
|
pos := float64(i) * float64(len(samples)-1) / float64(n-1)
|
||||||
|
idx := int(pos)
|
||||||
|
frac := float32(pos - float64(idx))
|
||||||
|
if idx+1 < len(samples) {
|
||||||
|
out[i] = samples[idx]*(1-frac) + samples[idx+1]*frac
|
||||||
|
} else {
|
||||||
|
out[i] = samples[idx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeMelSpectrogram computes the log mel spectrogram from PCM samples.
|
||||||
|
// Returns shape [numFrames, melBins] as float32 slice, and numFrames.
|
||||||
|
func computeMelSpectrogram(samples []float32) ([]float32, int) {
|
||||||
|
fftLen := 1
|
||||||
|
for fftLen < frameLength {
|
||||||
|
fftLen <<= 1
|
||||||
|
}
|
||||||
|
fftLen *= 2 // fft_overdrive=True
|
||||||
|
|
||||||
|
// Hanning-nonzero window.
|
||||||
|
window := make([]float64, frameLength)
|
||||||
|
arg := math.Pi * 2.0 / float64(frameLength)
|
||||||
|
for i := range frameLength {
|
||||||
|
window[i] = 0.5 - 0.5*math.Cos(arg*(float64(i)+0.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
numFreqBins := fftLen/2 + 1
|
||||||
|
melFilters := buildMelFilterBank(numFreqBins, melBins, minFrequency, maxFrequency, audioSampleRate)
|
||||||
|
|
||||||
|
frameSizeForUnfold := frameLength + 1
|
||||||
|
numFrames := (len(samples) - frameSizeForUnfold) / hopLength
|
||||||
|
if numFrames <= 0 {
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]float32, numFrames*melBins)
|
||||||
|
fftInput := make([]complex128, fftLen)
|
||||||
|
|
||||||
|
for f := range numFrames {
|
||||||
|
start := f * hopLength
|
||||||
|
for i := range frameLength {
|
||||||
|
fftInput[i] = complex(float64(samples[start+i])*window[i], 0)
|
||||||
|
}
|
||||||
|
for i := frameLength; i < fftLen; i++ {
|
||||||
|
fftInput[i] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fft(fftInput)
|
||||||
|
|
||||||
|
for m := range melBins {
|
||||||
|
var melVal float64
|
||||||
|
for k := range numFreqBins {
|
||||||
|
mag := cmplx.Abs(fftInput[k])
|
||||||
|
melVal += mag * float64(melFilters[k*melBins+m])
|
||||||
|
}
|
||||||
|
if melVal < melFloor {
|
||||||
|
melVal = melFloor
|
||||||
|
}
|
||||||
|
result[f*melBins+m] = float32(math.Log(melVal))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, numFrames
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMelFilterBank(numFreqBins, numMels int, fMin, fMax float64, sr int) []float32 {
|
||||||
|
hzToMel := func(f float64) float64 {
|
||||||
|
return 2595.0 * math.Log10(1.0+f/700.0)
|
||||||
|
}
|
||||||
|
melToHz := func(m float64) float64 {
|
||||||
|
return 700.0 * (math.Pow(10.0, m/2595.0) - 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
melMin := hzToMel(fMin)
|
||||||
|
melMax := hzToMel(fMax)
|
||||||
|
|
||||||
|
melPts := make([]float64, numMels+2)
|
||||||
|
for i := range melPts {
|
||||||
|
melPts[i] = melMin + float64(i)*(melMax-melMin)/float64(numMels+1)
|
||||||
|
}
|
||||||
|
filterFreqs := make([]float64, numMels+2)
|
||||||
|
for i, m := range melPts {
|
||||||
|
filterFreqs[i] = melToHz(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
fftFreqs := make([]float64, numFreqBins)
|
||||||
|
for i := range fftFreqs {
|
||||||
|
fftFreqs[i] = float64(i) * float64(sr) / float64(2*(numFreqBins-1))
|
||||||
|
}
|
||||||
|
|
||||||
|
filters := make([]float32, numFreqBins*numMels)
|
||||||
|
for m := range numMels {
|
||||||
|
fLeft := filterFreqs[m]
|
||||||
|
fCenter := filterFreqs[m+1]
|
||||||
|
fRight := filterFreqs[m+2]
|
||||||
|
for k := range numFreqBins {
|
||||||
|
f := fftFreqs[k]
|
||||||
|
var v float64
|
||||||
|
if f >= fLeft && f <= fCenter && fCenter > fLeft {
|
||||||
|
v = (f - fLeft) / (fCenter - fLeft)
|
||||||
|
} else if f > fCenter && f <= fRight && fRight > fCenter {
|
||||||
|
v = (fRight - f) / (fRight - fCenter)
|
||||||
|
}
|
||||||
|
if v > 0 {
|
||||||
|
filters[k*numMels+m] = float32(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filters
|
||||||
|
}
|
||||||
|
|
||||||
|
// fft performs an in-place Cooley-Tukey radix-2 FFT.
|
||||||
|
func fft(x []complex128) {
|
||||||
|
n := len(x)
|
||||||
|
if n <= 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
j := 0
|
||||||
|
for i := 1; i < n; i++ {
|
||||||
|
bit := n >> 1
|
||||||
|
for j&bit != 0 {
|
||||||
|
j ^= bit
|
||||||
|
bit >>= 1
|
||||||
|
}
|
||||||
|
j ^= bit
|
||||||
|
if i < j {
|
||||||
|
x[i], x[j] = x[j], x[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for size := 2; size <= n; size <<= 1 {
|
||||||
|
halfSize := size / 2
|
||||||
|
w := complex(math.Cos(2*math.Pi/float64(size)), -math.Sin(2*math.Pi/float64(size)))
|
||||||
|
for start := 0; start < n; start += size {
|
||||||
|
wn := complex(1, 0)
|
||||||
|
for k := range halfSize {
|
||||||
|
t := wn * x[start+k+halfSize]
|
||||||
|
x[start+k+halfSize] = x[start+k] - t
|
||||||
|
x[start+k] = x[start+k] + t
|
||||||
|
wn *= w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitAudioChunks splits PCM samples into chunks of at most maxChunkSamples,
|
||||||
|
// preferring to split at low-energy (silence) regions for natural boundaries.
|
||||||
|
func splitAudioChunks(samples []float32) [][]float32 {
|
||||||
|
if len(samples) <= maxChunkSamples {
|
||||||
|
return [][]float32{samples}
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks [][]float32
|
||||||
|
offset := 0
|
||||||
|
for offset < len(samples) {
|
||||||
|
remaining := len(samples) - offset
|
||||||
|
if remaining <= maxChunkSamples {
|
||||||
|
chunks = append(chunks, samples[offset:])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
splitAt := offset + maxChunkSamples
|
||||||
|
bestEnergy := float64(math.MaxFloat64)
|
||||||
|
|
||||||
|
scanStart := offset + maxChunkSamples - silenceWindowSize
|
||||||
|
scanEnd := offset + minChunkSamples
|
||||||
|
for pos := scanStart; pos >= scanEnd; pos -= silenceWindowSize / 2 {
|
||||||
|
end := pos + silenceWindowSize
|
||||||
|
if end > len(samples) {
|
||||||
|
end = len(samples)
|
||||||
|
}
|
||||||
|
var sumSq float64
|
||||||
|
for _, s := range samples[pos:end] {
|
||||||
|
sumSq += float64(s) * float64(s)
|
||||||
|
}
|
||||||
|
rms := math.Sqrt(sumSq / float64(end-pos))
|
||||||
|
if rms < bestEnergy {
|
||||||
|
bestEnergy = rms
|
||||||
|
splitAt = pos + silenceWindowSize/2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks = append(chunks, samples[offset:splitAt])
|
||||||
|
offset = splitAt
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("Audio chunked", "chunks", len(chunks), "total_samples", len(samples))
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAudioData checks if the data starts with WAV magic bytes.
|
||||||
|
func isAudioData(data []byte) bool {
|
||||||
|
return len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WAVE"
|
||||||
|
}
|
||||||
103
model/models/gemma4/process_image.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package gemma4
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"golang.org/x/image/draw"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ImageProcessor struct {
|
||||||
|
patchSize int
|
||||||
|
numChannels int
|
||||||
|
nMerge int
|
||||||
|
minPixels int
|
||||||
|
maxPixels int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newImageProcessor(c fs.Config) ImageProcessor {
|
||||||
|
patchSize := int(c.Uint("vision.patch_size", 16))
|
||||||
|
nMerge := int(c.Uint("vision.projector.scale_factor", 3))
|
||||||
|
numChannels := int(c.Uint("vision.num_channels", 3))
|
||||||
|
|
||||||
|
// Token limits from reference: min=40, max=280 output tokens after pooling.
|
||||||
|
// Convert to pixel counts: tokens * nMerge^2 * patchSize^2
|
||||||
|
minTokens := 40
|
||||||
|
maxTokens := 280
|
||||||
|
patchArea := patchSize * patchSize * nMerge * nMerge
|
||||||
|
minPixels := minTokens * patchArea
|
||||||
|
maxPixels := maxTokens * patchArea
|
||||||
|
|
||||||
|
return ImageProcessor{
|
||||||
|
patchSize: patchSize,
|
||||||
|
numChannels: numChannels,
|
||||||
|
nMerge: nMerge,
|
||||||
|
minPixels: minPixels,
|
||||||
|
maxPixels: maxPixels,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessImage resizes an image preserving aspect ratio, aligning dimensions
|
||||||
|
// to (patchSize * nMerge) boundaries, and normalizes pixels to [-1, 1].
|
||||||
|
// Returns the float32 pixel data and the actual output dimensions.
|
||||||
|
func (p *ImageProcessor) ProcessImage(img image.Image) ([]float32, int, int, error) {
|
||||||
|
// Compute target size preserving aspect ratio
|
||||||
|
alignSize := p.patchSize * p.nMerge
|
||||||
|
targetW, targetH := p.smartResize(img.Bounds().Dx(), img.Bounds().Dy(), alignSize)
|
||||||
|
|
||||||
|
// Resize directly without alpha compositing, matching MLX reference.
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, targetW, targetH))
|
||||||
|
draw.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil)
|
||||||
|
|
||||||
|
// Normalize to [-1, 1] using mean=0.5, std=0.5: (pixel/255 - 0.5) / 0.5 = 2*pixel/255 - 1
|
||||||
|
data := p.pack(dst)
|
||||||
|
return data, targetW, targetH, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// smartResize computes target dimensions that preserve aspect ratio and
|
||||||
|
// align to alignSize boundaries. It scales the image to fill the maximum
|
||||||
|
// patch budget (maxPixels), matching the MLX reference.
|
||||||
|
func (p *ImageProcessor) smartResize(origW, origH, alignSize int) (int, int) {
|
||||||
|
totalPx := origW * origH
|
||||||
|
|
||||||
|
var targetW, targetH int
|
||||||
|
if p.maxPixels > 0 && totalPx > 0 {
|
||||||
|
factor := math.Sqrt(float64(p.maxPixels) / float64(totalPx))
|
||||||
|
targetH = max(alignSize, int(math.Floor(factor*float64(origH)/float64(alignSize)))*alignSize)
|
||||||
|
targetW = max(alignSize, int(math.Floor(factor*float64(origW)/float64(alignSize)))*alignSize)
|
||||||
|
} else {
|
||||||
|
targetH = max(alignSize, (origH/alignSize)*alignSize)
|
||||||
|
targetW = max(alignSize, (origW/alignSize)*alignSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetW, targetH
|
||||||
|
}
|
||||||
|
|
||||||
|
// pack extracts RGB values from an image and normalizes to [-1, 1].
|
||||||
|
// Returns channel-first layout: [R..., G..., B...].
|
||||||
|
func (p *ImageProcessor) pack(img image.Image) []float32 {
|
||||||
|
bounds := img.Bounds()
|
||||||
|
w := bounds.Dx()
|
||||||
|
h := bounds.Dy()
|
||||||
|
size := w * h
|
||||||
|
|
||||||
|
pixelVals := make([]float32, 3*size)
|
||||||
|
rOff, gOff, bOff := 0, size, 2*size
|
||||||
|
|
||||||
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||||
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||||
|
c := img.At(x, y)
|
||||||
|
r, g, b, _ := c.RGBA()
|
||||||
|
idx := (y-bounds.Min.Y)*w + (x - bounds.Min.X)
|
||||||
|
|
||||||
|
// Normalize [0, 255] -> [-1, 1]: 2 * (val/255) - 1
|
||||||
|
pixelVals[rOff+idx] = float32(r>>8)/255.0*2.0 - 1.0
|
||||||
|
pixelVals[gOff+idx] = float32(g>>8)/255.0*2.0 - 1.0
|
||||||
|
pixelVals[bOff+idx] = float32(b>>8)/255.0*2.0 - 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pixelVals
|
||||||
|
}
|
||||||
102
model/models/gemma4/tokenizer_compare_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package gemma4
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/ml"
|
||||||
|
"github.com/ollama/ollama/model"
|
||||||
|
"github.com/ollama/ollama/tokenizer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTokenizerMatchesHF compares our tokenizer output against HuggingFace reference tokens.
|
||||||
|
func TestTokenizerMatchesHF(t *testing.T) {
|
||||||
|
modelPath := os.Getenv("GEMMA4_MODEL_PATH")
|
||||||
|
if modelPath == "" {
|
||||||
|
t.Skip("set GEMMA4_MODEL_PATH to a gemma4 GGUF file")
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := model.New(modelPath, ml.BackendParams{AllocMemory: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to load model: %v", err)
|
||||||
|
}
|
||||||
|
defer m.Backend().Close()
|
||||||
|
|
||||||
|
tok := m.(tokenizer.Tokenizer)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected []int32
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple",
|
||||||
|
input: "Hello, world!",
|
||||||
|
expected: []int32{9259, 236764, 1902, 236888},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "special_tokens",
|
||||||
|
input: "<|turn>user\nWhat is 2+2?<turn|>\n<|turn>model\n",
|
||||||
|
expected: []int32{105, 2364, 107, 3689, 563, 236743, 236778, 236862, 236778, 236881, 106, 107, 105, 4368, 107},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tool_declaration",
|
||||||
|
input: "<|tool>declaration:bash{description:<|\"|>Run a command<|\"|>}<tool|>",
|
||||||
|
expected: []int32{46, 163688, 236787, 42422, 236782, 7777, 236787, 52, 7306, 496, 4991, 52, 236783, 47},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tool_call",
|
||||||
|
input: "<|tool_call>call:bash{command:<|\"|>ls -la<|\"|>}<tool_call|>",
|
||||||
|
expected: []int32{48, 6639, 236787, 42422, 236782, 7674, 236787, 52, 5629, 753, 2149, 52, 236783, 49},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "thinking",
|
||||||
|
input: "<|channel>thought\nLet me think about this...<channel|>The answer is 42.",
|
||||||
|
expected: []int32{100, 45518, 107, 6481, 786, 1751, 1003, 672, 1390, 101, 818, 3890, 563, 236743, 236812, 236778, 236761},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "code",
|
||||||
|
input: "func main() { fmt.Println(\"hello\") }",
|
||||||
|
expected: []int32{6823, 1689, 825, 642, 22766, 236761, 29006, 885, 23391, 1373, 682},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "numbers",
|
||||||
|
input: "The answer is 42, not 43.5 or -1",
|
||||||
|
expected: []int32{818, 3890, 563, 236743, 236812, 236778, 236764, 711, 236743, 236812, 236800, 236761, 236810, 653, 753, 236770},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed_chat_with_tools",
|
||||||
|
input: "<|turn>system\nYou are a helpful assistant.\n<|tool>declaration:get_weather{description:<|\"|>Get weather<|\"|>,parameters:{properties:{city:{type:<|\"|>STRING<|\"|>}},type:<|\"|>OBJECT<|\"|>}}<tool|><turn|>\n<|turn>user\nWhat's the weather in Paris?<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
|
||||||
|
expected: []int32{105, 9731, 107, 3048, 659, 496, 11045, 16326, 236761, 107, 46, 163688, 236787, 828, 236779, 19323, 236782, 7777, 236787, 52, 3407, 7606, 52, 236764, 19031, 29616, 15921, 29616, 13319, 29616, 2084, 236787, 52, 35410, 52, 5237, 2084, 236787, 52, 60688, 52, 1807, 47, 106, 107, 105, 2364, 107, 3689, 236789, 236751, 506, 7606, 528, 9079, 236881, 106, 107, 105, 4368, 107, 100, 45518, 107, 101},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tokens, err := tok.Encode(tt.input, false) // no BOS
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tokens) != len(tt.expected) {
|
||||||
|
t.Errorf("token count mismatch: got %d, want %d", len(tokens), len(tt.expected))
|
||||||
|
t.Logf("got: %v", tokens)
|
||||||
|
t.Logf("want: %v", tt.expected)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mismatches := 0
|
||||||
|
for i := range tokens {
|
||||||
|
if tokens[i] != tt.expected[i] {
|
||||||
|
mismatches++
|
||||||
|
if mismatches <= 5 {
|
||||||
|
t.Errorf("mismatch at [%d]: got %d, want %d", i, tokens[i], tt.expected[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mismatches > 5 {
|
||||||
|
t.Errorf("... and %d more mismatches", mismatches-5)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
_ "github.com/ollama/ollama/model/models/gemma2"
|
_ "github.com/ollama/ollama/model/models/gemma2"
|
||||||
_ "github.com/ollama/ollama/model/models/gemma3"
|
_ "github.com/ollama/ollama/model/models/gemma3"
|
||||||
_ "github.com/ollama/ollama/model/models/gemma3n"
|
_ "github.com/ollama/ollama/model/models/gemma3n"
|
||||||
|
_ "github.com/ollama/ollama/model/models/gemma4"
|
||||||
_ "github.com/ollama/ollama/model/models/glm4moelite"
|
_ "github.com/ollama/ollama/model/models/glm4moelite"
|
||||||
_ "github.com/ollama/ollama/model/models/glmocr"
|
_ "github.com/ollama/ollama/model/models/glmocr"
|
||||||
_ "github.com/ollama/ollama/model/models/gptoss"
|
_ "github.com/ollama/ollama/model/models/gptoss"
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ type Masks struct {
|
|||||||
// GatedDeltaNet implements linear attention with SSM convolution and recurrent state.
|
// GatedDeltaNet implements linear attention with SSM convolution and recurrent state.
|
||||||
// It implements the Operator interface directly.
|
// It implements the Operator interface directly.
|
||||||
type GatedDeltaNet struct {
|
type GatedDeltaNet struct {
|
||||||
// Optimized path: pre-split QKV and gate
|
|
||||||
SSMQKV *nn.Linear `gguf:"attn_qkv"` // -> Q, K, V (concatenated)
|
SSMQKV *nn.Linear `gguf:"attn_qkv"` // -> Q, K, V (concatenated)
|
||||||
SSMQKVGate *nn.Linear `gguf:"attn_gate"` // -> Z gate
|
SSMQKVGate *nn.Linear `gguf:"attn_gate"` // -> Z gate
|
||||||
|
SSMIn *nn.Linear `gguf:"ssm_in"`
|
||||||
SSMBetaAlpha *nn.Linear `gguf:"ssm_ba"` // -> beta, alpha (legacy qwen3next)
|
SSMBetaAlpha *nn.Linear `gguf:"ssm_ba"` // -> beta, alpha (legacy qwen3next)
|
||||||
SSMBeta *nn.Linear `gguf:"ssm_beta"` // -> beta (qwen35)
|
SSMBeta *nn.Linear `gguf:"ssm_beta"` // -> beta (qwen35)
|
||||||
SSMAlpha *nn.Linear `gguf:"ssm_alpha"` // -> alpha (qwen35)
|
SSMAlpha *nn.Linear `gguf:"ssm_alpha"` // -> alpha (qwen35)
|
||||||
@@ -100,12 +100,27 @@ func (gdn *GatedDeltaNet) Forward(ctx ml.Context, hiddenStates, _ ml.Tensor, cac
|
|||||||
|
|
||||||
qkvDim := headKDim*numKHeads*2 + headVDim*numVHeads
|
qkvDim := headKDim*numKHeads*2 + headVDim*numVHeads
|
||||||
|
|
||||||
if gdn.SSMQKV == nil || gdn.SSMQKVGate == nil {
|
// Support both current split projections and older qwen3-next imports that use ssm_in.
|
||||||
return nil, errors.New("qwen3next: missing attn_qkv/attn_gate projections (legacy ssm_in is not supported)")
|
var qkvMixed, z ml.Tensor
|
||||||
|
switch {
|
||||||
|
case gdn.SSMQKV != nil && gdn.SSMQKVGate != nil:
|
||||||
|
qkvMixed = gdn.SSMQKV.Forward(ctx, hiddenStates).Reshape(ctx, qkvDim, nSeqTokens, nSeqs)
|
||||||
|
z = gdn.SSMQKVGate.Forward(ctx, hiddenStates)
|
||||||
|
case gdn.SSMIn != nil:
|
||||||
|
vPerHead := headVDim * numVHeads / numKHeads
|
||||||
|
qkvzDim := 2*headKDim + 2*vPerHead
|
||||||
|
|
||||||
|
combined := gdn.SSMIn.Forward(ctx, hiddenStates).Reshape(ctx, qkvzDim, numKHeads, nSeqTokens, nSeqs)
|
||||||
|
qPart := combined.Slice(ctx, 0, 0, headKDim, 1).Contiguous(ctx, headKDim*numKHeads, nSeqTokens, nSeqs)
|
||||||
|
kPart := combined.Slice(ctx, 0, headKDim, 2*headKDim, 1).Contiguous(ctx, headKDim*numKHeads, nSeqTokens, nSeqs)
|
||||||
|
vPart := combined.Slice(ctx, 0, 2*headKDim, 2*headKDim+vPerHead, 1).Contiguous(ctx, headVDim*numVHeads, nSeqTokens, nSeqs)
|
||||||
|
zPart := combined.Slice(ctx, 0, 2*headKDim+vPerHead, qkvzDim, 1).Contiguous(ctx, headVDim*numVHeads, nSeqTokens, nSeqs)
|
||||||
|
|
||||||
|
qkvMixed = qPart.Concat(ctx, kPart, 0).Concat(ctx, vPart, 0)
|
||||||
|
z = zPart
|
||||||
|
default:
|
||||||
|
return nil, errors.New("qwen3next: missing attn_qkv/attn_gate or ssm_in projections")
|
||||||
}
|
}
|
||||||
// Optimized path: pre-split QKV and gate
|
|
||||||
qkvMixed := gdn.SSMQKV.Forward(ctx, hiddenStates).Reshape(ctx, qkvDim, nSeqTokens, nSeqs)
|
|
||||||
z := gdn.SSMQKVGate.Forward(ctx, hiddenStates)
|
|
||||||
|
|
||||||
var beta ml.Tensor
|
var beta ml.Tensor
|
||||||
var alpha ml.Tensor
|
var alpha ml.Tensor
|
||||||
|
|||||||
@@ -454,7 +454,7 @@ func (m *Model) Validate() error {
|
|||||||
if !ok || gdn == nil {
|
if !ok || gdn == nil {
|
||||||
return fmt.Errorf("qwen3next: layer %d expected recurrent operator", i)
|
return fmt.Errorf("qwen3next: layer %d expected recurrent operator", i)
|
||||||
}
|
}
|
||||||
if gdn.SSMQKV == nil || gdn.SSMQKVGate == nil {
|
if gdn.SSMIn == nil && (gdn.SSMQKV == nil || gdn.SSMQKVGate == nil) {
|
||||||
return fmt.Errorf("qwen3next: layer %d missing attn_qkv/attn_gate projections", i)
|
return fmt.Errorf("qwen3next: layer %d missing attn_qkv/attn_gate projections", i)
|
||||||
}
|
}
|
||||||
if gdn.SSMBetaAlpha == nil && (gdn.SSMBeta == nil || gdn.SSMAlpha == nil) {
|
if gdn.SSMBetaAlpha == nil && (gdn.SSMBeta == nil || gdn.SSMAlpha == nil) {
|
||||||
|
|||||||