Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -424,6 +424,7 @@ jobs:
|
||||
lib/ollama/cuda_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/include*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
|
||||
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
|
||||
lib/ollama/rocm) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
|
||||
|
||||
@@ -64,6 +64,7 @@ jobs:
|
||||
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
|
||||
extra-packages: libcudnn9-dev-cuda-13 libopenblas-dev liblapack-dev liblapacke-dev git curl
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=87 -DBLAS_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu -DLAPACK_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu'
|
||||
install-go: true
|
||||
runs-on: linux
|
||||
container: ${{ matrix.container }}
|
||||
steps:
|
||||
@@ -90,6 +91,12 @@ jobs:
|
||||
fi
|
||||
env:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
- if: matrix.install-go
|
||||
name: Install Go
|
||||
run: |
|
||||
GO_VERSION=$(awk '/^go / { print $2 }' go.mod)
|
||||
curl -fsSL "https://golang.org/dl/go${GO_VERSION}.linux-$(dpkg --print-architecture).tar.gz" | tar xz -C /usr/local
|
||||
echo "/usr/local/go/bin" >> $GITHUB_PATH
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: /github/home/.cache/ccache
|
||||
|
||||
@@ -246,13 +246,21 @@ if(MLX_ENGINE)
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
|
||||
# Install CCCL headers for NVRTC JIT compilation at runtime.
|
||||
# Install headers for NVRTC JIT compilation at runtime.
|
||||
# MLX's own install rules use the default component so they get skipped by
|
||||
# --component MLX. Headers are installed alongside libmlx in OLLAMA_INSTALL_DIR.
|
||||
#
|
||||
# Layout:
|
||||
# ${OLLAMA_INSTALL_DIR}/include/cccl/{cuda,nv}/ — CCCL headers
|
||||
# ${OLLAMA_INSTALL_DIR}/include/*.h — CUDA toolkit headers
|
||||
#
|
||||
# MLX's jit_module.cpp resolves CCCL via
|
||||
# current_binary_dir()[.parent_path()] / "include" / "cccl"
|
||||
# On Linux, MLX's jit_module.cpp resolves CCCL via
|
||||
# current_binary_dir().parent_path() / "include" / "cccl", so we create a
|
||||
# symlink from lib/ollama/include -> ${OLLAMA_RUNNER_DIR}/include
|
||||
# This will need refinement if we add multiple CUDA versions for MLX in the future.
|
||||
# current_binary_dir().parent_path() / "include" / "cccl", so we create a
|
||||
# symlink from lib/ollama/include -> ${OLLAMA_RUNNER_DIR}/include
|
||||
# This will need refinement if we add multiple CUDA versions for MLX in the future.
|
||||
# CUDA runtime headers are found via CUDA_PATH env var (set by mlxrunner).
|
||||
if(EXISTS ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda)
|
||||
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
|
||||
@@ -271,6 +279,61 @@ if(MLX_ENGINE)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# Install minimal CUDA toolkit headers needed by MLX JIT kernels.
|
||||
# These are the transitive closure of includes from mlx/backend/cuda/device/*.cuh.
|
||||
# The Go mlxrunner sets CUDA_PATH to OLLAMA_INSTALL_DIR so MLX finds them at
|
||||
# $CUDA_PATH/include/*.h via NVRTC --include-path.
|
||||
if(CUDAToolkit_FOUND)
|
||||
# CUDAToolkit_INCLUDE_DIRS may be a semicolon-separated list
|
||||
# (e.g. ".../include;.../include/cccl"). Find the entry that
|
||||
# contains the CUDA runtime headers we need.
|
||||
set(_cuda_inc "")
|
||||
foreach(_dir ${CUDAToolkit_INCLUDE_DIRS})
|
||||
if(EXISTS "${_dir}/cuda_runtime_api.h")
|
||||
set(_cuda_inc "${_dir}")
|
||||
break()
|
||||
endif()
|
||||
endforeach()
|
||||
if(NOT _cuda_inc)
|
||||
message(WARNING "Could not find cuda_runtime_api.h in CUDAToolkit_INCLUDE_DIRS: ${CUDAToolkit_INCLUDE_DIRS}")
|
||||
else()
|
||||
set(_dst "${OLLAMA_INSTALL_DIR}/include")
|
||||
set(_MLX_JIT_CUDA_HEADERS
|
||||
builtin_types.h
|
||||
cooperative_groups.h
|
||||
cuda_bf16.h
|
||||
cuda_bf16.hpp
|
||||
cuda_device_runtime_api.h
|
||||
cuda_fp16.h
|
||||
cuda_fp16.hpp
|
||||
cuda_fp8.h
|
||||
cuda_fp8.hpp
|
||||
cuda_runtime_api.h
|
||||
device_types.h
|
||||
driver_types.h
|
||||
math_constants.h
|
||||
surface_types.h
|
||||
texture_types.h
|
||||
vector_functions.h
|
||||
vector_functions.hpp
|
||||
vector_types.h
|
||||
)
|
||||
foreach(_hdr ${_MLX_JIT_CUDA_HEADERS})
|
||||
install(FILES "${_cuda_inc}/${_hdr}"
|
||||
DESTINATION ${_dst}
|
||||
COMPONENT MLX)
|
||||
endforeach()
|
||||
# Subdirectory headers
|
||||
install(DIRECTORY "${_cuda_inc}/cooperative_groups"
|
||||
DESTINATION ${_dst}
|
||||
COMPONENT MLX
|
||||
FILES_MATCHING PATTERN "*.h")
|
||||
install(FILES "${_cuda_inc}/crt/host_defines.h"
|
||||
DESTINATION "${_dst}/crt"
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# On Windows, explicitly install dl.dll (dlfcn-win32 POSIX dlopen emulation)
|
||||
# RUNTIME_DEPENDENCIES auto-excludes it via POST_EXCLUDE_FILES_STRICT because
|
||||
# dlfcn-win32 is a known CMake target with its own install rules (which install
|
||||
|
||||
@@ -68,7 +68,7 @@ type MessagesRequest struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Messages []MessageParam `json:"messages"`
|
||||
System any `json:"system,omitempty"` // string or []ContentBlock
|
||||
System any `json:"system,omitempty"` // string or []map[string]any (JSON-decoded ContentBlock)
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
@@ -82,8 +82,27 @@ type MessagesRequest struct {
|
||||
|
||||
// MessageParam represents a message in the request
|
||||
type MessageParam struct {
|
||||
Role string `json:"role"` // "user" or "assistant"
|
||||
Content any `json:"content"` // string or []ContentBlock
|
||||
Role string `json:"role"` // "user" or "assistant"
|
||||
Content []ContentBlock `json:"content"` // always []ContentBlock; plain strings are normalized on unmarshal
|
||||
}
|
||||
|
||||
func (m *MessageParam) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
m.Role = raw.Role
|
||||
|
||||
var s string
|
||||
if err := json.Unmarshal(raw.Content, &s); err == nil {
|
||||
m.Content = []ContentBlock{{Type: "text", Text: &s}}
|
||||
return nil
|
||||
}
|
||||
|
||||
return json.Unmarshal(raw.Content, &m.Content)
|
||||
}
|
||||
|
||||
// ContentBlock represents a content block in a message.
|
||||
@@ -102,9 +121,9 @@ type ContentBlock struct {
|
||||
Source *ImageSource `json:"source,omitempty"`
|
||||
|
||||
// For tool_use and server_tool_use blocks
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input any `json:"input,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input api.ToolCallFunctionArguments `json:"input,omitzero"`
|
||||
|
||||
// For tool_result and web_search_tool_result blocks
|
||||
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||
@@ -377,178 +396,145 @@ func convertMessage(msg MessageParam) ([]api.Message, error) {
|
||||
var messages []api.Message
|
||||
role := strings.ToLower(msg.Role)
|
||||
|
||||
switch content := msg.Content.(type) {
|
||||
case string:
|
||||
messages = append(messages, api.Message{Role: role, Content: content})
|
||||
var textContent strings.Builder
|
||||
var images []api.ImageData
|
||||
var toolCalls []api.ToolCall
|
||||
var thinking string
|
||||
var toolResults []api.Message
|
||||
textBlocks := 0
|
||||
imageBlocks := 0
|
||||
toolUseBlocks := 0
|
||||
toolResultBlocks := 0
|
||||
serverToolUseBlocks := 0
|
||||
webSearchToolResultBlocks := 0
|
||||
thinkingBlocks := 0
|
||||
unknownBlocks := 0
|
||||
|
||||
case []any:
|
||||
var textContent strings.Builder
|
||||
var images []api.ImageData
|
||||
var toolCalls []api.ToolCall
|
||||
var thinking string
|
||||
var toolResults []api.Message
|
||||
textBlocks := 0
|
||||
imageBlocks := 0
|
||||
toolUseBlocks := 0
|
||||
toolResultBlocks := 0
|
||||
serverToolUseBlocks := 0
|
||||
webSearchToolResultBlocks := 0
|
||||
thinkingBlocks := 0
|
||||
unknownBlocks := 0
|
||||
|
||||
for _, block := range content {
|
||||
blockMap, ok := block.(map[string]any)
|
||||
if !ok {
|
||||
logutil.Trace("anthropic: invalid content block format", "role", role)
|
||||
return nil, errors.New("invalid content block format")
|
||||
for _, block := range msg.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
textBlocks++
|
||||
if block.Text != nil {
|
||||
textContent.WriteString(*block.Text)
|
||||
}
|
||||
|
||||
blockType, _ := blockMap["type"].(string)
|
||||
case "image":
|
||||
imageBlocks++
|
||||
if block.Source == nil {
|
||||
logutil.Trace("anthropic: invalid image source", "role", role)
|
||||
return nil, errors.New("invalid image source")
|
||||
}
|
||||
|
||||
switch blockType {
|
||||
case "text":
|
||||
textBlocks++
|
||||
if text, ok := blockMap["text"].(string); ok {
|
||||
textContent.WriteString(text)
|
||||
if block.Source.Type == "base64" {
|
||||
decoded, err := base64.StdEncoding.DecodeString(block.Source.Data)
|
||||
if err != nil {
|
||||
logutil.Trace("anthropic: invalid base64 image data", "role", role, "error", err)
|
||||
return nil, fmt.Errorf("invalid base64 image data: %w", err)
|
||||
}
|
||||
images = append(images, decoded)
|
||||
} else {
|
||||
logutil.Trace("anthropic: unsupported image source type", "role", role, "source_type", block.Source.Type)
|
||||
return nil, fmt.Errorf("invalid image source type: %s. Only base64 images are supported.", block.Source.Type)
|
||||
}
|
||||
|
||||
case "image":
|
||||
imageBlocks++
|
||||
source, ok := blockMap["source"].(map[string]any)
|
||||
if !ok {
|
||||
logutil.Trace("anthropic: invalid image source", "role", role)
|
||||
return nil, errors.New("invalid image source")
|
||||
}
|
||||
case "tool_use":
|
||||
toolUseBlocks++
|
||||
if block.ID == "" {
|
||||
logutil.Trace("anthropic: tool_use block missing id", "role", role)
|
||||
return nil, errors.New("tool_use block missing required 'id' field")
|
||||
}
|
||||
if block.Name == "" {
|
||||
logutil.Trace("anthropic: tool_use block missing name", "role", role)
|
||||
return nil, errors.New("tool_use block missing required 'name' field")
|
||||
}
|
||||
toolCalls = append(toolCalls, api.ToolCall{
|
||||
ID: block.ID,
|
||||
Function: api.ToolCallFunction{
|
||||
Name: block.Name,
|
||||
Arguments: block.Input,
|
||||
},
|
||||
})
|
||||
|
||||
sourceType, _ := source["type"].(string)
|
||||
if sourceType == "base64" {
|
||||
data, _ := source["data"].(string)
|
||||
decoded, err := base64.StdEncoding.DecodeString(data)
|
||||
if err != nil {
|
||||
logutil.Trace("anthropic: invalid base64 image data", "role", role, "error", err)
|
||||
return nil, fmt.Errorf("invalid base64 image data: %w", err)
|
||||
}
|
||||
images = append(images, decoded)
|
||||
} else {
|
||||
logutil.Trace("anthropic: unsupported image source type", "role", role, "source_type", sourceType)
|
||||
return nil, fmt.Errorf("invalid image source type: %s. Only base64 images are supported.", sourceType)
|
||||
}
|
||||
// URL images would need to be fetched - skip for now
|
||||
case "tool_result":
|
||||
toolResultBlocks++
|
||||
var resultContent string
|
||||
|
||||
case "tool_use":
|
||||
toolUseBlocks++
|
||||
id, ok := blockMap["id"].(string)
|
||||
if !ok {
|
||||
logutil.Trace("anthropic: tool_use block missing id", "role", role)
|
||||
return nil, errors.New("tool_use block missing required 'id' field")
|
||||
}
|
||||
name, ok := blockMap["name"].(string)
|
||||
if !ok {
|
||||
logutil.Trace("anthropic: tool_use block missing name", "role", role)
|
||||
return nil, errors.New("tool_use block missing required 'name' field")
|
||||
}
|
||||
tc := api.ToolCall{
|
||||
ID: id,
|
||||
Function: api.ToolCallFunction{
|
||||
Name: name,
|
||||
},
|
||||
}
|
||||
if input, ok := blockMap["input"].(map[string]any); ok {
|
||||
tc.Function.Arguments = mapToArgs(input)
|
||||
}
|
||||
toolCalls = append(toolCalls, tc)
|
||||
|
||||
case "tool_result":
|
||||
toolResultBlocks++
|
||||
toolUseID, _ := blockMap["tool_use_id"].(string)
|
||||
var resultContent string
|
||||
|
||||
switch c := blockMap["content"].(type) {
|
||||
case string:
|
||||
resultContent = c
|
||||
case []any:
|
||||
for _, cb := range c {
|
||||
if cbMap, ok := cb.(map[string]any); ok {
|
||||
if cbMap["type"] == "text" {
|
||||
if text, ok := cbMap["text"].(string); ok {
|
||||
resultContent += text
|
||||
}
|
||||
switch c := block.Content.(type) {
|
||||
case string:
|
||||
resultContent = c
|
||||
case []any:
|
||||
for _, cb := range c {
|
||||
if cbMap, ok := cb.(map[string]any); ok {
|
||||
if cbMap["type"] == "text" {
|
||||
if text, ok := cbMap["text"].(string); ok {
|
||||
resultContent += text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toolResults = append(toolResults, api.Message{
|
||||
Role: "tool",
|
||||
Content: resultContent,
|
||||
ToolCallID: toolUseID,
|
||||
})
|
||||
|
||||
case "thinking":
|
||||
thinkingBlocks++
|
||||
if t, ok := blockMap["thinking"].(string); ok {
|
||||
thinking = t
|
||||
}
|
||||
|
||||
case "server_tool_use":
|
||||
serverToolUseBlocks++
|
||||
id, _ := blockMap["id"].(string)
|
||||
name, _ := blockMap["name"].(string)
|
||||
tc := api.ToolCall{
|
||||
ID: id,
|
||||
Function: api.ToolCallFunction{
|
||||
Name: name,
|
||||
},
|
||||
}
|
||||
if input, ok := blockMap["input"].(map[string]any); ok {
|
||||
tc.Function.Arguments = mapToArgs(input)
|
||||
}
|
||||
toolCalls = append(toolCalls, tc)
|
||||
|
||||
case "web_search_tool_result":
|
||||
webSearchToolResultBlocks++
|
||||
toolUseID, _ := blockMap["tool_use_id"].(string)
|
||||
toolResults = append(toolResults, api.Message{
|
||||
Role: "tool",
|
||||
Content: formatWebSearchToolResultContent(blockMap["content"]),
|
||||
ToolCallID: toolUseID,
|
||||
})
|
||||
default:
|
||||
unknownBlocks++
|
||||
}
|
||||
}
|
||||
|
||||
if textContent.Len() > 0 || len(images) > 0 || len(toolCalls) > 0 || thinking != "" {
|
||||
m := api.Message{
|
||||
Role: role,
|
||||
Content: textContent.String(),
|
||||
Images: images,
|
||||
ToolCalls: toolCalls,
|
||||
Thinking: thinking,
|
||||
toolResults = append(toolResults, api.Message{
|
||||
Role: "tool",
|
||||
Content: resultContent,
|
||||
ToolCallID: block.ToolUseID,
|
||||
})
|
||||
|
||||
case "thinking":
|
||||
thinkingBlocks++
|
||||
if block.Thinking != nil {
|
||||
thinking = *block.Thinking
|
||||
}
|
||||
messages = append(messages, m)
|
||||
|
||||
case "server_tool_use":
|
||||
serverToolUseBlocks++
|
||||
toolCalls = append(toolCalls, api.ToolCall{
|
||||
ID: block.ID,
|
||||
Function: api.ToolCallFunction{
|
||||
Name: block.Name,
|
||||
Arguments: block.Input,
|
||||
},
|
||||
})
|
||||
|
||||
case "web_search_tool_result":
|
||||
webSearchToolResultBlocks++
|
||||
toolResults = append(toolResults, api.Message{
|
||||
Role: "tool",
|
||||
Content: formatWebSearchToolResultContent(block.Content),
|
||||
ToolCallID: block.ToolUseID,
|
||||
})
|
||||
default:
|
||||
unknownBlocks++
|
||||
}
|
||||
|
||||
// Add tool results as separate messages
|
||||
messages = append(messages, toolResults...)
|
||||
logutil.Trace("anthropic: converted block message",
|
||||
"role", role,
|
||||
"blocks", len(content),
|
||||
"text", textBlocks,
|
||||
"image", imageBlocks,
|
||||
"tool_use", toolUseBlocks,
|
||||
"tool_result", toolResultBlocks,
|
||||
"server_tool_use", serverToolUseBlocks,
|
||||
"web_search_result", webSearchToolResultBlocks,
|
||||
"thinking", thinkingBlocks,
|
||||
"unknown", unknownBlocks,
|
||||
"messages", TraceAPIMessages(messages),
|
||||
)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid message content type: %T", content)
|
||||
}
|
||||
|
||||
if textContent.Len() > 0 || len(images) > 0 || len(toolCalls) > 0 || thinking != "" {
|
||||
m := api.Message{
|
||||
Role: role,
|
||||
Content: textContent.String(),
|
||||
Images: images,
|
||||
ToolCalls: toolCalls,
|
||||
Thinking: thinking,
|
||||
}
|
||||
messages = append(messages, m)
|
||||
}
|
||||
|
||||
// Add tool results as separate messages
|
||||
messages = append(messages, toolResults...)
|
||||
logutil.Trace("anthropic: converted block message",
|
||||
"role", role,
|
||||
"blocks", len(msg.Content),
|
||||
"text", textBlocks,
|
||||
"image", imageBlocks,
|
||||
"tool_use", toolUseBlocks,
|
||||
"tool_result", toolResultBlocks,
|
||||
"server_tool_use", serverToolUseBlocks,
|
||||
"web_search_result", webSearchToolResultBlocks,
|
||||
"thinking", thinkingBlocks,
|
||||
"unknown", unknownBlocks,
|
||||
"messages", TraceAPIMessages(messages),
|
||||
)
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
@@ -882,7 +868,6 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
||||
slog.Error("failed to marshal tool arguments", "error", err, "tool_id", tc.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
events = append(events, StreamEvent{
|
||||
Event: "content_block_start",
|
||||
Data: ContentBlockStartEvent{
|
||||
@@ -892,7 +877,7 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
||||
Type: "tool_use",
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Input: map[string]any{},
|
||||
Input: api.NewToolCallFunctionArguments(),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -989,15 +974,6 @@ func ptr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// mapToArgs converts a map to ToolCallFunctionArguments
|
||||
func mapToArgs(m map[string]any) api.ToolCallFunctionArguments {
|
||||
args := api.NewToolCallFunctionArguments()
|
||||
for k, v := range m {
|
||||
args.Set(k, v)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// CountTokensRequest represents an Anthropic count_tokens request
|
||||
type CountTokensRequest struct {
|
||||
Model string `json:"model"`
|
||||
@@ -1030,17 +1006,13 @@ func estimateTokens(req CountTokensRequest) int {
|
||||
var totalLen int
|
||||
|
||||
// Count system prompt
|
||||
if req.System != nil {
|
||||
totalLen += countAnyContent(req.System)
|
||||
}
|
||||
totalLen += countAnyContent(req.System)
|
||||
|
||||
// Count messages
|
||||
for _, msg := range req.Messages {
|
||||
// Count role (always present)
|
||||
totalLen += len(msg.Role)
|
||||
// Count content
|
||||
contentLen := countAnyContent(msg.Content)
|
||||
totalLen += contentLen
|
||||
totalLen += countAnyContent(msg.Content)
|
||||
}
|
||||
|
||||
for _, tool := range req.Tools {
|
||||
@@ -1063,12 +1035,25 @@ func countAnyContent(content any) int {
|
||||
switch c := content.(type) {
|
||||
case string:
|
||||
return len(c)
|
||||
case []any:
|
||||
case []ContentBlock:
|
||||
total := 0
|
||||
for _, block := range c {
|
||||
total += countContentBlock(block)
|
||||
}
|
||||
return total
|
||||
case []any:
|
||||
total := 0
|
||||
for _, item := range c {
|
||||
data, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var block ContentBlock
|
||||
if err := json.Unmarshal(data, &block); err == nil {
|
||||
total += countContentBlock(block)
|
||||
}
|
||||
}
|
||||
return total
|
||||
default:
|
||||
if data, err := json.Marshal(content); err == nil {
|
||||
return len(data)
|
||||
@@ -1077,38 +1062,19 @@ func countAnyContent(content any) int {
|
||||
}
|
||||
}
|
||||
|
||||
func countContentBlock(block any) int {
|
||||
blockMap, ok := block.(map[string]any)
|
||||
if !ok {
|
||||
if s, ok := block.(string); ok {
|
||||
return len(s)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func countContentBlock(block ContentBlock) int {
|
||||
total := 0
|
||||
blockType, _ := blockMap["type"].(string)
|
||||
|
||||
if text, ok := blockMap["text"].(string); ok {
|
||||
total += len(text)
|
||||
if block.Text != nil {
|
||||
total += len(*block.Text)
|
||||
}
|
||||
|
||||
if thinking, ok := blockMap["thinking"].(string); ok {
|
||||
total += len(thinking)
|
||||
if block.Thinking != nil {
|
||||
total += len(*block.Thinking)
|
||||
}
|
||||
|
||||
if blockType == "tool_use" {
|
||||
if data, err := json.Marshal(blockMap); err == nil {
|
||||
if block.Type == "tool_use" || block.Type == "tool_result" {
|
||||
if data, err := json.Marshal(block); err == nil {
|
||||
total += len(data)
|
||||
}
|
||||
}
|
||||
|
||||
if blockType == "tool_result" {
|
||||
if data, err := json.Marshal(blockMap); err == nil {
|
||||
total += len(data)
|
||||
}
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,16 @@ const (
|
||||
testImage = `iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=`
|
||||
)
|
||||
|
||||
// testArgs creates ToolCallFunctionArguments from a map (convenience function for tests)
|
||||
func testArgs(m map[string]any) api.ToolCallFunctionArguments {
|
||||
// textContent is a convenience for constructing []ContentBlock with a single text block in tests.
|
||||
func textContent(s string) []ContentBlock {
|
||||
return []ContentBlock{{Type: "text", Text: &s}}
|
||||
}
|
||||
|
||||
// makeArgs creates ToolCallFunctionArguments from key-value pairs (convenience function for tests)
|
||||
func makeArgs(kvs ...any) api.ToolCallFunctionArguments {
|
||||
args := api.NewToolCallFunctionArguments()
|
||||
for k, v := range m {
|
||||
args.Set(k, v)
|
||||
for i := 0; i < len(kvs)-1; i += 2 {
|
||||
args.Set(kvs[i].(string), kvs[i+1])
|
||||
}
|
||||
return args
|
||||
}
|
||||
@@ -29,7 +34,7 @@ func TestFromMessagesRequest_Basic(t *testing.T) {
|
||||
Model: "test-model",
|
||||
MaxTokens: 1024,
|
||||
Messages: []MessageParam{
|
||||
{Role: "user", Content: "Hello"},
|
||||
{Role: "user", Content: textContent("Hello")},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -61,7 +66,7 @@ func TestFromMessagesRequest_WithSystemPrompt(t *testing.T) {
|
||||
MaxTokens: 1024,
|
||||
System: "You are a helpful assistant.",
|
||||
Messages: []MessageParam{
|
||||
{Role: "user", Content: "Hello"},
|
||||
{Role: "user", Content: textContent("Hello")},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -88,7 +93,7 @@ func TestFromMessagesRequest_WithSystemPromptArray(t *testing.T) {
|
||||
map[string]any{"type": "text", "text": " Be concise."},
|
||||
},
|
||||
Messages: []MessageParam{
|
||||
{Role: "user", Content: "Hello"},
|
||||
{Role: "user", Content: textContent("Hello")},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -113,7 +118,7 @@ func TestFromMessagesRequest_WithOptions(t *testing.T) {
|
||||
req := MessagesRequest{
|
||||
Model: "test-model",
|
||||
MaxTokens: 2048,
|
||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
||||
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||
Temperature: &temp,
|
||||
TopP: &topP,
|
||||
TopK: &topK,
|
||||
@@ -148,14 +153,14 @@ func TestFromMessagesRequest_WithImage(t *testing.T) {
|
||||
Messages: []MessageParam{
|
||||
{
|
||||
Role: "user",
|
||||
Content: []any{
|
||||
map[string]any{"type": "text", "text": "What's in this image?"},
|
||||
map[string]any{
|
||||
"type": "image",
|
||||
"source": map[string]any{
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": testImage,
|
||||
Content: []ContentBlock{
|
||||
{Type: "text", Text: ptr("What's in this image?")},
|
||||
{
|
||||
Type: "image",
|
||||
Source: &ImageSource{
|
||||
Type: "base64",
|
||||
MediaType: "image/png",
|
||||
Data: testImage,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -190,15 +195,15 @@ func TestFromMessagesRequest_WithToolUse(t *testing.T) {
|
||||
Model: "test-model",
|
||||
MaxTokens: 1024,
|
||||
Messages: []MessageParam{
|
||||
{Role: "user", Content: "What's the weather in Paris?"},
|
||||
{Role: "user", Content: textContent("What's the weather in Paris?")},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "tool_use",
|
||||
"id": "call_123",
|
||||
"name": "get_weather",
|
||||
"input": map[string]any{"location": "Paris"},
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "tool_use",
|
||||
ID: "call_123",
|
||||
Name: "get_weather",
|
||||
Input: makeArgs("location", "Paris"),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -234,11 +239,11 @@ func TestFromMessagesRequest_WithToolResult(t *testing.T) {
|
||||
Messages: []MessageParam{
|
||||
{
|
||||
Role: "user",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "call_123",
|
||||
"content": "The weather in Paris is sunny, 22°C",
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "tool_result",
|
||||
ToolUseID: "call_123",
|
||||
Content: "The weather in Paris is sunny, 22°C",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -270,7 +275,7 @@ func TestFromMessagesRequest_WithTools(t *testing.T) {
|
||||
req := MessagesRequest{
|
||||
Model: "test-model",
|
||||
MaxTokens: 1024,
|
||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
||||
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||
Tools: []Tool{
|
||||
{
|
||||
Name: "get_weather",
|
||||
@@ -305,7 +310,7 @@ func TestFromMessagesRequest_DropsCustomWebSearchWhenBuiltinPresent(t *testing.T
|
||||
req := MessagesRequest{
|
||||
Model: "test-model",
|
||||
MaxTokens: 1024,
|
||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
||||
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||
Tools: []Tool{
|
||||
{
|
||||
Type: "web_search_20250305",
|
||||
@@ -346,7 +351,7 @@ func TestFromMessagesRequest_KeepsCustomWebSearchWhenBuiltinAbsent(t *testing.T)
|
||||
req := MessagesRequest{
|
||||
Model: "test-model",
|
||||
MaxTokens: 1024,
|
||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
||||
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||
Tools: []Tool{
|
||||
{
|
||||
Type: "custom",
|
||||
@@ -377,7 +382,7 @@ func TestFromMessagesRequest_WithThinking(t *testing.T) {
|
||||
req := MessagesRequest{
|
||||
Model: "test-model",
|
||||
MaxTokens: 1024,
|
||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
||||
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||
Thinking: &ThinkingConfig{Type: "enabled", BudgetTokens: 1000},
|
||||
}
|
||||
|
||||
@@ -399,13 +404,13 @@ func TestFromMessagesRequest_ThinkingOnlyBlock(t *testing.T) {
|
||||
Model: "test-model",
|
||||
MaxTokens: 1024,
|
||||
Messages: []MessageParam{
|
||||
{Role: "user", Content: "Hello"},
|
||||
{Role: "user", Content: textContent("Hello")},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "thinking",
|
||||
"thinking": "Let me think about this...",
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "thinking",
|
||||
Thinking: ptr("Let me think about this..."),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -434,10 +439,10 @@ func TestFromMessagesRequest_ToolUseMissingID(t *testing.T) {
|
||||
Messages: []MessageParam{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "tool_use",
|
||||
"name": "get_weather",
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "tool_use",
|
||||
Name: "get_weather",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -460,10 +465,10 @@ func TestFromMessagesRequest_ToolUseMissingName(t *testing.T) {
|
||||
Messages: []MessageParam{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "tool_use",
|
||||
"id": "call_123",
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "tool_use",
|
||||
ID: "call_123",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -483,7 +488,7 @@ func TestFromMessagesRequest_InvalidToolSchema(t *testing.T) {
|
||||
req := MessagesRequest{
|
||||
Model: "test-model",
|
||||
MaxTokens: 1024,
|
||||
Messages: []MessageParam{{Role: "user", Content: "Hello"}},
|
||||
Messages: []MessageParam{{Role: "user", Content: textContent("Hello")}},
|
||||
Tools: []Tool{
|
||||
{
|
||||
Name: "bad_tool",
|
||||
@@ -548,7 +553,7 @@ func TestToMessagesResponse_WithToolCalls(t *testing.T) {
|
||||
ID: "call_123",
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: testArgs(map[string]any{"location": "Paris"}),
|
||||
Arguments: makeArgs("location", "Paris"),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -760,7 +765,7 @@ func TestStreamConverter_WithToolCalls(t *testing.T) {
|
||||
ID: "call_123",
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: testArgs(map[string]any{"location": "Paris"}),
|
||||
Arguments: makeArgs("location", "Paris"),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -843,7 +848,7 @@ func TestStreamConverter_ThinkingDirectlyFollowedByToolCall(t *testing.T) {
|
||||
ID: "call_abc",
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "ask_user",
|
||||
Arguments: testArgs(map[string]any{"question": "cats or dogs?"}),
|
||||
Arguments: makeArgs("question", "cats or dogs?"),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -965,7 +970,7 @@ func TestStreamConverter_MultipleToolCallsWithMixedValidity(t *testing.T) {
|
||||
ID: "call_good",
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "good_function",
|
||||
Arguments: testArgs(map[string]any{"location": "Paris"}),
|
||||
Arguments: makeArgs("location", "Paris"),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1067,6 +1072,57 @@ func TestContentBlockJSON_EmptyFieldsPresent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentBlockJSON_NonToolBlocksDoNotIncludeInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
block ContentBlock
|
||||
}{
|
||||
{
|
||||
name: "text block",
|
||||
block: ContentBlock{
|
||||
Type: "text",
|
||||
Text: ptr("hello"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "thinking block",
|
||||
block: ContentBlock{
|
||||
Type: "thinking",
|
||||
Thinking: ptr("let me think"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "image block",
|
||||
block: ContentBlock{
|
||||
Type: "image",
|
||||
Source: &ImageSource{
|
||||
Type: "base64",
|
||||
MediaType: "image/png",
|
||||
Data: testImage,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
data, err := json.Marshal(tt.block)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := result["input"]; ok {
|
||||
t.Fatalf("unexpected input field in non-tool block JSON: %s", string(data))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamConverter_ContentBlockStartIncludesEmptyFields(t *testing.T) {
|
||||
t.Run("text block start includes empty text", func(t *testing.T) {
|
||||
conv := NewStreamConverter("msg_123", "test-model", 0)
|
||||
@@ -1087,7 +1143,9 @@ func TestStreamConverter_ContentBlockStartIncludesEmptyFields(t *testing.T) {
|
||||
// Marshal and verify the text field is present
|
||||
data, _ := json.Marshal(start)
|
||||
var result map[string]any
|
||||
json.Unmarshal(data, &result)
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
t.Fatalf("failed to unmarshal content_block_start JSON: %v", err)
|
||||
}
|
||||
cb := result["content_block"].(map[string]any)
|
||||
if _, ok := cb["text"]; !ok {
|
||||
t.Error("content_block_start for text should include 'text' field")
|
||||
@@ -1134,13 +1192,71 @@ func TestStreamConverter_ContentBlockStartIncludesEmptyFields(t *testing.T) {
|
||||
t.Error("expected thinking content_block_start event")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tool_use block start includes empty input object", func(t *testing.T) {
|
||||
conv := NewStreamConverter("msg_123", "test-model", 0)
|
||||
|
||||
resp := api.ChatResponse{
|
||||
Model: "test-model",
|
||||
Message: api.Message{
|
||||
Role: "assistant",
|
||||
ToolCalls: []api.ToolCall{
|
||||
{
|
||||
ID: "call_123",
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: makeArgs("location", "Paris"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
events := conv.Process(resp)
|
||||
|
||||
var foundToolStart bool
|
||||
for _, e := range events {
|
||||
if e.Event == "content_block_start" {
|
||||
if start, ok := e.Data.(ContentBlockStartEvent); ok {
|
||||
if start.ContentBlock.Type == "tool_use" {
|
||||
foundToolStart = true
|
||||
if start.ContentBlock.Input.Len() != 0 {
|
||||
t.Errorf("expected empty input object, got len=%d", start.ContentBlock.Input.Len())
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(start)
|
||||
var result map[string]any
|
||||
json.Unmarshal(data, &result)
|
||||
cb := result["content_block"].(map[string]any)
|
||||
input, ok := cb["input"]
|
||||
if !ok {
|
||||
t.Error("content_block_start for tool_use should include 'input' field")
|
||||
continue
|
||||
}
|
||||
inputMap, ok := input.(map[string]any)
|
||||
if !ok {
|
||||
t.Errorf("input field should be an object, got %T", input)
|
||||
continue
|
||||
}
|
||||
if len(inputMap) != 0 {
|
||||
t.Errorf("expected empty input object in content_block_start, got %v", inputMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundToolStart {
|
||||
t.Error("expected tool_use content_block_start event")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEstimateTokens_SimpleMessage(t *testing.T) {
|
||||
req := CountTokensRequest{
|
||||
Model: "test-model",
|
||||
Messages: []MessageParam{
|
||||
{Role: "user", Content: "Hello, world!"},
|
||||
{Role: "user", Content: textContent("Hello, world!")},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1161,7 +1277,7 @@ func TestEstimateTokens_WithSystemPrompt(t *testing.T) {
|
||||
Model: "test-model",
|
||||
System: "You are a helpful assistant.",
|
||||
Messages: []MessageParam{
|
||||
{Role: "user", Content: "Hello"},
|
||||
{Role: "user", Content: textContent("Hello")},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1177,7 +1293,7 @@ func TestEstimateTokens_WithTools(t *testing.T) {
|
||||
req := CountTokensRequest{
|
||||
Model: "test-model",
|
||||
Messages: []MessageParam{
|
||||
{Role: "user", Content: "What's the weather?"},
|
||||
{Role: "user", Content: textContent("What's the weather?")},
|
||||
},
|
||||
Tools: []Tool{
|
||||
{
|
||||
@@ -1200,17 +1316,17 @@ func TestEstimateTokens_WithThinking(t *testing.T) {
|
||||
req := CountTokensRequest{
|
||||
Model: "test-model",
|
||||
Messages: []MessageParam{
|
||||
{Role: "user", Content: "Hello"},
|
||||
{Role: "user", Content: textContent("Hello")},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "thinking",
|
||||
"thinking": "Let me think about this carefully...",
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "thinking",
|
||||
Thinking: ptr("Let me think about this carefully..."),
|
||||
},
|
||||
map[string]any{
|
||||
"type": "text",
|
||||
"text": "Here is my response.",
|
||||
{
|
||||
Type: "text",
|
||||
Text: ptr("Here is my response."),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1308,12 +1424,12 @@ func TestConvertTool_RegularTool(t *testing.T) {
|
||||
func TestConvertMessage_ServerToolUse(t *testing.T) {
|
||||
msg := MessageParam{
|
||||
Role: "assistant",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "server_tool_use",
|
||||
"id": "srvtoolu_123",
|
||||
"name": "web_search",
|
||||
"input": map[string]any{"query": "test query"},
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "server_tool_use",
|
||||
ID: "srvtoolu_123",
|
||||
Name: "web_search",
|
||||
Input: makeArgs("query", "test query"),
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1344,11 +1460,11 @@ func TestConvertMessage_ServerToolUse(t *testing.T) {
|
||||
func TestConvertMessage_WebSearchToolResult(t *testing.T) {
|
||||
msg := MessageParam{
|
||||
Role: "user",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": "srvtoolu_123",
|
||||
"content": []any{
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "web_search_tool_result",
|
||||
ToolUseID: "srvtoolu_123",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "web_search_result",
|
||||
"title": "Test Result",
|
||||
@@ -1385,11 +1501,11 @@ func TestConvertMessage_WebSearchToolResult(t *testing.T) {
|
||||
func TestConvertMessage_WebSearchToolResultEmptyStillCreatesToolMessage(t *testing.T) {
|
||||
msg := MessageParam{
|
||||
Role: "user",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": "srvtoolu_empty",
|
||||
"content": []any{},
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "web_search_tool_result",
|
||||
ToolUseID: "srvtoolu_empty",
|
||||
Content: []any{},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1416,11 +1532,11 @@ func TestConvertMessage_WebSearchToolResultEmptyStillCreatesToolMessage(t *testi
|
||||
func TestConvertMessage_WebSearchToolResultErrorStillCreatesToolMessage(t *testing.T) {
|
||||
msg := MessageParam{
|
||||
Role: "user",
|
||||
Content: []any{
|
||||
map[string]any{
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": "srvtoolu_error",
|
||||
"content": map[string]any{
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "web_search_tool_result",
|
||||
ToolUseID: "srvtoolu_error",
|
||||
Content: map[string]any{
|
||||
"type": "web_search_tool_result_error",
|
||||
"error_code": "max_uses_exceeded",
|
||||
},
|
||||
|
||||
@@ -550,14 +550,12 @@ export class Error {
|
||||
}
|
||||
}
|
||||
export class ModelUpstreamResponse {
|
||||
digest?: string;
|
||||
pushTime: number;
|
||||
stale: boolean;
|
||||
error?: string;
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.digest = source["digest"];
|
||||
this.pushTime = source["pushTime"];
|
||||
this.stale = source["stale"];
|
||||
this.error = source["error"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ export async function getModels(query?: string): Promise<Model[]> {
|
||||
// Add query if it's in the registry and not already in the list
|
||||
if (!exactMatch) {
|
||||
const result = await getModelUpstreamInfo(new Model({ model: query }));
|
||||
const existsUpstream = !!result.digest && !result.error;
|
||||
const existsUpstream = result.exists;
|
||||
if (existsUpstream) {
|
||||
filteredModels.push(new Model({ model: query }));
|
||||
}
|
||||
@@ -339,7 +339,7 @@ export async function deleteChat(chatId: string): Promise<void> {
|
||||
// Get upstream information for model staleness checking
|
||||
export async function getModelUpstreamInfo(
|
||||
model: Model,
|
||||
): Promise<{ digest?: string; pushTime: number; error?: string }> {
|
||||
): Promise<{ stale: boolean; exists: boolean; error?: string }> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/model/upstream`, {
|
||||
method: "POST",
|
||||
@@ -353,22 +353,22 @@ export async function getModelUpstreamInfo(
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`Failed to check upstream digest for ${model.model}: ${response.status}`,
|
||||
`Failed to check upstream for ${model.model}: ${response.status}`,
|
||||
);
|
||||
return { pushTime: 0 };
|
||||
return { stale: false, exists: false };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
console.warn(`Upstream digest check: ${data.error}`);
|
||||
return { error: data.error, pushTime: 0 };
|
||||
console.warn(`Upstream check: ${data.error}`);
|
||||
return { stale: false, exists: false, error: data.error };
|
||||
}
|
||||
|
||||
return { digest: data.digest, pushTime: data.pushTime || 0 };
|
||||
return { stale: !!data.stale, exists: true };
|
||||
} catch (error) {
|
||||
console.warn(`Error checking model staleness:`, error);
|
||||
return { pushTime: 0 };
|
||||
return { stale: false, exists: false };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,24 +61,7 @@ export const ModelPicker = forwardRef<
|
||||
try {
|
||||
const upstreamInfo = await getModelUpstreamInfo(model);
|
||||
|
||||
// Compare local digest with upstream digest
|
||||
let isStale =
|
||||
model.digest &&
|
||||
upstreamInfo.digest &&
|
||||
model.digest !== upstreamInfo.digest;
|
||||
|
||||
// If the model has a modified time and upstream has a push time,
|
||||
// check if the model was modified after the push time - if so, it's not stale
|
||||
if (isStale && model.modified_at && upstreamInfo.pushTime > 0) {
|
||||
const modifiedAtTime =
|
||||
new Date(model.modified_at as string | number | Date).getTime() /
|
||||
1000;
|
||||
if (modifiedAtTime > upstreamInfo.pushTime) {
|
||||
isStale = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isStale) {
|
||||
if (upstreamInfo.stale) {
|
||||
const currentStaleModels =
|
||||
queryClient.getQueryData<Map<string, boolean>>(["staleModels"]) ||
|
||||
new Map();
|
||||
|
||||
@@ -133,9 +133,8 @@ type Error struct {
|
||||
}
|
||||
|
||||
type ModelUpstreamResponse struct {
|
||||
Digest string `json:"digest,omitempty"`
|
||||
PushTime int64 `json:"pushTime"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Stale bool `json:"stale"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Serializable data for the browser state
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"github.com/ollama/ollama/app/version"
|
||||
ollamaAuth "github.com/ollama/ollama/auth"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/manifest"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
_ "github.com/tkrajina/typescriptify-golang-structs/typescriptify"
|
||||
)
|
||||
@@ -193,7 +194,7 @@ func (s *Server) Handler() http.Handler {
|
||||
if CORS() {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, User-Agent, Accept, X-Requested-With")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
|
||||
// Handle preflight requests
|
||||
@@ -318,7 +319,7 @@ func (s *Server) handleError(w http.ResponseWriter, e error) {
|
||||
if CORS() {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, User-Agent, Accept, X-Requested-With")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
|
||||
@@ -1572,9 +1573,18 @@ func (s *Server) modelUpstream(w http.ResponseWriter, r *http.Request) error {
|
||||
return json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
n := model.ParseName(req.Model)
|
||||
stale := true
|
||||
if m, err := manifest.ParseNamedManifest(n); err == nil {
|
||||
if m.Digest() == digest {
|
||||
stale = false
|
||||
} else if pushTime > 0 && m.FileInfo().ModTime().Unix() >= pushTime {
|
||||
stale = false
|
||||
}
|
||||
}
|
||||
|
||||
response := responses.ModelUpstreamResponse{
|
||||
Digest: digest,
|
||||
PushTime: pushTime,
|
||||
Stale: stale,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -2065,6 +2065,10 @@ func runLauncherAction(cmd *cobra.Command, action tui.TUIAction, deps launcherDe
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("launching %s: %w", action.Integration, err)
|
||||
}
|
||||
// VS Code is a GUI app — exit the TUI loop after launching
|
||||
if action.Integration == "vscode" {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
default:
|
||||
return false, fmt.Errorf("unknown launcher action: %d", action.Kind)
|
||||
|
||||
@@ -209,6 +209,43 @@ func TestRunLauncherAction_RunModelContinuesAfterCancellation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLauncherAction_VSCodeExitsTUILoop(t *testing.T) {
|
||||
setCmdTestHome(t, t.TempDir())
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetContext(context.Background())
|
||||
|
||||
// VS Code should exit the TUI loop (return false) after a successful launch.
|
||||
continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "vscode"}, launcherDeps{
|
||||
resolveRunModel: unexpectedRunModelResolution(t),
|
||||
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
|
||||
return nil
|
||||
},
|
||||
runModel: unexpectedModelLaunch(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
if continueLoop {
|
||||
t.Fatal("expected vscode launch to exit the TUI loop (return false)")
|
||||
}
|
||||
|
||||
// Other integrations should continue the TUI loop (return true).
|
||||
continueLoop, err = runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "claude"}, launcherDeps{
|
||||
resolveRunModel: unexpectedRunModelResolution(t),
|
||||
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
|
||||
return nil
|
||||
},
|
||||
runModel: unexpectedModelLaunch(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
if !continueLoop {
|
||||
t.Fatal("expected non-vscode integration to continue the TUI loop (return true)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLauncherAction_IntegrationContinuesAfterCancellation(t *testing.T) {
|
||||
setCmdTestHome(t, t.TempDir())
|
||||
|
||||
|
||||
@@ -301,7 +301,7 @@ Weigh anchor!
|
||||
ParameterSize: "7B",
|
||||
QuantizationLevel: "FP16",
|
||||
},
|
||||
Requires: "0.14.0",
|
||||
Requires: "0.19.0",
|
||||
}, false, &b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -310,10 +310,17 @@ Weigh anchor!
|
||||
architecture test
|
||||
parameters 7B
|
||||
quantization FP16
|
||||
requires 0.14.0
|
||||
requires 0.19.0
|
||||
|
||||
`
|
||||
if diff := cmp.Diff(expect, b.String()); diff != "" {
|
||||
trimLinePadding := func(s string) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = strings.TrimRight(line, " \t\r")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
if diff := cmp.Diff(trimLinePadding(expect), trimLinePadding(b.String())); diff != "" {
|
||||
t.Errorf("unexpected output (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
@@ -1912,7 +1919,7 @@ func TestShowInfoImageGen(t *testing.T) {
|
||||
QuantizationLevel: "Q8",
|
||||
},
|
||||
Capabilities: []model.Capability{model.CapabilityImage},
|
||||
Requires: "0.14.0",
|
||||
Requires: "0.19.0",
|
||||
}, false, &b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1922,7 +1929,7 @@ func TestShowInfoImageGen(t *testing.T) {
|
||||
" architecture ZImagePipeline \n" +
|
||||
" parameters 10.3B \n" +
|
||||
" quantization Q8 \n" +
|
||||
" requires 0.14.0 \n" +
|
||||
" requires 0.19.0 \n" +
|
||||
"\n" +
|
||||
" Capabilities\n" +
|
||||
" image \n" +
|
||||
|
||||
@@ -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) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
@@ -179,6 +179,7 @@ Supported integrations:
|
||||
opencode OpenCode
|
||||
openclaw OpenClaw (aliases: clawdbot, moltbot)
|
||||
pi Pi
|
||||
vscode VS Code (aliases: code)
|
||||
|
||||
Examples:
|
||||
ollama launch
|
||||
@@ -489,8 +490,10 @@ func (c *launcherClient) launchEditorIntegration(ctx context.Context, name strin
|
||||
return err
|
||||
}
|
||||
models = selected
|
||||
} else if err := c.ensureModelsReady(ctx, models); err != nil {
|
||||
return err
|
||||
} else if len(models) > 0 {
|
||||
if err := c.ensureModelsReady(ctx, models[:1]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(models) == 0 {
|
||||
@@ -551,10 +554,14 @@ func (c *launcherClient) selectMultiModelsForIntegration(ctx context.Context, ru
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.ensureModelsReady(ctx, selected); err != nil {
|
||||
accepted, skipped, err := c.selectReadyModelsForSave(ctx, selected)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return selected, nil
|
||||
for _, skip := range skipped {
|
||||
fmt.Fprintf(os.Stderr, "Skipped %s: %s\n", skip.model, skip.reason)
|
||||
}
|
||||
return accepted, nil
|
||||
}
|
||||
|
||||
func (c *launcherClient) loadSelectableModels(ctx context.Context, preChecked []string, current, emptyMessage string) ([]ModelItem, []string, error) {
|
||||
@@ -575,16 +582,7 @@ func (c *launcherClient) loadSelectableModels(ctx context.Context, preChecked []
|
||||
}
|
||||
|
||||
func (c *launcherClient) ensureModelsReady(ctx context.Context, models []string) error {
|
||||
var deduped []string
|
||||
seen := make(map[string]bool, len(models))
|
||||
for _, model := range models {
|
||||
if model == "" || seen[model] {
|
||||
continue
|
||||
}
|
||||
seen[model] = true
|
||||
deduped = append(deduped, model)
|
||||
}
|
||||
models = deduped
|
||||
models = dedupeModelList(models)
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -602,6 +600,56 @@ func (c *launcherClient) ensureModelsReady(ctx context.Context, models []string)
|
||||
return ensureAuth(ctx, c.apiClient, cloudModels, models)
|
||||
}
|
||||
|
||||
func dedupeModelList(models []string) []string {
|
||||
deduped := make([]string, 0, len(models))
|
||||
seen := make(map[string]bool, len(models))
|
||||
for _, model := range models {
|
||||
if model == "" || seen[model] {
|
||||
continue
|
||||
}
|
||||
seen[model] = true
|
||||
deduped = append(deduped, model)
|
||||
}
|
||||
return deduped
|
||||
}
|
||||
|
||||
type skippedModel struct {
|
||||
model string
|
||||
reason string
|
||||
}
|
||||
|
||||
func (c *launcherClient) selectReadyModelsForSave(ctx context.Context, selected []string) ([]string, []skippedModel, error) {
|
||||
selected = dedupeModelList(selected)
|
||||
accepted := make([]string, 0, len(selected))
|
||||
skipped := make([]skippedModel, 0, len(selected))
|
||||
|
||||
for _, model := range selected {
|
||||
if err := c.ensureModelsReady(ctx, []string{model}); err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, nil, err
|
||||
}
|
||||
skipped = append(skipped, skippedModel{
|
||||
model: model,
|
||||
reason: skippedModelReason(model, err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
accepted = append(accepted, model)
|
||||
}
|
||||
|
||||
return accepted, skipped, nil
|
||||
}
|
||||
|
||||
func skippedModelReason(model string, err error) string {
|
||||
if errors.Is(err, ErrCancelled) {
|
||||
if isCloudModelName(model) {
|
||||
return "sign in was cancelled"
|
||||
}
|
||||
return "download was cancelled"
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func (c *launcherClient) resolveEditorLaunchModels(ctx context.Context, saved *config.IntegrationConfig, req IntegrationLaunchRequest) ([]string, bool) {
|
||||
if req.ForceConfigure {
|
||||
return editorPreCheckedModels(saved, req.ModelOverride), true
|
||||
@@ -801,13 +849,6 @@ func cloneAliases(aliases map[string]string) map[string]string {
|
||||
return cloned
|
||||
}
|
||||
|
||||
func singleModelPrechecked(current string) []string {
|
||||
if current == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{current}
|
||||
}
|
||||
|
||||
func firstModel(models []string) string {
|
||||
if len(models) == 0 {
|
||||
return ""
|
||||
|
||||
@@ -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) {
|
||||
tmpDir := t.TempDir()
|
||||
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) {
|
||||
tmpDir := t.TempDir()
|
||||
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) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
|
||||
@@ -147,6 +147,7 @@ func (o *OpenCode) Edit(modelList []string) error {
|
||||
ollama["models"] = models
|
||||
provider["ollama"] = ollama
|
||||
config["provider"] = provider
|
||||
config["model"] = "ollama/" + modelList[0]
|
||||
|
||||
configData, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
|
||||
@@ -49,6 +49,7 @@ func TestOpenCodeEdit(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertOpenCodeModelExists(t, configPath, "llama3.2")
|
||||
assertOpenCodeDefaultModel(t, configPath, "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"})
|
||||
assertOpenCodeModelExists(t, configPath, "llama3.2")
|
||||
assertOpenCodeModelExists(t, configPath, "mistral")
|
||||
assertOpenCodeDefaultModel(t, configPath, "ollama/llama3.2")
|
||||
|
||||
// Then remove one by only selecting the other
|
||||
o.Edit([]string{"llama3.2"})
|
||||
assertOpenCodeModelExists(t, configPath, "llama3.2")
|
||||
assertOpenCodeModelNotExists(t, configPath, "mistral")
|
||||
assertOpenCodeDefaultModel(t, configPath, "ollama/llama3.2")
|
||||
})
|
||||
|
||||
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) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
|
||||
@@ -20,20 +20,151 @@ import (
|
||||
// Pi implements Runner and Editor for Pi (Pi Coding Agent) integration
|
||||
type Pi struct{}
|
||||
|
||||
const (
|
||||
piNpmPackage = "@mariozechner/pi-coding-agent"
|
||||
piWebSearchSource = "npm:@ollama/pi-web-search"
|
||||
piWebSearchPkg = "@ollama/pi-web-search"
|
||||
)
|
||||
|
||||
func (p *Pi) String() string { return "Pi" }
|
||||
|
||||
func (p *Pi) Run(model string, args []string) error {
|
||||
if _, err := exec.LookPath("pi"); err != nil {
|
||||
return fmt.Errorf("pi is not installed, install with: npm install -g @mariozechner/pi-coding-agent")
|
||||
fmt.Fprintf(os.Stderr, "\n%sPreparing Pi...%s\n", ansiGray, ansiReset)
|
||||
if err := ensureNpmInstalled(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command("pi", args...)
|
||||
fmt.Fprintf(os.Stderr, "%sChecking Pi installation...%s\n", ansiGray, ansiReset)
|
||||
bin, err := ensurePiInstalled()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ensurePiWebSearchPackage(bin)
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n%sLaunching Pi...%s\n\n", ansiGray, ansiReset)
|
||||
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func ensureNpmInstalled() error {
|
||||
if _, err := exec.LookPath("npm"); err != nil {
|
||||
return fmt.Errorf("npm (Node.js) is required to launch pi\n\nInstall it first:\n https://nodejs.org/")
|
||||
}
|
||||
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 {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
@@ -33,6 +35,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) {
|
||||
pi := &Pi{}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ type IntegrationInfo struct {
|
||||
Description string
|
||||
}
|
||||
|
||||
var launcherIntegrationOrder = []string{"opencode", "droid", "pi", "cline"}
|
||||
var launcherIntegrationOrder = []string{"opencode", "droid", "pi"}
|
||||
|
||||
var integrationSpecs = []*IntegrationSpec{
|
||||
{
|
||||
@@ -52,6 +52,7 @@ var integrationSpecs = []*IntegrationSpec{
|
||||
Name: "cline",
|
||||
Runner: &Cline{},
|
||||
Description: "Autonomous coding agent with parallel execution",
|
||||
Hidden: true,
|
||||
Install: IntegrationInstallSpec{
|
||||
CheckInstalled: func() bool {
|
||||
_, err := exec.LookPath("cline")
|
||||
@@ -128,7 +129,24 @@ var integrationSpecs = []*IntegrationSpec{
|
||||
_, err := exec.LookPath("pi")
|
||||
return err == nil
|
||||
},
|
||||
Command: []string{"npm", "install", "-g", "@mariozechner/pi-coding-agent"},
|
||||
EnsureInstalled: func() error {
|
||||
_, err := ensurePiInstalled()
|
||||
return err
|
||||
},
|
||||
Command: []string{"npm", "install", "-g", "@mariozechner/pi-coding-agent@latest"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "vscode",
|
||||
Runner: &VSCode{},
|
||||
Aliases: []string{"code"},
|
||||
Description: "Microsoft's open-source AI code editor",
|
||||
Hidden: true,
|
||||
Install: IntegrationInstallSpec{
|
||||
CheckInstalled: func() bool {
|
||||
return (&VSCode{}).findBinary() != ""
|
||||
},
|
||||
URL: "https://code.visualstudio.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) {
|
||||
|
||||
binDir := t.TempDir()
|
||||
writeFakeBinary(t, binDir, tt.binary)
|
||||
if tt.name == "pi" {
|
||||
writeFakeBinary(t, binDir, "npm")
|
||||
}
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
configPath := tt.checkPath(home)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
return m, tea.Quit
|
||||
|
||||
case tea.KeyLeft:
|
||||
m.cancelled = true
|
||||
return m, tea.Quit
|
||||
|
||||
case tea.KeyEnter:
|
||||
filtered := m.filteredItems()
|
||||
if len(filtered) > 0 && m.cursor < len(filtered) {
|
||||
@@ -354,7 +358,7 @@ func (m selectorModel) renderContent() string {
|
||||
}
|
||||
|
||||
s.WriteString("\n")
|
||||
help := "↑/↓ navigate • enter select • esc cancel"
|
||||
help := "↑/↓ navigate • enter select • ← back"
|
||||
if m.helpText != "" {
|
||||
help = m.helpText
|
||||
}
|
||||
@@ -608,6 +612,10 @@ func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.cancelled = true
|
||||
return m, tea.Quit
|
||||
|
||||
case tea.KeyLeft:
|
||||
m.cancelled = true
|
||||
return m, tea.Quit
|
||||
|
||||
case tea.KeyTab:
|
||||
m.multi = !m.multi
|
||||
|
||||
@@ -810,7 +818,7 @@ func (m multiSelectorModel) View() string {
|
||||
s.WriteString("\n")
|
||||
|
||||
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 {
|
||||
count := m.selectedCount()
|
||||
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("\n\n")
|
||||
s.WriteString(selectorHelpStyle.Render("↑/↓ navigate • space toggle • tab select single • enter confirm • esc cancel"))
|
||||
s.WriteString(selectorHelpStyle.Render("↑/↓ navigate • space toggle • tab select single • enter confirm • ← back"))
|
||||
}
|
||||
|
||||
result := s.String()
|
||||
|
||||
@@ -782,6 +782,9 @@ func TestMulti_MultiModeHelpText(t *testing.T) {
|
||||
if !strings.Contains(content, "tab select single") {
|
||||
t.Error("multi mode should show 'tab select single' in help")
|
||||
}
|
||||
if !strings.Contains(content, "← back") {
|
||||
t.Error("multi mode should show '← back' in help")
|
||||
}
|
||||
}
|
||||
|
||||
// --- preChecked initialization order ---
|
||||
@@ -868,6 +871,46 @@ func TestMulti_UncheckingTopDefaultFallsBackToNearestCheckedBelow(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Left arrow back navigation ---
|
||||
|
||||
func TestSelectorLeftArrowCancelsWhenNoFilter(t *testing.T) {
|
||||
m := selectorModelWithCurrent("Pick:", items("a", "b", "c"), "")
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft})
|
||||
got := updated.(selectorModel)
|
||||
if !got.cancelled {
|
||||
t.Error("left arrow with empty filter should cancel (go back)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectorLeftArrowCancelsWhenFiltering(t *testing.T) {
|
||||
m := selectorModelWithCurrent("Pick:", items("a", "b", "c"), "")
|
||||
m.filter = "a"
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft})
|
||||
got := updated.(selectorModel)
|
||||
if !got.cancelled {
|
||||
t.Error("left arrow with active filter should still cancel (go back)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiSelectorLeftArrowCancelsWhenNoFilter(t *testing.T) {
|
||||
m := newMultiSelectorModel("Pick:", items("a", "b", "c"), nil)
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft})
|
||||
got := updated.(multiSelectorModel)
|
||||
if !got.cancelled {
|
||||
t.Error("left arrow with empty filter should cancel (go back)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiSelectorLeftArrowCancelsWhenFiltering(t *testing.T) {
|
||||
m := newMultiSelectorModel("Pick:", items("a", "b", "c"), nil)
|
||||
m.filter = "a"
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft})
|
||||
got := updated.(multiSelectorModel)
|
||||
if !got.cancelled {
|
||||
t.Error("left arrow with active filter should still cancel (go back)")
|
||||
}
|
||||
}
|
||||
|
||||
// Key message helpers for testing
|
||||
|
||||
type keyType = int
|
||||
|
||||
@@ -47,7 +47,7 @@ type menuItem struct {
|
||||
|
||||
var mainMenuItems = []menuItem{
|
||||
{
|
||||
title: "Run a model",
|
||||
title: "Chat with a model",
|
||||
description: "Start an interactive chat with a model",
|
||||
isRunModel: true,
|
||||
},
|
||||
|
||||
@@ -56,7 +56,7 @@ func launcherTestState() *launch.LauncherState {
|
||||
|
||||
func TestMenuRendersPinnedItemsAndMore(t *testing.T) {
|
||||
view := newModel(launcherTestState()).View()
|
||||
for _, want := range []string{"Run a model", "Launch Claude Code", "Launch Codex", "Launch OpenClaw", "More..."} {
|
||||
for _, want := range []string{"Chat with a model", "Launch Claude Code", "Launch Codex", "Launch OpenClaw", "More..."} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("expected menu view to contain %q\n%s", want, view)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ Configure and launch external applications to use Ollama models. This provides a
|
||||
- **OpenCode** - Open-source coding assistant
|
||||
- **Claude Code** - Anthropic's agentic coding tool
|
||||
- **Codex** - OpenAI's coding assistant
|
||||
- **VS Code** - Microsoft's IDE with built-in AI chat
|
||||
- **Droid** - Factory's AI coding agent
|
||||
|
||||
#### Examples
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
},
|
||||
{
|
||||
"group": "IDEs & Editors",
|
||||
"expanded": true,
|
||||
"pages": [
|
||||
"/integrations/cline",
|
||||
"/integrations/jetbrains",
|
||||
|
||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 2.7 MiB |
@@ -26,6 +26,17 @@ To configure without launching:
|
||||
ollama launch pi --config
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
### Manual setup
|
||||
|
||||
Add a configuration block to `~/.pi/agent/models.json`:
|
||||
|
||||
@@ -2,33 +2,84 @@
|
||||
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" }}>
|
||||
<img
|
||||
src="/images/vscode-sidebar.png"
|
||||
alt="VS Code chat Sidebar"
|
||||
width="75%"
|
||||
style={{ borderRadius: "4px" }}
|
||||
/>
|
||||
</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" }}>
|
||||
<img
|
||||
src="/images/vscode-models.png"
|
||||
src="/images/vscode-other-models.png"
|
||||
alt="VS Code model picker"
|
||||
width="75%"
|
||||
style={{ borderRadius: "4px" }}
|
||||
/>
|
||||
</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" }}>
|
||||
<img
|
||||
src="/images/vscode-model-options.png"
|
||||
alt="VS Code model options dropdown"
|
||||
src="/images/vscode-add-ollama.png"
|
||||
alt="VS Code model options dropdown to add ollama models"
|
||||
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>
|
||||
@@ -874,7 +874,7 @@ func (f GGML) SupportsFlashAttention() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
if slices.Contains([]string{"gemma2"}, arch) {
|
||||
if slices.Contains([]string{"gemma2", "grok"}, arch) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -283,7 +283,7 @@ func (w *WebSearchAnthropicWriter) runWebSearchLoop(ctx context.Context, initial
|
||||
Type: "server_tool_use",
|
||||
ID: toolUseID,
|
||||
Name: "web_search",
|
||||
Input: map[string]any{"query": query},
|
||||
Input: queryArgs(query),
|
||||
},
|
||||
anthropic.ContentBlock{
|
||||
Type: "web_search_tool_result",
|
||||
@@ -348,7 +348,7 @@ func (w *WebSearchAnthropicWriter) runWebSearchLoop(ctx context.Context, initial
|
||||
Type: "server_tool_use",
|
||||
ID: maxLoopToolUseID,
|
||||
Name: "web_search",
|
||||
Input: map[string]any{"query": maxLoopQuery},
|
||||
Input: queryArgs(maxLoopQuery),
|
||||
},
|
||||
anthropic.ContentBlock{
|
||||
Type: "web_search_tool_result",
|
||||
@@ -786,7 +786,7 @@ func (w *WebSearchAnthropicWriter) webSearchErrorResponse(errorCode, query strin
|
||||
Type: "server_tool_use",
|
||||
ID: toolUseID,
|
||||
Name: "web_search",
|
||||
Input: map[string]any{"query": query},
|
||||
Input: queryArgs(query),
|
||||
},
|
||||
{
|
||||
Type: "web_search_tool_result",
|
||||
@@ -942,6 +942,13 @@ func writeSSE(w http.ResponseWriter, eventType string, data any) error {
|
||||
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
|
||||
func serverToolUseID(messageID string) string {
|
||||
return "srvtoolu_" + strings.TrimPrefix(messageID, "msg_")
|
||||
|
||||
@@ -1208,7 +1208,7 @@ func TestWebSearchStreamResponse(t *testing.T) {
|
||||
Type: "server_tool_use",
|
||||
ID: "srvtoolu_test123",
|
||||
Name: "web_search",
|
||||
Input: map[string]any{"query": "test query"},
|
||||
Input: queryArgs("test query"),
|
||||
},
|
||||
{
|
||||
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)
|
||||
}
|
||||
// Verify input contains the query
|
||||
inputMap, ok := result.Content[0].Input.(map[string]any)
|
||||
if !ok {
|
||||
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"])
|
||||
if q, ok := result.Content[0].Input.Get("query"); !ok || q != "test query" {
|
||||
t.Errorf("expected query 'test query', got %v", q)
|
||||
}
|
||||
|
||||
// Block 1: web_search_tool_result with error
|
||||
@@ -1561,12 +1557,8 @@ func TestWebSearchSendError_EmptyQuery(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify the input has empty query
|
||||
inputMap, ok := result.Content[0].Input.(map[string]any)
|
||||
if !ok {
|
||||
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"])
|
||||
if q, ok := result.Content[0].Input.Get("query"); !ok || q != "" {
|
||||
t.Errorf("expected empty query, got %v", q)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,9 +34,9 @@ type Masks struct {
|
||||
// GatedDeltaNet implements linear attention with SSM convolution and recurrent state.
|
||||
// It implements the Operator interface directly.
|
||||
type GatedDeltaNet struct {
|
||||
// Optimized path: pre-split QKV and gate
|
||||
SSMQKV *nn.Linear `gguf:"attn_qkv"` // -> Q, K, V (concatenated)
|
||||
SSMQKVGate *nn.Linear `gguf:"attn_gate"` // -> Z gate
|
||||
SSMIn *nn.Linear `gguf:"ssm_in"`
|
||||
SSMBetaAlpha *nn.Linear `gguf:"ssm_ba"` // -> beta, alpha (legacy qwen3next)
|
||||
SSMBeta *nn.Linear `gguf:"ssm_beta"` // -> beta (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
|
||||
|
||||
if gdn.SSMQKV == nil || gdn.SSMQKVGate == nil {
|
||||
return nil, errors.New("qwen3next: missing attn_qkv/attn_gate projections (legacy ssm_in is not supported)")
|
||||
// Support both current split projections and older qwen3-next imports that use ssm_in.
|
||||
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 alpha ml.Tensor
|
||||
|
||||
@@ -454,7 +454,7 @@ func (m *Model) Validate() error {
|
||||
if !ok || gdn == nil {
|
||||
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)
|
||||
}
|
||||
if gdn.SSMBetaAlpha == nil && (gdn.SSMBeta == nil || gdn.SSMAlpha == nil) {
|
||||
|
||||
@@ -31,6 +31,31 @@ func TestValidateRecurrentLayerRequiresSSMDT(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRecurrentSSMInAccepted(t *testing.T) {
|
||||
// When SSMIn is set, Validate must not reject the layer for missing
|
||||
// attn_qkv/attn_gate. It should fail later on missing ssm_dt.
|
||||
m := &Model{
|
||||
Layers: []Layer{{
|
||||
Operator: &GatedDeltaNet{
|
||||
SSMIn: &nn.Linear{},
|
||||
SSMBeta: &nn.Linear{},
|
||||
SSMAlpha: &nn.Linear{},
|
||||
},
|
||||
}},
|
||||
Options: &Options{
|
||||
isRecurrent: []bool{true},
|
||||
},
|
||||
}
|
||||
|
||||
err := m.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("Validate() expected error, got nil")
|
||||
}
|
||||
if strings.Contains(err.Error(), "missing attn_qkv/attn_gate") {
|
||||
t.Fatalf("Validate() should not fail on attn_qkv/attn_gate when SSMIn is set, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNonRecurrentSkipsLinearChecks(t *testing.T) {
|
||||
m := &Model{
|
||||
Layers: []Layer{{Operator: &FullAttention{}}},
|
||||
|
||||
@@ -328,8 +328,8 @@ func (p *Qwen3Parser) eat() ([]qwen3Event, bool) {
|
||||
|
||||
func parseQwen3ToolCall(raw qwen3EventRawToolCall, tools []api.Tool) (api.ToolCall, error) {
|
||||
var parsed struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
Name string `json:"name"`
|
||||
Arguments api.ToolCallFunctionArguments `json:"arguments"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(raw.raw), &parsed); err != nil {
|
||||
@@ -345,13 +345,9 @@ func parseQwen3ToolCall(raw qwen3EventRawToolCall, tools []api.Tool) (api.ToolCa
|
||||
toolCall := api.ToolCall{
|
||||
Function: api.ToolCallFunction{
|
||||
Name: parsed.Name,
|
||||
Arguments: api.NewToolCallFunctionArguments(),
|
||||
Arguments: parsed.Arguments,
|
||||
},
|
||||
}
|
||||
|
||||
for key, value := range parsed.Arguments {
|
||||
toolCall.Function.Arguments.Set(key, value)
|
||||
}
|
||||
|
||||
return toolCall, nil
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const (
|
||||
const (
|
||||
qwen35ThinkingOpenTag = "<think>"
|
||||
qwen35ThinkingCloseTag = "</think>"
|
||||
qwen35ToolCallOpenTag = "<tool_call>"
|
||||
)
|
||||
|
||||
// Qwen35Parser handles qwen3.5 reasoning extraction and delegates post-thinking
|
||||
@@ -189,7 +190,7 @@ func (p *Qwen35Parser) eat() ([]qwen35Event, bool) {
|
||||
p.state = qwen35ParserStateCollectingContent
|
||||
}
|
||||
return events, true
|
||||
} else if overlapLen := overlap(acc, qwen35ThinkingCloseTag); overlapLen > 0 {
|
||||
} else if overlapLen := max(overlap(acc, qwen35ThinkingCloseTag), overlap(acc, qwen35ToolCallOpenTag)); overlapLen > 0 {
|
||||
beforePartialTag := acc[:len(acc)-overlapLen]
|
||||
trailingWsLen := trailingWhitespaceLen(beforePartialTag)
|
||||
ambiguousStart := len(beforePartialTag) - trailingWsLen
|
||||
@@ -202,6 +203,14 @@ func (p *Qwen35Parser) eat() ([]qwen35Event, bool) {
|
||||
events = append(events, qwen35EventThinkingContent{content: unambiguous})
|
||||
}
|
||||
return events, false
|
||||
} else if strings.Contains(acc, qwen35ToolCallOpenTag) {
|
||||
// qwen3.5:9b model forgets sometimes to use </think> tag before the <tool_call> block starts
|
||||
// this condition ends the Think block and continues with the <tool_call> when the tag
|
||||
// is found
|
||||
thinking, tooling := p.splitAtTag(qwen35ToolCallOpenTag, true)
|
||||
p.buffer.Reset()
|
||||
p.buffer.WriteString(thinking + qwen35ThinkingCloseTag + qwen35ToolCallOpenTag + tooling)
|
||||
return events, true
|
||||
}
|
||||
|
||||
whitespaceLen := trailingWhitespaceLen(acc)
|
||||
|
||||
@@ -108,7 +108,7 @@ func TestQwen35ParserAssistantPrefillStartsInContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwen35ParserToolCallEmittedInThinkingIsNotParsed(t *testing.T) {
|
||||
func TestQwen35ParserToolCallEmittedInThinkingIsParsed(t *testing.T) {
|
||||
parser := ParserForName("qwen3.5")
|
||||
if parser == nil {
|
||||
t.Fatal("expected qwen3.5 parser")
|
||||
@@ -141,14 +141,147 @@ SF
|
||||
if content != "" {
|
||||
t.Fatalf("expected empty content, got %q", content)
|
||||
}
|
||||
expectedThinking := `Need weather lookup<tool_call><function=get_weather><parameter=location>
|
||||
SF
|
||||
</parameter></function></tool_call>`
|
||||
if thinking != expectedThinking {
|
||||
t.Fatalf("expected thinking %q, got %q", expectedThinking, thinking)
|
||||
if thinking != "Need weather lookup" {
|
||||
t.Fatalf("expected thinking %q, got %q", "Need weather lookup", thinking)
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 tool call, got %d", len(calls))
|
||||
}
|
||||
|
||||
if calls[0].Function.Name != "get_weather" {
|
||||
t.Fatalf("expected tool name %q, got %q", "get_weather", calls[0].Function.Name)
|
||||
}
|
||||
|
||||
location, ok := calls[0].Function.Arguments.Get("location")
|
||||
if !ok || location != "SF" {
|
||||
t.Fatalf("expected location %q, got %v", "SF", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwen35ParserToolCallEmittedInThinkingIsParsedWhenToolCallTagIsSplitAcrossChunks(t *testing.T) {
|
||||
parser := ParserForName("qwen3.5")
|
||||
if parser == nil {
|
||||
t.Fatal("expected qwen3.5 parser")
|
||||
}
|
||||
|
||||
tools := []api.Tool{
|
||||
{
|
||||
Function: api.ToolFunction{
|
||||
Name: "get_weather",
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Properties: func() *api.ToolPropertiesMap {
|
||||
props := api.NewToolPropertiesMap()
|
||||
props.Set("location", api.ToolProperty{Type: api.PropertyType{"string"}})
|
||||
return props
|
||||
}(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
parser.Init(tools, nil, &api.ThinkValue{Value: true})
|
||||
content, thinking, calls, err := parser.Add("Need weather lookup<tool_c", false)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
if content != "" {
|
||||
t.Fatalf("expected empty content, got %q", content)
|
||||
}
|
||||
if thinking != "Need weather lookup" {
|
||||
t.Fatalf("expected thinking %q, got %q", "Need weather lookup", thinking)
|
||||
}
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("expected no tool calls before </think>, got %d", len(calls))
|
||||
t.Fatalf("expected no tool calls in first chunk, got %d", len(calls))
|
||||
}
|
||||
|
||||
content, thinking, calls, err = parser.Add(`all><function=get_weather><parameter=location>
|
||||
SF
|
||||
</parameter></function></tool_call>`, true)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
if content != "" {
|
||||
t.Fatalf("expected empty content, got %q", content)
|
||||
}
|
||||
if thinking != "" {
|
||||
t.Fatalf("expected no additional thinking, got %q", thinking)
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 tool call, got %d", len(calls))
|
||||
}
|
||||
|
||||
if calls[0].Function.Name != "get_weather" {
|
||||
t.Fatalf("expected tool name %q, got %q", "get_weather", calls[0].Function.Name)
|
||||
}
|
||||
|
||||
location, ok := calls[0].Function.Arguments.Get("location")
|
||||
if !ok || location != "SF" {
|
||||
t.Fatalf("expected location %q, got %v", "SF", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwen35ParserFakeoutPartialToolCallThenThinkCloseAcrossChunks(t *testing.T) {
|
||||
parser := ParserForName("qwen3.5")
|
||||
if parser == nil {
|
||||
t.Fatal("expected qwen3.5 parser")
|
||||
}
|
||||
|
||||
tools := []api.Tool{
|
||||
{
|
||||
Function: api.ToolFunction{
|
||||
Name: "get_weather",
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Properties: func() *api.ToolPropertiesMap {
|
||||
props := api.NewToolPropertiesMap()
|
||||
props.Set("location", api.ToolProperty{Type: api.PropertyType{"string"}})
|
||||
return props
|
||||
}(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
parser.Init(tools, nil, &api.ThinkValue{Value: true})
|
||||
content, thinking, calls, err := parser.Add("Need weather lookup<tool_c", false)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
if content != "" {
|
||||
t.Fatalf("expected empty content, got %q", content)
|
||||
}
|
||||
if thinking != "Need weather lookup" {
|
||||
t.Fatalf("expected thinking %q, got %q", "Need weather lookup", thinking)
|
||||
}
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("expected no tool calls in first chunk, got %d", len(calls))
|
||||
}
|
||||
|
||||
content, thinking, calls, err = parser.Add("</thi", false)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
if content != "" {
|
||||
t.Fatalf("expected empty content, got %q", content)
|
||||
}
|
||||
if thinking != "<tool_c" {
|
||||
t.Fatalf("expected thinking %q, got %q", "<tool_c", thinking)
|
||||
}
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("expected no tool calls in second chunk, got %d", len(calls))
|
||||
}
|
||||
|
||||
content, thinking, calls, err = parser.Add("nk>", true)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
if content != "" {
|
||||
t.Fatalf("expected empty content, got %q", content)
|
||||
}
|
||||
if thinking != "" {
|
||||
t.Fatalf("expected no additional thinking in third chunk, got %q", thinking)
|
||||
}
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("expected no tool calls in third chunk, got %d", len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -578,7 +578,7 @@ func PullModel(ctx context.Context, name string, regOpts *registryOptions, fn fu
|
||||
|
||||
fn(api.ProgressResponse{Status: "pulling manifest"})
|
||||
|
||||
mf, err := pullModelManifest(ctx, n, regOpts)
|
||||
mf, manifestData, err := pullModelManifest(ctx, n, regOpts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pull model manifest: %s", err)
|
||||
}
|
||||
@@ -591,7 +591,7 @@ func PullModel(ctx context.Context, name string, regOpts *registryOptions, fn fu
|
||||
|
||||
// Use fast transfer for models with tensor layers (many small blobs)
|
||||
if hasTensorLayers(layers) {
|
||||
if err := pullWithTransfer(ctx, n, layers, mf, regOpts, fn); err != nil {
|
||||
if err := pullWithTransfer(ctx, n, layers, manifestData, regOpts, fn); err != nil {
|
||||
return err
|
||||
}
|
||||
fn(api.ProgressResponse{Status: "success"})
|
||||
@@ -639,11 +639,6 @@ func PullModel(ctx context.Context, name string, regOpts *registryOptions, fn fu
|
||||
|
||||
fn(api.ProgressResponse{Status: "writing manifest"})
|
||||
|
||||
manifestJSON, err := json.Marshal(mf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fp, err := manifest.PathForName(n)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -652,12 +647,14 @@ func PullModel(ctx context.Context, name string, regOpts *registryOptions, fn fu
|
||||
return err
|
||||
}
|
||||
|
||||
err = os.WriteFile(fp, manifestJSON, 0o644)
|
||||
err = os.WriteFile(fp, manifestData, 0o644)
|
||||
if err != nil {
|
||||
slog.Info(fmt.Sprintf("couldn't write to %s", fp))
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Debug("manifest written", "path", fp, "sha256", fmt.Sprintf("%x", sha256.Sum256(manifestData)), "size", len(manifestData))
|
||||
|
||||
if !envconfig.NoPrune() && len(deleteMap) > 0 {
|
||||
fn(api.ProgressResponse{Status: "removing unused layers"})
|
||||
if err := deleteUnusedLayers(deleteMap); err != nil {
|
||||
@@ -681,7 +678,7 @@ func hasTensorLayers(layers []manifest.Layer) bool {
|
||||
}
|
||||
|
||||
// pullWithTransfer uses the simplified x/transfer package for downloading blobs.
|
||||
func pullWithTransfer(ctx context.Context, n model.Name, layers []manifest.Layer, mf *manifest.Manifest, regOpts *registryOptions, fn func(api.ProgressResponse)) error {
|
||||
func pullWithTransfer(ctx context.Context, n model.Name, layers []manifest.Layer, manifestData []byte, regOpts *registryOptions, fn func(api.ProgressResponse)) error {
|
||||
blobs := make([]transfer.Blob, len(layers))
|
||||
for i, layer := range layers {
|
||||
blobs[i] = transfer.Blob{
|
||||
@@ -738,10 +735,6 @@ func pullWithTransfer(ctx context.Context, n model.Name, layers []manifest.Layer
|
||||
|
||||
// Write manifest
|
||||
fn(api.ProgressResponse{Status: "writing manifest"})
|
||||
manifestJSON, err := json.Marshal(mf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fp, err := manifest.PathForName(n)
|
||||
if err != nil {
|
||||
@@ -751,7 +744,12 @@ func pullWithTransfer(ctx context.Context, n model.Name, layers []manifest.Layer
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(fp, manifestJSON, 0o644)
|
||||
if err := os.WriteFile(fp, manifestData, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Debug("manifest written", "path", fp, "sha256", fmt.Sprintf("%x", sha256.Sum256(manifestData)), "size", len(manifestData))
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushWithTransfer uses the simplified x/transfer package for uploading blobs and manifest.
|
||||
@@ -812,23 +810,28 @@ func pushWithTransfer(ctx context.Context, n model.Name, layers []manifest.Layer
|
||||
})
|
||||
}
|
||||
|
||||
func pullModelManifest(ctx context.Context, n model.Name, regOpts *registryOptions) (*manifest.Manifest, error) {
|
||||
func pullModelManifest(ctx context.Context, n model.Name, regOpts *registryOptions) (*manifest.Manifest, []byte, error) {
|
||||
requestURL := n.BaseURL().JoinPath("v2", n.DisplayNamespaceModel(), "manifests", n.Tag)
|
||||
|
||||
headers := make(http.Header)
|
||||
headers.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
|
||||
resp, err := makeRequestWithRetry(ctx, http.MethodGet, requestURL, headers, nil, regOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var m manifest.Manifest
|
||||
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
|
||||
return nil, err
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return &m, err
|
||||
var m manifest.Manifest
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return &m, data, err
|
||||
}
|
||||
|
||||
// GetSHA256Digest returns the SHA256 hash of a given buffer and returns it, and the size of buffer
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -289,3 +293,63 @@ func TestModelCheckCapabilities(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullModelManifest(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
manifest string
|
||||
}{
|
||||
{
|
||||
name: "pretty printed",
|
||||
manifest: `{ "schemaVersion": 2, "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
|
||||
"config": { "digest": "sha256:abc", "mediaType": "application/vnd.docker.container.image.v1+json", "size": 50 },
|
||||
"layers": [{ "digest": "sha256:t1", "mediaType": "application/vnd.ollama.image.tensor", "size": 1024, "name": "model.weight" }]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "non-standard field order",
|
||||
manifest: `{"layers":[{"size":999,"digest":"sha256:def","mediaType":"application/vnd.ollama.image.model"}],"schemaVersion":2,"config":{"size":50,"digest":"sha256:abc","mediaType":"application/vnd.docker.container.image.v1+json"},"mediaType":"application/vnd.docker.distribution.manifest.v2+json"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(tt.manifest))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
n := model.ParseName("test/model:latest")
|
||||
n.ProtocolScheme = "http"
|
||||
n.Host = strings.TrimPrefix(ts.URL, "http://")
|
||||
|
||||
mf, data, err := pullModelManifest(t.Context(), n, ®istryOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Raw bytes must be byte-for-byte identical to what the server sent
|
||||
if string(data) != tt.manifest {
|
||||
t.Fatalf("raw bytes differ from server response")
|
||||
}
|
||||
|
||||
// SHA256 of returned data must match the expected registry digest
|
||||
expectedDigest := fmt.Sprintf("%x", sha256.Sum256([]byte(tt.manifest)))
|
||||
gotDigest := fmt.Sprintf("%x", sha256.Sum256(data))
|
||||
if gotDigest != expectedDigest {
|
||||
t.Fatalf("digest mismatch\ngot: %s\nwant: %s", gotDigest, expectedDigest)
|
||||
}
|
||||
|
||||
// Parsed manifest must still be usable
|
||||
if mf.SchemaVersion != 2 {
|
||||
t.Fatalf("schemaVersion = %d, want 2", mf.SchemaVersion)
|
||||
}
|
||||
if mf.Config.Digest == "" {
|
||||
t.Fatal("config digest is empty")
|
||||
}
|
||||
if len(mf.Layers) == 0 {
|
||||
t.Fatal("expected at least one layer")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ const (
|
||||
cloudErrRemoteModelDetailsUnavailable = "remote model details are unavailable"
|
||||
cloudErrWebSearchUnavailable = "web search is unavailable"
|
||||
cloudErrWebFetchUnavailable = "web fetch is unavailable"
|
||||
copilotChatUserAgentPrefix = "GitHubCopilotChat/"
|
||||
)
|
||||
|
||||
func writeModelRefParseError(c *gin.Context, err error, fallbackStatus int, fallbackMessage string) {
|
||||
@@ -1158,6 +1159,17 @@ func (s *Server) ShowHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
userAgent := c.Request.UserAgent()
|
||||
if strings.HasPrefix(userAgent, copilotChatUserAgentPrefix) {
|
||||
if resp.ModelInfo == nil {
|
||||
resp.ModelInfo = map[string]any{}
|
||||
}
|
||||
// Copilot Chat prefers `general.basename`, but this is usually not what
|
||||
// users are familiar with, so let's just echo back what we had returned in
|
||||
// `/api/tags`
|
||||
resp.ModelInfo["general.basename"] = req.Model
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
@@ -1213,9 +1225,11 @@ func GetModelInfo(req api.ShowRequest) (*api.ShowResponse, error) {
|
||||
modelDetails.ParameterSize = format.HumanNumber(uint64(paramCount))
|
||||
}
|
||||
}
|
||||
// Get torch_dtype directly from config.json for quantization level
|
||||
if dtype, err := xserver.GetSafetensorsDtype(name); err == nil && dtype != "" {
|
||||
modelDetails.QuantizationLevel = dtype
|
||||
// Older manifests may not have file_type populated for safetensors models.
|
||||
if modelDetails.QuantizationLevel == "" {
|
||||
if dtype, err := xserver.GetSafetensorsDtype(name); err == nil && dtype != "" {
|
||||
modelDetails.QuantizationLevel = dtype
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/fs/ggml"
|
||||
"github.com/ollama/ollama/manifest"
|
||||
"github.com/ollama/ollama/openai"
|
||||
"github.com/ollama/ollama/server/internal/client/ollama"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
@@ -547,6 +548,38 @@ func TestRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetModelInfo_SafetensorsUsesStoredFileType(t *testing.T) {
|
||||
t.Setenv("OLLAMA_MODELS", t.TempDir())
|
||||
|
||||
cfgData, err := json.Marshal(model.ConfigV2{
|
||||
ModelFormat: "safetensors",
|
||||
FileType: "mxfp8",
|
||||
Capabilities: []string{"completion"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal config: %v", err)
|
||||
}
|
||||
|
||||
configLayer, err := manifest.NewLayer(bytes.NewReader(cfgData), "application/vnd.docker.container.image.v1+json")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create config layer: %v", err)
|
||||
}
|
||||
|
||||
name := model.ParseName("show-safetensors")
|
||||
if err := manifest.WriteManifest(name, configLayer, nil); err != nil {
|
||||
t.Fatalf("failed to write manifest: %v", err)
|
||||
}
|
||||
|
||||
resp, err := GetModelInfo(api.ShowRequest{Model: name.String()})
|
||||
if err != nil {
|
||||
t.Fatalf("GetModelInfo() error = %v", err)
|
||||
}
|
||||
|
||||
if resp.Details.QuantizationLevel != "mxfp8" {
|
||||
t.Fatalf("QuantizationLevel = %q, want %q", resp.Details.QuantizationLevel, "mxfp8")
|
||||
}
|
||||
}
|
||||
|
||||
func casingShuffle(s string) string {
|
||||
rr := []rune(s)
|
||||
for i := range rr {
|
||||
@@ -721,6 +754,111 @@ func TestShow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowCopilotUserAgentOverwritesExistingBasename(t *testing.T) {
|
||||
t.Setenv("OLLAMA_MODELS", t.TempDir())
|
||||
|
||||
var s Server
|
||||
|
||||
w := createRequest(t, s.CreateHandler, api.CreateRequest{
|
||||
Model: "show-model",
|
||||
From: "bob",
|
||||
RemoteHost: "https://ollama.com",
|
||||
Info: map[string]any{
|
||||
"model_family": "gptoss",
|
||||
"base_name": "upstream-base-name",
|
||||
},
|
||||
Stream: &stream,
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status code 200 creating model, actual %d", w.Code)
|
||||
}
|
||||
|
||||
h, err := s.GenerateRoutes(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
makeRequest := func(userAgent string) api.ShowResponse {
|
||||
t.Helper()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(`{"model":"show-model"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if userAgent != "" {
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
}
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status code 200, actual %d", w.Code)
|
||||
}
|
||||
|
||||
var resp api.ShowResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
withoutCopilot := makeRequest("")
|
||||
if withoutCopilot.ModelInfo["general.basename"] != "upstream-base-name" {
|
||||
t.Fatalf("expected general.basename to be %q, got %v", "upstream-base-name", withoutCopilot.ModelInfo["general.basename"])
|
||||
}
|
||||
|
||||
withCopilot := makeRequest("GitHubCopilotChat/0.41.1")
|
||||
if withCopilot.ModelInfo["general.basename"] != "show-model" {
|
||||
t.Fatalf("expected general.basename to be %q, got %v", "show-model", withCopilot.ModelInfo["general.basename"])
|
||||
}
|
||||
|
||||
if withCopilot.ModelInfo["general.architecture"] != "gptoss" {
|
||||
t.Fatalf("expected general.architecture to be %q, got %v", "gptoss", withCopilot.ModelInfo["general.architecture"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowCopilotUserAgentSetsBasenameWhenModelInfoIsEmpty(t *testing.T) {
|
||||
t.Setenv("OLLAMA_MODELS", t.TempDir())
|
||||
|
||||
var s Server
|
||||
|
||||
w := createRequest(t, s.CreateHandler, api.CreateRequest{
|
||||
Model: "show-remote",
|
||||
From: "bob",
|
||||
RemoteHost: "https://ollama.com",
|
||||
Stream: &stream,
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status code 200 creating model, actual %d", w.Code)
|
||||
}
|
||||
|
||||
h, err := s.GenerateRoutes(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(`{"model":"show-remote"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "GitHubCopilotChat/0.41.1")
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status code 200, actual %d", w.Code)
|
||||
}
|
||||
|
||||
var resp api.ShowResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if resp.ModelInfo["general.basename"] != "show-remote" {
|
||||
t.Fatalf("expected general.basename to be %q, got %v", "show-remote", resp.ModelInfo["general.basename"])
|
||||
}
|
||||
|
||||
if len(resp.ModelInfo) != 1 {
|
||||
t.Fatalf("expected model_info to contain only general.basename, got %#v", resp.ModelInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
type testCase struct {
|
||||
input []float32
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
)
|
||||
|
||||
// MinOllamaVersion is the minimum Ollama version required for safetensors models.
|
||||
const MinOllamaVersion = "0.14.0"
|
||||
const MinOllamaVersion = "0.19.0"
|
||||
|
||||
// ModelfileConfig holds configuration extracted from a Modelfile.
|
||||
type ModelfileConfig struct {
|
||||
@@ -132,12 +132,7 @@ func CreateModel(opts CreateOptions, p *progress.Progress) error {
|
||||
if isSafetensors {
|
||||
modelType = "safetensors model"
|
||||
spinnerKey = "create"
|
||||
capabilities = []string{"completion"}
|
||||
|
||||
// Check if model supports thinking based on architecture
|
||||
if supportsThinking(opts.ModelDir) {
|
||||
capabilities = append(capabilities, "thinking")
|
||||
}
|
||||
capabilities = inferSafetensorsCapabilities(opts.ModelDir)
|
||||
|
||||
// Set parser and renderer name based on architecture
|
||||
parserName = getParserName(opts.ModelDir)
|
||||
@@ -188,6 +183,21 @@ func CreateModel(opts CreateOptions, p *progress.Progress) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func inferSafetensorsCapabilities(modelDir string) []string {
|
||||
capabilities := []string{"completion"}
|
||||
|
||||
// Qwen3.5 multimodal checkpoints use ConditionalGeneration architectures.
|
||||
if supportsVision(modelDir) {
|
||||
capabilities = append(capabilities, "vision")
|
||||
}
|
||||
|
||||
if supportsThinking(modelDir) {
|
||||
capabilities = append(capabilities, "thinking")
|
||||
}
|
||||
|
||||
return capabilities
|
||||
}
|
||||
|
||||
// newLayerCreator returns a LayerCreator callback for creating config/JSON layers.
|
||||
func newLayerCreator() create.LayerCreator {
|
||||
return func(r io.Reader, mediaType, name string) (create.LayerInfo, error) {
|
||||
@@ -338,6 +348,7 @@ func newManifestWriter(opts CreateOptions, capabilities []string, parserName, re
|
||||
// Create config blob with version requirement
|
||||
configData := model.ConfigV2{
|
||||
ModelFormat: "safetensors",
|
||||
FileType: strings.ToLower(strings.TrimSpace(opts.Quantize)),
|
||||
Capabilities: caps,
|
||||
Requires: MinOllamaVersion,
|
||||
Parser: resolveParserName(opts.Modelfile, parserName),
|
||||
@@ -485,6 +496,34 @@ func supportsThinking(modelDir string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// supportsVision checks if the model supports image input based on its architecture.
|
||||
// Qwen3.5 multimodal checkpoints are published as ConditionalGeneration architectures.
|
||||
func supportsVision(modelDir string) bool {
|
||||
configPath := filepath.Join(modelDir, "config.json")
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var cfg struct {
|
||||
Architectures []string `json:"architectures"`
|
||||
ModelType string `json:"model_type"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, arch := range cfg.Architectures {
|
||||
archLower := strings.ToLower(arch)
|
||||
if strings.Contains(archLower, "qwen3") && strings.Contains(archLower, "conditionalgeneration") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
typeLower := strings.ToLower(cfg.ModelType)
|
||||
return strings.Contains(typeLower, "qwen3") && strings.Contains(typeLower, "conditionalgeneration")
|
||||
}
|
||||
|
||||
// getParserName returns the parser name for a model based on its architecture.
|
||||
// This reads the config.json from the model directory and determines the appropriate parser.
|
||||
func getParserName(modelDir string) string {
|
||||
|
||||
@@ -3,11 +3,15 @@ package client
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/manifest"
|
||||
"github.com/ollama/ollama/parser"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
"github.com/ollama/ollama/x/create"
|
||||
)
|
||||
|
||||
func TestModelfileConfig(t *testing.T) {
|
||||
@@ -120,8 +124,8 @@ func TestMinOllamaVersion(t *testing.T) {
|
||||
if MinOllamaVersion == "" {
|
||||
t.Error("MinOllamaVersion should not be empty")
|
||||
}
|
||||
if MinOllamaVersion != "0.14.0" {
|
||||
t.Errorf("MinOllamaVersion = %q, want %q", MinOllamaVersion, "0.14.0")
|
||||
if MinOllamaVersion != "0.19.0" {
|
||||
t.Errorf("MinOllamaVersion = %q, want %q", MinOllamaVersion, "0.19.0")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +293,52 @@ func TestCreateOptions_Defaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferSafetensorsCapabilities(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configJSON string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "qwen3.5 text model",
|
||||
configJSON: `{
|
||||
"architectures": ["Qwen3_5ForCausalLM"],
|
||||
"model_type": "qwen3"
|
||||
}`,
|
||||
want: []string{"completion", "thinking"},
|
||||
},
|
||||
{
|
||||
name: "qwen3.5 multimodal model",
|
||||
configJSON: `{
|
||||
"architectures": ["Qwen3_5ForConditionalGeneration"],
|
||||
"model_type": "qwen3"
|
||||
}`,
|
||||
want: []string{"completion", "vision", "thinking"},
|
||||
},
|
||||
{
|
||||
name: "non-qwen conditional generation model",
|
||||
configJSON: `{
|
||||
"architectures": ["SomeOtherForConditionalGeneration"],
|
||||
"model_type": "other"
|
||||
}`,
|
||||
want: []string{"completion"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(tt.configJSON), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := inferSafetensorsCapabilities(dir); !slices.Equal(got, tt.want) {
|
||||
t.Fatalf("inferSafetensorsCapabilities() = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuantizeSupported(t *testing.T) {
|
||||
// This just verifies the function exists and returns a boolean
|
||||
// The actual value depends on build tags (mlx vs non-mlx)
|
||||
@@ -339,3 +389,43 @@ func TestCreateModelfileLayersIncludesParameters(t *testing.T) {
|
||||
t.Fatalf("temperature = %v, want %v", got["temperature"], float64(0.7))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewManifestWriter_PopulatesFileTypeFromQuantize(t *testing.T) {
|
||||
t.Setenv("OLLAMA_MODELS", t.TempDir())
|
||||
|
||||
opts := CreateOptions{
|
||||
ModelName: "test-quantized",
|
||||
ModelDir: t.TempDir(),
|
||||
Quantize: "MXFP8",
|
||||
}
|
||||
|
||||
writer := newManifestWriter(opts, []string{"completion"}, "qwen3", "qwen3")
|
||||
if err := writer(opts.ModelName, create.LayerInfo{}, nil); err != nil {
|
||||
t.Fatalf("newManifestWriter() error = %v", err)
|
||||
}
|
||||
|
||||
name := model.ParseName(opts.ModelName)
|
||||
mf, err := manifest.ParseNamedManifest(name)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseNamedManifest() error = %v", err)
|
||||
}
|
||||
|
||||
configPath, err := manifest.BlobsPath(mf.Config.Digest)
|
||||
if err != nil {
|
||||
t.Fatalf("BlobsPath() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
|
||||
var cfg model.ConfigV2
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.FileType != "mxfp8" {
|
||||
t.Fatalf("FileType = %q, want %q", cfg.FileType, "mxfp8")
|
||||
}
|
||||
}
|
||||
@@ -101,12 +101,25 @@ file(COPY ${_mlx_c_hdrs} DESTINATION "${CMAKE_SOURCE_DIR}/x/mlxrunner/mlx/includ
|
||||
# Regenerate Go/C shim wrappers from the (possibly updated) headers.
|
||||
find_program(GO_EXECUTABLE go REQUIRED)
|
||||
message(STATUS "Regenerating MLX Go wrappers")
|
||||
|
||||
# Go's cgo splits CC on whitespace, so a CC like "C:/Program Files/…/cl.exe"
|
||||
# (set by cmake on Windows) breaks with "C:/Program" not found. Clear CC
|
||||
# when it contains spaces so cgo falls back to its default (gcc).
|
||||
if(WIN32 AND "$ENV{CC}" MATCHES " ")
|
||||
set(_SAVE_CC "$ENV{CC}")
|
||||
set(ENV{CC} "")
|
||||
endif()
|
||||
|
||||
execute_process(
|
||||
COMMAND ${GO_EXECUTABLE} generate ./x/...
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||
COMMAND_ERROR_IS_FATAL ANY
|
||||
)
|
||||
|
||||
if(DEFINED _SAVE_CC)
|
||||
set(ENV{CC} "${_SAVE_CC}")
|
||||
endif()
|
||||
|
||||
# For local dev builds, override MLX_VERSION with git describe output
|
||||
if(TARGET mlx_version AND DEFINED FETCHCONTENT_SOURCE_DIR_MLX)
|
||||
execute_process(
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/logutil"
|
||||
@@ -37,6 +38,12 @@ type kvCache struct {
|
||||
pagedOutBytes int64 // total bytes in paged-out snapshots across the trie
|
||||
}
|
||||
|
||||
// pendingSnapshot is a snapshot scheduled to be taken during prefill.
|
||||
type pendingSnapshot struct {
|
||||
offset int
|
||||
user bool
|
||||
}
|
||||
|
||||
// cacheSession manages caches for a single pipeline run.
|
||||
// Callers should append generated tokens to outputs and
|
||||
// defer close to save the cache state.
|
||||
@@ -48,11 +55,10 @@ type cacheSession struct {
|
||||
caches []cache.Cache
|
||||
remaining []int32
|
||||
|
||||
// snapshotOffset, if > 0, is a trie node boundary where we need to
|
||||
// capture a snapshot during prefill. This enables future requests
|
||||
// branching at this point to restore non-rewindable caches (e.g.
|
||||
// RecurrentCache) instead of re-evaluating from scratch.
|
||||
snapshotOffset int
|
||||
// pendingSnapshots lists offsets where snapshots should be captured
|
||||
// during prefill, sorted by offset. Entries are consumed as the
|
||||
// cache advances past them.
|
||||
pendingSnapshots []pendingSnapshot
|
||||
}
|
||||
|
||||
func (c *kvCache) ensureCaches(m base.Model) {
|
||||
@@ -100,31 +106,26 @@ func (c *kvCache) begin(m base.Model, inputs []int32) *cacheSession {
|
||||
prefix := c.minCacheOffset()
|
||||
remaining := inputs[prefix:]
|
||||
|
||||
session := &cacheSession{
|
||||
cache: c,
|
||||
inputs: inputs,
|
||||
caches: c.caches,
|
||||
remaining: remaining,
|
||||
}
|
||||
|
||||
// Schedule a snapshot at the branch point during prefill so future
|
||||
// requests diverging here can restore instead of re-evaluating.
|
||||
var snapshotAt int
|
||||
if prefix < matched {
|
||||
snapshotAt = matched
|
||||
session.pendingSnapshots = append(session.pendingSnapshots, pendingSnapshot{offset: matched, user: false})
|
||||
}
|
||||
|
||||
args := []any{"total", len(inputs), "matched", originalMatched}
|
||||
args = append(args, "cached", prefix, "left", len(remaining))
|
||||
if snapshotAt > 0 {
|
||||
args = append(args, "pending_snapshot", snapshotAt)
|
||||
}
|
||||
msg := "cache hit"
|
||||
if prefix == 0 {
|
||||
slog.Info("cache miss", args...)
|
||||
} else {
|
||||
slog.Info("cache hit", args...)
|
||||
msg = "cache miss"
|
||||
}
|
||||
slog.Info(msg, "total", len(inputs), "matched", originalMatched, "cached", prefix, "left", len(remaining))
|
||||
|
||||
return &cacheSession{
|
||||
cache: c,
|
||||
inputs: inputs,
|
||||
snapshotOffset: snapshotAt,
|
||||
caches: c.caches,
|
||||
remaining: remaining,
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
// switchToPath transitions from the current active path to a new path,
|
||||
@@ -238,25 +239,66 @@ pageIn:
|
||||
}
|
||||
}
|
||||
|
||||
// Update last-used time on only the final used node. For recurrent
|
||||
// caches we don't need the intermediate snapshots and for KV caches
|
||||
// we can reslice the data out of merged edges.
|
||||
if len(c.activePath) > 0 {
|
||||
c.activePath[len(c.activePath)-1].lastUsed = time.Now()
|
||||
}
|
||||
|
||||
if pageOutCount > 0 || pageInCount > 0 {
|
||||
slog.Debug("switching cache path", "page_out", pageOutCount, "page_in", pageInCount)
|
||||
}
|
||||
}
|
||||
|
||||
// snapshot creates a snapshot at the current cache position. During prefill,
|
||||
// it is called at branch points (user=false) to create restore points for
|
||||
// future diverging requests and with user=true to mark an explicit reusable
|
||||
// restore point.
|
||||
func (s *cacheSession) snapshot(user bool) {
|
||||
// requestSnapshot schedules a user snapshot at the given absolute token
|
||||
// offset. The snapshot will be captured during prefill when the cache
|
||||
// reaches this offset.
|
||||
func (s *cacheSession) requestSnapshot(offset int) {
|
||||
baseOffset := len(s.inputs) - len(s.remaining)
|
||||
if offset <= baseOffset || offset > len(s.inputs) {
|
||||
return
|
||||
}
|
||||
// Deduplicate: if this offset already exists, upgrade to user.
|
||||
for i := range s.pendingSnapshots {
|
||||
if s.pendingSnapshots[i].offset == offset {
|
||||
s.pendingSnapshots[i].user = true
|
||||
return
|
||||
}
|
||||
}
|
||||
s.pendingSnapshots = append(s.pendingSnapshots, pendingSnapshot{offset: offset, user: true})
|
||||
slices.SortFunc(s.pendingSnapshots, func(a, b pendingSnapshot) int {
|
||||
return a.offset - b.offset
|
||||
})
|
||||
}
|
||||
|
||||
// nextPendingSnapshot returns the offset of the next pending snapshot,
|
||||
// or 0 if there are none.
|
||||
func (s *cacheSession) nextPendingSnapshot() int {
|
||||
if len(s.pendingSnapshots) == 0 {
|
||||
return 0
|
||||
}
|
||||
return s.pendingSnapshots[0].offset
|
||||
}
|
||||
|
||||
// snapshot creates a snapshot at the current cache position. It determines
|
||||
// whether this is a user snapshot by consuming pending entries whose offset
|
||||
// has been reached.
|
||||
func (s *cacheSession) snapshot() {
|
||||
c := s.cache
|
||||
cacheOffset := c.minCacheOffset()
|
||||
if cacheOffset <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Clear pending intermediate snapshot if we've reached or passed it.
|
||||
if s.snapshotOffset > 0 && cacheOffset >= s.snapshotOffset {
|
||||
s.snapshotOffset = 0
|
||||
// Consume pending snapshots up to the current offset and derive
|
||||
// the user flag from them.
|
||||
user := false
|
||||
for len(s.pendingSnapshots) > 0 && cacheOffset >= s.pendingSnapshots[0].offset {
|
||||
if s.pendingSnapshots[0].user {
|
||||
user = true
|
||||
}
|
||||
s.pendingSnapshots = s.pendingSnapshots[1:]
|
||||
}
|
||||
|
||||
// The last node in activePath is the frontier where caches are advancing.
|
||||
@@ -355,6 +397,7 @@ func (s *cacheSession) attachSnapshots(node *trieNode, cacheOffset int) {
|
||||
}
|
||||
}
|
||||
node.setSnapshots(snaps, &c.pagedOutBytes)
|
||||
node.lastUsed = time.Now()
|
||||
slog.Debug("created snapshot", "offset", cacheOffset)
|
||||
c.enforceEvictionPolicy()
|
||||
}
|
||||
@@ -412,10 +455,7 @@ func (s *cacheSession) close() {
|
||||
newTokens := stored[frontier.endOffset:offset]
|
||||
c.advancePath(frontier, newTokens, offset)
|
||||
}
|
||||
now := time.Now()
|
||||
for _, node := range c.activePath {
|
||||
node.lastUsed = now
|
||||
}
|
||||
c.activePath[len(c.activePath)-1].lastUsed = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,7 +473,7 @@ func (c *kvCache) enforceEvictionPolicy() {
|
||||
for c.pagedOutBytes > maxPagedOutBytes {
|
||||
var best *trieNode
|
||||
walkNodes(c.root, func(n *trieNode) bool {
|
||||
if n == c.root || activeSet[n] || !n.hasSnapshots() {
|
||||
if n == c.root || activeSet[n] || len(n.children) > 1 {
|
||||
return true
|
||||
}
|
||||
// Evict: oldest, then deepest, then largest.
|
||||
@@ -457,27 +497,16 @@ func (c *kvCache) enforceEvictionPolicy() {
|
||||
func (c *kvCache) evictNode(node *trieNode) {
|
||||
if len(node.children) == 0 {
|
||||
// Leaf: remove entirely.
|
||||
parent := node.parent
|
||||
kind := "evicting leaf"
|
||||
if node.user {
|
||||
kind = "evicting user snapshot"
|
||||
}
|
||||
slog.Debug(kind, "offset", node.startOffset(), "tokens", len(node.tokens), "freed", mlx.PrettyBytes(int(node.snapshotBytes())))
|
||||
slog.Debug("evicting leaf", "offset", node.startOffset(), "tokens", len(node.tokens), "freed", mlx.PrettyBytes(int(node.snapshotBytes())))
|
||||
removeNode(node, &c.pagedOutBytes)
|
||||
|
||||
// If parent is a regular (non-user-snapshot) node with one remaining child, auto-merge.
|
||||
if parent != nil && !parent.user && len(parent.children) == 1 && parent != c.root {
|
||||
logutil.Trace(fmt.Sprintf("auto-merging parent at offset %d with single child", parent.endOffset))
|
||||
mergeWithChild(parent, c.caches, &c.pagedOutBytes)
|
||||
}
|
||||
} else if len(node.children) == 1 {
|
||||
// Interior snapshot node with one child: merge with child.
|
||||
slog.Debug("evicting snapshot node", "offset", node.endOffset, "tokens", len(node.tokens), "freed", mlx.PrettyBytes(int(node.snapshotBytes())))
|
||||
// Interior node with one child: merge with child.
|
||||
before := c.pagedOutBytes
|
||||
tokens := len(node.tokens)
|
||||
mergeWithChild(node, c.caches, &c.pagedOutBytes)
|
||||
slog.Debug("evicting interior node", "offset", node.startOffset(), "tokens", tokens, "freed", mlx.PrettyBytes(int(before-c.pagedOutBytes)))
|
||||
} else {
|
||||
// Multi-child branch point: drop snapshots but keep the node.
|
||||
slog.Debug("evicting branch snapshot", "offset", node.endOffset, "tokens", len(node.tokens), "freed", mlx.PrettyBytes(int(node.snapshotBytes())))
|
||||
node.setSnapshots(nil, &c.pagedOutBytes)
|
||||
panic("evictNode called on multi-child branch point")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,8 +109,8 @@ func (c *KVCache) Snapshot(fromOffset int) Snapshot {
|
||||
|
||||
kSlice := c.keys.Slice(mlx.Slice(), mlx.Slice(), mlx.Slice(from, to), mlx.Slice())
|
||||
vSlice := c.values.Slice(mlx.Slice(), mlx.Slice(), mlx.Slice(from, to), mlx.Slice())
|
||||
kCopy := mlx.Copy(kSlice)
|
||||
vCopy := mlx.Copy(vSlice)
|
||||
kCopy := mlx.Contiguous(kSlice, false)
|
||||
vCopy := mlx.Contiguous(vSlice, false)
|
||||
mlx.Pin(kCopy, vCopy)
|
||||
mlx.AsyncEval(kCopy, vCopy)
|
||||
|
||||
@@ -196,10 +196,10 @@ func (c *KVCache) Split(snapshot Snapshot, at int) (Snapshot, Snapshot) {
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
pk := mlx.Copy(snap.keys.Slice(mlx.Slice(), mlx.Slice(), mlx.Slice(0, splitIdx), mlx.Slice()))
|
||||
pv := mlx.Copy(snap.values.Slice(mlx.Slice(), mlx.Slice(), mlx.Slice(0, splitIdx), mlx.Slice()))
|
||||
ck := mlx.Copy(snap.keys.Slice(mlx.Slice(), mlx.Slice(), mlx.Slice(splitIdx, seqLen), mlx.Slice()))
|
||||
cv := mlx.Copy(snap.values.Slice(mlx.Slice(), mlx.Slice(), mlx.Slice(splitIdx, seqLen), mlx.Slice()))
|
||||
pk := mlx.Contiguous(snap.keys.Slice(mlx.Slice(), mlx.Slice(), mlx.Slice(0, splitIdx), mlx.Slice()), false)
|
||||
pv := mlx.Contiguous(snap.values.Slice(mlx.Slice(), mlx.Slice(), mlx.Slice(0, splitIdx), mlx.Slice()), false)
|
||||
ck := mlx.Contiguous(snap.keys.Slice(mlx.Slice(), mlx.Slice(), mlx.Slice(splitIdx, seqLen), mlx.Slice()), false)
|
||||
cv := mlx.Contiguous(snap.values.Slice(mlx.Slice(), mlx.Slice(), mlx.Slice(splitIdx, seqLen), mlx.Slice()), false)
|
||||
mlx.Pin(pk, pv, ck, cv)
|
||||
mlx.AsyncEval(pk, pv, ck, cv)
|
||||
|
||||
|
||||
@@ -18,34 +18,22 @@ type RecurrentCache struct {
|
||||
headKDim int
|
||||
}
|
||||
|
||||
func (c *RecurrentCache) setStateRaw(old, v *mlx.Array) *mlx.Array {
|
||||
func (c *RecurrentCache) setState(old, v *mlx.Array, contiguous bool) *mlx.Array {
|
||||
if v == nil || !v.Valid() {
|
||||
return old
|
||||
}
|
||||
|
||||
if contiguous {
|
||||
v = mlx.Contiguous(v, false)
|
||||
}
|
||||
v = v.Clone()
|
||||
|
||||
mlx.Pin(v)
|
||||
mlx.Unpin(old)
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func (c *RecurrentCache) setStateDetached(old, v *mlx.Array, ensureContiguous bool) *mlx.Array {
|
||||
if v == nil || !v.Valid() {
|
||||
return old
|
||||
}
|
||||
|
||||
root := v
|
||||
if ensureContiguous {
|
||||
root = mlx.Contiguous(v, false)
|
||||
}
|
||||
detached := root.Clone()
|
||||
|
||||
mlx.Pin(detached)
|
||||
mlx.Unpin(old)
|
||||
|
||||
return detached
|
||||
}
|
||||
|
||||
func NewRecurrentCache(convTail, convDim, numVHeads, headVDim, headKDim int32) *RecurrentCache {
|
||||
return &RecurrentCache{
|
||||
convTail: int(convTail),
|
||||
@@ -70,10 +58,10 @@ func (c *RecurrentCache) ensure(batch int, dtype mlx.DType) {
|
||||
}
|
||||
|
||||
if needConv {
|
||||
c.convState = c.setStateRaw(c.convState, mlx.Zeros(dtype, batch, c.convTail, c.convDim))
|
||||
c.convState = c.setState(c.convState, mlx.Zeros(dtype, batch, c.convTail, c.convDim), false)
|
||||
}
|
||||
if needDelta {
|
||||
c.deltaState = c.setStateRaw(c.deltaState, mlx.Zeros(dtype, batch, c.numVHeads, c.headVDim, c.headKDim))
|
||||
c.deltaState = c.setState(c.deltaState, mlx.Zeros(dtype, batch, c.numVHeads, c.headVDim, c.headKDim), false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +71,7 @@ func (c *RecurrentCache) ConvState(batch int, dtype mlx.DType) *mlx.Array {
|
||||
}
|
||||
|
||||
func (c *RecurrentCache) SetConvState(v *mlx.Array) {
|
||||
c.convState = c.setStateDetached(c.convState, v, true)
|
||||
c.convState = c.setState(c.convState, v, true)
|
||||
}
|
||||
|
||||
func (c *RecurrentCache) DeltaState(batch int, dtype mlx.DType) *mlx.Array {
|
||||
@@ -92,7 +80,7 @@ func (c *RecurrentCache) DeltaState(batch int, dtype mlx.DType) *mlx.Array {
|
||||
}
|
||||
|
||||
func (c *RecurrentCache) SetDeltaState(v *mlx.Array) {
|
||||
c.deltaState = c.setStateDetached(c.deltaState, v, false)
|
||||
c.deltaState = c.setState(c.deltaState, v, false)
|
||||
}
|
||||
|
||||
func (c *RecurrentCache) Advance(n int) {
|
||||
@@ -147,8 +135,8 @@ func (c *RecurrentCache) Restore(snapshot Snapshot, target int) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
c.convState = c.setStateRaw(c.convState, snap.convState)
|
||||
c.deltaState = c.setStateRaw(c.deltaState, snap.deltaState)
|
||||
c.convState = c.setState(c.convState, snap.convState, false)
|
||||
c.deltaState = c.setState(c.deltaState, snap.deltaState, false)
|
||||
c.offset = snap.offset
|
||||
|
||||
return true
|
||||
|
||||
@@ -3,6 +3,7 @@ package mlxrunner
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/x/mlxrunner/cache"
|
||||
"github.com/ollama/ollama/x/mlxrunner/mlx"
|
||||
@@ -376,24 +377,25 @@ func (e *testEnv) assertAllTokens(t *testing.T, label string, expected []int32)
|
||||
// begin -> prefill with snapshot(false) at branch points -> generate -> close
|
||||
|
||||
type requestResult struct {
|
||||
remaining []int32
|
||||
snapshotOffset int
|
||||
remaining []int32
|
||||
pendingSnapshots int
|
||||
}
|
||||
|
||||
// simulateRequest runs a request through the harness. If userSnapshotAt > 0,
|
||||
// a user snapshot (snapshot(true)) is created at that offset during prefill.
|
||||
// a user snapshot is requested at that offset during prefill.
|
||||
func simulateRequest(t *testing.T, kvc *kvCache, inputs, generated []int32, userSnapshotAt ...int) requestResult {
|
||||
t.Helper()
|
||||
|
||||
userSnapAt := 0
|
||||
if len(userSnapshotAt) > 0 {
|
||||
userSnapAt = userSnapshotAt[0]
|
||||
session := kvc.begin(nil, inputs)
|
||||
for _, at := range userSnapshotAt {
|
||||
if at > 0 {
|
||||
session.requestSnapshot(at)
|
||||
}
|
||||
}
|
||||
|
||||
session := kvc.begin(nil, inputs)
|
||||
result := requestResult{
|
||||
remaining: slices.Clone(session.remaining),
|
||||
snapshotOffset: session.snapshotOffset,
|
||||
remaining: slices.Clone(session.remaining),
|
||||
pendingSnapshots: len(session.pendingSnapshots),
|
||||
}
|
||||
|
||||
assertCacheOffsetAlignment(t, kvc, "after begin")
|
||||
@@ -401,22 +403,9 @@ func simulateRequest(t *testing.T, kvc *kvCache, inputs, generated []int32, user
|
||||
baseOffset := kvc.minCacheOffset()
|
||||
remaining := inputs[baseOffset:]
|
||||
|
||||
// Collect snapshot points (offset -> user flag) in ascending order.
|
||||
type snapPoint struct {
|
||||
offset int
|
||||
user bool
|
||||
}
|
||||
var points []snapPoint
|
||||
if session.snapshotOffset > 0 && session.snapshotOffset > baseOffset {
|
||||
points = append(points, snapPoint{session.snapshotOffset, false})
|
||||
}
|
||||
if userSnapAt > 0 && userSnapAt > baseOffset {
|
||||
points = append(points, snapPoint{userSnapAt, true})
|
||||
}
|
||||
slices.SortFunc(points, func(a, b snapPoint) int { return a.offset - b.offset })
|
||||
|
||||
// Prefill: feed tokens, pausing at each snapshot point.
|
||||
for _, sp := range points {
|
||||
// Prefill: feed tokens, pausing at each pending snapshot.
|
||||
for len(session.pendingSnapshots) > 0 {
|
||||
sp := session.pendingSnapshots[0]
|
||||
count := sp.offset - baseOffset
|
||||
if count > len(remaining) {
|
||||
break
|
||||
@@ -427,7 +416,7 @@ func simulateRequest(t *testing.T, kvc *kvCache, inputs, generated []int32, user
|
||||
baseOffset = sp.offset
|
||||
}
|
||||
assertCacheOffsetAlignment(t, kvc, "at snapshot point")
|
||||
session.snapshot(sp.user)
|
||||
session.snapshot()
|
||||
}
|
||||
|
||||
// Feed rest of input tokens.
|
||||
@@ -614,15 +603,15 @@ func TestBranchCreationAndReuse(t *testing.T) {
|
||||
// caches (RecurrentCache), the rewind fails and freeAll fires.
|
||||
resB := simulateRequest(t, kvc, []int32{1, 2, 3, 4, 5, 10, 11, 12}, []int32{30, 31})
|
||||
if env.rewindable {
|
||||
if resB.snapshotOffset != 0 {
|
||||
t.Fatalf("B: snapshotOffset = %d, want 0 (rewind succeeded)", resB.snapshotOffset)
|
||||
if resB.pendingSnapshots != 0 {
|
||||
t.Fatalf("B: pendingSnapshots = %d, want 0 (rewind succeeded)", resB.pendingSnapshots)
|
||||
}
|
||||
if len(resB.remaining) != 3 {
|
||||
t.Fatalf("B: remaining = %d, want 3 (rewind to match point)", len(resB.remaining))
|
||||
}
|
||||
} else {
|
||||
if resB.snapshotOffset != 5 {
|
||||
t.Fatalf("B: snapshotOffset = %d, want 5", resB.snapshotOffset)
|
||||
if resB.pendingSnapshots != 1 {
|
||||
t.Fatalf("B: pendingSnapshots = %d, want 1", resB.pendingSnapshots)
|
||||
}
|
||||
if len(resB.remaining) != 8 {
|
||||
t.Fatalf("B: remaining = %d, want 8 (freeAll fallback)", len(resB.remaining))
|
||||
@@ -671,15 +660,15 @@ func TestExactMatchSeedBehavior(t *testing.T) {
|
||||
if len(resB.remaining) != 1 {
|
||||
t.Fatalf("B: remaining = %d, want 1 (rewind to holdback point)", len(resB.remaining))
|
||||
}
|
||||
if resB.snapshotOffset != 0 {
|
||||
t.Fatalf("B: snapshotOffset = %d, want 0 (rewind succeeded)", resB.snapshotOffset)
|
||||
if resB.pendingSnapshots != 0 {
|
||||
t.Fatalf("B: pendingSnapshots = %d, want 0 (rewind succeeded)", resB.pendingSnapshots)
|
||||
}
|
||||
} else {
|
||||
if len(resB.remaining) != 5 {
|
||||
t.Fatalf("B: remaining = %d, want 5 (freeAll fallback)", len(resB.remaining))
|
||||
}
|
||||
if resB.snapshotOffset != 4 {
|
||||
t.Fatalf("B: snapshotOffset = %d, want 4", resB.snapshotOffset)
|
||||
if resB.pendingSnapshots != 1 {
|
||||
t.Fatalf("B: pendingSnapshots = %d, want 1", resB.pendingSnapshots)
|
||||
}
|
||||
}
|
||||
env.assertAllTokens(t, "after B", []int32{1, 2, 3, 4, 5, 20, 21})
|
||||
@@ -761,8 +750,8 @@ func TestEvictionPreservesActiveConversations(t *testing.T) {
|
||||
t.Fatalf("activePath should have >= 2 nodes, got %d", len(kvc.activePath))
|
||||
}
|
||||
|
||||
// System prompt prefix should still be findable (evicting a
|
||||
// multi-child branch point only drops snapshots, not the node).
|
||||
// System prompt prefix should still be findable (multi-child
|
||||
// branch points are protected from eviction entirely).
|
||||
_, matched := findBestMatch(kvc.root, systemPrompt)
|
||||
if matched < len(systemPrompt) {
|
||||
t.Fatalf("system prompt match = %d, want %d", matched, len(systemPrompt))
|
||||
@@ -895,3 +884,55 @@ func TestBranchSwitchRestoresCorrectState(t *testing.T) {
|
||||
checkTrieInvariants(t, kvc.root)
|
||||
})
|
||||
}
|
||||
|
||||
// TestLRUOnlyUpdatesUsedNodes verifies that intermediate nodes on the active
|
||||
// path whose snapshots were not actually restored don't get their lastUsed
|
||||
// refreshed, allowing them to age out and collapse.
|
||||
func TestLRUOnlyUpdatesUsedNodes(t *testing.T) {
|
||||
forEachEnv(t, func(t *testing.T, env *testEnv) {
|
||||
kvc := env.kvc
|
||||
|
||||
// Request A: creates path [1,2,3,4,5] + generate [10,11]
|
||||
simulateRequest(t, kvc, []int32{1, 2, 3, 4, 5}, []int32{10, 11})
|
||||
|
||||
// Request B: diverges at token 4, creating a branch point at offset 3
|
||||
// with a split snapshot.
|
||||
simulateRequest(t, kvc, []int32{1, 2, 3, 6, 7}, []int32{20, 21})
|
||||
|
||||
// Set all lastUsed to a known old time.
|
||||
oldTime := time.Now().Add(-1 * time.Hour)
|
||||
walkNodes(kvc.root, func(n *trieNode) bool {
|
||||
n.lastUsed = oldTime
|
||||
return true
|
||||
})
|
||||
|
||||
// Request C: continue on B's branch. This will match B's path
|
||||
// and extend it. The branch point's snapshot may be paged in
|
||||
// for some cache types but not others.
|
||||
beforeRequest := time.Now()
|
||||
simulateRequest(t, kvc, []int32{1, 2, 3, 6, 7, 20, 21, 30}, nil)
|
||||
|
||||
// The path must have enough depth to exercise intermediate nodes.
|
||||
if len(kvc.activePath) < 3 {
|
||||
t.Fatalf("activePath too short to test intermediate nodes: got %d nodes", len(kvc.activePath))
|
||||
}
|
||||
|
||||
// The frontier (deepest node on the active path) must be updated.
|
||||
frontier := kvc.activePath[len(kvc.activePath)-1]
|
||||
if frontier.lastUsed.Before(beforeRequest) {
|
||||
t.Errorf("frontier lastUsed was not updated: got %v, want >= %v",
|
||||
frontier.lastUsed, beforeRequest)
|
||||
}
|
||||
|
||||
// Every non-frontier node on the active path (including root)
|
||||
// should retain its old lastUsed — only the frontier gets refreshed.
|
||||
for i, node := range kvc.activePath[:len(kvc.activePath)-1] {
|
||||
if !node.lastUsed.Before(beforeRequest) {
|
||||
t.Errorf("activePath[%d] (endOffset=%d) lastUsed was refreshed: got %v, want < %v",
|
||||
i, node.endOffset, node.lastUsed, beforeRequest)
|
||||
}
|
||||
}
|
||||
|
||||
checkTrieInvariants(t, kvc.root)
|
||||
})
|
||||
}
|
||||
@@ -400,6 +400,21 @@ func (c *Client) Load(ctx context.Context, _ ml.SystemInfo, gpus []ml.DeviceInfo
|
||||
slog.Debug("mlx subprocess library path", libPathEnvVar, pathEnvVal)
|
||||
}
|
||||
|
||||
// Point MLX's JIT compiler at our bundled CUDA runtime headers.
|
||||
// MLX resolves headers via $CUDA_PATH/include/*.h (and checks CUDA_HOME first).
|
||||
// Always use bundled headers to avoid version mismatches with any
|
||||
// system-installed CUDA toolkit.
|
||||
if mlxDirs, err := filepath.Glob(filepath.Join(ml.LibOllamaPath, "mlx_cuda_*")); err == nil {
|
||||
for _, d := range mlxDirs {
|
||||
if _, err := os.Stat(filepath.Join(d, "include")); err == nil {
|
||||
setEnv(cmd, "CUDA_PATH", d)
|
||||
setEnv(cmd, "CUDA_HOME", d)
|
||||
slog.Debug("mlx subprocess CUDA headers", "CUDA_PATH", d)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.cmd = cmd
|
||||
|
||||
// Forward subprocess stdout/stderr to server logs
|
||||
@@ -519,3 +534,16 @@ func (c *Client) VRAMByGPU(id ml.DeviceID) uint64 {
|
||||
}
|
||||
|
||||
var _ llm.LlamaServer = (*Client)(nil)
|
||||
|
||||
// setEnv sets or replaces an environment variable in cmd.Env.
|
||||
func setEnv(cmd *exec.Cmd, key, value string) {
|
||||
entry := key + "=" + value
|
||||
prefix := strings.ToUpper(key + "=")
|
||||
for i, e := range cmd.Env {
|
||||
if strings.HasPrefix(strings.ToUpper(e), prefix) {
|
||||
cmd.Env[i] = entry
|
||||
return
|
||||
}
|
||||
}
|
||||
cmd.Env = append(cmd.Env, entry)
|
||||
}
|
||||
@@ -262,9 +262,11 @@ func LogArrays() {
|
||||
return arrays[i].NumBytes() > arrays[j].NumBytes()
|
||||
})
|
||||
|
||||
var total int
|
||||
for _, t := range arrays {
|
||||
nb := t.NumBytes()
|
||||
total += nb
|
||||
logutil.Trace(fmt.Sprintf("tensor %-60s %5s %5s pinned=%d %v", t.name, t.DType(), PrettyBytes(nb), t.pinned, t.Dims()))
|
||||
}
|
||||
logutil.Trace(fmt.Sprintf("tensors total: %d, size: %s", len(arrays), PrettyBytes(ActiveMemory())))
|
||||
logutil.Trace(fmt.Sprintf("tensors total: %d, size: %s, active: %s", len(arrays), PrettyBytes(total), PrettyBytes(ActiveMemory())))
|
||||
}
|
||||
@@ -494,15 +494,6 @@ func Collect(v any) []*Array {
|
||||
return arrays
|
||||
}
|
||||
|
||||
func Copy(a *Array) *Array {
|
||||
if a == nil || !a.Valid() {
|
||||
return a
|
||||
}
|
||||
out := New("COPY")
|
||||
C.mlx_copy(&out.ctx, a.ctx, DefaultStream().ctx)
|
||||
return out
|
||||
}
|
||||
|
||||
func collect(v reflect.Value, arrays *[]*Array, seen map[uintptr]bool) {
|
||||
if !v.IsValid() {
|
||||
return
|
||||
|
||||
@@ -79,10 +79,24 @@ func (r *Runner) TextGenerationPipeline(request Request) error {
|
||||
|
||||
session := r.cache.begin(r.Model, inputs)
|
||||
defer session.close()
|
||||
|
||||
caches := session.caches
|
||||
tokens := session.remaining
|
||||
prefillChunk := prefillChunkSize()
|
||||
|
||||
// Request periodic snapshots during prefill and near the end of the
|
||||
// prompt so that long prompts can be partially restored and
|
||||
// thinking/generation can be retried without full reprocessing.
|
||||
const snapshotInterval = 8192
|
||||
for offset := snapshotInterval; offset < len(inputs); offset += snapshotInterval {
|
||||
session.requestSnapshot(offset)
|
||||
}
|
||||
|
||||
const preThinking = 4
|
||||
if end := len(inputs) - preThinking; end > 0 {
|
||||
session.requestSnapshot(end)
|
||||
}
|
||||
|
||||
materializeCaches := func() {
|
||||
state := make([]*mlx.Array, 0, 2*len(caches))
|
||||
for _, c := range caches {
|
||||
@@ -103,12 +117,11 @@ func (r *Runner) TextGenerationPipeline(request Request) error {
|
||||
|
||||
n := min(prefillChunk, total-processed-1)
|
||||
|
||||
// If there's a pending intermediate snapshot, split the batch
|
||||
// so we can capture it at the exact offset. The cache offset
|
||||
// after this batch will be: baseOffset + processed + n.
|
||||
if session.snapshotOffset > 0 {
|
||||
// If there's a pending snapshot, split the batch so we can
|
||||
// capture it at the exact offset.
|
||||
if snapOffset := session.nextPendingSnapshot(); snapOffset > 0 {
|
||||
baseOffset := len(session.inputs) - len(tokens)
|
||||
tokensUntilSnapshot := session.snapshotOffset - (baseOffset + processed)
|
||||
tokensUntilSnapshot := snapOffset - (baseOffset + processed)
|
||||
if tokensUntilSnapshot > 0 && tokensUntilSnapshot < n {
|
||||
n = tokensUntilSnapshot
|
||||
}
|
||||
@@ -120,11 +133,11 @@ func (r *Runner) TextGenerationPipeline(request Request) error {
|
||||
processed += n
|
||||
slog.Info("Prompt processing progress", "processed", processed, "total", total)
|
||||
|
||||
// Create snapshot at branch point for future diverging requests.
|
||||
if session.snapshotOffset > 0 {
|
||||
// Create snapshot if we've reached a pending offset.
|
||||
if snapOffset := session.nextPendingSnapshot(); snapOffset > 0 {
|
||||
baseOffset := len(session.inputs) - len(tokens)
|
||||
if baseOffset+processed >= session.snapshotOffset {
|
||||
session.snapshot(false)
|
||||
if baseOffset+processed >= snapOffset {
|
||||
session.snapshot()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ import (
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func canonicalQuantType(quantType string) string {
|
||||
return strings.ToLower(strings.TrimSpace(quantType))
|
||||
}
|
||||
|
||||
// modelConfig represents the HuggingFace config.json structure
|
||||
type modelConfig struct {
|
||||
Architectures []string `json:"architectures"`
|
||||
@@ -256,7 +260,7 @@ func getTensorInfoFromManifest(mf *manifest.Manifest) ([]api.Tensor, error) {
|
||||
}
|
||||
|
||||
if info.QuantType != "" {
|
||||
quantType := strings.ToUpper(info.QuantType)
|
||||
quantType := canonicalQuantType(info.QuantType)
|
||||
|
||||
shape := make([]uint64, len(info.Shape))
|
||||
for i, s := range info.Shape {
|
||||
@@ -323,8 +327,8 @@ func GetSafetensorsDtype(name model.Name) (string, error) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.QuantType != "" {
|
||||
return strings.ToUpper(info.QuantType), nil
|
||||
if quantType := canonicalQuantType(info.QuantType); quantType != "" {
|
||||
return quantType, nil
|
||||
}
|
||||
// Only check the first tensor blob
|
||||
break
|
||||
|
||||
@@ -705,8 +705,8 @@ func TestGetTensorInfoFromManifest_Quantized(t *testing.T) {
|
||||
if tensor.Name != "model.layers.0.mlp.up_proj.weight" {
|
||||
t.Errorf("Name = %v, want model.layers.0.mlp.up_proj.weight", tensor.Name)
|
||||
}
|
||||
if tensor.Type != "INT4" {
|
||||
t.Errorf("Type = %v, want INT4", tensor.Type)
|
||||
if tensor.Type != "int4" {
|
||||
t.Errorf("Type = %v, want int4", tensor.Type)
|
||||
}
|
||||
// Shape should be unpacked: 320 * 8 = 2560
|
||||
if len(tensor.Shape) != 2 || tensor.Shape[0] != 2560 || tensor.Shape[1] != 2560 {
|
||||
@@ -1196,6 +1196,17 @@ func TestGetTensorInfoFromManifest_Packed(t *testing.T) {
|
||||
if !packedNames["model.layers.0.mlp.experts.0.gate_proj.weight"] {
|
||||
t.Error("missing packed tensor: model.layers.0.mlp.experts.0.gate_proj.weight")
|
||||
}
|
||||
|
||||
packedTypes := make(map[string]string)
|
||||
for _, r := range result[1:] {
|
||||
packedTypes[r.Name] = r.Type
|
||||
}
|
||||
if packedTypes["model.layers.0.mlp.experts.0.down_proj.weight"] != "int8" {
|
||||
t.Errorf("down_proj.Type = %v, want int8", packedTypes["model.layers.0.mlp.experts.0.down_proj.weight"])
|
||||
}
|
||||
if packedTypes["model.layers.0.mlp.experts.0.gate_proj.weight"] != "int4" {
|
||||
t.Errorf("gate_proj.Type = %v, want int4", packedTypes["model.layers.0.mlp.experts.0.gate_proj.weight"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSafetensorsHeader(t *testing.T) {
|
||||
|
||||